diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b404769 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +; EditorConfig file: https://EditorConfig.org +; Install the "EditorConfig" plugin into your editor to use + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.json] +indent_size = 2 diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..fe61de1 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,26 @@ +name: Node.js CI +on: + push: + branches: + - master + pull_request: + branches: + - master +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: + - 20.x + - 22.x + - 24.x + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm run build --if-present + - run: npm test diff --git a/.gitignore b/.gitignore index 073cfe2..3fece53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ npm-debug.log node_modules +coverage +.idea +dist diff --git a/.ncurc.cjs b/.ncurc.cjs new file mode 100644 index 0000000..c7df28d --- /dev/null +++ b/.ncurc.cjs @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + reject: [ + // Vulnerability in higher versions + 'colors', + + // ESM-only; only switch when dropping dual CJS support + 'node-fetch', + 'mime', + + // Node 20+ + 'minimatch' + ] +}; diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..5eaecc9 --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +test +benchmark +coverage +.nyc_output +rollup.config.js +*.d.ts.map diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..d69d3c8 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,70 @@ +# CHANGES for `node-static` + +## 0.8.0 (UNRELEASED) + +### User-facing + +- **Breaking change** (npm): Set `engines` to 20.11.0+ +- **Breaking change**: Add `type: 'module'` and `exports` to `package.json`; + change internal CJS path +- **Breaking change**: avoid serving hidden files by default (reenable with `--serve-hidden`/`serveHidden`) +- **Breaking change**: `cache: false` now sets `no-cache` (as do 0 or + negative values). Set to `null` instead to reproduce old behavior +- Security: Fix dependency vulnerabilities by switching from `optimist` to + `command-line-basics` (@brettz9) +- Security: Update `mime` and `colors` (@fidian) and pin `colors` + (@mannyluvstacos) +- Security Update/fix: Use `URL` constructor over deprecated `url.parse`; + should fix Open Redirect issue +- Security Update/fix: Protect `fs.stat` calls from bad path arguments; fixes + Denial of Service issue + (@brpvieira) +- Security fix?: The Unauthorized File Access issue + does not appear to be an issue + per testing (if it ever was); if you can provide a test case where it + fails, please report +- Fix: Support `bytes=0-0` Range header (@prajwalkman) +- Fix: Avoid octal (@bgao / @Ilrilan) +- Fix: For `spa`, allow dots after path (@gjuchault) +- Fix: ensure query string on directory request is passed on +- Fix: Ensure package `version` stays up to date +- Fix: path should be more generous in unescaping anything valid in a + path (such as a hash) +- Fix: Avoid logging range errors to console +- Fix: ensure `--default-extension` and `--server-info` are settable by CLI +- Fix: change `fs.createReadStream()` mode to integer (@pixcai) +- Enhancement: TypeScript support +- Enhancement: Add `directoryCallback` option +- Enhancement: Emit warning if gzipped file is older than source file +- Enhancement: add `gzipOnly` option +- Enhancement: Allow access with local ip (@flyingsky) +- Enhancement: Allow `serverInfo` to be `null` (@martindale) +- Enhancement: Time display logging with leading 0 (@mauris) +- Enhancement: Respect static `--cache 0` (@matthew-andrews) +- Enhancement: New option: `defaultExtension` (@fmalk) +- Enhancement: Added glob matching for setting cache headers (@lightswitch05) +- Update: Switch from deprecated `request` to `node-fetch` +- Optimization: 'use strict' directive +- Refactoring: Switch to ESM +- Docs: For examples (and internally) avoid `static` reserved word +- Docs: Fix header example (@emmanouil) +- Docs: Sp. (@EdwardBetts) +- Docs: Make install section more visible, make defaults visible in + semantically marked-up headings and add CLI options +- Docs: Add `CHANGES.md` +- Docs: Add ESM file-server example + +### Dev-facing + +- Linting: Prefer const, no-var, fix indent, comment-out unused, + prefer `startsWith` and `includes` +- Refactoring: Use safer non-prototype version of `colors` +- Maintenance: Add `.editorconfig` +- Testing: Full test coverage +- Testing: Add checks for supposed direct `node-static` vulnerabilities +- Testing: Allow tests to end (@fmalk) +- Testing: Switch to `mocha`/`chai`/`c8` +- Testing: Add CI workflow +- Testing: Begin binary file coverage +- npm: Add eslint devDep. and script +- npm: Add lock file diff --git a/LICENSE b/LICENSE index 91f6632..9acd794 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ -Copyright (c) 2010 Alexis Sellier +Copyright (c) 2010-14 Alexis Sellier +Copyright (c) 2021 Brett Zamir Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 20ca0ec..81e260e 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,360 @@ -node-static -=========== +# node-static + +[![Node.js CI status](https://github.com/http://github.com/cloudhead/node-static/workflows/Node.js%20CI/badge.svg)](https://github.com/http://github.com/cloudhead/node-static/actions) > a simple, *rfc 2616 compliant* file streaming module for [node](http://nodejs.org) -node-static has an in-memory file cache, making it highly efficient. node-static understands and supports *conditional GET* and *HEAD* requests. -node-static was inspired by some of the other static-file serving modules out there, -such as node-paperboy and antinode. +node-static was inspired by some of the other static-file serving modules out +there, such as node-paperboy and antinode. -Synopsis --------- +## Installation - var static = require('node-static'); +```sh +$ npm install node-static +``` - // - // Create a node-static server instance to serve the './public' folder - // - var file = new(static.Server)('./public'); +## Set-up - require('http').createServer(function (request, response) { - request.addListener('end', function () { - // - // Serve files! - // - file.serve(request, response); - }); - }).listen(8080); +### ESM + +```js +import {Server, version, mime} from 'node-static'; + +// OR: +// import * as statik from 'node-static'; +``` + +### CommonJS + +```js +const statik = require('node-static'); +``` -API ---- +## Usage -### Creating a node-static Server # +```js +import http from 'node:http'; + +// +// Create a node-static server instance to serve the './public' folder +// +const file = new statik.Server('./public'); + +http.createServer(function (request, response) { + request.addListener('end', function () { + // + // Serve files! + // + file.serve(request, response); + }).resume(); +}).listen(8080); +``` + +## API + +### Creating a node-static Server Creating a file server instance is as simple as: - new static.Server(); +```js +new statik.Server(); +``` + +This will serve files in the current directory. If you want to serve files in +a specific directory, pass it as the first argument: -This will serve files in the current directory. If you want to serve files in a specific -directory, pass it as the first argument: +```js +new statik.Server('./public'); +``` - new static.Server('./public'); +You can also specify how long the client is supposed to cache the files +node-static serves: -You can also specify how long the client is supposed to cache the files node-static serves: +```js +new statik.Server('./public', { cache: 3600 }); +``` - new static.Server('./public', { cache: 3600 }); +This will set the `Cache-Control` header, telling clients to cache the file for +an hour. This is the default setting. -This will set the `Cache-Control` header, telling clients to cache the file for an hour. -This is the default setting. +### Serving files under a directory -### Serving files under a directory # +To serve files under a directory, simply call the `serve` method on a `Server` +instance, passing it the HTTP request and response object: -To serve files under a directory, simply call the `serve` method on a `Server` instance, passing it -the HTTP request and response object: +```js +import http from 'node:http'; +import * as statik from 'node-static'; - var fileServer = new static.Server('./public'); +const fileServer = new statik.Server('./public'); - require('http').createServer(function (request, response) { - request.addListener('end', function () { - fileServer.serve(request, response); +http.createServer(function (request, response) { + request.addListener('end', function () { + fileServer.serve(request, response); + }).resume(); +}).listen(8080); +``` + +### Serving specific files + +If you want to serve a specific file, like an error page for example, use the +`serveFile` method: + +```js +fileServer.serveFile('/error.html', 500, {}, request, response); +``` + +This will serve the `error.html` file, from under the file root directory, with +a `500` status code. +For example, you could serve an error page, when the initial request wasn't +found: + +```js +http.createServer(function (request, response) { + request.addListener('end', function () { + fileServer.serve(request, response, function (e, res) { + if (e && (e.status === 404)) { // If the file wasn't found + fileServer.serveFile( + '/not-found.html', 404, {}, request, response + ); + } }); - }).listen(8080); + }).resume(); +}).listen(8080); +``` -### Serving specific files # +More on intercepting errors below. -If you want to serve a specific file, like an error page for example, use the `serveFile` method: +### Transforming text files - fileServer.serveFile('/error.html', 500, {}, request, response); +If you wish to apply a transform to text files before they are streamed in +response, you can supply a `transform` callback which returns a `stream` +`Transform`. -This will serve the `error.html` file, from under the file root directory, with a `500` status code. -For example, you could serve an error page, when the initial request wasn't found: +For example, if we wished to upper-case everything in text content: - require('http').createServer(function (request, response) { - request.addListener('end', function () { - fileServer.serve(request, response, function (e, res) { - if (e && (e.status === 404)) { // If the file wasn't found - fileServer.serveFile('/not-found.html', 404, {}, request, response); +```js +import {Transform} from 'stream'; + +const server = http.createServer((req, res) => { + const s = new statik.Server(__dirname + '/public', { + transform (fileString, pathname, req, res) { + return new Transform({ + transform(chunk, _enc, cb) { + // Supply the transformed data as the second argument + cb(null, chunk.toString().toUpperCase()); } }); + } + }); + s.serve(req, res); +}).listen(9010); +``` + +### Serving custom directory + +```js +const fileServer = new statik.Server(__dirname + '/public', { + directoryCallback (pathname, req, res) { + res.writeHead(200, { + 'Content-Type': 'text/html' }); - }).listen(8080); + res.end(`Hi ${basename(pathname)}!`); -More on intercepting errors bellow. + // You could add a listing of files by `readdir` here + } +}); +``` -### Intercepting errors & Listening # +### Intercepting errors & Listening -An optional callback can be passed as last argument, it will be called every time a file -has been served successfully, or if there was an error serving the file: +An optional callback can be passed as last argument, it will be called every +time a file has been served successfully, or if there was an error serving the +file: - var fileServer = new static.Server('./public'); +```js +import http from 'node:http'; +import * as statik from 'node-static'; - require('http').createServer(function (request, response) { - request.addListener('end', function () { - fileServer.serve(request, response, function (err, result) { - if (err) { // There was an error serving the file - sys.error("Error serving " + request.url + " - " + err.message); +const fileServer = new statik.Server('./public'); - // Respond to the client - response.writeHead(err.status, err.headers); - response.end(); - } - }); +http.createServer(function (request, response) { + request.addListener('end', function () { + fileServer.serve(request, response, function (err, result) { + if (err) { // There was an error serving the file + console.error( + "Error serving " + request.url + " - " + err.message + ); + + // Respond to the client + response.writeHead(err.status, err.headers); + response.end(); + } }); - }).listen(8080); + }).resume(); +}).listen(8080); +``` -Note that if you pass a callback, and there is an error serving the file, node-static -*will not* respond to the client. This gives you the opportunity to re-route the request, -or handle it differently. +Note that if you pass a callback, and there is an error serving the file, +node-static *will not* respond to the client. This gives you the opportunity +to re-route the request, or handle it differently. -For example, you may want to interpret a request as a static request, but if the file isn't found, -send it to an application. +For example, you may want to interpret a request as a static request, but if +the file isn't found, send it to an application. If you only want to *listen* for errors, you can use *event listeners*: - fileServer.serve(request, response).addListener('error', function (err) { - sys.error("Error serving " + request.url + " - " + err.message); - }); +```js +fileServer.serve(request, response).addListener('error', function (err) { + console.error("Error serving " + request.url + " - " + err.message); +}); +``` -With this method, you don't have to explicitly send the response back, in case of an error. +With this method, you don't have to explicitly send the response back, in case +of an error. -### Options when creating an instance of `Server` # +### Options when creating an instance of `Server` -#### `cache` # +#### `cache` (Default: `3600`) Sets the `Cache-Control` header. -example: `{ cache: 7200 }` +example: `{ cache: 7200 }` will set the max-age for all files to 7200 seconds +example: `{ cache: {'**/*.css': 300}}` will set the max-age for all CSS files to 5 minutes. Passing a number will set the cache duration to that number of seconds. Passing `false` will disable the `Cache-Control` header. +Passing a object with [minimatch glob pattern](https://github.com/isaacs/minimatch) +keys and number values will set cache max-age for any matching paths. -> Defaults to `3600` - - -#### `serverInfo` # +#### `serverInfo` (Default: `node-static/{version}`) Sets the `Server` header. example: `{ serverInfo: "myserver" }` -> Defaults to `node-static/{version}` - -#### `headers` # +#### `headers` (Default: `{}`) Sets response headers. -example: `{ 'X-Hello': 'World!' }` +example: `{ headers: { 'X-Hello': 'World!' } }` -> defaults to `{}` +#### `gzip` (Default: `false`) -Command Line Interface ----------------------- +Enable support for sending compressed responses. This will enable a check for +a file with the same name plus '.gz' in the same folder. If the compressed +file is found and the client has indicated support for gzip file transfer, +the contents of the .gz file will be sent in place of the uncompressed file +along with a `Content-Encoding: gzip` header to inform the client the data has +been compressed. -`node-static` also provides a CLI. +example: `{ gzip: true }` +example: `{ gzip: /^\/text/ }` + +Passing `true` will enable this check for all files. + +Passing a `RegExp` instance will only enable this check if the content-type of +the respond would match that `RegExp` using its `test()` method. + +For cases where a gzip file is older than the source file, you can +listen for warnings: + +```js +const gzipFileServer = new statik.Server(__dirname + '/public', { + gzip: true +}); +gzipFileServer.on( + 'warn', + (warning, file, statMtime, gzStatMtime) => { + if (warning === 'Gzipped version is older than source file') { + // One could append an extension and create the gzip file here + console.log(file); + } + } +); +``` -### Installation # +#### `gzipOnly` (Default: `undefined`) - $ npm install -g node-static +The default behavior is to require checking of a source file's existence. +If you want to allow gzipped files without source files, set +`gzipOnly` to `allow`. If you want to go further and require that source +files not be present for gzipped equivalents, then set `gzipOnly` to +`require`. -### Example Usage # +#### `gzipAuto` (Default: `undefined`) - # serve up the current directory - $ static - serving "." at http://127.0.0.1:8080 +If set, `gzipAuto` will trigger automatic gzipping of resources. - # serve up a different directory - $ static public - serving "public" at http://127.0.0.1:8080 +This will follow the settings of `gzip` to determine which files should be +gzipped. - # specify additional headers (this one is useful for development) - $ static -H '{"Cache-Control": "no-cache, must-revaliate"}' - serving "." at http://127.0.0.1:8080 +#### `indexFile` (Default: `index.html`) - # set cache control max age - $ static -c 7200 - serving "." at http://127.0.0.1:8080 +Choose a custom index file when serving up directories. + +example: `{ indexFile: "index.htm" }` + +#### `defaultExtension` (Default: `null`) + +Choose a default extension when serving files. +A request to '/myFile' would check for a `myFile` folder (first) then a +`myFile.html` (second). + +example: `{ defaultExtension: "html" }` + +### Listening for emitted warnings + +Certain warnings are not logged to console but instead may be listened for. + +```js +fileServer.on('warn', (msg) => { + console.log(msg); +}); +``` + +## Command Line Interface + +`node-static` also provides a CLI. - # show help message, including all options - $ static -h +```text +--port, -p TCP port at which the files will be served [default: 8080] +--host-address, -a the local network interface at which to listen [default: "127.0.0.1"] +--cache, -c "Cache-Control" header setting, defaults to 3600 +--version, -v node-static version +--headers, -H additional headers (in JSON format) +--header-file, -f JSON file of additional headers +--gzip, -z enable compression (tries to serve file of same name plus '.gz') +--spa Serve the content as a single page app by redirecting all + non-file requests to the index HTML file. +--indexFile, -i Specify a custom index file when serving up directories. [default: "index.html"] +--help, -h display this help message +``` + +### Example Usage + +```sh +# serve up the current directory +$ static +serving "." at http://127.0.0.1:8080 + +# serve up a different directory +$ static public +serving "public" at http://127.0.0.1:8080 + +# specify additional headers (this one is useful for development) +$ static -H '{"Cache-Control": "no-cache, must-revalidate"}' +serving "." at http://127.0.0.1:8080 + +# set cache control max age +$ static -c 7200 +serving "." at http://127.0.0.1:8080 + +# expose the server to your local network +$ static -a 0.0.0.0 +serving "." at http://0.0.0.0:8080 + +# show help message, including all options +$ static -h +``` diff --git a/bin/cli.js b/bin/cli.js index b678643..b809ae2 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,104 +1,145 @@ #!/usr/bin/env node -var fs = require('fs'), - path = require('path'), - tty = require('tty'), - statik = require('./../lib/node-static'); - - var argv = require('optimist') - .usage([ - 'USAGE: $0 [-p ] []', - 'simple, rfc 2616 compliant file streaming module for node'] - .join('\n\n')) - .option('port', { - alias: 'p', - 'default': 8080, - description: 'TCP port at which the files will be served' - }) - .option('cache', { - alias: 'c', - description: '"Cache-Control" header setting, defaults to 3600' - }) - .option('version', { - alias: 'v', - description: 'node-static version' - }) - .option('headers', { - alias: 'H', - description: 'additional headers (in JSON format)' - }) - .option('header-file', { - alias: 'f', - description: 'JSON file of additional headers' - }) - .option('help', { - alias: 'h', - description: 'display this help message' - }) - .argv; - - var dir = argv._[0] || '.'; - - var trainwreck = fs.readFileSync(path.join(__dirname, '../etc/trainwreck.jpg')), - notFound = fs.readFileSync(path.join(__dirname, '../etc/404.html')) - .toString() - .replace('{{trainwreck}}', trainwreck.toString('base64')); - - var colors = require('colors'); - - var log = function(request, response, statusCode) { - var d = new Date(); - var seconds = d.getSeconds() < 10? '0'+d.getSeconds() : d.getSeconds(), - datestr = d.getHours() + ':' + d.getMinutes() + ':' + seconds, - line = datestr + ' [' + response.statusCode + ']: ' + request.url, - colorized = line; - if (tty.isatty(process.stdout.fd)) - colorized = (response.statusCode >= 500) ? line.red.bold : - (response.statusCode >= 400) ? line.red : - line; - console.log(colorized); - }; - - var file, options; - -if (argv.help){ - require('optimist').showHelp(console.log); +import http from 'http'; +import fs from 'fs'; +import tty from 'tty'; + +import {cliBasics} from 'command-line-basics'; +import colors from 'colors/safe.js'; + +import * as statik from './../lib/node-static.js'; + +const args = await cliBasics( + import.meta.dirname + '/optionDefinitions.js', + { + packageJsonPath: import.meta.dirname + '/../package.json' + } +); +if (!args) { // cliBasics handled process.exit(0); } -if (argv.version){ - console.log('node-static', statik.version.join('.')); - process.exit(0); +const dir = args.directory || '.'; + +/** + * @param {http.IncomingMessage} request + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} response + * @param {number} [statusCode] + */ +const log = function(request, response, statusCode) { + const d = new Date(); + /* c8 ignore next 3 -- Time-dependent */ + const seconds = d.getSeconds() < 10 ? '0' + d.getSeconds() : d.getSeconds(), + minutes = d.getMinutes() < 10 ? '0' + d .getMinutes() : d.getMinutes(), + hours = d.getHours() < 10 ? '0' + d .getHours() : d.getHours(), + datestr = hours + ':' + minutes + ':' + seconds, + + line = datestr + ' [' + response.statusCode + ']: ' + request.url; + let colorized = line; + /* c8 ignore next 7 -- Environment */ + if (tty.isatty(process.stdout.fd)) { + colorized = (response.statusCode >= 500) + // @ts-expect-error TS error + ? colors.red.bold(line) + : (response.statusCode >= 400) ? colors.red(line) : + line; + } + console.log(colorized); +}; + +const options = {}; + +if ('cache' in args) { + options.cache = args['cache'] +} + +if (args['headers']) { + options.headers = JSON.parse(args['headers']); +} + +if (args['header-file']) { + options.headers = JSON.parse( + // @ts-expect-error Works fine + fs.readFileSync(args['header-file']) + ); +} + +if (args['gzip']) { + options.gzip = true; +} + +if (args['gzip-auto']) { + options.gzipAuto = true; +} + +if (args['gzip-only']) { + options.gzipOnly = args['gzip-only']; +} + +if (args['index-file']) { + options.indexFile = args['index-file']; } -if (argv.cache){ - (options = options || {}).cache = argv.cache; +if (args['default-extension']) { + options.defaultExtension = args['default-extension']; } -if (argv.headers){ - (options = options || {}).headers = JSON.parse(argv.headers); +if (args['server-info']) { + options.serverInfo = args['server-info']; } -if (argv['header-file']){ - (options = options || {}).headers = - JSON.parse(fs.readFileSync(argv['header-file'])); +if (args['serve-hidden']) { + options.serveHidden = args['serve-hidden']; } -file = new(statik.Server)(dir, options); +const file = new(statik.Server)(dir, options); -require('http').createServer(function (request, response) { +const server = http.createServer(function (request, response) { request.addListener('end', function () { - file.serve(request, response, function(e, rsp) { + /** + * @param {null|import('../lib/node-static.js').ResultInfo} e + * @param {import('../lib/node-static.js').ResultInfo} [rsp] + */ + const callback = function(e, rsp) { if (e && e.status === 404) { response.writeHead(e.status, e.headers); - response.end(notFound); + response.end("Not Found"); log(request, response); } else { log(request, response); } - }); - }); -}).listen(+argv.port); - -console.log('serving "' + dir + '" at http://127.0.0.1:' + argv.port); + }; + + /* c8 ignore next 3 -- TS guard */ + if (typeof request.url !== 'string') { + return; + } + + // Parsing catches: + // npm start -- --spa --index-file test/fixtures/there/index.html + // with http://127.0.0.1:8080/test/fixtures/there?email=john.cena + if (args['spa'] && !new URL(request.url, 'http://localhost').pathname.includes(".")) { + file.serveFile(args['index-file'] || 'index.html', 200, {}, request, response); + } else { + file.serve(request, response, callback); + } + }).resume(); +}); + +const port = args['port'] || 8080; +const hostAddress = args['host-address'] || '127.0.0.1'; + +if (hostAddress === '127.0.0.1') { + server.listen(port); +/* c8 ignore next 3 -- Not working with localhost or 0.0.0.0 */ +} else { + server.listen(port, hostAddress); +} +console.log('serving "' + dir + '" at http://' + hostAddress + ':' + port); +if (args['spa']) { + const indexFile = args['index-file'] || 'index.html'; + console.log('serving as a single page app (all non-file requests redirect to ' + indexFile +')'); +} diff --git a/bin/optionDefinitions.js b/bin/optionDefinitions.js new file mode 100644 index 0000000..099d71b --- /dev/null +++ b/bin/optionDefinitions.js @@ -0,0 +1,93 @@ +import {readFileSync} from 'fs'; + +const pkg = JSON.parse( + // @ts-expect-error Works fine + readFileSync(new URL('../package.json', import.meta.url)) +); + +const optionDefinitions = [ + { + name: 'directory', alias: 'd', type: String, defaultOption: true, + description: 'A specific directory in which to serve files. Optional.', + typeLabel: '{underline filepath}' + }, + { + name: 'port', alias: 'p', type: Number, + description: 'TCP port at which the files will be served. ' + + '[default: 8080]', + typeLabel: '{underline PORT}' + }, + { + name: 'host-address', alias: 'a', type: String, + description: 'The local network interface at which to listen. ' + + '[default: "127.0.0.1"]', + typeLabel: '{underline ADDRESS}' + }, + { + name: 'cache', alias: 'c', type: JSON.parse, + description: '"Cache-Control" header setting. [default: 3600]', + typeLabel: '{underline SECONDS}' + }, + { + name: 'default-extension', alias: 'e', type: String, + description: 'Optional default extension', + typeLabel: '{underline extension name}' + }, + { + name: 'server-info', type: String, + description: 'Info to indicate in a header about the server', + typeLabel: '{underline server info}' + }, + { + name: 'serve-hidden', type: Boolean, + description: 'Whether to serve hidden files. Defaults to `false`.', + }, + { + name: 'headers', alias: 'H', type: String, + description: 'Additional headers in JSON format.', + typeLabel: '{underline HEADERS}' + }, + { + name: 'header-file', alias: 'f', type: String, + description: 'JSON file of additional headers.', + typeLabel: '{underline FILE}' + }, + { + name: 'gzip', alias: 'z', type: Boolean, + description: 'Enable compression (tries to serve file of same name ' + + 'plus ".gz"). Optional.' + }, + { + name: 'gzip-only', type: String, + description: 'Allows or requires compression. Optional.', + typeLabel: '{underline "allow"|"require"}' + }, + { + name: 'gzip-auto', type: Boolean, + description: 'Whether to automatically gzip resources according to `gzip`.', + }, + { + name: 'spa', type: Boolean, + description: 'Serve the content as a single page app by redirecting ' + + 'all non-file requests to the index HTML file. Optional.' + }, + { + name: 'index-file', alias: 'i', type: String, + description: 'Specify a custom index file when serving up ' + + 'directories. [default: "index.html"]', + typeLabel: '{underline FILENAME}' + } +]; + +const cliSections = [ + { + // Add italics: `{italic textToItalicize}` + content: 'Node-Static CLI - ' + pkg.description + + '\n\nUSAGE: {italic static [OPTIONS] [-p PORT] []}' + }, + { + optionList: optionDefinitions + } +]; + +export {optionDefinitions as definitions, cliSections as sections}; diff --git a/dist/node-static.cjs b/dist/node-static.cjs new file mode 100644 index 0000000..8a25a74 --- /dev/null +++ b/dist/node-static.cjs @@ -0,0 +1,668 @@ +'use strict'; + +var fs = require('fs'); +var events = require('events'); +var http = require('http'); +var path = require('path'); +var mime = require('mime'); +var minimatch = require('minimatch'); + +var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; +/** + * @typedef {{ + * size: number, + * mtime: Date, + * ino: number + * }} StatInfo + */ + +/** + * @param {string} dir + * @param {string[]} files + * @param {(errOrNull: NodeJS.ErrnoException|null, result?: StatInfo) => void} callback + */ +function mstat (dir, files, callback) { + (function mstat(files, stats) { + const file = files.shift(); + + if (file) { + try { + fs.stat(path.join(dir, file), function (e, stat) { + if (e) { + callback(e); + } else { + mstat(files, stats.concat(stat)); + } + }); + } catch (e) { + callback(/** @type {NodeJS.ErrnoException} */ (e)); + } + } else { + callback(null, { + size: stats.reduce((total, stat) => { + return total + stat.size; + }, 0), + mtime: stats.reduce((latest, stat) => { + return latest > stat.mtime ? latest : stat.mtime; + }, new Date(-864e13)), + ino: stats.reduce((total, stat) => { + return total + stat.ino; + }, 0) + }); + } + })(files.slice(0), /** @type {fs.Stats[]} */ ([])); +} + +/** + * @typedef {{ + * status: number, + * headers: Record, + * message?: string + * }} ResultInfo + */ + +/** + * @typedef {( + * status: number, + * headers: Record, + * streaming?: boolean + * ) => void} Finish + */ + +const pkg = JSON.parse( + // @ts-expect-error Works fine + fs.readFileSync( + new URL('../package.json', (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('node-static.cjs', document.baseURI).href))) + ) +); + +const version = pkg.version.split('.'); + +/** + * @param {string} p + * @param {(err: NodeJS.ErrnoException | null, stats?: fs.Stats) => void} callback + */ +function tryStat(p, callback) { + try { + fs.stat(p, callback); + } catch (e) { + callback(/** @type {NodeJS.ErrnoException} */ (e)); + } +} + +/** + * @typedef {{ + * indexFile?: string, + * gzip?: boolean|RegExp, + * headers?: Record + * serverInfo?: string|null, + * cache?: boolean|number|Record, + * defaultExtension?: string + * }} ServerOptions + */ + +class Server { + /** + * @param {string|ServerOptions|null} [root] + * @param {ServerOptions} [options] + */ + constructor (root, options) { + if (root && typeof root === 'object') { options = root; root = null; } + + // resolve() doesn't normalize (to lowercase) drive letters on Windows + this.root = path.normalize(path.resolve(root || '.')); + /** @type {Required> & ServerOptions} */ + this.options = { + indexFile: 'index.html', + ...(options || {}), + }; + + /** @type {Record} */ + this.cache = {'**': 3600}; + + /** @type {Record} */ + this.defaultHeaders = {}; + this.options.headers = this.options.headers || {}; + + if ('cache' in this.options) { + if (typeof(this.options.cache) === 'number') { + this.cache = {'**': this.options.cache}; + } else if (typeof(this.options.cache) === 'object') { + this.cache = this.options.cache; + } else if (!this.options.cache) { + this.cache = {}; + } + } + + if ('serverInfo' in this.options && this.options.serverInfo) { + this.serverInfo = this.options.serverInfo.toString(); + } else { + this.serverInfo = 'node-static/' + version.join('.'); + } + + if ('defaultExtension' in this.options) { + this.defaultExtension = '.' + this.options.defaultExtension; + } else { + this.defaultExtension = null; + } + + if (this.options.serverInfo !== null) { + this.defaultHeaders['server'] = this.serverInfo; + } + + for (const k in this.defaultHeaders) { + this.options.headers[k] = this.options.headers[k] || + this.defaultHeaders[k]; + } + } + + /** + * @param {string} pathname + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {(status: number, headers: Record) => void} finish + */ + serveDir (pathname, req, res, finish) { + const htmlIndex = path.join(pathname, this.options.indexFile); + + tryStat(htmlIndex, (e, stat) => { + if (!e && stat) { + const status = 200; + /** @type {Record} */ + const headers = {}; + const originalPathname = decodeURIComponent(new URL( + /* c8 ignore next -- TS */ + req.url ?? '', + 'http://localhost' + ).pathname); + if (originalPathname.length && originalPathname.charAt(originalPathname.length - 1) !== '/') { + return finish(301, { Location: originalPathname + '/' }); + } else { + this.respond(null, status, headers, [htmlIndex], stat, req, res, finish); + } + } else { + // Stream a directory of files as a single file. + fs.readFile(path.join(pathname, 'index.json'), function (e, contents) { + if (e) { return finish(404, {}) } + const index = JSON.parse(contents.toString()); + streamFiles(index.files); + }); + } + }); + + /** + * @param {string[]} files + */ + const streamFiles = (files) => { + mstat(pathname, files, (e, stat) => { + if (e || !stat) { return finish(404, {}) } + this.respond(pathname, 200, {}, files, stat, req, res, finish); + }); + }; + } + + /** + * @param {string} pathname + * @param {number} status + * @param {Record} headers + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + */ + serveFile (pathname, status, headers, req, res) { + const promise = new(events.EventEmitter); + + pathname = this.resolve(pathname); + + tryStat(pathname, (e, stat) => { + if (e || !stat) { + return promise.emit('error', e); + } + this.respond(null, status, headers, [pathname], stat, req, res, (status, headers, streaming) => { + this.finish(status, headers, req, res, promise, undefined, streaming); + }); + }); + return promise; + } + + /** + * @param {number} status + * @param {Record} headers + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {events<[never]>} promise + * @param {(error: null|ResultInfo, result?: ResultInfo) => void} [callback] + * @param {boolean} [streaming] + */ + finish (status, headers, req, res, promise, callback, streaming) { + const result = { + status, + headers, + message: http.STATUS_CODES[status] + }; + + if (this.options.serverInfo !== null) { + headers['server'] = this.serverInfo; + } + + if (!status || status >= 400) { + if (callback) { + callback(result); + } else { + if (promise.listeners('error').length > 0) { + promise.emit('error', result); + } + else { + res.writeHead(status, headers); + res.end(); + } + } + } else { + // Don't end the request here, if we're streaming; + // it's taken care of in `prototype.stream`. + if (!streaming) { + res.writeHead(status, headers); + res.end(); + } + callback && callback(null, result); + promise.emit('success', result); + } + } + + /** + * @param {string} pathname + * @param {number} status + * @param {Record} headers + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + servePath (pathname, status, headers, req, res, finish) { + const promise = new(events.EventEmitter); + + pathname = this.resolve(pathname); + + // Make sure we're not trying to access a + // file outside of the root. + if (pathname.startsWith(this.root)) { + tryStat(pathname, (e, stat) => { + if (e || !stat) { + // possibly not found, check default extension + if (this.defaultExtension) { + tryStat(pathname + this.defaultExtension, (e2, stat2) => { + if (e2 || !stat2) { + // really not found + finish(404, {}); + } else if (stat2.isFile()) { + this.respond(null, status, headers, [pathname + this.defaultExtension], stat2, req, res, finish); + /* c8 ignore next 3 -- Symblink didn't trigger */ + } else { + finish(400, {}); + } + }); + } else { + finish(404, {}); + } + } else if (stat.isFile()) { // Stream a single file. + this.respond(null, status, headers, [pathname], stat, req, res, finish); + } else if (stat.isDirectory()) { // Stream a directory of files. + this.serveDir(pathname, req, res, finish); + /* c8 ignore next 3 -- Symblink didn't trigger */ + } else { + finish(400, {}); + } + }); + /* c8 ignore next 4 -- Not possible? */ + } else { + // Forbidden + finish(403, {}); + } + return promise; + } + + /** + * @param {string} pathname + */ + resolve (pathname) { + return path.resolve(path.join(this.root, pathname)); + } + + /** + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {(error: null|ResultInfo, result?: ResultInfo) => void} [callback] + */ + serve (req, res, callback) { + const promise = new(events.EventEmitter); + let pathname; + + /** + * @param {number} status + * @param {Record} headers + * @param {boolean} [streaming] + */ + const finish = (status, headers, streaming) => { + this.finish(status, headers, req, res, promise, callback, streaming); + }; + + try { + pathname = decodeURIComponent( + new URL( + /* c8 ignore next -- TS */ + req.url ?? '', + 'http://localhost' + ).pathname + ); + } + catch { + return process.nextTick(function() { + return finish(400, {}); + }); + } + + process.nextTick(() => { + this.servePath(pathname, 200, {}, req, res, finish).on('success', function (result) { + /* c8 ignore next -- How to cover? */ + promise.emit('success', result); + }).on('error', function (err) { + /* c8 ignore next -- How to cover? */ + promise.emit('error'); + }); + }); + if (!callback) { return promise } + } + + /** + * Check if we should consider sending a gzip version of the file based on the + * file content type and client's Accept-Encoding header value. + * @param {http.IncomingMessage} req + * @param {string} contentType + */ + gzipOk (req, contentType) { + const enable = this.options.gzip; + if(enable && + (typeof enable === 'boolean' || + (contentType && (enable instanceof RegExp) && + enable.test(contentType))) + ) { + const acceptEncoding = req.headers['accept-encoding']; + return acceptEncoding && acceptEncoding.includes('gzip'); + } + return false; + } + + /** + * Send a gzipped version of the file if the options and the client indicate gzip is enabled and + * we find a .gz file matching the static resource requested. + * @param {string|null} pathname + * @param {number} status + * @param {string} contentType + * @param {Record} _headers + * @param {string[]} files + * @param {import('./node-static/util.js').StatInfo} stat + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + respondGzip (pathname, status, contentType, _headers, files, stat, req, res, finish) { + if (files.length == 1 && this.gzipOk(req, contentType)) { + const gzFile = files[0] + '.gz'; + tryStat(gzFile, (e, gzStat) => { + if (!e && gzStat && gzStat.isFile()) { + const vary = _headers['Vary']; + _headers['Vary'] = (vary && vary != 'Accept-Encoding' ? vary + ', ' : '') + 'Accept-Encoding'; + _headers['Content-Encoding'] = 'gzip'; + stat.size = gzStat.size; + files = [gzFile]; + } + this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); + }); + } else { + // Client doesn't want gzip or we're sending multiple files + this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); + } + } + + /** + * @param {http.IncomingMessage} req + * @param {import('./node-static/util.js').StatInfo} stat + */ + parseByteRange (req, stat) { + const byteRange = { + from: 0, + to: 0, + valid: false + }; + + const rangeHeader = req.headers['range']; + const flavor = 'bytes='; + + if (rangeHeader) { + if (rangeHeader.startsWith(flavor) && !rangeHeader.includes(',')) { + /* Parse */ + const rangeHeaderArr = rangeHeader.slice(flavor.length).split('-'); + byteRange.from = parseInt(rangeHeaderArr[0]); + byteRange.to = parseInt(rangeHeaderArr[1]); + + /* Replace empty fields of differential requests by absolute values */ + if (isNaN(byteRange.from) && !isNaN(byteRange.to)) { + byteRange.from = stat.size - byteRange.to; + byteRange.to = stat.size ? stat.size - 1 : 0; + } else if (!isNaN(byteRange.from) && isNaN(byteRange.to)) { + byteRange.to = stat.size ? stat.size - 1 : 0; + } + + /* General byte range validation */ + if (!isNaN(byteRange.from) && !isNaN(byteRange.to) && 0 <= byteRange.from && byteRange.from <= byteRange.to) { + byteRange.valid = true; + } else { + console.warn('Request contains invalid range header: ', rangeHeaderArr); + } + } else { + console.warn('Request contains unsupported range header: ', rangeHeader); + } + } + return byteRange; + } + + /** + * @param {string|null} pathname + * @param {number} status + * @param {string} contentType + * @param {Record} _headers + * @param {string[]} files + * @param {import('./node-static/util.js').StatInfo} stat + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + respondNoGzip (pathname, status, contentType, _headers, files, stat, req, res, finish) { + const mtime = Date.parse(stat.mtime.toString()), + key = pathname || files[0], + headers = /** @type {Record} */ ({}), + clientETag = req.headers['if-none-match'], + clientMTime = Date.parse(req.headers['if-modified-since'] ?? ''), + byteRange = this.parseByteRange(req, stat); + let startByte = 0, + length = stat.size; + + /* Handle byte ranges */ + if (files.length == 1 && byteRange.valid) { + if (byteRange.to < length) { + + // Note: HTTP Range param is inclusive + startByte = byteRange.from; + length = byteRange.to - byteRange.from + 1; + status = 206; + + // Set Content-Range response header (we advertise initial resource size on server here (stat.size)) + headers['Content-Range'] = 'bytes ' + byteRange.from + '-' + byteRange.to + '/' + stat.size; + + } else { + byteRange.valid = false; + console.warn('Range request exceeds file boundaries, goes until byte no', byteRange.to, 'against file size of', length, 'bytes'); + } + } + + /* In any case, check for unhandled byte range headers */ + if (!byteRange.valid && req.headers['range']) { + console.error(new Error('Range request present but invalid, might serve whole file instead')); + } + + // Copy default headers + for (const k in this.options.headers) { headers[k] = this.options.headers[k]; } + + headers['Etag'] = JSON.stringify([stat.ino, stat.size, mtime].join('-')); + headers['Date'] = new(Date)().toUTCString(); + headers['Last-Modified'] = new(Date)(stat.mtime).toUTCString(); + headers['Content-Type'] = contentType; + headers['Content-Length'] = String(length); + + // Copy custom headers + for (const k in _headers) { headers[k] = _headers[k]; } + + // Conditional GET + // If the "If-Modified-Since" or "If-None-Match" headers + // match the conditions, send a 304 Not Modified. + if ((clientMTime || clientETag) && + (!clientETag || clientETag === headers['Etag']) && + (!clientMTime || clientMTime >= mtime)) { + // 304 response should not contain entity headers + ['Content-Encoding', + 'Content-Language', + 'Content-Length', + 'Content-Location', + 'Content-MD5', + 'Content-Range', + 'Content-Type', + 'Expires', + 'Last-Modified'].forEach(function (entityHeader) { + delete headers[entityHeader]; + }); + finish(304, headers); + } else { + res.writeHead(status, headers); + + this.stream(key, files, length, startByte, res, function (e) { + /* c8 ignore next -- Internal uses already checked for bad paths */ + if (e) { return finish(500, {}, true) } + finish(status, headers, true); + }); + } + } + + /** + * @param {string|null} pathname + * @param {number} status + * @param {Record} _headers + * @param {string[]} files + * @param {import('./node-static/util.js').StatInfo} stat + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + respond (pathname, status, _headers, files, stat, req, res, finish) { + const contentType = _headers['Content-Type'] || + mime.getType(files[0]) || + 'application/octet-stream'; + _headers = this.setCacheHeaders(_headers, req); + + if(this.options.gzip) { + this.respondGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); + } else { + this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); + } + } + + /** + * @param {string|undefined} pathname + * @param {string[]} files + * @param {number} length + * @param {number} startByte + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {(err: NodeJS.ErrnoException | null, offset?: number) => void} callback + */ + stream (pathname, files, length, startByte, res, callback) { + + (function streamFile(files, offset) { + let file = files.shift(); + + if (file) { + file = path.resolve(file) === path.normalize(file) ? file : path.join(pathname || '.', file); + + // Stream the file to the client + fs.createReadStream(file, { + flags: 'r', + mode: 0o666, + start: startByte, + end: startByte + (length ? length - 1 : 0) + }).on('data', function (chunk) { + // Bounds check the incoming chunk and offset, as copying + // a buffer from an invalid offset will throw an error and crash + if (chunk.length && offset < length && offset >= 0) { + offset += chunk.length; + } + }).on('close', function () { + streamFile(files, offset); + }).on('error', function (err) { + callback(err); + console.error(err); + }).pipe(res, { end: false }); + } else { + res.end(); + callback(null, offset); + } + })(files.slice(0), 0); + } + + /** + * @param {Record} _headers + * @param {http.IncomingMessage} req + */ + setCacheHeaders (_headers, req) { + /* c8 ignore next 3 -- TS */ + if (!req.url) { + return _headers; + } + const maxAge = this.getMaxAge(req.url); + if (typeof(maxAge) === 'number') { + _headers['cache-control'] = 'max-age=' + maxAge; + } + return _headers; + } + + /** + * @param {string} requestUrl + */ + getMaxAge (requestUrl) { + if (this.cache) { + for (const pattern in this.cache) { + if (minimatch.minimatch(requestUrl, pattern)) { + return this.cache[pattern]; + } + } + } + return false; + } +} + +exports.mime = mime; +exports.Server = Server; +exports.version = version; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..cd40b6e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +export default [ + { + ignores: ['dist'] + }, + js.configs.recommended, + { + languageOptions: { + globals: globals.node, + parserOptions: { + sourceType: 'module', + ecmaVersion: 2024 + } + }, + rules: { + indent: ['error', 4], + 'no-var': ['error'], + 'no-unused-vars': ['error', {args: 'none'}], + 'prefer-const': ['error'] + } + }, + { + files: ['test/**'], + languageOptions: { + globals: globals.mocha + } + } +]; diff --git a/etc/404.html b/etc/404.html deleted file mode 100644 index 147aeb1..0000000 --- a/etc/404.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - -

not found

-

don't worry though, it could be worse.

- - - diff --git a/etc/trainwreck.jpg b/etc/trainwreck.jpg deleted file mode 100644 index ac85b63..0000000 Binary files a/etc/trainwreck.jpg and /dev/null differ diff --git a/examples/file-server.cjs b/examples/file-server.cjs new file mode 100644 index 0000000..d90aefa --- /dev/null +++ b/examples/file-server.cjs @@ -0,0 +1,22 @@ +'use strict'; + +const statik = require('../dist/node-static.cjs'); + +// +// Create a node-static server to serve the current directory +// +const file = new statik.Server('.', { cache: 7200, headers: {'X-Hello':'World!'} }); + +require('http').createServer(function (request, response) { + file.serve(request, response, function (err, res) { + if (err) { // An error has occurred + console.error("> Error serving " + request.url + " - " + err.message); + response.writeHead(err.status, err.headers); + response.end(); + } else { // The file was served successfully + console.log("> " + request.url + " - " + res.message); + } + }); +}).listen(8080); + +console.log("> node-static is listening on http://127.0.0.1:8080"); diff --git a/examples/file-server.js b/examples/file-server.js index f133b80..e387772 100644 --- a/examples/file-server.js +++ b/examples/file-server.js @@ -1,24 +1,20 @@ -var static = require('../lib/node-static'); +import http from 'http'; +import * as statik from '../lib/node-static.js'; // // Create a node-static server to serve the current directory // -var file = new(static.Server)('.', { cache: 7200, headers: {'X-Hello':'World!'} }); +const file = new statik.Server('.', { cache: 7200, headers: {'X-Hello':'World!'} }); -require('http').createServer(function (request, response) { - request.addListener('end', function () { - // - // Serve files! - // - file.serve(request, response, function (err, res) { - if (err) { // An error as occured - console.error("> Error serving " + request.url + " - " + err.message); - response.writeHead(err.status, err.headers); - response.end(); - } else { // The file was served successfully - console.log("> " + request.url + " - " + res.message); - } - }); +http.createServer(function (request, response) { + file.serve(request, response, function (err, res) { + if (err) { // An error has occurred + console.error("> Error serving " + request.url + " - " + err.message); + response.writeHead(err.status, err.headers); + response.end(); + } else { // The file was served successfully + console.log("> " + request.url + " - " + res.message); + } }); }).listen(8080); diff --git a/lib/node-static.js b/lib/node-static.js index f0c0766..f30aaa7 100644 --- a/lib/node-static.js +++ b/lib/node-static.js @@ -1,269 +1,839 @@ -var fs = require('fs') - , events = require('events') - , buffer = require('buffer') - , http = require('http') - , url = require('url') - , path = require('path'); - -exports.version = [0, 6, 5]; - -var mime = require('./node-static/mime'); -var util = require('./node-static/util'); - -// In-memory file store -exports.store = {}; -exports.indexStore = {}; - -exports.Server = function (root, options) { - if (root && (typeof(root) === 'object')) { options = root; root = null } - - this.root = path.resolve(root || '.'); - this.options = options || {}; - this.cache = 3600; - - this.defaultHeaders = {}; - this.options.headers = this.options.headers || {}; +import fs from 'node:fs'; +import events from 'node:events'; +import http from 'node:http'; +import path from 'node:path'; +// import buffer from 'node:buffer'; + +import {isHiddenFile as isHiddenFileOrDirectory} from 'is-hidden-file'; +import mime from 'mime'; +import {minimatch} from 'minimatch'; +import {mstat} from './node-static/mstat.js'; +import {gzip} from './node-static/gzip.js'; + + +/** + * @typedef {{ + * status: number, + * headers: http.OutgoingHttpHeaders, + * message?: string + * }} ResultInfo + */ + +/** + * @typedef {( + * status: number, + * headers: http.OutgoingHttpHeaders, + * streaming?: boolean + * ) => void} Finish + */ + +const pkg = JSON.parse( + // @ts-expect-error Works fine + fs.readFileSync( + new URL('../package.json', import.meta.url) + ) +); + +const version = pkg.version.split('.'); + +/** + * @param {string} p + * @param {(err: NodeJS.ErrnoException | null, stats?: fs.Stats) => void} callback + */ +function tryStat(p, callback) { + try { + fs.stat(p, callback); + } catch (e) { + callback(/** @type {NodeJS.ErrnoException} */ (e)); + } +} + +/** + * @typedef {( + * file: string, + * pathname: string|null, + * req: http.IncomingMessage, + * res: http.ServerResponse + * ) => import('node:stream').Transform} TransformCallback + */ + +/** + * @typedef {{ + * indexFile?: string, + * directoryCallback?: ( + * pathname: string, + * req: http.IncomingMessage, + * res: http.ServerResponse & { + * req: http.IncomingMessage; + * } + * ) => void, + * gzip?: boolean|RegExp, + * gzipOnly?: "allow"|"require", + * gzipAuto?: boolean, + * headers?: http.OutgoingHttpHeaders, + * serverInfo?: string|null, + * cache?: null|boolean|number|Record, + * serveHidden?: boolean, + * defaultExtension?: string, + * transform?: TransformCallback + * }} ServerOptions + */ + +class Server extends events.EventEmitter { + /** + * @param {string|ServerOptions|null} [root] + * @param {ServerOptions} [options] + */ + constructor (root, options) { + super(); + if (root && typeof root === 'object') { options = root; root = null } + + // resolve() doesn't normalize (to lowercase) drive letters on Windows + this.root = path.normalize(path.resolve(root || '.')); + /** @type {Required> & ServerOptions} */ + this.options = { + indexFile: 'index.html', + ...(options || {}), + }; + + if (this.options.gzipOnly && this.options.gzipAuto) { + throw new Error( + '`gzipOnly` and `gzipAuto` may not be used togther.' + ); + } - if ('cache' in this.options) { - if (typeof(this.options.cache) === 'number') { - this.cache = this.options.cache; - } else if (! this.options.cache) { - this.cache = false; + /** @type {Record} */ + this.cache = {'**': 3600}; + + /** @type {http.OutgoingHttpHeaders} */ + this.defaultHeaders = {}; + this.options.headers = this.options.headers || {}; + + if ('cache' in this.options) { + if (typeof(this.options.cache) === 'number') { + this.cache = {'**': this.options.cache}; + } else if (this.options.cache === null) { + this.cache = {}; + } else if (typeof(this.options.cache) === 'object') { + this.cache = this.options.cache; + } else if (!this.options.cache) { + this.cache = { + '**': 0 + }; + } } - } - if ('serverInfo' in this.options) { - this.serverInfo = this.options.serverInfo.toString(); - } else { - this.serverInfo = 'node-static/' + exports.version.join('.'); - } + if ('serverInfo' in this.options && this.options.serverInfo) { + this.serverInfo = this.options.serverInfo.toString(); + } else { + this.serverInfo = 'node-static/' + version.join('.'); + } - this.defaultHeaders['server'] = this.serverInfo; + if ('defaultExtension' in this.options) { + this.defaultExtension = '.' + this.options.defaultExtension; + } else { + this.defaultExtension = null; + } - if (this.cache !== false) { - this.defaultHeaders['cache-control'] = 'max-age=' + this.cache; - } + if (this.options.serverInfo !== null) { + this.defaultHeaders['server'] = this.serverInfo; + } - for (var k in this.defaultHeaders) { - this.options.headers[k] = this.options.headers[k] || - this.defaultHeaders[k]; + for (const k in this.defaultHeaders) { + this.options.headers[k] = this.options.headers[k] || + this.defaultHeaders[k]; + } } -}; -exports.Server.prototype.serveDir = function (pathname, req, res, finish) { - var htmlIndex = path.join(pathname, 'index.html'), - that = this; + /** + * @param {string} pathname + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {(status: number, headers: http.OutgoingHttpHeaders) => void} finish + */ + serveDir (pathname, req, res, finish) { + if (this.options.directoryCallback) { + this.options.directoryCallback(pathname, req, res); + return; + } - fs.stat(htmlIndex, function (e, stat) { - if (!e) { - that.respond(null, 200, {}, [htmlIndex], stat, req, res, finish); - } else { - if (pathname in exports.indexStore) { - streamFiles(exports.indexStore[pathname].files); + const htmlIndex = path.join(pathname, this.options.indexFile); + + tryStat(htmlIndex, (e, stat) => { + if (!e && stat) { + const status = 200; + /** @type {http.OutgoingHttpHeaders} */ + const headers = res.getHeaders(); + const originalPathname = decodeURIComponent(new URL( + /* c8 ignore next -- TS */ + req.url ?? '', + 'http://localhost' + ).pathname); + if (originalPathname.length && originalPathname.at(-1) !== '/') { + const url = new URL( + /* c8 ignore next -- TS */ + req.url ?? '', + 'http://localhost' + ); + url.pathname += '/'; + return finish(301, { Location: url.pathname + url.search }); + } else { + this.respond(null, status, headers, [htmlIndex], stat, req, res, finish); + } } else { // Stream a directory of files as a single file. fs.readFile(path.join(pathname, 'index.json'), function (e, contents) { if (e) { return finish(404, {}) } - var index = JSON.parse(contents); - exports.indexStore[pathname] = index; + const index = JSON.parse(contents.toString()); streamFiles(index.files); }); } - } - }); - function streamFiles(files) { - util.mstat(pathname, files, function (e, stat) { - if (e) { return finish(404, {}) } - that.respond(pathname, 200, {}, files, stat, req, res, finish); }); - } -}; -exports.Server.prototype.serveFile = function (pathname, status, headers, req, res) { - var that = this; - var promise = new(events.EventEmitter); + /** + * @param {string[]} files + */ + const streamFiles = (files) => { + mstat(pathname, files, (e, stat) => { + if (e || !stat) { return finish(404, {}) } + this.respond(pathname, 200, res.getHeaders(), files, stat, req, res, finish); + }); + }; + } - pathname = this.resolve(pathname); + /** + * @param {string} pathname + * @param {number} status + * @param {http.OutgoingHttpHeaders} headers + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + */ + serveFile (pathname, status, headers, req, res) { + const promise = new(events.EventEmitter); + + pathname = this.resolve(pathname); + + tryStat(pathname, (e, stat) => { + if (e || !stat) { + return promise.emit('error', e); + } + this.respond(null, status, headers, [pathname], stat, req, res, (status, headers, streaming) => { + this.finish(status, headers, req, res, promise, undefined, streaming); + }); + }); + return promise; + } - fs.stat(pathname, function (e, stat) { - if (e) { - return promise.emit('error', e); + /** + * @param {number} status + * @param {http.OutgoingHttpHeaders} headers + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {events<[never]>} promise + * @param {(error: null|ResultInfo, result?: ResultInfo) => void} [callback] + * @param {boolean} [streaming] + */ + finish (status, headers, req, res, promise, callback, streaming) { + const result = { + status, + headers, + message: http.STATUS_CODES[status] + }; + + if (this.options.serverInfo !== null) { + headers['server'] = this.serverInfo; } - that.respond(null, status, headers, [pathname], stat, req, res, function (status, headers) { - that.finish(status, headers, req, res, promise); - }); - }); - return promise; -}; - -exports.Server.prototype.finish = function (status, headers, req, res, promise, callback) { - var result = { - status: status, - headers: headers, - message: http.STATUS_CODES[status] - }; - - headers['server'] = this.serverInfo; - - if (!status || status >= 400) { - if (callback) { - callback(result); + + if (!status || status >= 400) { + if (callback) { + callback(result); + } else { + if (promise.listeners('error').length > 0) { + promise.emit('error', result); + } + else { + res.writeHead(status, headers); + res.end(); + } + } } else { - if (promise.listeners('error').length > 0) { - promise.emit('error', result); + // Don't end the request here, if we're streaming; + // it's taken care of in `prototype.stream`. + if (!streaming) { + res.writeHead(status, headers); + res.end(); } - res.writeHead(status, headers); - res.end(); + callback && callback(null, result); + promise.emit('success', result); } - } else { - // Don't end the request here, if we're streaming; - // it's taken care of in `prototype.stream`. - if (status !== 200 || req.method !== 'GET') { - res.writeHead(status, headers); - res.end(); + } + + /** + * @param {string} pathname + * @param {number} status + * @param {http.OutgoingHttpHeaders} headers + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + servePath (pathname, status, headers, req, res, finish) { + const promise = new(events.EventEmitter); + pathname = this.resolve(pathname); + + // Make sure we're not trying to access a + // file outside of the root. + if (pathname.startsWith(this.root)) { + /** + * @param {NodeJS.ErrnoException | null} e + * @param {fs.Stats} [stat] + */ + const tryPath = (e, stat) => { + if (e || !stat) { + // possibly not found, check default extension + if (this.defaultExtension) { + tryStat(pathname + this.defaultExtension, (e2, stat2) => { + if (e2 || !stat2) { + // really not found + finish(404, {}); + } else if (stat2.isFile()) { + this.respond(null, status, headers, [pathname + this.defaultExtension], stat2, req, res, finish); + /* c8 ignore next 3 -- Symblink didn't trigger */ + } else { + finish(400, {}); + } + }); + } else if (this.options.gzipOnly === 'require') { + tryStat(pathname + '.gz', tryPath); + } else { + finish(404, {}); + } + } else if (this.options.serveHidden !== true && isHiddenFileOrDirectory(pathname)) { + finish(404, {}); + } else if (stat.isFile()) { + if (this.options.gzipOnly) { + this.respond(null, status, headers, [ + pathname.replace(/\.gz$/, '') + ], stat, req, res, finish); + } else { + // Stream a single file. + this.respond(null, status, headers, [pathname], stat, req, res, finish); + } + } else if (stat.isDirectory()) { // Stream a directory of files. + this.serveDir(pathname, req, res, finish); + /* c8 ignore next 3 -- Symlink didn't trigger */ + } else { + finish(400, {}); + } + }; + + if (this.options.gzipOnly === 'allow') { + tryStat(pathname + '.gz', tryPath); + } else { + tryStat(pathname, tryPath); + } + + /* c8 ignore next 4 -- Not possible? */ + } else { + // Forbidden + finish(403, {}); } - callback && callback(null, result); - promise.emit('success', result); + return promise; } -}; -exports.Server.prototype.servePath = function (pathname, status, headers, req, res, finish) { - var that = this, - promise = new(events.EventEmitter); + /** + * @param {string} pathname + */ + resolve (pathname) { + return path.resolve(path.join(this.root, pathname)); + } - pathname = this.resolve(pathname); + /** + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {(error: null|ResultInfo, result?: ResultInfo) => void} [callback] + */ + serve (req, res, callback) { + const promise = new(events.EventEmitter); + let pathname; + + /** + * @param {number} status + * @param {http.OutgoingHttpHeaders} headers + * @param {boolean} [streaming] + */ + const finish = (status, headers, streaming) => { + this.finish(status, headers, req, res, promise, callback, streaming); + }; + + try { + pathname = decodeURIComponent( + new URL( + /* c8 ignore next -- TS */ + req.url ?? '', + 'http://localhost' + ).pathname + ); + } + catch { + return process.nextTick(function() { + return finish(400, {}); + }); + } - // Only allow GET and HEAD requests - if (req.method !== 'GET' && req.method !== 'HEAD') { - finish(405, { 'Allow': 'GET, HEAD' }); - return promise; + process.nextTick(() => { + this.servePath(pathname, 200, res.getHeaders(), req, res, finish).on('success', function (result) { + /* c8 ignore next -- How to cover? */ + promise.emit('success', result); + }).on('error', function (err) { + /* c8 ignore next -- How to cover? */ + promise.emit('error'); + }); + }); + if (!callback) { return promise } + } + + /** + * Check if we should consider sending a gzip version of the file based on the + * file content type and client's Accept-Encoding header value. + * @param {http.IncomingMessage} req + * @param {string} contentType + */ + gzipOk (req, contentType) { + const enable = this.options.gzip; + if(enable && + (typeof enable === 'boolean' || + (contentType && (enable instanceof RegExp) && + enable.test(contentType))) + ) { + const acceptEncoding = req.headers['accept-encoding']; + return acceptEncoding && acceptEncoding.includes('gzip'); + } + return false; } - // Make sure we're not trying to access a - // file outside of the root. - if (pathname.indexOf(that.root) === 0) { - fs.stat(pathname, function (e, stat) { - if (e) { - finish(404, {}); - } else if (stat.isFile()) { // Stream a single file. - that.respond(null, status, headers, [pathname], stat, req, res, finish); - } else if (stat.isDirectory()) { // Stream a directory of files. - that.serveDir(pathname, req, res, finish); + /** + * Send a gzipped version of the file if the options and the client indicate gzip is enabled and + * we find a .gz file matching the static resource requested. + * @param {string|null} pathname + * @param {number} status + * @param {string} contentType + * @param {http.OutgoingHttpHeaders} _headers + * @param {string[]} files + * @param {import('./node-static/mstat.js').StatInfo} stat + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + respondGzip (pathname, status, contentType, _headers, files, stat, req, res, finish) { + if (files.length == 1 && this.gzipOk(req, contentType)) { + const gzFile = files[0] + '.gz'; + const respondNoGzip = () => { + this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); + }; + /* c8 ignore next -- How to simulate? */ + const badRequest = () => { + /* c8 ignore next -- How to simulate? */ + finish(500, {}); + }; + const autoGzip = () => { + gzip(files[0], gzFile).then(() => { + tryStat(gzFile, (e, gzStat) => { + if (!e && gzStat) { + setGzipHeaders(gzStat); + respondNoGzip(); + /* c8 ignore next 3 -- How to simulate? */ + } else { + badRequest(); + } + }); + /* c8 ignore next 3 -- How to simulate? */ + }).catch(() => { + badRequest(); + }); + }; + + /** + * @param {fs.Stats} gzStat + */ + const setGzipHeaders = (gzStat) => { + const vary = _headers['Vary']; + _headers['Vary'] = (vary && vary != 'Accept-Encoding' ? vary + ', ' : '') + 'Accept-Encoding'; + _headers['Content-Encoding'] = 'gzip'; + stat.size = gzStat.size; + files = [gzFile]; + }; + tryStat(gzFile, (e, gzStat) => { + if (this.options.gzipAuto || (!e && gzStat && gzStat.isFile())) { + if (gzStat && stat.mtime > gzStat.mtime) { + if (this.options.gzipAuto) { + autoGzip(); + return; + } + this.emit( + 'warn', + 'Gzipped version is older than source file', + files[0], + stat.mtime, + gzStat.mtime + ); + } else { + if (!gzStat) { + autoGzip(); + return; + } + setGzipHeaders(gzStat); + } + } + respondNoGzip(); + }); + } else { + // Client doesn't want gzip or we're sending multiple files + this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); + } + } + + /** + * @param {http.IncomingMessage} req + * @param {import('./node-static/mstat.js').StatInfo} stat + */ + parseByteRange (req, stat) { + const byteRange = { + from: 0, + to: 0, + valid: false + } + + const rangeHeader = req.headers['range']; + const flavor = 'bytes='; + + if (rangeHeader) { + if (rangeHeader.startsWith(flavor) && !rangeHeader.includes(',')) { + /* Parse */ + const rangeHeaderArr = rangeHeader.slice(flavor.length).split('-'); + byteRange.from = parseInt(rangeHeaderArr[0]); + byteRange.to = parseInt(rangeHeaderArr[1]); + + /* Replace empty fields of differential requests by absolute values */ + if (isNaN(byteRange.from) && !isNaN(byteRange.to)) { + byteRange.from = stat.size - byteRange.to; + byteRange.to = stat.size ? stat.size - 1 : 0; + } else if (!isNaN(byteRange.from) && isNaN(byteRange.to)) { + byteRange.to = stat.size ? stat.size - 1 : 0; + } + + /* General byte range validation */ + if (!isNaN(byteRange.from) && !isNaN(byteRange.to) && 0 <= byteRange.from && byteRange.from <= byteRange.to) { + byteRange.valid = true; + } else { + this.emit('warn', 'Request contains invalid range header: ' + rangeHeaderArr.join(', ')); + } } else { - finish(400, {}); + this.emit('warn', 'Request contains unsupported range header: ' + rangeHeader); } - }); - } else { - // Forbidden - finish(403, {}); + } + return byteRange; } - return promise; -}; - -exports.Server.prototype.resolve = function (pathname) { - return path.resolve(path.join(this.root, pathname)); -}; -exports.Server.prototype.serve = function (req, res, callback) { - var that = this, - promise = new(events.EventEmitter); + /** + * @param {string|null} pathname + * @param {number} status + * @param {string} contentType + * @param {http.OutgoingHttpHeaders} _headers + * @param {string[]} files + * @param {import('./node-static/mstat.js').StatInfo} stat + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + respondNoGzip (pathname, status, contentType, _headers, files, stat, req, res, finish) { + const mtime = Date.parse(stat.mtime.toString()), + key = pathname || files[0], + headers = /** @type {http.OutgoingHttpHeaders} */ ({}), + clientETag = req.headers['if-none-match'], + clientMTime = Date.parse(req.headers['if-modified-since'] ?? ''), + byteRange = this.parseByteRange(req, stat); + let startByte = 0, + length = stat.size; + + /* Handle byte ranges */ + if (files.length == 1 && byteRange.valid) { + if (byteRange.to < length) { + + // Note: HTTP Range param is inclusive + startByte = byteRange.from; + length = byteRange.to - byteRange.from + 1; + status = 206; + + // Set Content-Range response header (we advertise initial resource size on server here (stat.size)) + headers['Content-Range'] = 'bytes ' + byteRange.from + '-' + byteRange.to + '/' + stat.size; - var pathname = decodeURI(url.parse(req.url).pathname); + } else { + byteRange.valid = false; + this.emit('warn', 'Range request exceeds file boundaries, goes until byte no ' + byteRange.to + ' against file size of ' + length + ' bytes'); + } + } - var finish = function (status, headers) { - that.finish(status, headers, req, res, promise, callback); - }; + /* In any case, check for unhandled byte range headers */ + if (!byteRange.valid && req.headers['range']) { + this.emit('warn', 'Range request present but invalid, might serve whole file instead'); + } - process.nextTick(function () { - that.servePath(pathname, 200, {}, req, res, finish).on('success', function (result) { - promise.emit('success', result); - }).on('error', function (err) { - promise.emit('error'); - }); - }); - if (! callback) { return promise } -}; - -exports.Server.prototype.respond = function (pathname, status, _headers, files, stat, req, res, finish) { - var mtime = Date.parse(stat.mtime), - key = pathname || files[0], - headers = {}, - clientETag = req.headers['if-none-match'], - clientMTime = Date.parse(req.headers['if-modified-since']); - - // Copy default headers - for (var k in this.options.headers) { headers[k] = this.options.headers[k] } - - headers['etag'] = JSON.stringify([stat.ino, stat.size, mtime].join('-')); - headers['date'] = new(Date)().toUTCString(); - headers['last-modified'] = new(Date)(stat.mtime).toUTCString(); - - // Conditional GET - // If the "If-Modified-Since" or "If-None-Match" headers - // match the conditions, send a 304 Not Modified. - if ((clientMTime || clientETag) && - (!clientETag || clientETag === headers['etag']) && - (!clientMTime || clientMTime >= mtime)) { - finish(304, headers); - } else { - var fileExtension = path.extname(files[0]).slice(1).toLowerCase(); - headers['content-length'] = stat.size; - headers['content-type'] = mime.contentTypes[fileExtension] || - 'application/octet-stream'; - - for (var k in _headers) { headers[k] = _headers[k] } - - res.writeHead(status, headers); - - if (req.method === 'HEAD') { - finish(200, headers); - return; + // Copy default headers + for (const k in this.options.headers) { headers[k] = this.options.headers[k] } + + headers['Etag'] = JSON.stringify([stat.ino, stat.size, mtime].join('-')); + headers['Date'] = new(Date)().toUTCString(); + headers['Last-Modified'] = new(Date)(stat.mtime).toUTCString(); + headers['Content-Type'] = contentType; + // If a transform is configured, the output length may differ from the + // original file length, so omit `Content-Length` to allow chunked + // transfer. + if (this.options.transform) { + delete headers['Content-Length']; + // Node adds implicitly + // headers['Transfer-Encoding'] = 'chunked'; + } else { + headers['Content-Length'] = String(length); } - // If the file was cached and it's not older - // than what's on disk, serve the cached version. - if (this.cache && (key in exports.store) && - exports.store[key].stat.mtime >= stat.mtime) { - res.end(exports.store[key].buffer); - finish(status, headers); + // Copy custom headers + for (const k in _headers) { headers[k] = _headers[k] } + + // Conditional GET + // If the "If-Modified-Since" or "If-None-Match" headers + // match the conditions, send a 304 Not Modified. + if ((clientMTime || clientETag) && + (!clientETag || clientETag === headers['Etag']) && + (!clientMTime || clientMTime >= mtime)) { + // 304 response should not contain entity headers + ['Content-Encoding', + 'Content-Language', + 'Content-Length', + 'Content-Location', + 'Content-MD5', + 'Content-Range', + 'Content-Type', + 'Expires', + 'Last-Modified'].forEach(function (entityHeader) { + delete headers[entityHeader]; + }); + finish(304, headers); } else { - this.stream(pathname, files, new(buffer.Buffer)(stat.size), res, function (e, buffer) { - if (e) { return finish(500, {}) } - exports.store[key] = { - stat: stat, - buffer: buffer, - timestamp: Date.now() - }; - finish(status, headers); + // Only apply transforms for text-like content types + const isTextLike = typeof contentType === 'string' && + (contentType.startsWith('text/') || + contentType === 'application/json' || + contentType.endsWith('+json') || + contentType.endsWith('+xml') || + contentType.startsWith('application/javascript')); + + // If a custom factory is used, try calling it synchronously for the + // first file to detect immediate exceptions before sending headers. + if (isTextLike && + typeof this.options.transform === 'function' && files.length > 0 + ) { + try { + // call with the first file to detect sync errors; result is + // discarded here — stream will call factory again. + this.options.transform(files[0], pathname, req, res); + } catch { + return finish(500, {}, true); + } + } + + res.writeHead(status, headers); + + this.stream(key, files, length, startByte, res, req, isTextLike, function (e) { + if (e) { + // If headers were already sent, avoid attempting to write + // a new status header. Just end the response. + if (res.headersSent) { + try { res.end(); } catch { + // Ignore + } + return; + } + return finish(500, {}, true); + } + finish(status, headers, true); }); } } -}; - -exports.Server.prototype.stream = function (pathname, files, buffer, res, callback) { - (function streamFile(files, offset) { - var file = files.shift(); - - if (file) { - file = file[0] === '/' ? file : path.join(pathname || '.', file); - - // Stream the file to the client - fs.createReadStream(file, { - flags: 'r', - mode: 0666 - }).on('data', function (chunk) { - chunk.copy(buffer, offset); - offset += chunk.length; - }).on('close', function () { - streamFile(files, offset); - }).on('error', function (err) { - callback(err); - console.error(err); - }).pipe(res, { end: false }); + + /** + * @param {string|null} pathname + * @param {number} status + * @param {http.OutgoingHttpHeaders} _headers + * @param {string[]} files + * @param {import('./node-static/mstat.js').StatInfo} stat + * @param {http.IncomingMessage} req + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {Finish} finish + */ + respond (pathname, status, _headers, files, stat, req, res, finish) { + const contentType = _headers['Content-Type'] || + mime.getType(files[0]) || + 'application/octet-stream'; + _headers = this.setCacheHeaders(_headers, req); + + if(this.options.gzip) { + this.respondGzip(pathname, status, /** @type {string} */ (contentType), _headers, files, stat, req, res, finish); } else { - res.end(); - callback(null, buffer, offset); + this.respondNoGzip(pathname, status, /** @type {string} */ (contentType), _headers, files, stat, req, res, finish); + } + } + + /** + * @typedef {( + * err: NodeJS.ErrnoException | null, + * offset?: number + * ) => void} StreamCallback + */ + + /** + * @param {string|null} pathname + * @param {string[]} files + * @param {number} length + * @param {number} startByte + * @param {http.ServerResponse & { + * req: http.IncomingMessage; + * }} res + * @param {http.IncomingMessage|StreamCallback|undefined} req + * @param {boolean} [applyTransform] + * @param {StreamCallback} [callback] + */ + stream (pathname, files, length, startByte, res, req, applyTransform, callback) { + // Support legacy signature: + // stream(pathname, files, length, startByte, res, callback) + if (typeof req === 'function') { + callback = req; + req = undefined; + applyTransform = false; + } + + const transformOption = this.options.transform; + let callbackCalled = false; + + /** + * @param {string[]} files + * @param {number} offset + */ + const streamFile = (files, offset) => { + let file = files.shift(); + + if (file) { + file = path.resolve(file) === path.normalize(file) ? file : path.join(pathname || '.', file); + + // Create the read stream + const readStream = fs.createReadStream(file, { + flags: 'r', + mode: 0o666, + start: startByte, + end: startByte + (length ? length - 1 : 0) + }); + + // Track bytes for offset and handle read errors early so a destroy() + // during transform creation will be caught by this handler. + readStream.on('data', function (chunk) { + if (chunk.length && offset < length && offset >= 0) { + offset += chunk.length; + } + }).on('close', function () { + streamFile(files, offset); + }).on('error', function (err) { + if (typeof callback === 'function' && !callbackCalled) { + callbackCalled = true; + callback(err); + } + console.error(err); + }); + + // Optionally create a transform stream (only if flagged and + // configured) + let transformStream = null; + try { + if (req && applyTransform && + typeof transformOption === 'function' + ) { + // allow custom factory (file, pathname, req, res) + transformStream = transformOption(file, pathname, req, res); + } + } catch (err) { + // If transform creation fails, destroy the read stream (will trigger + // the error handler above, which will call the callback). Do not + // call the callback here to avoid double-calling it. + readStream.destroy(/** @type {Error} */ (err)); + return; + } + + if (transformStream) { + readStream.pipe(transformStream).on('error', function (err) { + if (typeof callback === 'function' && !callbackCalled) { + callbackCalled = true; + callback(err); + } + console.error(err); + }).pipe(res, { end: false }); + } else { + readStream.pipe(res, { end: false }); + } + } else { + res.end(); + if (typeof callback === 'function' && !callbackCalled) { + callbackCalled = true; + callback(null, offset); + } + } + }; + streamFile(files.slice(0), 0); + } + + /** + * @param {http.OutgoingHttpHeaders} _headers + * @param {http.IncomingMessage} req + */ + setCacheHeaders (_headers, req) { + /* c8 ignore next 3 -- TS */ + if (!req.url) { + return _headers; } - })(files.slice(0), 0); -}; + const maxAge = this.getMaxAge(req.url); + if (typeof maxAge === 'number') { + if (maxAge > 0) { + _headers['cache-control'] = 'max-age=' + maxAge; + } else { + _headers['cache-control'] = 'no-cache'; + } + } + return _headers; + } + + /** + * @param {string} requestUrl + */ + getMaxAge (requestUrl) { + for (const pattern in this.cache) { + if (minimatch(requestUrl, pattern)) { + return this.cache[pattern]; + } + } + } +} +export {Server, version, mime}; diff --git a/lib/node-static/gzip.js b/lib/node-static/gzip.js new file mode 100644 index 0000000..be7ec95 --- /dev/null +++ b/lib/node-static/gzip.js @@ -0,0 +1,17 @@ +import { createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import { + createReadStream, + createWriteStream, +} from 'node:fs'; + +/** + * @param {string} input + * @param {string} output + */ +export async function gzip (input, output) { + const gzip = createGzip(); + const source = createReadStream(input); + const destination = createWriteStream(output); + return await pipeline(source, gzip, destination); +} diff --git a/lib/node-static/mime.js b/lib/node-static/mime.js deleted file mode 100644 index 7308669..0000000 --- a/lib/node-static/mime.js +++ /dev/null @@ -1,144 +0,0 @@ -exports.contentTypes = { - "aiff": "audio/x-aiff", - "arj": "application/x-arj-compressed", - "appcache": "text/cache-manifest", - "asf": "video/x-ms-asf", - "asx": "video/x-ms-asx", - "au": "audio/ulaw", - "avi": "video/x-msvideo", - "bcpio": "application/x-bcpio", - "ccad": "application/clariscad", - "cod": "application/vnd.rim.cod", - "com": "application/x-msdos-program", - "cpio": "application/x-cpio", - "cpt": "application/mac-compactpro", - "csh": "application/x-csh", - "css": "text/css", - "deb": "application/x-debian-package", - "dl": "video/dl", - "doc": "application/msword", - "drw": "application/drafting", - "dvi": "application/x-dvi", - "dwg": "application/acad", - "dxf": "application/dxf", - "dxr": "application/x-director", - "etx": "text/x-setext", - "ez": "application/andrew-inset", - "fli": "video/x-fli", - "flv": "video/x-flv", - "gif": "image/gif", - "gl": "video/gl", - "gtar": "application/x-gtar", - "gz": "application/x-gzip", - "hdf": "application/x-hdf", - "hqx": "application/mac-binhex40", - "htm": "text/html", - "html": "text/html", - "ice": "x-conference/x-cooltalk", - "ico": "image/x-icon", - "ief": "image/ief", - "igs": "model/iges", - "ips": "application/x-ipscript", - "ipx": "application/x-ipix", - "jad": "text/vnd.sun.j2me.app-descriptor", - "jar": "application/java-archive", - "jpeg": "image/jpeg", - "jpg": "image/jpeg", - "js": "text/javascript", - "json": "application/json", - "latex": "application/x-latex", - "less": "text/css", - "lsp": "application/x-lisp", - "lzh": "application/octet-stream", - "m": "text/plain", - "m3u": "audio/x-mpegurl", - "man": "application/x-troff-man", - "manifest": "text/cache-manifest", - "me": "application/x-troff-me", - "midi": "audio/midi", - "mif": "application/x-mif", - "mime": "www/mime", - "movie": "video/x-sgi-movie", - "mp4": "video/mp4", - "mpg": "video/mpeg", - "mpga": "audio/mpeg", - "ms": "application/x-troff-ms", - "nc": "application/x-netcdf", - "oda": "application/oda", - "oga": "audio/ogg", - "ogg": "application/ogg", - "ogm": "application/ogg", - "ogv": "video/ogg", - "pbm": "image/x-portable-bitmap", - "pdf": "application/pdf", - "pgm": "image/x-portable-graymap", - "pgn": "application/x-chess-pgn", - "pgp": "application/pgp", - "pm": "application/x-perl", - "png": "image/png", - "pnm": "image/x-portable-anymap", - "ppm": "image/x-portable-pixmap", - "ppz": "application/vnd.ms-powerpoint", - "pre": "application/x-freelance", - "prt": "application/pro_eng", - "ps": "application/postscript", - "qt": "video/quicktime", - "ra": "audio/x-realaudio", - "rar": "application/x-rar-compressed", - "ras": "image/x-cmu-raster", - "rgb": "image/x-rgb", - "rm": "audio/x-pn-realaudio", - "rpm": "audio/x-pn-realaudio-plugin", - "rtf": "text/rtf", - "rtx": "text/richtext", - "scm": "application/x-lotusscreencam", - "set": "application/set", - "sgml": "text/sgml", - "sh": "application/x-sh", - "shar": "application/x-shar", - "silo": "model/mesh", - "sit": "application/x-stuffit", - "skt": "application/x-koan", - "smil": "application/smil", - "snd": "audio/basic", - "sol": "application/solids", - "spl": "application/x-futuresplash", - "src": "application/x-wais-source", - "stl": "application/SLA", - "stp": "application/STEP", - "sv4cpio": "application/x-sv4cpio", - "sv4crc": "application/x-sv4crc", - "svg": "image/svg+xml", - "swf": "application/x-shockwave-flash", - "tar": "application/x-tar", - "tcl": "application/x-tcl", - "tex": "application/x-tex", - "texinfo": "application/x-texinfo", - "tgz": "application/x-tar-gz", - "tiff": "image/tiff", - "tr": "application/x-troff", - "tsi": "audio/TSP-audio", - "tsp": "application/dsptype", - "tsv": "text/tab-separated-values", - "txt": "text/plain", - "unv": "application/i-deas", - "ustar": "application/x-ustar", - "vcd": "application/x-cdlink", - "vda": "application/vda", - "vivo": "video/vnd.vivo", - "vrm": "x-world/x-vrml", - "wav": "audio/x-wav", - "wax": "audio/x-ms-wax", - "wma": "audio/x-ms-wma", - "wmv": "video/x-ms-wmv", - "wmx": "video/x-ms-wmx", - "wrl": "model/vrml", - "wvx": "video/x-ms-wvx", - "xbm": "image/x-xbitmap", - "xlw": "application/vnd.ms-excel", - "xml": "text/xml", - "xpm": "image/x-xpixmap", - "xwd": "image/x-xwindowdump", - "xyz": "chemical/x-pdb", - "zip": "application/zip" -}; diff --git a/lib/node-static/mstat.js b/lib/node-static/mstat.js new file mode 100644 index 0000000..3bded5a --- /dev/null +++ b/lib/node-static/mstat.js @@ -0,0 +1,49 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * @typedef {{ + * size: number, + * mtime: Date, + * ino: number + * }} StatInfo + */ + +/** + * @param {string} dir + * @param {string[]} files + * @param {(errOrNull: NodeJS.ErrnoException|null, result?: StatInfo) => void} callback + */ +function mstat (dir, files, callback) { + (function mstat(files, stats) { + const file = files.shift(); + + if (file) { + try { + fs.stat(path.join(dir, file), function (e, stat) { + if (e) { + callback(e); + } else { + mstat(files, stats.concat(stat)); + } + }); + } catch (e) { + callback(/** @type {NodeJS.ErrnoException} */ (e)); + } + } else { + callback(null, { + size: stats.reduce((total, stat) => { + return total + stat.size; + }, 0), + mtime: stats.reduce((latest, stat) => { + return latest > stat.mtime ? latest : stat.mtime; + }, new Date(-8640000000000000)), + ino: stats.reduce((total, stat) => { + return total + stat.ino; + }, 0) + }); + } + })(files.slice(0), /** @type {fs.Stats[]} */ ([])); +} + +export {mstat}; diff --git a/lib/node-static/util.js b/lib/node-static/util.js deleted file mode 100644 index 02de548..0000000 --- a/lib/node-static/util.js +++ /dev/null @@ -1,30 +0,0 @@ -var fs = require('fs') - , path = require('path'); - -exports.mstat = function (dir, files, callback) { - (function mstat(files, stats) { - var file = files.shift(); - - if (file) { - fs.stat(path.join(dir, file), function (e, stat) { - if (e) { - callback(e); - } else { - mstat(files, stats.concat([stat])); - } - }); - } else { - callback(null, { - size: stats.reduce(function (total, stat) { - return total + stat.size; - }, 0), - mtime: stats.reduce(function (latest, stat) { - return latest > stat.mtime ? latest : stat.mtime; - }, 0), - ino: stats.reduce(function (total, stat) { - return total + stat.ino; - }, 0) - }); - } - })(files.slice(0), []); -}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eb32e7b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3756 @@ +{ + "name": "node-static", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-static", + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "colors": "1.4.0", + "command-line-basics": "^3.0.0", + "is-hidden-file": "^1.1.2", + "mime": "^3.0.0", + "minimatch": "^9.0.0" + }, + "bin": { + "static": "bin/cli.js" + }, + "devDependencies": { + "@eslint/js": "^9.37.0", + "@types/chai": "^5.2.2", + "@types/mime": "^3.0.4", + "@types/mocha": "^10.0.10", + "@types/node": "^24.7.2", + "@types/node-fetch": "^2.6.13", + "@types/node-gzip": "^1.1.3", + "c8": "^10.1.3", + "chai": "^6.2.0", + "eslint": "^9.37.0", + "globals": "^16.4.0", + "mocha": "^11.7.4", + "node-fetch": "^2.6.6", + "rollup": "^4.52.4", + "typescript": "^5.9.3" + }, + "engines": { + "node": "^20.11.0 || >= 22.0.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", + "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.4.tgz", + "integrity": "sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.14.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/node-gzip": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/node-gzip/-/node-gzip-1.1.3.tgz", + "integrity": "sha512-gonKbqhKCTrnTpgM5VoVIILYF6odOS4nN2xaIkOUq8ckdrbD3PyF6h5SHIM23eHK/Q1dpHAQsWk5v2WUW7q14Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomically": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", + "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", + "dependencies": { + "stubborn-fs": "^1.2.5", + "when-exit": "^2.1.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/command-line-args": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", + "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.2.0" + }, + "engines": { + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/command-line-basics": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/command-line-basics/-/command-line-basics-3.0.0.tgz", + "integrity": "sha512-9B5OVd2NJ1rNGm7M33CUR6ZtmahRZqBat02WgUe498B4DYpdQJWONn1DDme1B+IlPMXcOrvOW357qQ6wIGb/sw==", + "license": "MIT", + "dependencies": { + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.3", + "update-notifier": "^7.3.1" + }, + "engines": { + "node": "^20.11.0 || >= 22.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/configstore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.0", + "@eslint/plugin-kit": "^0.4.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hidden-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-hidden-file/-/is-hidden-file-1.1.2.tgz", + "integrity": "sha512-WS2Y+gFNWlK8IPAvcvsqa4rwf4kZUqGz3VTpHVhAu4Zvrbk+XPbve/RKacyyVNYxHQulubZshXXlzmfCR7G+WQ==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "cross-spawn": "^7.0.3" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally/node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/ky": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.11.0.tgz", + "integrity": "sha512-NEyo0ICpS0cqSuyoJFMCnHOZJILqXsKhIZlHJGDYaH8OB5IFrGzuBpEwyoMZG6gUKMPrazH30Ax5XKaujvD8ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", + "license": "MIT", + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.7.4", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.4.tgz", + "integrity": "sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", + "license": "MIT", + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.0.tgz", + "integrity": "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==", + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/undici-types": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-notifier": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/when-exit": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", + "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index c052cb8..52dfad1 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,90 @@ { - "name" : "node-static", - "description" : "simple, compliant file streaming module for node", - "url" : "http://github.com/cloudhead/node-static", - "keywords" : ["http", "static", "file", "server"], - "author" : "Alexis Sellier ", - "contributors" : [ - { - "name": "Pablo Cantero", - "email": "pablo@pablocantero.com" + "name": "node-static", + "version": "0.8.0", + "description": "simple, compliant file streaming module for node", + "author": "Alexis Sellier ", + "contributors": [ + "Pablo Cantero ", + "Ionică Bizău ", + "Brett Zamir" + ], + "type": "module", + "main": "./lib/node-static.js", + "exports": { + ".": { + "types": "./dist/lib/node-static.d.ts", + "import": "./lib/node-static.js", + "require": "./dist/node-static.cjs" } + }, + "license": "MIT", + "bin": { + "static": "bin/cli.js" + }, + "keywords": [ + "http", + "static", + "file", + "server" ], "repository": { "type": "git", "url": "http://github.com/cloudhead/node-static" }, - "main" : "./lib/node-static", "scripts": { - "test": "vows --spec --isolate" + "prepublishOnly": "npm run build", + "tsc": "tsc", + "gzip": "node test/gzip.js", + "build": "rollup -c && tsc -p tsconfig-prod.json", + "start": "./bin/cli.js", + "lint": "eslint .", + "mocha": "mocha test/integration --parallel", + "test": "c8 npm run mocha" }, - "bin": { - "static": "bin/cli.js" + "dependencies": { + "colors": "1.4.0", + "command-line-basics": "^3.0.0", + "is-hidden-file": "^1.1.2", + "mime": "^3.0.0", + "minimatch": "^9.0.0" + }, + "c8": { + "check-coverage": true, + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100, + "exclude": [ + "dist", + "test" + ] + }, + "devDependencies": { + "@eslint/js": "^9.37.0", + "@types/chai": "^5.2.2", + "@types/mime": "^3.0.4", + "@types/mocha": "^10.0.10", + "@types/node": "^24.7.2", + "@types/node-fetch": "^2.6.13", + "@types/node-gzip": "^1.1.3", + "c8": "^10.1.3", + "chai": "^6.2.0", + "eslint": "^9.37.0", + "globals": "^16.4.0", + "mocha": "^11.7.4", + "node-fetch": "^2.6.6", + "rollup": "^4.52.4", + "typescript": "^5.9.3" }, - "license" : "MIT", - "dependencies" : { - "optimist": ">=0.3.4", - "colors": ">=0.6.0" + "engines": { + "node": "^20.11.0 || >= 22.0.0" }, - "devDependencies" : { - "request": "latest", - "vows": "latest" + "bugs": { + "url": "https://github.com/cloudhead/node-static/issues" }, - "version" : "0.6.5", - "engines" : { "node": ">= 0.4.1" } + "homepage": "https://github.com/cloudhead/node-static", + "directories": { + "example": "examples", + "test": "test" + } } - diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..432f42f --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,21 @@ +/** + * @param {object} config + * @param {string} config.input + * @returns {RollupConfig} + */ +function getRollupObject ({input} = {}) { + return { + external: ['fs', 'events', 'http', 'path', 'mime', 'minimatch'], + input, + output: { + format: 'cjs', + file: input.replace(/^.\/lib\//u, './dist/').replace(/\.js$/u, '.cjs') + } + }; +} + +export default [ + getRollupObject({ + input: './lib/node-static.js', minifying: true + }) +]; diff --git a/test/fixtures/.hidden-hello.txt b/test/fixtures/.hidden-hello.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/test/fixtures/.hidden-hello.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/fixtures/auto-gz-hello.txt b/test/fixtures/auto-gz-hello.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/test/fixtures/auto-gz-hello.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/fixtures/empty.css b/test/fixtures/empty.css new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/example.com/index.html b/test/fixtures/example.com/index.html new file mode 100644 index 0000000..785e0e2 --- /dev/null +++ b/test/fixtures/example.com/index.html @@ -0,0 +1,10 @@ + + + + + Other page + + + hello there! + + diff --git a/test/fixtures/header-file.json b/test/fixtures/header-file.json new file mode 100644 index 0000000..3715ea2 --- /dev/null +++ b/test/fixtures/header-file.json @@ -0,0 +1,3 @@ +{ + "Access-Control-Allow-Origin": "*" +} diff --git a/test/fixtures/hello-with-older-auto-gz.txt b/test/fixtures/hello-with-older-auto-gz.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/test/fixtures/hello-with-older-auto-gz.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/fixtures/hello-with-older-auto-gz.txt.gz b/test/fixtures/hello-with-older-auto-gz.txt.gz new file mode 100644 index 0000000..49977b9 Binary files /dev/null and b/test/fixtures/hello-with-older-auto-gz.txt.gz differ diff --git a/test/fixtures/hello-with-older-gz.txt b/test/fixtures/hello-with-older-gz.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/test/fixtures/hello-with-older-gz.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/fixtures/hello-with-older-gz.txt.gz b/test/fixtures/hello-with-older-gz.txt.gz new file mode 100644 index 0000000..49977b9 Binary files /dev/null and b/test/fixtures/hello-with-older-gz.txt.gz differ diff --git a/test/fixtures/hello.txt.gz b/test/fixtures/hello.txt.gz new file mode 100644 index 0000000..49977b9 Binary files /dev/null and b/test/fixtures/hello.txt.gz differ diff --git a/test/fixtures/index-with-bad-json/index.json b/test/fixtures/index-with-bad-json/index.json new file mode 100644 index 0000000..5d06e1c --- /dev/null +++ b/test/fixtures/index-with-bad-json/index.json @@ -0,0 +1,3 @@ +{ + "files": ["bad-file.txt"] +} diff --git a/test/fixtures/index-with-json-files/hello-extra.txt b/test/fixtures/index-with-json-files/hello-extra.txt new file mode 100644 index 0000000..b14df64 --- /dev/null +++ b/test/fixtures/index-with-json-files/hello-extra.txt @@ -0,0 +1 @@ +Hi diff --git a/test/fixtures/index-with-json-files/hello.txt b/test/fixtures/index-with-json-files/hello.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/test/fixtures/index-with-json-files/hello.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/fixtures/index-with-json-files/index.json b/test/fixtures/index-with-json-files/index.json new file mode 100644 index 0000000..66843cb --- /dev/null +++ b/test/fixtures/index-with-json-files/index.json @@ -0,0 +1,3 @@ +{ + "files": ["hello-extra.txt", "hello.txt"] +} diff --git a/test/fixtures/index-with-json/hello.txt b/test/fixtures/index-with-json/hello.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/test/fixtures/index-with-json/hello.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/fixtures/index-with-json/index.json b/test/fixtures/index-with-json/index.json new file mode 100644 index 0000000..3bb2c17 --- /dev/null +++ b/test/fixtures/index-with-json/index.json @@ -0,0 +1,3 @@ +{ + "files": ["hello.txt"] +} diff --git a/test/fixtures/index-with-malformed-json-files/index.json b/test/fixtures/index-with-malformed-json-files/index.json new file mode 100644 index 0000000..d68b1ee --- /dev/null +++ b/test/fixtures/index-with-malformed-json-files/index.json @@ -0,0 +1,3 @@ +{ + "files": ["\u0000"] +} diff --git a/test/fixtures/index.html b/test/fixtures/index.html index eee1c7a..489a4d3 100644 --- a/test/fixtures/index.html +++ b/test/fixtures/index.html @@ -1,3 +1,4 @@ + Awesome page diff --git a/test/fixtures/lone-hello.txt.gz b/test/fixtures/lone-hello.txt.gz new file mode 100644 index 0000000..49977b9 Binary files /dev/null and b/test/fixtures/lone-hello.txt.gz differ diff --git a/test/fixtures/no-extension b/test/fixtures/no-extension new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/test/fixtures/no-extension @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/fixtures/there.html b/test/fixtures/there.html new file mode 100644 index 0000000..c6a1aa7 --- /dev/null +++ b/test/fixtures/there.html @@ -0,0 +1,9 @@ + + + + Awesome page + + + hello there! + + diff --git a/test/fixtures/there/index.html b/test/fixtures/there/index.html new file mode 100644 index 0000000..785e0e2 --- /dev/null +++ b/test/fixtures/there/index.html @@ -0,0 +1,10 @@ + + + + + Other page + + + hello there! + + diff --git a/test/fixtures/thereat/index.html b/test/fixtures/thereat/index.html new file mode 100644 index 0000000..785e0e2 --- /dev/null +++ b/test/fixtures/thereat/index.html @@ -0,0 +1,10 @@ + + + + + Other page + + + hello there! + + diff --git a/test/fixtures/utf8.html b/test/fixtures/utf8.html new file mode 100644 index 0000000..af7da4a --- /dev/null +++ b/test/fixtures/utf8.html @@ -0,0 +1,10 @@ + + + + + UTF-8 text + + + 你好,世界! + + diff --git a/test/gzip.js b/test/gzip.js new file mode 100644 index 0000000..7e2334a --- /dev/null +++ b/test/gzip.js @@ -0,0 +1,30 @@ +import { + writeFile +} from 'node:fs/promises'; +import { + setTimeout, +} from 'node:timers/promises'; +import { gzip } from '../lib/node-static/gzip.js'; + +await Promise.all([ + gzip( + import.meta.dirname + '/fixtures/hello.txt', + import.meta.dirname + '/fixtures/hello.txt.gz' + ), + gzip( + import.meta.dirname + '/fixtures/hello.txt', + import.meta.dirname + '/fixtures/hello-with-older-gz.txt.gz' + ), + gzip( + import.meta.dirname + '/fixtures/hello.txt', + import.meta.dirname + '/fixtures/lone-hello.txt.gz' + ) +]); + +// Add delay to ensure source file is newer +await setTimeout(100); +writeFile( + import.meta.dirname + '/fixtures/hello-with-older-gz.txt', + 'hello world', + 'utf8' +); diff --git a/test/integration/binary.js b/test/integration/binary.js new file mode 100644 index 0000000..dfba4b5 --- /dev/null +++ b/test/integration/binary.js @@ -0,0 +1,648 @@ +import {join} from 'node:path'; +import {unlink} from 'node:fs/promises'; + +import {assert} from 'chai'; +import fetch from 'node-fetch'; + +import {spawnPromise, spawnConditional} from '../utils/spawnPromise.js'; + +const __dirname = import.meta.dirname; + +const binFile = join(__dirname, '../../bin/cli.js'); +const fixturePath = join(__dirname, '../fixtures'); +const autoGzPath = join(__dirname, '../fixtures/auto-gz-hello.txt.gz'); + +let testPort = 8281; + +/** + * @param {Mocha.Context} obj + */ +async function updatePort (obj) { + obj.port = ++testPort; +} + + +describe('node-static (CLI)', function () { + it('Gets help text', async function () { + const {stdout} = + /** + * @type {{ stdout: string; stderr: string; }} + */ + (await spawnPromise(binFile, ['-h'])); + assert.match(stdout, /USAGE: /u); + }); + + describe('Get files', function () { + const timeout = 10000; + this.timeout(timeout); + beforeEach(async function () { + await updatePort(this); + }); + it('serving file within directory', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ (await spawnConditional( + binFile, + [ + '-p', this.port, fixturePath + ], + timeout - 9000, + { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/hello.txt` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory with UTF-8 content-type', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ (await spawnConditional( + binFile, + [ + '-p', this.port, fixturePath, + '-H', JSON.stringify({ + 'Content-Type': 'text/html;charset=UTF-8' + }) + ], + timeout - 9000, + { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/utf8.html` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/html', 'should respond with text/html'); + assert.include(text, '你好,世界!', 'should respond with hello world in Chinese'); + }); + + it('serving file within directory with server info', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ (await spawnConditional( + binFile, + [ + '-p', this.port, fixturePath, + '--server-info', 'my-server' + ], + timeout - 9000, + { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/hello.txt` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const server = response.headers.get('server'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(server, 'my-server', 'should respond with my-server') + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory with default extension', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ (await spawnConditional( + binFile, + [ + '-p', this.port, fixturePath, + '--default-extension', 'txt' + ], + timeout - 9000, + { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/hello` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory with hidden extension', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ (await spawnConditional( + binFile, + [ + '-p', this.port, fixturePath, + '--serve-hidden' + ], + timeout - 9000, + { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/.hidden-hello.txt` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving 404 for file with hidden extension', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ (await spawnConditional( + binFile, + [ + '-p', this.port, fixturePath + ], + timeout - 9000, + { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/.hidden-hello.txt` + ); + } + })); + + const {status} = response; + const text = await response.text(); + + assert.equal(status, 404, 'should respond with 404'); + assert.equal(text, 'Not Found', 'should respond with Not Found'); + }); + + it('serving file without directory', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ (await spawnConditional(binFile, [ + '-p', this.port + ], timeout - 9000, { + condition: /serving "."/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/test/fixtures/hello.txt` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const cacheControl = response.headers.get('cache-control'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(text, 'hello world', 'should respond with hello world'); + assert.equal(cacheControl, 'max-age=3600', 'should respond with cache-control'); + }); + + it('serving file within directory and 404', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, fixturePath + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/bad-file` + ); + } + })); + + const {status} = response; + const text = await response.text(); + + assert.equal(status, 404, 'should respond with 404'); + assert.equal(text, 'Not Found', 'should respond with Not Found'); + }); + + it('serving file within directory and indexFile', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, fixturePath, '--index-file', 'hello.txt' + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory and spa and indexFile', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, fixturePath, '--index-file', 'hello.txt', '--spa' + ], timeout - 9000, { + condition: /serving ".*?"/, + error (err) { + console.log('err', err); + }, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/some/other/path` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory and spa and default indexFile (and default port)', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + fixturePath, '--spa' + ], timeout - 9000, { + condition: 'serving as a single page app', + error (err) { + console.log('err', err); + }, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:8080/some/other/path` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + // const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/html', 'should respond with text/html'); + // assert.contains(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory with headers', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, + '--headers', JSON.stringify({ + 'Access-Control-Allow-Origin': '*' + }), + fixturePath + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/hello.txt` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(allowOrigin, '*', 'should respond with all origins'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory with header file', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, + '--header-file', + fixturePath + '/header-file.json', + fixturePath + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/hello.txt` + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(allowOrigin, '*', 'should respond with all origins'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory and gzip', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, fixturePath, '--gzip' + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/hello.txt`, { + headers: { + 'accept-encoding': 'gzip' + } + } + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const contentEncoding = response.headers.get('content-encoding'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(contentEncoding, 'gzip', 'should respond with gzip encoding'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + describe('`gzipAuto`', function () { + const tryUnlink = async () => { + try { + await unlink(autoGzPath); + } catch (err) { + if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') { + throw err; + } + } + }; + beforeEach(async () => { + await tryUnlink(); + }); + afterEach(async () => { + await tryUnlink(); + }); + it('serving file within directory and gzip and gzipAuto', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, fixturePath, '--gzip', '--gzip-auto' + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/auto-gz-hello.txt`, { + headers: { + 'accept-encoding': 'gzip' + } + } + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const contentEncoding = response.headers.get('content-encoding'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(contentEncoding, 'gzip', 'should respond with gzip encoding'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + }); + + it('serving file within directory and gzip and gzipOnly', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, fixturePath, '--gzip', + '--gzip-only', 'allow' + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/lone-hello.txt`, { + headers: { + 'accept-encoding': 'gzip' + } + } + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const contentEncoding = response.headers.get('content-encoding'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(contentEncoding, 'gzip', 'should respond with gzip encoding'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serving file within directory and gzip but without gzip accept request', async function () { + const {response /* , stdout */} = + /** + * @type {{ + * response: Response, + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, fixturePath, '--gzip' + ], timeout - 9000, { + condition: /serving ".*?"/, + action: (/* err, stdout */) => { + return fetch( + `http://localhost:${this.port}/hello.txt`, { + headers: { + 'accept-encoding': 'nothing' + } + } + ); + } + })); + + const {status} = response; + const contentType = response.headers.get('content-type'); + const contentEncoding = response.headers.get('content-encoding'); + const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(contentType, 'text/plain', 'should respond with text/plain'); + assert.equal(contentEncoding, null, 'should not respond with gzip encoding'); + assert.equal(text, 'hello world', 'should respond with hello world'); + }); + + it('serves custom cache', async function () { + const {response: responses /* , stdout */} = + /** + * @type {{ + * response: Response[], + * stdout: string + * }} + */ + (await spawnConditional(binFile, [ + '-p', this.port, '--cache', JSON.stringify({ + '**/*.txt': 100, + '**/': 300 + }), fixturePath + ], timeout - 9000, { + condition: /serving ".*?"/, + error (err) { + throw err; + }, + action: (/* err, stdout */) => { + return Promise.all([ + fetch( + `http://localhost:${this.port}/` + ), + fetch( + `http://localhost:${this.port}/hello.txt` + ), + fetch( + `http://localhost:${this.port}/empty.css` + ) + ]); + } + })); + + [ + ['max-age=300'], + ['max-age=100'], + [undefined] + ].forEach(([expectedCacheControl], i) => { + const response = responses[i]; + const {status} = response; + const cacheControl = response.headers.get('cache-control'); + // const contentType = response.headers.get('content-type'); + // const text = await response.text(); + + assert.equal(status, 200, 'should respond with 200'); + assert.equal(cacheControl, expectedCacheControl, 'should respond with cache-control or lack'); + }); + }); + }); +}); diff --git a/test/integration/commonjs.js b/test/integration/commonjs.js new file mode 100644 index 0000000..86c20ab --- /dev/null +++ b/test/integration/commonjs.js @@ -0,0 +1,11 @@ +import {assert} from 'chai'; + +import statik from '../../dist/node-static.cjs'; + +describe('node-static (CommonJS)', function () { + it('Has expected properties', function () { + assert.isArray(statik.version, '`version` is an array'); + assert.isFunction(statik.Server, '`Server` is a constructor'); + assert.isFunction(statik.mime.getType, '`mime.getType` is a function'); + }); +}); diff --git a/test/integration/node-static-test.js b/test/integration/node-static-test.js index 22afbef..189c140 100644 --- a/test/integration/node-static-test.js +++ b/test/integration/node-static-test.js @@ -1,137 +1,1104 @@ -var vows = require('vows') - , request = require('request') - , assert = require('assert') - , static = require('../../lib/node-static'); - -var fileServer = new(static.Server)(__dirname + '/../fixtures', {serverInfo: 'custom-server-name'}); - -var suite = vows.describe('node-static'); - -var TEST_PORT = 8080; -var TEST_SERVER = 'http://localhost:' + TEST_PORT; -var server; - -suite.addBatch({ - 'once an http server is listening': { - topic: function () { - server = require('http').createServer(function (request, response) { - request.addListener('end', function () { - fileServer.serve(request, response); - }); - }).listen(TEST_PORT, this.callback) - }, - 'should be listening' : function(){ - /* This test is necessary to ensure the topic execution. - * A topic without tests will be not executed */ - assert.isTrue(true); - } - } -}).addBatch({ - 'requesting a file not found': { - topic : function(){ - request.get(TEST_SERVER + '/not-found', this.callback); - }, - 'should respond with 404' : function(error, response, body){ - assert.equal(response.statusCode, 404); - } - } -}).addBatch({ - 'serving hello.txt': { - topic : function(){ - request.get(TEST_SERVER + '/hello.txt', this.callback); - }, - 'should respond with 200' : function(error, response, body){ - assert.equal(response.statusCode, 200); - }, - 'should respond with text/plain': function(error, response, body){ - assert.equal(response.headers['content-type'], 'text/plain'); - }, - 'should respond with hello world': function(error, response, body){ - assert.equal(body, 'hello world'); - } - } -}).addBatch({ - 'serving directory index': { - topic : function(){ - request.get(TEST_SERVER, this.callback); - }, - 'should respond with 200' : function(error, response, body){ - assert.equal(response.statusCode, 200); - }, - 'should respond with text/html': function(error, response, body){ - assert.equal(response.headers['content-type'], 'text/html'); - } - } -}).addBatch({ - 'serving index.html from the cache': { - topic : function(){ - request.get(TEST_SERVER + '/index.html', this.callback); - }, - 'should respond with 200' : function(error, response, body){ - assert.equal(response.statusCode, 200); - }, - 'should respond with text/html': function(error, response, body){ - assert.equal(response.headers['content-type'], 'text/html'); - } - } -}).addBatch({ - 'requesting with If-None-Match': { - topic : function(){ - var _this = this; - request.get(TEST_SERVER + '/index.html', function(error, response, body){ - request({ - method: 'GET', - uri: TEST_SERVER + '/index.html', - headers: {'if-none-match': response.headers['etag']} - }, - _this.callback); - }); - }, - 'should respond with 304' : function(error, response, body){ - assert.equal(response.statusCode, 304); - } - }, - 'requesting with If-None-Match and If-Modified-Since': { - topic : function(){ - var _this = this; - request.get(TEST_SERVER + '/index.html', function(error, response, body){ - var modified = Date.parse(response.headers['last-modified']); - var oneDayLater = new Date(modified + (24 * 60 * 60 * 1000)).toUTCString(); - var nonMatchingEtag = '1111222233334444'; - request({ - method: 'GET', - uri: TEST_SERVER + '/index.html', - headers: { - 'if-none-match': nonMatchingEtag, - 'if-modified-since': oneDayLater - } - }, - _this.callback); - }); - }, - 'should respond with a 200': function(error, response, body){ - assert.equal(response.statusCode, 200); - } - } -}).addBatch({ - 'requesting HEAD': { - topic : function(){ - request.head(TEST_SERVER + '/index.html', this.callback); - }, - 'should respond with 200' : function(error, response, body){ - assert.equal(response.statusCode, 200); - }, - 'head must has no body' : function(error, response, body){ - assert.isUndefined(body); - } - } -}).addBatch({ - 'requesting headers': { - topic : function(){ - request.head(TEST_SERVER + '/index.html', this.callback); - }, - 'should respond with node-static/0.6.0' : function(error, response, body){ - assert.equal(response.headers['server'], 'custom-server-name'); - } - } -}).export(module); +import http from 'node:http'; +import {basename} from 'node:path'; +import {writeFile} from 'node:fs/promises'; +import { + setTimeout, +} from 'node:timers/promises'; + +import {assert} from 'chai'; +import fetch from 'node-fetch'; +import * as statik from '../../lib/node-static.js'; +import {gzip} from '../../lib/node-static/gzip.js'; + +const __dirname = import.meta.dirname; + +let testPort = 8151; +const getTestServer = () => { + return 'http://localhost:' + testPort; +}; + +/** + * @param {Mocha.Context} obj + * @param {( + * serveProm: import('events')<[never]>|void, + * request: http.IncomingMessage, + * response: http.ServerResponse & { + * req: http.IncomingMessage; + * } + * ) => void} [cb] + * @param {( + * request: http.IncomingMessage, + * response: http.ServerResponse & { + * req: http.IncomingMessage; + * } + * ) => void} [preprocessCb] + */ +async function setupStaticServer (obj, cb, preprocessCb) { + obj.port = ++testPort; + obj.server = await startStaticServer(obj.port, cb, preprocessCb); + obj.getTestServer = () => { + return 'http://localhost:' + obj.port; + }; +} +const version = statik.version.join('.'); + +let fileServer = new statik.Server(__dirname + '/../fixtures'); + +/** + * @param {number} port + * @param {( + * serveProm: import('events')<[never]>|void, + * request: http.IncomingMessage, + * response: http.ServerResponse & { + * req: http.IncomingMessage; + * } + * ) => void} [callback] + * @param {( + * request: http.IncomingMessage, + * response: http.ServerResponse & { + * req: http.IncomingMessage; + * } + * ) => void} [preprocessCallback] + */ +function startStaticServer (port, callback, preprocessCallback) { + return new Promise((resolve, reject) => { + const server = http.createServer(function (request, response) { + if (preprocessCallback) { + preprocessCallback(request, response); + } + const serveProm = fileServer.serve(request, response); + if (callback) { + callback(serveProm, request, response); + } + }); + server.listen(port, () => { + resolve(server); + }); + }); +} + +/** + * @param {number} port + * @param {(err: Error) => void} errBack + */ +function startErringStaticFileServer (port, errBack) { + return new Promise((resolve, reject) => { + const server = http.createServer(function (request, response) { + fileServer.serveFile('bad-file.html', 200, {}, request, response).on('error', () => { + errBack(new Error('triggered error')); + }); + }); + server.listen(port, () => { + resolve(server); + }); + }); +} + +let gzipFileServer = new statik.Server(__dirname + '/../fixtures', { + gzip: true, +}); + +/** + * @param {number} port + * @param {Record} headers + */ +function startStaticFileServerWithGzipAndHeaders (port, headers) { + return new Promise((resolve, reject) => { + const server = http.createServer(function (request, response) { + /* c8 ignore next 3 -- TS */ + if (!request.url) { + return; + } + gzipFileServer.servePath(request.url, 200, headers, request, response, () => { + // Finish + }); + }); + server.listen(port, () => { + resolve(server); + }); + }); +} + +/** + * @param {number} port + * @param {( + * request: http.IncomingMessage, + * response: http.ServerResponse & { + * req: http.IncomingMessage; + * }, + * err: statik.ResultInfo | null, + * result: statik.ResultInfo | undefined + * ) => void} callback + */ +function startStaticServerWithCallback (port, callback) { + return new Promise((resolve, reject) => { + const server = http.createServer((request, response) => { + fileServer.serve(request, response, (err, result) => { + callback(request, response, err, result); + }); + }).listen(port, () => { + resolve(server); + }); + }); +} + +describe('node-static', function () { + + it('handles stream error', function (done) { + /** @type {NodeJS.ErrnoException|null} */ + let setError = null; + const server = http.createServer(function (request, response) { + fileServer.stream( + null, ['bad-file.txt'], 0, 0, response, (err) => { + if (err) { + setError = err; + } + } + ); + }); + server.listen('8081', async () => { + const response = await fetch('http://localhost:8081'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(await response.text(), '', 'should respond with empty string'); + assert.equal(setError?.code, 'ENOENT'); + server.close(); + done(); + }); + }); + it('streaming a 404 page', async function () { + testPort++; + const server = await startStaticServerWithCallback(testPort, async (request, response, err, result) => { + if (err) { + response.writeHead(err.status, err.headers); + await setTimeout(100); + response.end('Custom 404 Stream.') + } + }); + const response = await fetch(getTestServer() + '/not-found'); + + assert.equal(response.status, 404, 'should respond with 404'); + + assert.equal( + await response.text(), + 'Custom 404 Stream.', + 'should respond with the streamed content' + ); + + server.close(); + }); + + it('mixes Vary headers', async function () { + testPort++; + const server = await startStaticFileServerWithGzipAndHeaders(testPort, { + 'Vary': 'Accept-Language' + }); + + const response = await fetch(getTestServer() + '/hello.txt'); + + const vary = response.headers.get('vary'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(vary, 'Accept-Language, Accept-Encoding'); + assert.equal( + await response.text(), + 'hello world', + 'should respond with hello world' + ); + + server.close(); + }); + + it('avoids using gzipped file if older than source file', async function () { + testPort++; + + let emittedWarning; + let emittedFile = ''; + + gzipFileServer = new statik.Server(__dirname + '/../fixtures', { + gzip: true + }); + gzipFileServer.on( + 'warn', + /** + * @param {string} warning + * @param {string} file + */ + (warning, file) => { + emittedWarning = warning; + emittedFile = file; + } + ); + + const server = await startStaticFileServerWithGzipAndHeaders(testPort, {}); + + const response = await fetch(getTestServer() + '/hello-with-older-gz.txt'); + + const vary = response.headers.get('vary'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(vary, null, 'should not have vary header'); + assert.equal(emittedWarning, 'Gzipped version is older than source file', 'should show warning about gzipped version'); + assert.equal( + emittedFile.endsWith('hello-with-older-gz.txt'), + true, + 'should emit file name' + ); + assert.equal( + await response.text(), + 'hello world', + 'should respond with hello world' + ); + + server.close(); + }); + + describe('`gzipAuto`', function () { + const buildOlderGzipped = async () => { + await gzip( + __dirname + '/../fixtures/hello-with-older-auto-gz.txt', + __dirname + '/../fixtures/hello-with-older-auto-gz.txt.gz' + ); + // Rewrite the contents after a delay, so the gzip is now older + await setTimeout(100); + await writeFile( + __dirname + '/../fixtures/hello-with-older-auto-gz.txt', + 'hello world', + 'utf8' + ); + }; + beforeEach(async () => { + await buildOlderGzipped(); + }); + afterEach(async () => { + await buildOlderGzipped(); + }); + it('rebuilds gzipped file if older than source file', async function () { + testPort++; + + gzipFileServer = new statik.Server(__dirname + '/../fixtures', { + gzip: true, + gzipAuto: true + }); + + const server = await startStaticFileServerWithGzipAndHeaders(testPort, {}); + + const response = await fetch(getTestServer() + '/hello-with-older-auto-gz.txt'); + + const vary = response.headers.get('vary'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(vary, 'Accept-Encoding'); + assert.equal( + await response.text(), + 'hello world', + 'should respond with hello world' + ); + + server.close(); + }); + }); + + it('gets gzipped file without source file', async function () { + testPort++; + + gzipFileServer = new statik.Server(__dirname + '/../fixtures', { + gzip: true, + gzipOnly: 'allow' + }); + const server = await startStaticFileServerWithGzipAndHeaders(testPort, {}); + const response = await fetch(getTestServer() + '/lone-hello.txt'); + + const vary = response.headers.get('vary'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(vary, 'Accept-Encoding'); + assert.equal( + await response.text(), + 'hello world', + 'should respond with hello world' + ); + + server.close(); + }); + + it('throws with both `gzipOnly` and `gzipAuto` arguments', function () { + /** @type {Error|null} */ + let thrownError = null; + try { + new statik.Server(__dirname + '/../fixtures', { + gzip: true, + gzipAuto: true, + gzipOnly: 'require' + }); + } catch (err) { + thrownError = /** @type {Error} */ (err); + } + assert.equal( + thrownError?.message, + '`gzipOnly` and `gzipAuto` may not be used togther.', + 'May not use `gzipOnly` and `gzipAuto` options together' + ); + }); + + it('gets required gzipped file only (without source file)', async function () { + testPort++; + + gzipFileServer = new statik.Server(__dirname + '/../fixtures', { + gzip: true, + gzipOnly: 'require' + }); + const server = await startStaticFileServerWithGzipAndHeaders(testPort, {}); + const response = await fetch(getTestServer() + '/lone-hello.txt'); + + const vary = response.headers.get('vary'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(vary, 'Accept-Encoding'); + assert.equal( + await response.text(), + 'hello world', + 'should respond with hello world' + ); + + server.close(); + }); + + it('get error triggered for file serving', function (done) { + testPort++; + + /** @type {http.Server} */ + let server; + startErringStaticFileServer(testPort, (err) => { + assert.equal(err.message, 'triggered error'); + server.close(); + done(); + }).then(async (srvr) => { + server = srvr; + fetch(getTestServer() + '/not-found'); + }); + }); + + describe('once an http server is listening without a callback', function () { + beforeEach(async function () { + await setupStaticServer(this, (server, req, res) => { + if (server) { + server.on('error', (err) => { + res.writeHead(404, err.headers); + res.end(); + }) + } + }); + }); + afterEach(async function () { + this.server.close(); + }); + it('requesting a file not found', async function () { + const response = await fetch(this.getTestServer() + '/not-found'); + + assert.equal(response.status, 404, 'should respond with 404'); + }); + }); + + describe('once an http server is listening without a callback', function () { + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + it('requesting a file not found', async function () { + const response = await fetch(this.getTestServer() + '/not-found'); + + assert.equal(response.status, 404, 'should respond with 404'); + }); + it('requesting a file not found (file with same initial letters)', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures/there'); + const response = await fetch(this.getTestServer() + '/there.html'); + assert.equal(response.status, 404, 'should respond with 404'); + }) + it('requesting a file not found (directory with same initial letters)', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures/there'); + const response = await fetch(this.getTestServer() + '/thereat/index.html'); + assert.equal(response.status, 404, 'should respond with 404'); + }); + it('requesting a malformed URI', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures'); + const response = await fetch(this.getTestServer() + '/a%AFc'); + assert.equal(response.status, 400, 'should respond with 400'); + }); + + it('requesting against empty root Server constructor', async function () { + fileServer = new statik.Server({}); + const response = await fetch(this.getTestServer() + '/test/fixtures/hello.txt'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + + it('serving empty.css', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures'); + const response = await fetch(this.getTestServer() + '/empty.css'); + assert.equal(response.status, 200, 'should respond with 200'); + + assert.equal(response.headers.get('content-type'), 'text/css', 'should respond with text/css'); + + assert.equal(await response.text(), '', 'should respond with empty string'); + }); + + it('serving hello.txt', async function () { + const response = await fetch(this.getTestServer() + '/hello.txt'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + + it('serving no-extension', async function () { + const response = await fetch(this.getTestServer() + '/no-extension'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'application/octet-stream', 'should respond with application/octet-stream'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + + it('serving hello.txt without server header', async function (){ + fileServer = new statik.Server(__dirname + '/../fixtures', { + serverInfo: null + }); + + const response = await fetch(this.getTestServer() + '/hello.txt'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + + assert.equal(response.headers.get('server'), null, 'should contain server header'); + + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + it('serving hello.txt with custom server header', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures', { + serverInfo: 'own header' + }); + + const response = await fetch(this.getTestServer() + '/hello.txt'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('server'), 'own header', 'should contain custom server header'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + + it('serving hello.txt with non-accepting gzip', async function (){ + fileServer = new statik.Server(__dirname + '/../fixtures', { + gzip: /special-encoding/v + }); + + const response = await fetch(this.getTestServer() + '/hello.txt', { + headers: { + 'accept-encoding': 'gzip' + } + }); + const contentEncoding = response.headers.get('content-encoding'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(contentEncoding, null, 'should not respond with gzip encoding'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + + it('serving first 5 bytes of hello.txt', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures'); + const options = { + headers: { + 'Range': 'bytes=0-4' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + + assert.equal(response.status, 206, 'should respond with 206'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '5', 'should have content-length of 5 bytes'); + + assert.equal(response.headers.get('content-range'), 'bytes 0-4/11', 'should have a valid Content-Range header in response'); + assert.equal(await response.text(), 'hello', 'should respond with hello'); + }); + it('serving last 5 bytes of hello.txt', async function () { + const options = { + headers: { + 'Range': 'bytes=6-10' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + assert.equal(response.status, 206, 'should respond with 206'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '5', 'should have content-length of 5 bytes'); + assert.equal(response.headers.get('content-range'), 'bytes 6-10/11', 'should have a valid Content-Range header in response'); + assert.equal(await response.text(), 'world', 'should respond with world'); + }); + it('serving first byte of hello.txt', async function (){ + const options = { + headers: { + 'Range': 'bytes=0-0' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + assert.equal(response.status, 206, 'should respond with 206'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '1', 'should have content-length of 1 bytes'); + assert.equal(response.headers.get('content-range'), 'bytes 0-0/11', 'should have a valid Content-Range header in response'); + assert.equal(await response.text(), 'h', 'should respond with h'); + }); + it('serving all from the start of hello.txt', async function () { + const options = { + headers: { + 'Range': 'bytes=0-' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + assert.equal(response.status, 206, 'should respond with 206'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '11', 'should have content-length of 11 bytes'); + assert.equal(response.headers.get('content-range'), 'bytes 0-10/11', 'should have a valid Content-Range header in response'); + assert.equal(await response.text(), 'hello world', 'should respond with "hello world"'); + }); + + it('serving differential amount of hello.txt', async function () { + const options = { + headers: { + 'Range': 'bytes=-2' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + assert.equal(response.status, 206, 'should respond with 206'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '2', 'should have content-length of 11 bytes'); + assert.equal(response.headers.get('content-range'), 'bytes 9-10/11', 'should have a valid Content-Range header in response'); + assert.equal(await response.text(), 'ld', 'should respond with "ld"'); + }); + + it('serving differential amount of empty.css (with to)', async function () { + const options = { + headers: { + 'Range': 'bytes=-2' + } + }; + const response = await fetch(this.getTestServer() + '/empty.css', options); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/css', 'should respond with text/css'); + assert.equal(response.headers.get('content-length'), '0', 'should have content-length of 0 bytes'); + assert.equal(response.headers.get('content-range'), null, 'should have no Content-Range header in response'); + assert.equal(await response.text(), '', 'should respond with ""'); + }); + + it('serving differential amount of empty.css (with from)', async function () { + const options = { + headers: { + 'Range': 'bytes=2-' + } + }; + const response = await fetch(this.getTestServer() + '/empty.css', options); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/css', 'should respond with text/css'); + assert.equal(response.headers.get('content-length'), '0', 'should have content-length of 0 bytes'); + assert.equal(response.headers.get('content-range'), null, 'should have no Content-Range header in response'); + assert.equal(await response.text(), '', 'should respond with ""'); + }); + + it('serving full bytes of hello.txt with bad range', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures'); + let emittedWarning; + fileServer.on('warn', (warning) => { + emittedWarning = warning; + }); + + const options = { + headers: { + 'Range': 'bytes=1000-1004' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '11', 'should have content-length of 5 bytes'); + + assert.equal(response.headers.get('content-range'), null); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + assert.equal(emittedWarning, 'Range request present but invalid, might serve whole file instead') + }); + + it('serving full bytes of hello.txt with unsupported range flavor', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures'); + let emittedWarning; + fileServer.once('warn', (warning) => { + emittedWarning = warning; + }); + const options = { + headers: { + 'Range': 'qubits=1-5' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '11', 'should have content-length of 5 bytes'); + + assert.equal(response.headers.get('content-range'), null); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + assert.equal(emittedWarning, 'Request contains unsupported range header: qubits=1-5'); + }); + + it('serving full bytes of hello.txt with invalid range header', async function () { + fileServer = new statik.Server(__dirname + '/../fixtures'); + let emittedWarning; + fileServer.once('warn', (warning) => { + emittedWarning = warning; + }); + const options = { + headers: { + 'Range': 'bytes=a-b' + } + }; + const response = await fetch(this.getTestServer() + '/hello.txt', options); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(response.headers.get('content-length'), '11', 'should have content-length of 5 bytes'); + + assert.equal(response.headers.get('content-range'), null); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + assert.equal(emittedWarning, "Request contains invalid range header: a, b") + }); + + it('serving directory index', async function (){ + const response = await fetch(this.getTestServer()); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/html', 'should respond with text/html'); + }); + it('serving index.html from the cache', async function () { + const response = await fetch(this.getTestServer() + '/index.html'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/html', 'should respond with text/html'); + assert.equal(response.headers.get('cache-control'), 'max-age=3600', 'should respond with cache-control'); + }); + it('requesting with If-None-Match', async function () { + const serverPath = this.getTestServer(); + let response = await fetch(serverPath + '/index.html'); + response = await fetch(serverPath + '/index.html', { + headers: {'if-none-match': response.headers.get('etag') ?? ''} + }); + + assert.equal(response.status, 304, 'should respond with 304'); + }) + it('requesting with If-Modified-Since', async function () { + const serverPath = this.getTestServer(); + let response = await fetch(serverPath + '/index.html'); + response = await fetch(serverPath + '/index.html', { + headers: {'if-modified-since': String(new Date())} + }); + + assert.equal(response.status, 304, 'should respond with 304'); + }) + it('requesting with If-None-Match and If-Modified-Since', async function () { + const serverPath = this.getTestServer(); + const response = await fetch(serverPath + '/index.html'); + const modified = Date.parse(response.headers.get('last-modified') ?? ''); + const oneDayLater = new Date(modified + (24 * 60 * 60 * 1000)).toUTCString(); + const nonMatchingEtag = '1111222233334444'; + await fetch(serverPath + '/index.html', { + headers: { + 'if-none-match': nonMatchingEtag, + 'if-modified-since': oneDayLater + } + }); + assert.equal(response.status, 200, 'should respond with a 200'); + }); + it('requesting POST', async function (){ + const response = await fetch(this.getTestServer() + '/index.html', { + method: 'POST' + }); + assert.equal(response.status, 200, 'should respond with 200'); + assert.isNotEmpty(await response.text(), 'should not be empty'); + }); + + it('requesting HEAD', async function () { + const response = await fetch(this.getTestServer() + '/index.html', { + method: 'HEAD' + }); + assert.equal(response.status, 200, 'should respond with 200'); + assert.isEmpty(await response.text(), 'head must have no body'); + }); + + it('requesting headers', async function () { + const response = await fetch(this.getTestServer() + '/index.html', { + method: 'HEAD' + }); + assert.equal(response.headers.get('server'), 'node-static/' + version, 'should respond with node-static/' + version); + }); + it('addings custom mime types', async function () { + statik.mime.define({ + // 'application/font-woff': ['woff'], // Will throw + 'application/x-special': ['special'] + }); + assert.equal(statik.mime.getType('special'), 'application/x-special', 'should add special'); + }); + it('addings custom mime types (force)', async function () { + const force = true; + statik.mime.define({ + 'application/font-woff': ['woff'], + 'application/x-special': ['special'] + }, force); + assert.equal(statik.mime.getType('special'), 'application/x-special', 'should add special'); + assert.equal(statik.mime.getType('woff'), 'application/font-woff', 'should add woff without overriding built-in mapping'); + }); + it('serving subdirectory index', async function () { + const response = await fetch(this.getTestServer() + '/there/'); // with trailing slash + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/html', 'should respond with text/html'); + }); + it('serving subdirectory embedding website name (should not redirect)', async function () { + const response = await fetch(this.getTestServer() + '//example.com', { + redirect: 'manual' + }); // without trailing slash + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/html', 'should respond with text/html'); + }) + it('redirecting to subdirectory index', async function () { + const response = await fetch(this.getTestServer() + '/there', { + redirect: 'manual' + }); // without trailing slash + assert.equal(response.status, 301, 'should respond with 301'); + + // Todo: set location `endsWith` check back to equality check + // when this may be fixed: + // https://github.com/node-fetch/node-fetch/issues/1086 + // assert.equal(response.headers.get('location'), '/there/', 'should respond with location header'); // now with trailing slash + assert(response.headers.get('location')?.endsWith('/there/'), 'should respond with location header'); // now with trailing slash + + assert.equal(await response.text(), '', 'should respond with empty string body'); + }); + it('requesting a subdirectory (with trailing slash) not found', async function () { + const response = await fetch(this.getTestServer() + '/notthere/'); // with trailing slash + assert.equal(response.status, 404, 'should respond with 404'); + }); + it('requesting a subdirectory (without trailing slash) not found', async function () { + const response = await fetch(this.getTestServer() + '/notthere', { + redirect: 'manual' + }); // without trailing slash + assert.equal(response.status, 404, 'should respond with 404'); + }); + }); + + describe('once an http server is listening with a custom header', function () { + beforeEach(async function () { + await setupStaticServer(this, undefined, (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + }); + }); + afterEach(async function () { + this.server.close(); + }); + it('requesting a text file as HTML', async function () { + fileServer = new statik.Server(__dirname+'/../fixtures'); + const response = await fetch(this.getTestServer() + '/hello.txt'); + + assert.equal(response.headers.get('content-type'), 'text/html', 'should respond with text/html'); + assert.equal(response.status, 200, 'should respond with 200'); + }); + }); + + describe('once an http server is listening with custom index configuration', function () { + before(function () { + fileServer = new statik.Server(__dirname + '/../fixtures', { indexFile: "hello.txt" }); + }); + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + + it('serving custom index file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + + it('handling malicious urls', async function () { + const response = await fetch(this.getTestServer() + '/%00'); + assert.equal(response.status, 404, 'should respond with 404'); + }); + + it('serving custom index file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + }); + + describe('once an http server is listening with JSON files configuration', function () { + before(function () { + fileServer = new statik.Server(__dirname + '/../fixtures/index-with-json'); + }); + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + + it('serving JSON file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + }); + + describe('once an http server is listening with missing JSON files configuration', function () { + before(function () { + fileServer = new statik.Server(__dirname + '/../fixtures/index-without-json'); + }); + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + + it('returns 404 with missing JSON file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 404, 'should respond with 404'); + }); + }); + + describe('once an http server is listening with multiple JSON files configuration', function () { + before(function () { + fileServer = new statik.Server(__dirname + '/../fixtures/index-with-json-files'); + }); + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + + it('returns 200 with multiple JSON files config', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(await response.text(), 'Hi\nhello world', 'should respond with Hi\nhello world'); + }); + }); + + describe('once an http server is listening with directoryCallback', function () { + before(function () { + fileServer = new statik.Server(__dirname + '/../fixtures', { + directoryCallback (pathname, req, res) { + res.writeHead(200, { + 'Content-Type': 'text/html' + }); + res.end(`Hi ${basename(pathname)}!`); + } + }); + }); + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + + it('returns 200 with directoryCallback', async function () { + const response = await fetch(this.getTestServer() + '/there'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(await response.text(), 'Hi there!', 'should respond with Hi there!'); + }); + }); + + describe('once an http server is listening with bad JSON files configuration', function () { + before(function () { + fileServer = new statik.Server(__dirname + '/../fixtures/index-with-bad-json'); + }); + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + + it('returns 404 with missing file from JSON file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 404, 'should respond with 404'); + }); + }); + + describe('once an http server is listening with malformed JSON files configuration', function () { + before(function () { + fileServer = new statik.Server(__dirname + '/../fixtures/index-with-malformed-json-files'); + }); + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + + it('returns 404 with missing file from JSON file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 404, 'should respond with 404'); + }); + }); + + describe('default extension', function () { + beforeEach(async function () { + await setupStaticServer(this); + }); + afterEach(async function () { + this.server.close(); + }); + it('finding a file by default extension', async function() { + fileServer = new statik.Server(__dirname+'/../fixtures', {defaultExtension: "txt"}); + + const response = await fetch(this.getTestServer() + '/hello'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('content-type'), 'text/plain', 'should respond with text/plain'); + assert.equal(await response.text(), 'hello world', 'should respond with hello world'); + }); + it('responds with 404 if default extension result not found', async function() { + fileServer = new statik.Server(__dirname+'/../fixtures', {defaultExtension: "txt"}); + + const response = await fetch(this.getTestServer() + '/bad-file'); + + assert.equal(response.status, 404, 'should respond with 404'); + }); + it('default extension does not interfere with folders', async function () { + fileServer = new statik.Server(__dirname+'/../fixtures', {defaultExtension: "html"}); + + const response = await fetch( + this.getTestServer() + '/there', + {redirect: 'manual'} + ); // without trailing slash + + assert.equal(response.status, 301, 'should respond with 301'); + + // Todo: set location `endsWith` check back to equality check + // when this may be fixed: + // https://github.com/node-fetch/node-fetch/issues/1086 + // assert.equal(response.headers.get('location'), '/there/', 'should respond with location header'); // now with trailing slash + assert(response.headers.get('location')?.endsWith('/there/'), 'should respond with location header'); // now with trailing slash + + assert.equal(await response.text(), '', 'should respond with empty string body'); + }); + }); + describe('once an http server is listening with custom cache configuration', function () { + beforeEach(async function () { + await setupStaticServer(this); + fileServer = new statik.Server(__dirname + '/../fixtures', { + cache: { + '**/*.txt': 100, + '**/': 300 + } + }); + }); + afterEach(async function () { + this.server.close(); + }); + + it('requesting custom cache index file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'max-age=300', 'should respond with cache-control'); + }); + it('requesting custom cache text file', async function () { + const response = await fetch(this.getTestServer() + '/hello.txt'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'max-age=100', 'should respond with cache-control'); + }); + it('requesting custom cache un-cached file', async function () { + const response = await fetch(this.getTestServer() + '/empty.css'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), undefined, 'should not respond with cache-control'); + }); + }); + + describe('once an http server is listening with numeric custom cache configuration', function () { + beforeEach(async function () { + await setupStaticServer(this); + fileServer = new statik.Server(__dirname + '/../fixtures', { + cache: 300 + }); + }); + afterEach(async function () { + this.server.close(); + }); + + it('requesting custom cache index file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'max-age=300', 'should respond with cache-control'); + }); + it('requesting custom cache text file', async function () { + const response = await fetch(this.getTestServer() + '/hello.txt'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'max-age=300', 'should respond with cache-control'); + }); + it('requesting custom cache un-cached file', async function () { + const response = await fetch(this.getTestServer() + '/empty.css'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'max-age=300', 'should respond with cache-control'); + }); + }); + + describe('once an http server is listening with null custom cache configuration', function () { + beforeEach(async function () { + await setupStaticServer(this); + fileServer = new statik.Server(__dirname + '/../fixtures', { + cache: null + }); + }); + afterEach(async function () { + this.server.close(); + }); + + it('requesting custom cache index file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), null, 'should not respond with cache-control'); + }); + it('requesting custom cache text file', async function () { + const response = await fetch(this.getTestServer() + '/hello.txt'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), null, 'should not respond with cache-control'); + }); + it('requesting custom cache un-cached file', async function () { + const response = await fetch(this.getTestServer() + '/empty.css'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), null, 'should not respond with cache-control'); + }); + }); + + describe('once an http server is listening with false custom cache configuration', function () { + beforeEach(async function () { + await setupStaticServer(this); + fileServer = new statik.Server(__dirname + '/../fixtures', { + cache: false + }); + }); + afterEach(async function () { + this.server.close(); + }); + + it('requesting custom cache index file', async function () { + const response = await fetch(this.getTestServer() + '/'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'no-cache', 'should respond with cache-control'); + }); + it('requesting custom cache text file', async function () { + const response = await fetch(this.getTestServer() + '/hello.txt'); + + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'no-cache', 'should respond with cache-control'); + }); + it('requesting custom cache un-cached file', async function () { + const response = await fetch(this.getTestServer() + '/empty.css'); + assert.equal(response.status, 200, 'should respond with 200'); + assert.equal(response.headers.get('cache-control'), 'no-cache', 'should respond with cache-control'); + }); + }); +}); diff --git a/test/integration/respondNoGzip-callback-headers-test.js b/test/integration/respondNoGzip-callback-headers-test.js new file mode 100644 index 0000000..1637b1c --- /dev/null +++ b/test/integration/respondNoGzip-callback-headers-test.js @@ -0,0 +1,40 @@ +import {assert} from 'chai'; +import * as statik from '../../lib/node-static.js'; + +it('respondNoGzip calls finish with 500 when stream callback errors and headers not sent', function (done) { + const s = new statik.Server('./'); + + // monkeypatch stream to synchronously invoke callback with error + s.stream = function (key, files, length, startByte, res, req, applyTransform, cb) { + if (typeof cb === 'function') cb(new Error('stream-cb-error')); + }; + + // fake req/stat/res + + const req = { headers: {} }; + const stat = { size: 0, mtime: new Date(), ino: 1 }; + + /** @type {import('node:http').OutgoingHttpHeaders} */ + const headers = {}; + + // res with writeHead that does not set headersSent + const res = { + getHeaders() { return {}; }, + headersSent: false, + writeHead() { /* intentionally doesn't set headersSent */ }, + end() { /* noop */ } + }; + + s.respondNoGzip( + null, 200, 'text/plain', headers, ['file.txt'], stat, + // @ts-expect-error Just a stub + req, + res, + function (status, hdrs) { + try { + assert.equal(status, 500); + done(); + } catch (err) { done(err); } + } + ); +}); diff --git a/test/integration/respondNoGzip-headers-sent-on-stream-error-test.js b/test/integration/respondNoGzip-headers-sent-on-stream-error-test.js new file mode 100644 index 0000000..f13c8cb --- /dev/null +++ b/test/integration/respondNoGzip-headers-sent-on-stream-error-test.js @@ -0,0 +1,33 @@ +import http from 'http'; +import fetch from 'node-fetch'; +import {assert} from 'chai'; +import * as statik from '../../lib/node-static.js'; + +const __dirname = import.meta.dirname; + +it('respondNoGzip handles stream errors after headers sent by ending response', function (done) { + const serverObj = new statik.Server(__dirname + '/../fixtures'); + + const server = http.createServer(function (req, res) { + // Create a fake stat object with necessary fields. Use size=0 so + // a prematurely ended response doesn't block waiting for bytes. + const stat = { size: 0, mtime: new Date(), ino: 1 }; + // Call respondNoGzip with a file that does not exist to force a read error + serverObj.respondNoGzip(null, 200, 'text/plain', {}, ['this-file-does-not-exist.txt'], stat, req, res, function () { + // finish callback shouldn't be called in this path + }); + }); + + server.listen(9040, async () => { + try { + const resp = await fetch('http://localhost:9040/this-file-does-not-exist.txt'); + // Because headers were sent before streaming, the response should be 200 + assert.equal(resp.status, 200); + server.close(); + done(); + } catch (e) { + server.close(); + done(e); + } + }); +}); diff --git a/test/integration/respondNoGzip-res-end-throw-test.js b/test/integration/respondNoGzip-res-end-throw-test.js new file mode 100644 index 0000000..8c1d68b --- /dev/null +++ b/test/integration/respondNoGzip-res-end-throw-test.js @@ -0,0 +1,47 @@ +import {assert} from 'chai'; +import * as statik from '../../lib/node-static.js'; + +it('respondNoGzip swallows res.end() errors when headersSent is true', function (done) { + const serverObj = new statik.Server('.'); + + // Fake request and stat + const req = { headers: {} }; + const stat = { size: 10, mtime: new Date(), ino: 1 }; + + // Track if finish was called + let finishCalled = false; + + // Fake response with headersSent true and end throws + let endCalls = 0; + const res = { + headersSent: true, + writeHead() {}, + getHeaders() { return {}; }, + end() { endCalls += 1; throw new Error('end-throws'); } + }; + + // Stub stream to immediately call callback with an error + serverObj.stream = function (key, files, length, startByte, resp, req2, applyTransform, cb) { + // simulate async + process.nextTick(() => cb && cb(new Error('stream-error'))); + }; + + serverObj.respondNoGzip( + null, 200, 'text/plain', {}, ['missing.txt'], stat, + // @ts-expect-error Just a stub + req, + res, + function () { + finishCalled = true; + } + ); + + // Give the nextTick a chance + setTimeout(() => { + try { + assert.isFalse(finishCalled, 'finish should not be called when headers were sent'); + assert.equal(endCalls, 1, 'res.end should have been invoked once'); + done(); + } catch (e) { done(e); } + }, 10); +}); diff --git a/test/integration/stream-internal-factory-throws-test.js b/test/integration/stream-internal-factory-throws-test.js new file mode 100644 index 0000000..8d751ce --- /dev/null +++ b/test/integration/stream-internal-factory-throws-test.js @@ -0,0 +1,31 @@ +import http from 'http'; +import {assert} from 'chai'; +import fetch from 'node-fetch'; +import * as statik from '../../lib/node-static.js'; + +const __dirname = import.meta.dirname; + +it('stream reports error when transform factory throws inside stream (direct stream call)', function (done) { + const factory = () => { throw new Error('factory-stream-failed'); }; + const serverObj = new statik.Server(__dirname + '/../fixtures', { transform: factory }); + + const server = http.createServer(function (request, response) { + serverObj.stream(null, ['hello.txt'], 11, 0, response, request, true, (err) => { + try { + assert.isNotNull(err, 'expected an error from transform factory'); + assert.equal(err.message, 'factory-stream-failed'); + response.end(); + server.close(); + done(); + } catch (e) { + server.close(); + done(e); + } + }); + }); + + server.listen(9030, async () => { + // Trigger the request handler + await fetch('http://localhost:9030/hello.txt').catch(() => {}); + }); +}); diff --git a/test/integration/transform-factory-edgecases-test.js b/test/integration/transform-factory-edgecases-test.js new file mode 100644 index 0000000..205e7be --- /dev/null +++ b/test/integration/transform-factory-edgecases-test.js @@ -0,0 +1,44 @@ +import http from 'http'; +import {assert} from 'chai'; +import fetch from 'node-fetch'; +import * as statik from '../../lib/node-static.js'; + +const __dirname = import.meta.dirname; + +it('responds 500 when transform factory throws', async function () { + const factory = () => { throw new Error('factory failed'); }; + + const server = http.createServer((req, res) => { + const s = new statik.Server(__dirname + '/../fixtures', { transform: factory }); + s.serve(req, res); + }).listen(9020); + + const response = await fetch('http://localhost:9020/hello.txt'); + assert.equal(response.status, 500); + + server.close(); +}); + +it('falls back to piping original stream when factory returns non-stream', async function () { + let called = false; + + /** @type {import('../../lib/node-static.js').TransformCallback} */ + const factory = () => { + called = true; + // @ts-expect-error Just testing + return null; + }; + + const server = http.createServer((req, res) => { + const s = new statik.Server(__dirname + '/../fixtures', { transform: factory }); + s.serve(req, res); + }).listen(9021); + + const response = await fetch('http://localhost:9021/hello.txt'); + assert.equal(response.status, 200); + const body = await response.text(); + assert.isTrue(called, 'factory should have been called'); + assert.equal(body, 'hello world'); + + server.close(); +}); diff --git a/test/integration/transform-factory-test.js b/test/integration/transform-factory-test.js new file mode 100644 index 0000000..d00082e --- /dev/null +++ b/test/integration/transform-factory-test.js @@ -0,0 +1,33 @@ +import http from 'http'; +import {assert} from 'chai'; +import fetch from 'node-fetch'; +import {Transform} from 'stream'; +import * as statik from '../../lib/node-static.js'; + +const __dirname = import.meta.dirname; + +it('invokes custom transform factory and applies its transform', async function () { + let called = false; + + const factory = (/* file, pathname, req, res */) => { + called = true; + return new Transform({ + transform(chunk, _enc, cb) { + cb(null, chunk.toString().toUpperCase() + '\n--CUSTOM--'); + } + }); + }; + + const server = http.createServer((req, res) => { + const s = new statik.Server(__dirname + '/../fixtures', { transform: factory }); + s.serve(req, res); + }).listen(9010); + + const response = await fetch('http://localhost:9010/hello.txt'); + assert.equal(response.status, 200); + const body = await response.text(); + assert.isTrue(called, 'factory should have been called'); + assert.equal(body, 'HELLO WORLD\n--CUSTOM--'); + + server.close(); +}); diff --git a/test/integration/transform-runtime-error-test.js b/test/integration/transform-runtime-error-test.js new file mode 100644 index 0000000..e470921 --- /dev/null +++ b/test/integration/transform-runtime-error-test.js @@ -0,0 +1,28 @@ +import http from 'http'; +import {assert} from 'chai'; +import fetch from 'node-fetch'; +import {Transform} from 'stream'; +import * as statik from '../../lib/node-static.js'; + +const __dirname = import.meta.dirname; + +it('handles transform runtime error emitted from transform stream', function (done) { + const factory = () => new Transform({ + transform(chunk, _enc, cb) { + cb(new Error('transform runtime error')); + } + }); + + const s = new statik.Server(__dirname + '/../fixtures', { transform: factory }); + + const server = http.createServer((req, res) => { + s.serve(req, res); + }).listen(9031, async () => { + const response = await fetch('http://localhost:9031/hello.txt'); + // When transform errors, server should still respond (possibly 500), + // but not crash + assert.oneOf(response.status, [200, 500]); + server.close(); + done(); + }); +}); diff --git a/test/utils/spawnPromise.js b/test/utils/spawnPromise.js new file mode 100644 index 0000000..efc931a --- /dev/null +++ b/test/utils/spawnPromise.js @@ -0,0 +1,202 @@ +import {spawn} from 'child_process'; + +/** + * @typedef {object} SpawnOptions + */ + +/** +* @callback EventWatcher +* @param {string} stdout Aggregate stdout +* @param {string} data +* @returns {Promise|void} +*/ + +/** + * Control expiration of spawn with a user timeout + * @overload + * @param {string} path + * @param {SpawnOptions} [opts] Spawn options + * @param {string[]} [args] + * @param {number} [killDelay] + * @param {EventWatcher|null} [watchEvents] + * @returns {Promise<{ + * stdout: string, + * stderr: string + * }|void>} + */ + +/** + * Control expiration of spawn with a user timeout + * @overload + * @param {string} path + * @param {string[]} [args] + * @param {number} [killDelay] + * @param {EventWatcher|null} [watchEvents] + * @returns {Promise<{ + * stdout: string, + * stderr: string + * }|void>} + */ + +/** + * Control expiration of spawn with a user timeout + * @param {string} path + * @param {SpawnOptions|string[]} [opts] + * @param {string[]|number} [args] + * @param {number|EventWatcher|null} [killDelay] + * @param {EventWatcher|null} [watchEvents] + */ +const spawnPromise = ( + path, opts, args, killDelay, watchEvents = null +) => { + if (Array.isArray(opts)) { + watchEvents = /** @type {EventWatcher|null} */ (killDelay); + killDelay = /** @type {number} */ (args); + args = opts; + opts = undefined; + } + if (!killDelay) { + killDelay = 10000; + } + + return new Promise((resolve, reject) => { + let stderr = '', stdout = ''; + const cli = spawn( + path, + /** @type {string[]} */ (args), + opts + ); + cli.stdout.on('data', (data) => { + stdout += data; + if (watchEvents) { + watchEvents(stdout, data); + } + }); + + cli.stderr.on('data', (data) => { + stderr += data; + }); + + cli.on('error', (data) => { + reject(data); + }); + + cli.on('close', (code) => { + resolve({ + stdout, + stderr + }); + }); + // Todo: We should really just signal this when we know the server + // is running + setTimeout(() => { + cli.kill(); + }, /** @type {number} */ (killDelay)); + }); +}; + +/** + * @typedef {{ + * condition: string|RegExp|((stdout: string) => boolean) + * action: () => void + * error?: (err: Error) => void + * }} AwaitInfo + */ + +/** + * @overload + * @param {string} binFile + * @param {SpawnOptions} opts + * @param {string[]} args + * @param {number} killDelay + * @param {AwaitInfo} awaitInfo + * @returns {Promise} + */ + +/** + * @overload + * @param {string} binFile + * @param {string[]} args + * @param {number} killDelay + * @param {AwaitInfo} awaitInfo + * @returns {Promise} + */ + +/** + * @param {string} binFile + * @param {SpawnOptions|string[]|undefined} opts + * @param {string[]|number} args + * @param {number|AwaitInfo} killDelay + * @param {AwaitInfo} awaitInfo + */ +const spawnConditional = async ( + binFile, opts, args, killDelay, awaitInfo +) => { + if (Array.isArray(opts)) { + awaitInfo = /** @type {AwaitInfo} */ (killDelay); + killDelay = /** @type {number} */ (args); + args = /** @type {string[]} */ (opts); + opts = undefined; + } + const { + condition, + action: actionCallback, + error: errBack + } = awaitInfo; + + /** @type {boolean} */ + let awaiting; + /** + * @type {Promise<{ + * stdout: string; + * stderr: string; + * }|void>} + */ + let cliProm; + const response = await new Promise((resolve, reject) => { + cliProm = spawnPromise( + binFile, + opts, + /** @type {string[]} */ + (args), + /** @type {number} */ + (killDelay), + + async (stdout) => { + if (awaiting) { + return; + } + if (typeof condition === 'string' + ? !stdout.includes(condition) + : 'exec' in condition && 'test' in condition + ? !condition.test(stdout) + : condition(stdout) + ) { + return; + } + awaiting = true; + try { + const resp = await actionCallback(); + resolve(resp); + } catch (err) { + reject(err); + } + } + ); + }); + // @ts-expect-error Ok + const {stderr, stdout} = await cliProm; + if (stderr && errBack) { + errBack(new Error(stderr)); + return; + } + return {response, stdout}; +}; + +export {spawnPromise, spawnConditional}; diff --git a/tsconfig-prod.json b/tsconfig-prod.json new file mode 100644 index 0000000..84e8637 --- /dev/null +++ b/tsconfig-prod.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["es2024"], + "moduleResolution": "nodenext", + "module": "NodeNext", + "skipLibCheck": true, + "allowJs": true, + "checkJs": true, + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "strict": true, + "target": "es2024", + "outDir": "dist" + }, + "include": ["lib/**/*.js", "bin/**/*.js"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a9dba7f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["es2024"], + "moduleResolution": "nodenext", + "module": "NodeNext", + "skipLibCheck": true, + "allowJs": true, + "checkJs": true, + "noEmit": true, + "declaration": true, + "declarationMap": true, + "strict": true, + "target": "es2024", + "outDir": "dist" + }, + "include": ["lib/**/*.js", "bin/**/*.js", "test/**/*.js"], + "exclude": ["node_modules", "test/integration/commonjs.js"] +}