From c2dae0d5f1b69ed0eec2733b1df0bca83c99c21f Mon Sep 17 00:00:00 2001 From: Florens Verschelde <243601+fvsch@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:55:51 +0100 Subject: [PATCH] refactor: use .ts sources and vitest --- .github/workflows/test.yml | 2 +- .gitignore | 1 + globals.d.ts | 9 - lib/utils.js | 107 -- package-lock.json | 1304 ++++++++++++++++++- package.json | 17 +- scripts/{bundle.js => prebuild.js} | 26 +- lib/args.js => src/args.ts | 89 +- lib/cli.js => src/cli.ts | 95 +- lib/constants.js => src/constants.ts | 13 +- lib/content-type.js => src/content-type.ts | 57 +- lib/fs-utils.js => src/fs-utils.ts | 45 +- lib/handler.js => src/handler.ts | 176 ++- lib/index.js => src/index.ts | 0 lib/logger.js => src/logger.ts | 127 +- lib/options.js => src/options.ts | 102 +- lib/page-assets.js => src/page-assets.ts | 0 lib/pages.js => src/pages.ts | 75 +- lib/path-matcher.js => src/path-matcher.ts | 32 +- lib/resolver.js => src/resolver.ts | 110 +- {lib => src}/types.d.ts | 13 + src/utils.ts | 96 ++ test/{args.test.js => args.test.ts} | 221 ++-- test/content-type.test.js | 153 --- test/content-type.test.ts | 142 ++ test/{fs-utils.test.js => fs-utils.test.ts} | 48 +- test/{handler.test.js => handler.test.ts} | 203 ++- test/{logger.test.js => logger.test.ts} | 55 +- test/{options.test.js => options.test.ts} | 53 +- test/package.test.js | 25 - test/package.test.ts | 24 + test/pages.test.js | 202 --- test/pages.test.ts | 173 +++ test/path-matcher.test.js | 91 -- test/path-matcher.test.ts | 105 ++ test/resolver.test.js | 301 ----- test/resolver.test.ts | 280 ++++ test/{shared.js => shared.ts} | 51 +- test/{jsconfig.json => tsconfig.json} | 6 +- test/utils.test.js | 240 ---- test/utils.test.ts | 242 ++++ jsconfig.json => tsconfig.json | 8 +- 42 files changed, 3094 insertions(+), 2025 deletions(-) delete mode 100644 globals.d.ts delete mode 100644 lib/utils.js rename scripts/{bundle.js => prebuild.js} (60%) rename lib/args.js => src/args.ts (70%) rename lib/cli.js => src/cli.ts (77%) rename lib/constants.js => src/constants.ts (82%) rename lib/content-type.js => src/content-type.ts (86%) rename lib/fs-utils.js => src/fs-utils.ts (64%) rename lib/handler.js => src/handler.ts (74%) rename lib/index.js => src/index.ts (100%) rename lib/logger.js => src/logger.ts (65%) rename lib/options.js => src/options.ts (62%) rename lib/page-assets.js => src/page-assets.ts (100%) rename lib/pages.js => src/pages.ts (70%) rename lib/path-matcher.js => src/path-matcher.ts (72%) rename lib/resolver.js => src/resolver.ts (58%) rename {lib => src}/types.d.ts (86%) create mode 100644 src/utils.ts rename test/{args.test.js => args.test.ts} (56%) delete mode 100644 test/content-type.test.js create mode 100644 test/content-type.test.ts rename test/{fs-utils.test.js => fs-utils.test.ts} (58%) rename test/{handler.test.js => handler.test.ts} (69%) rename test/{logger.test.js => logger.test.ts} (64%) rename test/{options.test.js => options.test.ts} (83%) delete mode 100644 test/package.test.js create mode 100644 test/package.test.ts delete mode 100644 test/pages.test.js create mode 100644 test/pages.test.ts delete mode 100644 test/path-matcher.test.js create mode 100644 test/path-matcher.test.ts delete mode 100644 test/resolver.test.js create mode 100644 test/resolver.test.ts rename test/{shared.js => shared.ts} (52%) rename test/{jsconfig.json => tsconfig.json} (68%) delete mode 100644 test/utils.test.js create mode 100644 test/utils.test.ts rename jsconfig.json => tsconfig.json (56%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 965a654..a7e78c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [20, 22] + node: [18, 22] name: Run tests on Node ${{ matrix.node }} steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 60877d1..6da00ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +/lib /node_modules /*.tgz diff --git a/globals.d.ts b/globals.d.ts deleted file mode 100644 index 9606243..0000000 --- a/globals.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare var Bun: Bun | undefined; -declare var Deno: Deno | undefined; - -interface Bun {} - -interface Deno { - noColor: boolean; - permissions: any; -} diff --git a/lib/utils.js b/lib/utils.js deleted file mode 100644 index 0edbb44..0000000 --- a/lib/utils.js +++ /dev/null @@ -1,107 +0,0 @@ -import { env, versions } from 'node:process'; - -/** @type {(value: number, min: number, max: number) => number} */ -export function clamp(value, min, max) { - if (typeof value !== 'number') value = min; - return Math.min(max, Math.max(min, value)); -} - -/** @type {(input: string, context?: 'text' | 'attr') => string} */ -export function escapeHtml(input, context = 'text') { - if (typeof input !== 'string') return ''; - let result = input.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); - if (context === 'attr') result = result.replaceAll(`"`, '"').replaceAll(`'`, '''); - return result; -} - -/** @type {() => { (msg: string): void; list: string[] }} */ -export function errorList() { - /** @type {string[]} */ - const list = []; - const fn = (msg = '') => list.push(msg); - fn.list = list; - return fn; -} - -/** @type {(input: string) => string} */ -export function fwdSlash(input = '') { - return input.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); -} - -/** @type {(key: string) => string} */ -export function getEnv(key) { - return env[key] ?? ''; -} - -/** @type {() => 'bun' | 'deno' | 'node' | 'webcontainer'} */ -export const getRuntime = once(() => { - if (versions.bun && globalThis.Bun) return 'bun'; - if (versions.deno && globalThis.Deno) return 'deno'; - if (versions.webcontainer && getEnv('SHELL').endsWith('/jsh')) return 'webcontainer'; - return 'node'; -}); - -/** @type {(name: string) => string} */ -export function headerCase(name) { - return name.replace(/((^|\b|_)[a-z])/g, (s) => s.toUpperCase()); -} - -/** @type {(address: string) => boolean} */ -export function isPrivateIPv4(address = '') { - const bytes = address.split('.').map(Number); - if (bytes.length !== 4) return false; - for (const byte of bytes) { - if (!(byte >= 0 && byte <= 255)) return false; - } - return ( - // 10/8 - bytes[0] === 10 || - // 172.16/12 - (bytes[0] === 172 && bytes[1] >= 16 && bytes[1] < 32) || - // 192.168/16 - (bytes[0] === 192 && bytes[1] === 168) - ); -} - -/** @type {(start: number, end: number, limit?: number) => number[]} */ -export function intRange(start, end, limit = 1_000) { - for (const [key, val] of Object.entries({ start, end, limit })) { - if (!Number.isSafeInteger(val)) throw new Error(`Invalid ${key} param: ${val}`); - } - const length = Math.min(Math.abs(end - start) + 1, Math.abs(limit)); - const increment = start < end ? 1 : -1; - return Array(length) - .fill(undefined) - .map((_, i) => start + i * increment); -} - -/** -Cache a function's result after the first call -@type {(fn: () => Result) => () => Result} -*/ -export function once(fn) { - /** @type {ReturnType} */ - let value; - return () => { - if (typeof value === 'undefined') value = fn(); - return value; - }; -} - -/** -@type {(input: string, options?: { start?: boolean; end?: boolean }) => string} -*/ -export function trimSlash(input = '', { start, end } = { start: true, end: true }) { - if (start === true) input = input.replace(/^[\/\\]/, ''); - if (end === true) input = input.replace(/[\/\\]$/, ''); - return input; -} - -export function withResolvers() { - /** @type {{ resolve: (value?: any) => void; reject: (reason?: any) => void }} */ - let resolvers = { resolve: () => {}, reject: () => {} }; - const promise = new Promise((resolve, reject) => { - resolvers = { resolve, reject }; - }); - return { promise, ...resolvers }; -} diff --git a/package-lock.json b/package-lock.json index 46877d4..fd8db8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,667 @@ "fs-fixture": "^2.6.0", "linkedom": "^0.18.5", "prettier": "^3.3.3", - "typescript": "~5.6.3" + "typescript": "~5.6.3", + "vitest": "^2.1.5" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.26.0.tgz", + "integrity": "sha512-gJNwtPDGEaOEgejbaseY6xMFu+CPltsc8/T+diUTTbOQLqD+bnrJq9ulH6WD69TqwqWmrfRAtUv30cCFZlbGTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.26.0.tgz", + "integrity": "sha512-YJa5Gy8mEZgz5JquFruhJODMq3lTHWLm1fOy+HIANquLzfIOzE9RA5ie3JjCdVb9r46qfAQY/l947V0zfGJ0OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.26.0.tgz", + "integrity": "sha512-ErTASs8YKbqTBoPLp/kA1B1Um5YSom8QAc4rKhg7b9tyyVqDBlQxy7Bf2wW7yIlPGPg2UODDQcbkTlruPzDosw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.26.0.tgz", + "integrity": "sha512-wbgkYDHcdWW+NqP2mnf2NOuEbOLzDblalrOWcPyY6+BRbVhliavon15UploG7PpBRQ2bZJnbmh8o3yLoBvDIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.26.0.tgz", + "integrity": "sha512-Y9vpjfp9CDkAG4q/uwuhZk96LP11fBz/bYdyg9oaHYhtGZp7NrbkQrj/66DYMMP2Yo/QPAsVHkV891KyO52fhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.26.0.tgz", + "integrity": "sha512-A/jvfCZ55EYPsqeaAt/yDAG4q5tt1ZboWMHEvKAH9Zl92DWvMIbnZe/f/eOXze65aJaaKbL+YeM0Hz4kLQvdwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.26.0.tgz", + "integrity": "sha512-paHF1bMXKDuizaMODm2bBTjRiHxESWiIyIdMugKeLnjuS1TCS54MF5+Y5Dx8Ui/1RBPVRE09i5OUlaLnv8OGnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.26.0.tgz", + "integrity": "sha512-cwxiHZU1GAs+TMxvgPfUDtVZjdBdTsQwVnNlzRXC5QzIJ6nhfB4I1ahKoe9yPmoaA/Vhf7m9dB1chGPpDRdGXg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.26.0.tgz", + "integrity": "sha512-4daeEUQutGRCW/9zEo8JtdAgtJ1q2g5oHaoQaZbMSKaIWKDQwQ3Yx0/3jJNmpzrsScIPtx/V+1AfibLisb3AMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.26.0.tgz", + "integrity": "sha512-eGkX7zzkNxvvS05ROzJ/cO/AKqNvR/7t1jA3VZDi2vRniLKwAWxUr85fH3NsvtxU5vnUUKFHKh8flIBdlo2b3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.26.0.tgz", + "integrity": "sha512-Odp/lgHbW/mAqw/pU21goo5ruWsytP7/HCC/liOt0zcGG0llYWKrd10k9Fj0pdj3prQ63N5yQLCLiE7HTX+MYw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.26.0.tgz", + "integrity": "sha512-MBR2ZhCTzUgVD0OJdTzNeF4+zsVogIR1U/FsyuFerwcqjZGvg2nYe24SAHp8O5sN8ZkRVbHwlYeHqcSQ8tcYew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.26.0.tgz", + "integrity": "sha512-YYcg8MkbN17fMbRMZuxwmxWqsmQufh3ZJFxFGoHjrE7bv0X+T6l3glcdzd7IKLiwhT+PZOJCblpnNlz1/C3kGQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.26.0.tgz", + "integrity": "sha512-ZuwpfjCwjPkAOxpjAEjabg6LRSfL7cAJb6gSQGZYjGhadlzKKywDkCUnJ+KEfrNY1jH5EEoSIKLCb572jSiglA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.26.0.tgz", + "integrity": "sha512-+HJD2lFS86qkeF8kNu0kALtifMpPCZU80HvwztIKnYwym3KnA1os6nsX4BGSTLtS2QVAGG1P3guRgsYyMA0Yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.26.0.tgz", + "integrity": "sha512-WUQzVFWPSw2uJzX4j6YEbMAiLbs0BUysgysh8s817doAYhR5ybqTI1wtKARQKo6cGop3pHnrUJPFCsXdoFaimQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.26.0.tgz", + "integrity": "sha512-D4CxkazFKBfN1akAIY6ieyOqzoOoBV1OICxgUblWxff/pSjCA2khXlASUx7mK6W1oP4McqhgcCsu6QaLj3WMWg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.26.0.tgz", + "integrity": "sha512-2x8MO1rm4PGEP0xWbubJW5RtbNLk3puzAMaLQd3B3JHVw4KcHlmXcO+Wewx9zCoo7EUFiMlu/aZbCJ7VjMzAag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", @@ -29,6 +687,129 @@ "undici-types": "~6.19.2" } }, + "node_modules/@vitest/expect": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.5.tgz", + "integrity": "sha512-nZSBTW1XIdpZvEJyoP/Sy8fUg0b8od7ZpGDkTUcfJ7wz/VoZAFzFfLyxVxGFhUjJzhYqSbIpfMtl/+k/dpWa3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.5.tgz", + "integrity": "sha512-XYW6l3UuBmitWqSUXTNXcVBUCRytDogBsWuNXQijc00dtnU/9OqpXWp4OJroVrad/gLIomAq9aW8yWDBtMthhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.5.tgz", + "integrity": "sha512-4ZOwtk2bqG5Y6xRGHcveZVr+6txkH7M2e+nPFd6guSoN638v/1XQ0K06eOpi0ptVU/2tW/pIU4IoPotY/GZ9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.5.tgz", + "integrity": "sha512-pKHKy3uaUdh7X6p1pxOkgkVAFW7r2I818vHDthYLvUyjRfkKOU6P45PztOch4DZarWQne+VOaIMwA/erSSpB9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.5", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.5.tgz", + "integrity": "sha512-zmYw47mhfdfnYbuhkQvkkzYroXUumrwWDGlMjpdUr4jBd3HZiV2w7CQHj+z7AAS4VOtWxI4Zt4bWt4/sKcoIjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.5", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.5.tgz", + "integrity": "sha512-aWZF3P0r3w6DiYTVskOYuhBc7EMc3jvn1TkBg8ttylFFRqNN2XGD7V5a4aQdk6QiUzZQ4klNBSpCLJgWNdIiNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.5.tgz", + "integrity": "sha512-yfj6Yrp0Vesw2cwJbP+cl04OC+IHFsuQsrsJBL9pyGeQXE56v1UAOQco+SR55Vf1nQzfV0QJg1Qum7AaWUwwYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.5", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -36,6 +817,43 @@ "dev": true, "license": "ISC" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -73,6 +891,34 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -145,6 +991,72 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fs-fixture": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/fs-fixture/-/fs-fixture-2.6.0.tgz", @@ -158,6 +1070,21 @@ "url": "https://github.com/privatenumber/fs-fixture?sponsor=1" } }, + "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/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -199,6 +1126,49 @@ "uhyphen": "^0.2.0" } }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "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/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -212,6 +1182,59 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prettier": { "version": "3.3.3", "dev": true, @@ -226,6 +1249,119 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/rollup": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.26.0.tgz", + "integrity": "sha512-ilcl12hnWonG8f+NxU6BlgysVA0gvY2l8N0R84S1HcINbW20bvwuCngJkkInV6LXhwRpucsW5k1ovDwEdBVrNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.26.0", + "@rollup/rollup-android-arm64": "4.26.0", + "@rollup/rollup-darwin-arm64": "4.26.0", + "@rollup/rollup-darwin-x64": "4.26.0", + "@rollup/rollup-freebsd-arm64": "4.26.0", + "@rollup/rollup-freebsd-x64": "4.26.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.26.0", + "@rollup/rollup-linux-arm-musleabihf": "4.26.0", + "@rollup/rollup-linux-arm64-gnu": "4.26.0", + "@rollup/rollup-linux-arm64-musl": "4.26.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.26.0", + "@rollup/rollup-linux-riscv64-gnu": "4.26.0", + "@rollup/rollup-linux-s390x-gnu": "4.26.0", + "@rollup/rollup-linux-x64-gnu": "4.26.0", + "@rollup/rollup-linux-x64-musl": "4.26.0", + "@rollup/rollup-win32-arm64-msvc": "4.26.0", + "@rollup/rollup-win32-ia32-msvc": "4.26.0", + "@rollup/rollup-win32-x64-msvc": "4.26.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", + "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -253,6 +1389,172 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.5.tgz", + "integrity": "sha512-rd0QIgx74q4S1Rd56XIiL2cYEdyWn13cunYBIuqh9mpmQr7gGS0IxXoP8R6OaZtNQQLyXSWbd4rXKYUbhFpK5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.5.tgz", + "integrity": "sha512-P4ljsdpuzRTPI/kbND2sDZ4VmieerR2c9szEZpjc+98Z9ebvnXmM5+0tHEKqYZumXqlvnmfWsjeFOjXVriDG7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.5", + "@vitest/mocker": "2.1.5", + "@vitest/pretty-format": "^2.1.5", + "@vitest/runner": "2.1.5", + "@vitest/snapshot": "2.1.5", + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.5", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.5", + "@vitest/ui": "2.1.5", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index a725b80..9db8ff3 100644 --- a/package.json +++ b/package.json @@ -36,17 +36,22 @@ "./README.md" ], "scripts": { - "prepack": "npm run build && npm run typecheck && npm test", - "build": "node scripts/bundle.js", - "format": "prettier --write '**/*.{js,css}' '**/*config*.json'", - "test": "node --test --test-reporter=spec", - "typecheck": "tsc -p jsconfig.json && tsc -p test/jsconfig.json" + "prepack": "npm run build && npm test", + "build": "node scripts/prebuild.js && tsc -p tsconfig.json --listEmittedFiles && prettier --write 'lib/*.{js,ts}'", + "format": "prettier --write '**/*.{css,js,ts}' '**/*config*.json'", + "test": "vitest --run test/*.test.ts", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p test/tsconfig.json --noEmit" }, "devDependencies": { "@types/node": "^20.17.6", "fs-fixture": "^2.6.0", "linkedom": "^0.18.5", "prettier": "^3.3.3", - "typescript": "~5.6.3" + "typescript": "~5.6.3", + "vitest": "^2.1.5" + }, + "imports": { + "#src/*.js": "./src/*.js", + "#types": "./src/types.d.ts" } } diff --git a/scripts/bundle.js b/scripts/prebuild.js similarity index 60% rename from scripts/bundle.js rename to scripts/prebuild.js index 5e3cceb..8b72b86 100644 --- a/scripts/bundle.js +++ b/scripts/prebuild.js @@ -1,14 +1,20 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -bundleAssets(); +main(); + +async function main() { + await bundleAssets(); + await cleanLib(); +} /** Read text files from the assets folder and write */ async function bundleAssets() { - const outPath = 'lib/page-assets.js'; + const outPath = pkgFilePath('src/page-assets.ts'); + console.log(`Updating assets bundle:\n ${outPath}\n`); + const assets = { FAVICON_ERROR: await readPkgFile('assets/favicon-error.svg'), FAVICON_LIST: await readPkgFile('assets/favicon-list.svg'), @@ -22,13 +28,17 @@ async function bundleAssets() { return `export const ${key} = \`${escape(minify(contents))}\`;`; }); - await writeFile(pkgFilePath(outPath), out.join('\n\n') + '\n'); - console.log('Updated ' + outPath); + await writeFile(outPath, out.join('\n\n') + '\n'); +} + +async function cleanLib() { + const libDir = pkgFilePath('lib'); + console.log(`Deleting lib dir:\n ${libDir}\n`); + await rm(libDir, { recursive: true, force: true }); } export function pkgFilePath(localPath = '') { - const dirname = fileURLToPath(new URL('.', import.meta.url)); - return join(dirname, '..', localPath); + return join(import.meta.dirname, '..', localPath); } export async function readPkgFile(localPath = '') { diff --git a/lib/args.js b/src/args.ts similarity index 70% rename from lib/args.js rename to src/args.ts index dbde0e2..bb1be08 100644 --- a/lib/args.js +++ b/src/args.ts @@ -1,26 +1,18 @@ import { CLI_OPTIONS, PORTS_CONFIG } from './constants.js'; +import type { HttpHeaderRule, OptionSpec, ServerOptions } from './types.d.ts'; import { intRange } from './utils.js'; -/** -@typedef {import('./types.d.ts').HttpHeaderRule} HttpHeaderRule -@typedef {import('./types.d.ts').OptionSpec} OptionSpec -@typedef {import('./types.d.ts').ServerOptions} ServerOptions -*/ - export class CLIArgs { - /** @type {Array<[string, string]>} */ - #map = []; + #map: Array<[string, string]> = []; - /** @type {string[]} */ - #list = []; + #list: string[] = []; - /** @type {(keys: string | string[]) => (entry: [string, string]) => boolean} */ - #mapFilter(keys) { - return (entry) => (typeof keys === 'string' ? keys === entry[0] : keys.includes(entry[0])); + #mapFilter(keys: string | string[]) { + return (entry: [string, string]) => + typeof keys === 'string' ? keys === entry[0] : keys.includes(entry[0]); } - /** @param {string[]} args */ - constructor(args) { + constructor(args: string[]) { const optionPattern = /^-{1,2}[\w]/; let pos = 0; while (pos < args.length) { @@ -43,8 +35,7 @@ export class CLIArgs { } } - /** @type {(key: string | null, value: string) => void} */ - add(key, value) { + add(key: string | null, value: string) { if (key == null) { this.#list.push(value); } else { @@ -52,8 +43,7 @@ export class CLIArgs { } } - /** @type {(query: number | string | string[]) => boolean} */ - has(query) { + has(query: number | string | string[]): boolean { if (typeof query === 'number') { return typeof this.#list.at(query) === 'string'; } else { @@ -63,9 +53,8 @@ export class CLIArgs { /** Get the last value for one or several option names, or a specific positional index. - @type {(query: number | string | string[]) => string | undefined} */ - get(query) { + get(query: number | string | string[]): string | undefined { if (typeof query === 'number') { return this.#list.at(query); } else { @@ -76,15 +65,13 @@ export class CLIArgs { /** Get mapped values for one or several option names. Values are merged in order of appearance. - @type {(query: string | string[]) => string[]} query */ - all(query) { + all(query: string | string[]): string[] { return this.#map.filter(this.#mapFilter(query)).map((entry) => entry[1]); } keys() { - /** @type {string[]} */ - const keys = []; + const keys: string[] = []; for (const [key] of this.#map) { if (!keys.includes(key)) keys.push(key); } @@ -99,46 +86,43 @@ export class CLIArgs { } } -/** @type {(include?: string, entries?: string[][]) => HttpHeaderRule} */ -function makeHeadersRule(include = '', entries = []) { +function makeHeadersRule(include: string = '', entries: string[][] = []): HttpHeaderRule { const headers = Object.fromEntries(entries); return include.length > 0 && include !== '*' ? { headers, include: include.split(',').map((s) => s.trim()) } : { headers }; } -/** @type {(value: string) => string} */ -function normalizeExt(value = '') { +function normalizeExt(value: string = ''): string { if (typeof value === 'string' && value.length && !value.startsWith('.')) { return `.${value}`; } return value; } -/** @type {(args: CLIArgs, context: { onError(msg: string): void }) => Partial} */ -export function parseArgs(args, { onError }) { +export function parseArgs( + args: CLIArgs, + { onError }: { onError(msg: string): void }, +): Partial { const invalid = (optName = '', input = '') => { const value = typeof input === 'string' ? `'${input.replaceAll(`'`, `\'`)}'` : JSON.stringify(input); onError(`invalid ${optName} value: ${value}`); }; - /** @type {(spec: OptionSpec) => string | undefined} */ - const getStr = ({ names: argNames, negate: negativeArg }) => { + const getStr = ({ names: argNames, negate: negativeArg }: OptionSpec) => { if (negativeArg && args.has(negativeArg)) return; const input = args.get(argNames); if (input != null) return input.trim(); }; - /** @type {(spec: OptionSpec) => string[] | undefined} */ - const getList = ({ names: argNames, negate: negativeArg }) => { + const getList = ({ names: argNames, negate: negativeArg }: OptionSpec) => { if (negativeArg && args.has(negativeArg)) return []; const input = args.all(argNames); if (input.length) return splitOptionValue(input); }; - /** @type {(spec: OptionSpec, emptyValue?: boolean) => boolean | undefined} */ - const getBool = ({ names: argNames, negate: negativeArg }, emptyValue) => { + const getBool = ({ names: argNames, negate: negativeArg }: OptionSpec, emptyValue?: boolean) => { if (negativeArg && args.has(negativeArg)) return false; const input = args.get(argNames); if (input == null) return; @@ -147,8 +131,7 @@ export function parseArgs(args, { onError }) { else invalid(argNames.at(-1), input); }; - /** @type {Partial} */ - const options = { + const options: Partial = { root: args.get(0), host: getStr(CLI_OPTIONS.host), cors: getBool(CLI_OPTIONS.cors), @@ -192,8 +175,7 @@ export function parseArgs(args, { onError }) { return Object.fromEntries(Object.entries(options).filter((entry) => entry[1] != null)); } -/** @type {(input: string) => HttpHeaderRule | undefined} */ -export function parseHeaders(input) { +export function parseHeaders(input: string): HttpHeaderRule | undefined { input = input.trim(); const colonPos = input.indexOf(':'); const bracketPos = input.indexOf('{'); @@ -232,8 +214,7 @@ export function parseHeaders(input) { } } -/** @type {(input: string) => number[] | undefined} */ -export function parsePort(input) { +export function parsePort(input: string): number[] | undefined { const matches = input.match(/^(?\d{1,})(?\+|-\d{1,})?$/); if (matches?.groups) { const { start: rawStart = '', end: rawEnd = '' } = matches.groups; @@ -249,10 +230,8 @@ export function parsePort(input) { } } -/** @type {(values: string[]) => string[]} */ -export function splitOptionValue(values) { - /** @type {string[]} */ - const result = []; +export function splitOptionValue(values: string[]): string[] { + const result: string[] = []; for (let value of values.flatMap((s) => s.split(','))) { value = value.trim(); if (value && !result.includes(value)) { @@ -262,22 +241,18 @@ export function splitOptionValue(values) { return result; } -/** @type {(input?: string, emptyValue?: boolean) => boolean | undefined} */ -export function strToBool(input, emptyValue) { - if (typeof input === 'string') { - input = input.trim().toLowerCase(); - } - if (input === 'true' || input === '1') { +export function strToBool(input?: string, emptyValue?: boolean) { + const val = typeof input === 'string' ? input.trim().toLowerCase() : undefined; + if (val === 'true' || val === '1') { return true; - } else if (input === 'false' || input === '0') { + } else if (val === 'false' || val === '0') { return false; - } else if (input === '') { + } else if (val === '') { return emptyValue; } } -/** @type {(args: CLIArgs) => string[]} */ -export function unknownArgs(args) { +export function unknownArgs(args: CLIArgs): string[] { const known = Object.values(CLI_OPTIONS).flatMap((spec) => { return spec.negate ? [...spec.names, spec.negate] : spec.names; }); diff --git a/lib/cli.js b/src/cli.ts similarity index 77% rename from lib/cli.js rename to src/cli.ts index 009092a..ab73339 100644 --- a/lib/cli.js +++ b/src/cli.ts @@ -1,5 +1,4 @@ import { createServer } from 'node:http'; -import { createRequire } from 'node:module'; import { homedir, networkInterfaces } from 'node:os'; import { sep as dirSep } from 'node:path'; import process, { argv, exit, platform, stdin } from 'node:process'; @@ -12,13 +11,9 @@ import { RequestHandler } from './handler.js'; import { color, logger, requestLogLine } from './logger.js'; import { serverOptions } from './options.js'; import { FileResolver } from './resolver.js'; +import type { OptionName, ServerOptions } from './types.d.ts'; import { clamp, errorList, getRuntime, isPrivateIPv4 } from './utils.js'; -/** -@typedef {import('./types.d.ts').OptionSpec} OptionSpec -@typedef {import('./types.d.ts').ServerOptions} ServerOptions -*/ - /** Start servitsy with configuration from command line arguments. */ @@ -53,26 +48,15 @@ export async function run() { } export class CLIServer { - /** @type {ServerOptions} */ - #options; - - /** @type {number | undefined} */ - #port; - - /** @type {IterableIterator} */ - #portIterator; - - /** @type {import('node:os').NetworkInterfaceInfo | undefined} */ - #localNetworkInfo; - - /** @type {import('node:http').Server} */ - #server; - - /** @type {FileResolver} */ - #resolver; + #options: ServerOptions; + #port: number | undefined; + #portIterator: IterableIterator; + #localNetworkInfo: import('node:os').NetworkInterfaceInfo | undefined; + #server: import('node:http').Server; + #resolver: FileResolver; + #shuttingDown = false; - /** @param {ServerOptions} options */ - constructor(options) { + constructor(options: ServerOptions) { this.#options = options; this.#portIterator = new Set(options.ports).values(); this.#localNetworkInfo = Object.values(networkInterfaces()) @@ -117,8 +101,8 @@ export class CLIServer { const address = this.#server.address(); if (address !== null && typeof address === 'object') { const { local, network } = displayHosts({ - configuredHost: host, - currentHost: address.address, + configured: host, + actual: address.address, networkAddress: this.#localNetworkInfo?.address, }); const data = [ @@ -164,7 +148,6 @@ export class CLIServer { process.on('SIGTERM', this.shutdown); } - #shuttingDown = false; shutdown = async () => { if (this.#shuttingDown) return; this.#shuttingDown = true; @@ -178,8 +161,7 @@ export class CLIServer { exit(); }; - /** @type {(error: NodeJS.ErrnoException & {hostname?: string}) => void} */ - #onServerError(error) { + #onServerError(error: NodeJS.ErrnoException & { hostname?: string }) { // Try restarting with the next port if (error.code === 'EADDRINUSE') { const { value: nextPort } = this.#portIterator.next(); @@ -214,9 +196,9 @@ export class CLIServer { export function helpPage() { const spaces = (count = 0) => ' '.repeat(count); const indent = spaces(2); + const colGap = spaces(4); - /** @type {Array} */ - const optionsOrder = [ + const optionsOrder: OptionName[] = [ 'help', 'version', 'host', @@ -231,28 +213,29 @@ export function helpPage() { ]; const options = optionsOrder.map((key) => CLI_OPTIONS[key]); - /** @type {(heading?: string, lines?: string[]) => string} */ - const section = (heading = '', lines = []) => { + const section = (heading: string = '', lines: string[] = []) => { const result = []; if (heading.length) result.push(indent + color.style(heading, 'bold')); if (lines.length) result.push(lines.map((l) => indent.repeat(2) + l).join('\n')); return result.join('\n\n'); }; - /** @type {(options: OptionSpec[], config: {gap: string, firstWidth: number}) => string[]} */ - const optionCols = (options, { gap, firstWidth }) => - options.flatMap(({ help, names, default: argDefault = '' }) => { + const optionCols = () => { + const hMaxLength = Math.max(...options.map((opt) => opt.names.join(', ').length)); + const firstWidth = clamp(hMaxLength, 14, 20); + return options.flatMap(({ help, names, default: argDefault = '' }) => { const header = names.join(', ').padEnd(firstWidth); - const first = `${header}${gap}${help}`; + const first = `${header}${colGap}${help}`; if (!argDefault) return [first]; const secondRaw = `(default: '${Array.isArray(argDefault) ? argDefault.join(', ') : argDefault}')`; const second = color.style(secondRaw, 'gray'); if (first.length + secondRaw.length < 80) { return [`${first} ${second}`]; } else { - return [first, spaces(header.length + gap.length) + second]; + return [first, spaces(header.length + colGap.length) + second]; } }); + }; return [ section( @@ -262,40 +245,36 @@ export function helpPage() { `${color.style('$', 'bold dim')} ${color.style('servitsy', 'magentaBright')} --help`, `${color.style('$', 'bold dim')} ${color.style('servitsy', 'magentaBright')} ${color.brackets('directory')} ${color.brackets('options')}`, ]), - section( - 'OPTIONS', - optionCols(options, { - gap: spaces(4), - firstWidth: clamp(Math.max(...options.map((opt) => opt.names.join(', ').length)), 14, 20), - }), - ), + section('OPTIONS', optionCols()), ].join('\n\n'); } -/** -@param {{ configuredHost: string; currentHost: string; networkAddress?: string }} address -@returns {{ local: string; network?: string }} -*/ -function displayHosts({ configuredHost, currentHost, networkAddress }) { +function displayHosts({ + configured, + actual, + networkAddress, +}: { + configured: string; + actual: string; + networkAddress?: string; +}): { local: string; network?: string } { const isLocalhost = (value = '') => HOSTS_LOCAL.includes(value); const isWildcard = (value = '') => HOSTS_WILDCARD.v4 === value || HOSTS_WILDCARD.v6 === value; - if (!isWildcard(configuredHost) && !isLocalhost(configuredHost)) { - return { local: configuredHost }; + if (!isWildcard(configured) && !isLocalhost(configured)) { + return { local: configured }; } return { - local: isWildcard(currentHost) || isLocalhost(currentHost) ? 'localhost' : currentHost, - network: - isWildcard(configuredHost) && getRuntime() !== 'webcontainer' ? networkAddress : undefined, + local: isWildcard(actual) || isLocalhost(actual) ? 'localhost' : actual, + network: isWildcard(configured) && getRuntime() !== 'webcontainer' ? networkAddress : undefined, }; } /** Replace the home dir with '~' in path -@type {(root: string) => string} */ -function displayRoot(root) { +function displayRoot(root: string): string { if ( // skip: not a common windows convention platform !== 'win32' && diff --git a/lib/constants.js b/src/constants.ts similarity index 82% rename from lib/constants.js rename to src/constants.ts index 726cbd7..9f260b2 100644 --- a/lib/constants.js +++ b/src/constants.ts @@ -1,4 +1,5 @@ -/** @type {string[]} */ +import type { OptionName, OptionSpec, ServerOptions } from './types.d.ts'; + export const HOSTS_LOCAL = ['localhost', '127.0.0.1', '::1']; export const HOSTS_WILDCARD = { @@ -12,13 +13,11 @@ export const PORTS_CONFIG = { maxCount: 100, }; -/** @type {string[]} */ export const SUPPORTED_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST']; export const MAX_COMPRESS_SIZE = 50_000_000; -/** @type {Omit} */ -export const DEFAULT_OPTIONS = { +export const DEFAULT_OPTIONS: Omit = { host: HOSTS_WILDCARD.v6, ports: [8080, 8081, 8082, 8083, 8084, 8085, 8086, 8087, 8088, 8089], gzip: true, @@ -30,11 +29,7 @@ export const DEFAULT_OPTIONS = { exclude: ['.*', '!.well-known'], }; -/** -@typedef {'cors' | 'dirFile' | 'dirList' | 'exclude' | 'ext' | 'gzip' | 'header' | 'help' | 'host' | 'port' | 'version'} OptionName -@type {Record} -*/ -export const CLI_OPTIONS = { +export const CLI_OPTIONS: Record = { cors: { help: 'Send CORS HTTP headers in responses', names: ['--cors'], diff --git a/lib/content-type.js b/src/content-type.ts similarity index 86% rename from lib/content-type.js rename to src/content-type.ts index a330309..9a3c71d 100644 --- a/lib/content-type.js +++ b/src/content-type.ts @@ -1,22 +1,17 @@ +import type { FileHandle } from 'node:fs/promises'; import { basename, extname } from 'node:path'; -/** -@typedef {import('node:fs/promises').FileHandle} FileHandle -@typedef {{ +interface TypeMap { default: string; file: string[]; extension: string[]; extensionMap: Record; suffix: string[]; -}} TypeMap -*/ +} const strarr = (s = '') => s.trim().split(/\s+/); -const DEFAULT_CHARSET = 'UTF-8'; - -/** @type {TypeMap} */ -export const TEXT_TYPES = { +export const TEXT_TYPES: TypeMap = { default: 'text/plain', extensionMap: { atom: 'application/atom+xml', @@ -74,8 +69,7 @@ export const TEXT_TYPES = { suffix: strarr(`config file html ignore rc`), }; -/** @type {TypeMap} */ -const BIN_TYPES = { +const BIN_TYPES: TypeMap = { default: 'application/octet-stream', extensionMap: { '7z': 'application/x-7z-compressed', @@ -141,15 +135,12 @@ const BIN_TYPES = { }; export class TypeResult { - /** @type {'text' | 'bin' | 'unknown'} */ - group = 'unknown'; - - /** @type {string} */ - type = BIN_TYPES.default; + group: 'text' | 'bin' | 'unknown' = 'unknown'; + type: string = BIN_TYPES.default; + charset: string = ''; - /** @param {string | null} [charset] */ - constructor(charset = DEFAULT_CHARSET) { - this.charset = charset; + constructor(charset: string = 'UTF-8') { + if (typeof charset === 'string') this.charset = charset; } bin(type = BIN_TYPES.default) { @@ -179,8 +170,7 @@ export class TypeResult { } } -/** @type {(filePath: string, charset?: string | null) => TypeResult} */ -export function typeForFilePath(filePath, charset) { +export function typeForFilePath(filePath: string, charset?: string): TypeResult { const result = new TypeResult(charset); const name = filePath ? basename(filePath).toLowerCase() : ''; @@ -205,12 +195,7 @@ export function typeForFilePath(filePath, charset) { return result.unknown(); } -/** -@param {FileHandle} handle -@param {string | null} [charset] -@returns {Promise} -*/ -export async function typeForFile(handle, charset) { +export async function typeForFile(handle: FileHandle, charset?: string): Promise { const result = new TypeResult(charset); try { const { buffer, bytesRead } = await handle.read({ @@ -227,11 +212,13 @@ export async function typeForFile(handle, charset) { } } -/** -@param {{ path?: string; handle?: FileHandle }} file -@returns {Promise} -*/ -export async function getContentType({ path, handle }) { +export async function getContentType({ + path, + handle, +}: { + path?: string; + handle?: FileHandle; +}): Promise { if (path) { const result = typeForFilePath(path); if (result.group !== 'unknown') { @@ -247,9 +234,8 @@ export async function getContentType({ path, handle }) { /** https://mimesniff.spec.whatwg.org/#sniffing-a-mislabeled-binary-resource -@type {(bytes: Uint8Array) => boolean} */ -export function isBinHeader(bytes) { +export function isBinHeader(bytes: Uint8Array): boolean { const limit = Math.min(bytes.length, 2000); const [b0, b1, b2] = bytes; @@ -275,9 +261,8 @@ export function isBinHeader(bytes) { /** https://mimesniff.spec.whatwg.org/#binary-data-byte -@type {(int: number) => boolean} */ -export function isBinDataByte(int) { +export function isBinDataByte(int: number): boolean { if (int >= 0 && int <= 0x1f) { return ( (int >= 0 && int <= 0x08) || diff --git a/lib/fs-utils.js b/src/fs-utils.ts similarity index 64% rename from lib/fs-utils.js rename to src/fs-utils.ts index 04b22fe..1aa68d4 100644 --- a/lib/fs-utils.js +++ b/src/fs-utils.ts @@ -2,15 +2,13 @@ import { access, constants, lstat, readdir, realpath, stat } from 'node:fs/promi import { createRequire } from 'node:module'; import { isAbsolute, join, sep as dirSep } from 'node:path'; +import type { FSKind, FSLocation } from './types.d.ts'; import { trimSlash } from './utils.js'; -/** -@typedef {import('./types.d.ts').FSKind} FSKind -@typedef {import('./types.d.ts').FSLocation} FSLocation -*/ - -/** @type {(dirPath: string, context: { onError(msg: string): void }) => Promise} */ -export async function checkDirAccess(dirPath, { onError }) { +export async function checkDirAccess( + dirPath: string, + context: { onError(msg: string): void }, +): Promise { let msg = ''; try { const stats = await stat(dirPath); @@ -21,7 +19,7 @@ export async function checkDirAccess(dirPath, { onError }) { } else { msg = `not a directory: ${dirPath}`; } - } catch (/** @type {any} */ err) { + } catch (err: any) { if (err.code === 'ENOENT') { msg = `not a directory: ${dirPath}`; } else if (err.code === 'EACCES') { @@ -30,12 +28,11 @@ export async function checkDirAccess(dirPath, { onError }) { msg = err.toString(); } } - if (msg) onError(msg); + if (msg) context.onError(msg); return false; } -/** @type {(dirPath: string) => Promise} */ -export async function getIndex(dirPath) { +export async function getIndex(dirPath: string): Promise { try { const entries = await readdir(dirPath, { withFileTypes: true }); return entries.map((entry) => ({ @@ -47,8 +44,7 @@ export async function getIndex(dirPath) { } } -/** @type {(filePath: string) => Promise} */ -export async function getKind(filePath) { +export async function getKind(filePath: string): Promise { try { const stats = await lstat(filePath); return statsKind(stats); @@ -57,8 +53,7 @@ export async function getKind(filePath) { } } -/** @type {(root: string, filePath: string) => string | null} */ -export function getLocalPath(root, filePath) { +export function getLocalPath(root: string, filePath: string): string | null { if (isSubpath(root, filePath)) { return trimSlash(filePath.slice(root.length), { start: true, end: true }); } @@ -66,7 +61,7 @@ export function getLocalPath(root, filePath) { } /** @type {(filePath: string) => Promise} */ -export async function getRealpath(filePath) { +export async function getRealpath(filePath: string): Promise { try { const real = await realpath(filePath); return real; @@ -75,8 +70,7 @@ export async function getRealpath(filePath) { } } -/** @type {(filePath: string, kind?: FSKind) => Promise} */ -export async function isReadable(filePath, kind) { +export async function isReadable(filePath: string, kind?: FSKind): Promise { if (kind === undefined) { kind = await getKind(filePath); } @@ -90,20 +84,23 @@ export async function isReadable(filePath, kind) { return false; } -/** @type {(parent: string, filePath: string) => boolean} */ -export function isSubpath(parent, filePath) { +export function isSubpath(parent: string, filePath: string): boolean { if (filePath.includes('..') || !isAbsolute(filePath)) return false; parent = trimSlash(parent, { end: true }); return filePath === parent || filePath.startsWith(parent + dirSep); } -/** @type {() => Record} */ -export function readPkgJson() { +export function readPkgJson(): Record { return createRequire(import.meta.url)('../package.json'); } -/** @type {(stats: {isSymbolicLink?(): boolean; isDirectory?(): boolean; isFile?(): boolean}) => FSKind} */ -export function statsKind(stats) { +interface StatsLike { + isSymbolicLink?(): boolean; + isDirectory?(): boolean; + isFile?(): boolean; +} + +export function statsKind(stats: StatsLike): FSKind { if (stats.isSymbolicLink?.()) return 'link'; if (stats.isDirectory?.()) return 'dir'; else if (stats.isFile?.()) return 'file'; diff --git a/lib/handler.js b/src/handler.ts similarity index 74% rename from lib/handler.js rename to src/handler.ts index a69a46c..333edb6 100644 --- a/lib/handler.js +++ b/src/handler.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; import { createReadStream } from 'node:fs'; -import { open, stat } from 'node:fs/promises'; +import { open, stat, type FileHandle } from 'node:fs/promises'; import { createGzip, gzipSync } from 'node:zlib'; import { MAX_COMPRESS_SIZE, SUPPORTED_METHODS } from './constants.js'; @@ -8,46 +8,40 @@ import { getContentType, typeForFilePath } from './content-type.js'; import { getLocalPath, isSubpath } from './fs-utils.js'; import { dirListPage, errorPage } from './pages.js'; import { PathMatcher } from './path-matcher.js'; +import type { FSLocation, ResMetaData, ServerOptions } from './types.d.ts'; import { headerCase, trimSlash } from './utils.js'; +import { FileResolver } from './resolver.js'; -/** -@typedef {import('node:http').IncomingMessage & {originalUrl?: string}} Request -@typedef {import('node:http').ServerResponse} Response -@typedef {import('./types.d.ts').FSLocation} FSLocation -@typedef {import('./types.d.ts').ResMetaData} ResMetaData -@typedef {import('./types.d.ts').ServerOptions} ServerOptions -@typedef {{ +type Request = import('node:http').IncomingMessage & { originalUrl?: string }; +type Response = import('node:http').ServerResponse; + +interface Config { + req: Request; + res: Response; + resolver: FileResolver; + options: ServerOptions & { _noStream?: boolean }; +} + +/** @internal */ +type SendPayload = { body?: string | Buffer | import('node:fs').ReadStream; contentType?: string; isText?: boolean; statSize?: number; -}} SendPayload -*/ +}; export class RequestHandler { - #req; - #res; - #resolver; - #options; - - /** @type {ResMetaData['timing']} */ - timing = { start: Date.now() }; - /** @type {string | null} */ - urlPath = null; - /** @type {FSLocation | null} */ - file = null; - /** @type {Error | string | undefined} */ - error; + #req: Config['req']; + #res: Config['res']; + #resolver: Config['resolver']; + #options: Config['options']; - /** - @param {{ - req: Request; - res: Response; - resolver: import('./resolver.js').FileResolver; - options: ServerOptions & {_noStream?: boolean}; - }} config - */ - constructor({ req, res, resolver, options }) { + timing: ResMetaData['timing'] = { start: Date.now() }; + urlPath: string | null = null; + file: FSLocation | null = null; + error?: Error | string; + + constructor({ req, res, resolver, options }: Config) { this.#req = req; this.#res = res; this.#resolver = resolver; @@ -55,7 +49,7 @@ export class RequestHandler { try { this.urlPath = extractUrlPath(req.url ?? ''); - } catch (/** @type {any} */ err) { + } catch (err: any) { this.error = err; } @@ -122,12 +116,9 @@ export class RequestHandler { return this.#sendErrorPage(); } - /** @type {(filePath: string) => Promise} */ - async #sendFile(filePath) { - /** @type {import('node:fs/promises').FileHandle | undefined} */ - let handle; - /** @type {SendPayload} */ - let data = {}; + async #sendFile(filePath: string) { + let handle: FileHandle | undefined; + let data: SendPayload = {}; try { // already checked in resolver, but better safe than sorry @@ -142,7 +133,7 @@ export class RequestHandler { isText: type.group === 'text', statSize: (await stat(filePath)).size, }; - } catch (/** @type {any} */ err) { + } catch (err: any) { this.status = err?.code === 'EBUSY' ? 403 : 500; if (err && (err.message || typeof err === 'object')) this.error = err; } finally { @@ -170,8 +161,7 @@ export class RequestHandler { return this.#send(data); } - /** @type {(filePath: string) => Promise} */ - async #sendListPage(filePath) { + async #sendListPage(filePath: string) { this.#setHeaders('index.html', { cors: false, headers: [], @@ -181,7 +171,7 @@ export class RequestHandler { return this.#send(); } const items = await this.#resolver.index(filePath); - const body = await dirListPage({ + const body = dirListPage({ root: this.#options.root, ext: this.#options.ext, urlPath: this.urlPath ?? '', @@ -191,8 +181,7 @@ export class RequestHandler { return this.#send({ body, isText: true }); } - /** @type {() => Promise} */ - async #sendErrorPage() { + async #sendErrorPage(): Promise { this.#setHeaders('error.html', { cors: this.#options.cors, headers: [], @@ -200,7 +189,7 @@ export class RequestHandler { if (this.method === 'OPTIONS') { return this.#send(); } - const body = await errorPage({ + const body = errorPage({ status: this.status, url: this.#req.url ?? '', urlPath: this.urlPath, @@ -208,8 +197,7 @@ export class RequestHandler { return this.#send({ body, isText: true }); } - /** @type {(payload?: SendPayload) => void} */ - #send({ body, isText = false, statSize } = {}) { + #send({ body, isText = false, statSize }: SendPayload = {}) { this.timing.send = Date.now(); // stop early if possible @@ -261,10 +249,7 @@ export class RequestHandler { } } - /** - @type {(name: string, value: null | number | string | string[], normalizeCase?: boolean) => void} - */ - #header(name, value, normalizeCase = true) { + #header(name: string, value: null | number | string | string[], normalizeCase = true) { if (this.#res.headersSent) return; if (normalizeCase) name = headerCase(name); if (typeof value === 'number') value = String(value); @@ -277,10 +262,13 @@ export class RequestHandler { /** Set all response headers, except for content-length - @type {(filePath: string, options: Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>) => void} */ - #setHeaders(filePath, { contentType, cors, headers }) { + #setHeaders( + filePath: string, + options: Partial<{ contentType: string; cors: boolean; headers: ServerOptions['headers'] }>, + ) { if (this.#res.headersSent) return; + const { contentType, cors, headers } = options; const isOptions = this.method === 'OPTIONS'; const headerRules = headers ?? this.#options.headers; @@ -290,8 +278,8 @@ export class RequestHandler { } if (!isOptions) { - contentType ??= typeForFilePath(filePath).toString(); - this.#header('content-type', contentType); + const value = contentType ?? typeForFilePath(filePath).toString(); + this.#header('content-type', value); } if (cors ?? this.#options.cors) { @@ -321,8 +309,7 @@ export class RequestHandler { } } - /** @type {() => ResMetaData} */ - data() { + data(): ResMetaData { return { status: this.status, method: this.method, @@ -335,10 +322,15 @@ export class RequestHandler { } } -/** -@type {(data: { accept?: string | string[]; isText?: boolean; statSize?: number }) => boolean} -*/ -function canCompress({ accept = '', statSize = 0, isText = false }) { +function canCompress({ + accept = '', + statSize = 0, + isText = false, +}: { + accept?: string | string[]; + isText?: boolean; + statSize?: number; +}): boolean { accept = Array.isArray(accept) ? accept.join(',') : accept; if (isText && statSize <= MAX_COMPRESS_SIZE && accept) { return accept @@ -349,12 +341,21 @@ function canCompress({ accept = '', statSize = 0, isText = false }) { return false; } -/** -@type {(localPath: string, rules: ServerOptions['headers'], blockList?: string[]) => Array<{name: string; value: string}>} -*/ -export function fileHeaders(localPath, rules, blockList = []) { - /** @type {ReturnType} */ - const result = []; +export function extractUrlPath(url: string): string { + if (url === '*') return url; + const path = new URL(url, 'http://localhost/').pathname || '/'; + if (!isValidUrlPath(path)) { + throw new Error(`Invalid URL path: '${path}'`); + } + return path; +} + +export function fileHeaders( + localPath: string, + rules: ServerOptions['headers'], + blockList: string[] = [], +) { + const result: Array<{ name: string; value: string }> = []; for (const rule of rules) { if (Array.isArray(rule.include)) { const matcher = new PathMatcher(rule.include); @@ -368,36 +369,15 @@ export function fileHeaders(localPath, rules, blockList = []) { return result; } -/** @type {(req: Pick) => boolean} */ -function isPreflight({ method, headers }) { +function isPreflight(req: Pick): boolean { return ( - method === 'OPTIONS' && - typeof headers['origin'] === 'string' && - typeof headers['access-control-request-method'] === 'string' + req.method === 'OPTIONS' && + typeof req.headers['origin'] === 'string' && + typeof req.headers['access-control-request-method'] === 'string' ); } -/** @type {(input?: string) => string[]} */ -function parseHeaderNames(input = '') { - const isHeader = (h = '') => /^[A-Za-z\d-_]+$/.test(h); - return input - .split(',') - .map((h) => h.trim()) - .filter(isHeader); -} - -/** @type {(url: string) => string} */ -export function extractUrlPath(url) { - if (url === '*') return url; - const path = new URL(url, 'http://localhost/').pathname || '/'; - if (!isValidUrlPath(path)) { - throw new Error(`Invalid URL path: '${path}'`); - } - return path; -} - -/** @type {(urlPath: string) => boolean} */ -export function isValidUrlPath(urlPath) { +export function isValidUrlPath(urlPath: string): boolean { if (urlPath === '/') return true; if (!urlPath.startsWith('/') || urlPath.includes('//')) return false; for (const s of trimSlash(urlPath).split('/')) { @@ -408,3 +388,11 @@ export function isValidUrlPath(urlPath) { } return true; } + +function parseHeaderNames(input: string = ''): string[] { + const isHeader = (h = '') => /^[A-Za-z\d-_]+$/.test(h); + return input + .split(',') + .map((h) => h.trim()) + .filter(isHeader); +} diff --git a/lib/index.js b/src/index.ts similarity index 100% rename from lib/index.js rename to src/index.ts diff --git a/lib/logger.js b/src/logger.ts similarity index 65% rename from lib/logger.js rename to src/logger.ts index 64cfda1..e6893a2 100644 --- a/lib/logger.js +++ b/src/logger.ts @@ -3,36 +3,31 @@ import { platform } from 'node:process'; import { stderr, stdout } from 'node:process'; import { inspect } from 'node:util'; -import { clamp, fwdSlash, getEnv, trimSlash, withResolvers } from './utils.js'; +import type { ResMetaData } from './types.d.ts'; +import { clamp, fwdSlash, getEnv, getRuntime, trimSlash, withResolvers } from './utils.js'; -/** -@typedef {import('./types.d.ts').ResMetaData} ResMetaData -@typedef {{ +interface LogItem { group: 'header' | 'info' | 'request' | 'error'; text: string; - padding: {top: number; bottom: number}; -}} LogItem -*/ + padding: { top: number; bottom: number }; +} export class ColorUtils { - /** @param {boolean} [colorEnabled] */ - constructor(colorEnabled) { + enabled: boolean; + + constructor(colorEnabled?: boolean) { this.enabled = typeof colorEnabled === 'boolean' ? colorEnabled : true; } - /** @type {(text: string, format?: string) => string} */ - style = (text, format = '') => { - if (!this.enabled) return text; - return styleText(format.trim().split(/\s+/g), text); - }; - - /** @type {(text: string, format?: string, chars?: [string, string]) => string} */ - brackets = (text, format = 'dim,,dim', chars = ['[', ']']) => { + brackets = ( + text: string, + format: string = 'dim,,dim', + chars: [string, string] = ['[', ']'], + ): string => { return this.sequence([chars[0], text, chars[1]], format); }; - /** @type {(parts: string[], format?: string) => string} */ - sequence = (parts, format = '') => { + sequence = (parts: string[], format: string = ''): string => { if (!format || !this.enabled) { return parts.join(''); } @@ -41,37 +36,22 @@ export class ColorUtils { .map((part, index) => (formats[index] ? this.style(part, formats[index]) : part)) .join(''); }; + + style = (text: string, format: string = ''): string => { + if (!this.enabled) return text; + return styleText(format.trim().split(/\s+/g), text); + }; } class Logger { - /** @type {LogItem | null} */ - #lastout = null; - /** @type {LogItem | null} */ - #lasterr = null; - - /** - @type {(prev: LogItem | null, next: LogItem) => string} - */ - #withPadding(prev, { group, text, padding }) { - const maxPad = 4; - let start = ''; - let end = ''; - if (padding.top) { - const count = padding.top - (prev?.padding.bottom ?? 0); - start = '\n'.repeat(clamp(count, 0, maxPad)); - } else if (prev && !prev.padding.bottom && prev.group !== group) { - start = '\n'; - } - if (padding.bottom) { - end = '\n'.repeat(clamp(padding.bottom, 0, maxPad)); - } - return `${start}${text}\n${end}`; - } - - /** - @type {(group: LogItem['group'], data: string | string[], padding?: LogItem['padding']) => Promise} - */ - async write(group, data = '', padding = { top: 0, bottom: 0 }) { + #lastout?: LogItem; + #lasterr?: LogItem; + + async write( + group: LogItem['group'], + data: string | string[] = '', + padding: LogItem['padding'] = { top: 0, bottom: 0 }, + ) { const item = { group, text: Array.isArray(data) ? data.join('\n') : data, @@ -81,8 +61,8 @@ class Logger { return; } - const { promise, resolve, reject } = withResolvers(); - const writeCallback = (/** @type {Error|undefined} */ err) => { + const { promise, resolve, reject } = withResolvers(); + const writeCallback = (err: Error | undefined) => { if (err) reject(err); else resolve(); }; @@ -98,11 +78,8 @@ class Logger { return promise; } - /** - @type {(...errors: Array) => void} - */ - error(...errors) { - this.write( + error(...errors: Array) { + return this.write( 'error', errors.map((error) => { if (typeof error === 'string') return `servitsy: ${error}`; @@ -110,10 +87,33 @@ class Logger { }), ); } + + #withPadding(prev: LogItem | undefined, item: LogItem): string { + const maxPad = 4; + let start = ''; + let end = ''; + if (item.padding.top) { + const count = item.padding.top - (prev?.padding.bottom ?? 0); + start = '\n'.repeat(clamp(count, 0, maxPad)); + } else if (prev && !prev.padding.bottom && prev.group !== item.group) { + start = '\n'; + } + if (item.padding.bottom) { + end = '\n'.repeat(clamp(item.padding.bottom, 0, maxPad)); + } + return `${start}${item.text}\n${end}`; + } } -/** @type {(data: import('./types.d.ts').ResMetaData) => string} */ -export function requestLogLine({ status, method, url, urlPath, localPath, timing, error }) { +export function requestLogLine({ + status, + method, + url, + urlPath, + localPath, + timing, + error, +}: ResMetaData): string { const { start, close } = timing; const { style: _, brackets } = color; @@ -148,8 +148,7 @@ export function requestLogLine({ status, method, url, urlPath, localPath, timing return line; } -/** @type {(basePath: string, fullPath: string) => string | undefined} */ -function pathSuffix(basePath, fullPath) { +function pathSuffix(basePath: string, fullPath: string): string | undefined { if (basePath === fullPath) { return ''; } else if (fullPath.startsWith(basePath)) { @@ -159,9 +158,8 @@ function pathSuffix(basePath, fullPath) { /** Basic implementation of 'node:util' styleText to support Node 18 + Deno. -@type {(format: string | string[], text: string) => string} */ -export function styleText(format, text) { +export function styleText(format: string | string[], text: string): string { let before = ''; let after = ''; for (const style of Array.isArray(format) ? format : [format]) { @@ -173,10 +171,11 @@ export function styleText(format, text) { return `${before}${text}${after}`; } -/** @type {() => boolean} */ -function supportsColor() { - if (typeof globalThis.Deno?.noColor === 'boolean') { - return !globalThis.Deno.noColor; +function supportsColor(): boolean { + // Avoid reading env variables in Deno to limit prompts; + // instead rely on its built-in parsing of the NO_COLOR env variable + if (getRuntime() === 'deno') { + return (globalThis as any).Deno?.noColor !== true; } if (getEnv('NO_COLOR')) { diff --git a/lib/options.js b/src/options.ts similarity index 62% rename from lib/options.js rename to src/options.ts index ab07d83..e66b5c5 100644 --- a/lib/options.js +++ b/src/options.ts @@ -1,48 +1,37 @@ import { isAbsolute, resolve } from 'node:path'; import { DEFAULT_OPTIONS, PORTS_CONFIG } from './constants.js'; - -/** -@typedef {import('./types.d.ts').HttpHeaderRule} HttpHeaderRule -@typedef {import('./types.d.ts').ServerOptions} ServerOptions -*/ +import type { HttpHeaderRule, ServerOptions } from './types.d.ts'; export class OptionsValidator { - /** @param {(msg: string) => void} [onError] */ - constructor(onError) { + onError?: (msg: string) => void; + + constructor(onError?: (msg: string) => void) { this.onError = onError; } - /** - @type {(input: T[] | undefined, filterFn: (item: T) => boolean) => T[] | undefined} - */ - #array(input, filterFn) { + #array(input: T[] | undefined, filterFn: (item: T) => boolean): T[] | undefined { if (!Array.isArray(input)) return; if (input.length === 0) return input; const value = input.filter(filterFn); if (value.length) return value; } - /** - @type {(optName: string, input?: boolean) => boolean | undefined} - */ - #bool(optName, input) { + #bool(optName: string, input?: boolean): boolean | undefined { if (typeof input === 'undefined') return; if (typeof input === 'boolean') return input; else this.#error(`invalid ${optName} value: '${input}'`); } - #error(msg = '') { + #error(msg: string) { this.onError?.(msg); } - /** @type {(input?: boolean) => boolean | undefined} */ - cors(input) { + cors(input?: boolean): boolean | undefined { return this.#bool('cors', input); } - /** @type {(input?: string[]) => string[] | undefined} */ - dirFile(input) { + dirFile(input?: string[]): string[] | undefined { return this.#array(input, (item) => { const ok = isValidPattern(item); if (!ok) this.#error(`invalid dirFile value: '${item}'`); @@ -50,13 +39,11 @@ export class OptionsValidator { }); } - /** @type {(input?: boolean) => boolean | undefined} */ - dirList(input) { + dirList(input?: boolean): boolean | undefined { return this.#bool('dirList', input); } - /** @type {(input?: string[]) => string[] | undefined} */ - exclude(input) { + exclude(input?: string[]): string[] | undefined { return this.#array(input, (item) => { const ok = isValidPattern(item); if (!ok) this.#error(`invalid exclude pattern: '${item}'`); @@ -64,8 +51,7 @@ export class OptionsValidator { }); } - /** @type {(input?: string[]) => string[] | undefined} */ - ext(input) { + ext(input?: string[]): string[] | undefined { return this.#array(input, (item) => { const ok = isValidExt(item); if (!ok) this.#error(`invalid ext value: '${item}'`); @@ -73,13 +59,11 @@ export class OptionsValidator { }); } - /** @type {(input?: boolean) => boolean | undefined} */ - gzip(input) { + gzip(input?: boolean): boolean | undefined { return this.#bool('gzip', input); } - /** @type {(input?: HttpHeaderRule[]) => HttpHeaderRule[] | undefined} */ - headers(input) { + headers(input?: HttpHeaderRule[]): HttpHeaderRule[] | undefined { return this.#array(input, (rule) => { const ok = isValidHeaderRule(rule); if (!ok) this.#error(`invalid header value: ${JSON.stringify(rule)}`); @@ -87,15 +71,13 @@ export class OptionsValidator { }); } - /** @type {(input?: string) => string | undefined} */ - host(input) { + host(input?: string): string | undefined { if (typeof input !== 'string') return; if (isValidHost(input)) return input; else this.#error(`invalid host value: '${input}'`); } - /** @type {(input?: number[]) => number[] | undefined} */ - ports(input) { + ports(input?: number[]): number[] | undefined { if (!Array.isArray(input) || input.length === 0) return; const value = input.slice(0, PORTS_CONFIG.maxCount); const invalid = value.find((num) => !isValidPort(num)); @@ -103,33 +85,29 @@ export class OptionsValidator { else this.#error(`invalid port number: '${invalid}'`); } - /** @type {(input?: string) => string} */ - root(input) { + root(input?: string): string { const value = typeof input === 'string' ? input : ''; return isAbsolute(value) ? value : resolve(value); } } -/** @type {(input: unknown) => input is string[]} */ -export function isStringArray(input) { +export function isStringArray(input: unknown): input is string[] { return Array.isArray(input) && input.every((item) => typeof item === 'string'); } -/** @type {(input: string) => boolean} */ -export function isValidExt(input) { +export function isValidExt(input: string): boolean { if (typeof input !== 'string' || !input) return false; return /^\.[\w\-]+(\.[\w\-]+){0,4}$/.test(input); } -/** @type {(name: string) => boolean} */ -export function isValidHeader(name) { +export function isValidHeader(name: string): boolean { return typeof name === 'string' && /^[a-z\d\-\_]+$/i.test(name); } /** @type {(value: any) => value is HttpHeaderRule} */ -export function isValidHeaderRule(value) { - const include = value?.include; - const headers = value?.headers; +export function isValidHeaderRule(value: unknown): value is HttpHeaderRule { + if (!value || typeof value !== 'object') return false; + const { include, headers } = value as any; if (typeof include !== 'undefined' && !isStringArray(include)) { return false; } @@ -149,35 +127,29 @@ export function isValidHeaderRule(value) { /** Checking that all characters are valid for a domain or ip, as a usability nicety to catch obvious errors -@type {(input: string) => boolean} */ -export function isValidHost(input) { +export function isValidHost(input: string): boolean { if (typeof input !== 'string' || !input.length) return false; const domainLike = /^([a-z\d\-]+)(\.[a-z\d\-]+)*$/i; const ipLike = /^([\d\.]+|[a-f\d\:]+)$/i; return domainLike.test(input) || ipLike.test(input); } -/** @type {(value: string) => boolean} */ -export function isValidPattern(value) { +export function isValidPattern(value: string): boolean { return typeof value === 'string' && value.length > 0 && !/[\\\/\:]/.test(value); } -/** @type {(num: number) => boolean} */ -export function isValidPort(num) { +export function isValidPort(num: number): boolean { return Number.isSafeInteger(num) && num >= 1 && num <= 65_535; } -/** -@param {{ root: string } & Partial} options -@param {{ onError(msg: string): void }} [context] -@returns {ServerOptions} -*/ -export function serverOptions(options, context) { +export function serverOptions( + options: { root: string } & Partial, + context: { onError(msg: string): void }, +): ServerOptions { const validator = new OptionsValidator(context?.onError); - /** @type {Partial} */ - const checked = { + const checked: Partial = { ports: validator.ports(options.ports), gzip: validator.gzip(options.gzip), host: validator.host(options.host), @@ -189,13 +161,15 @@ export function serverOptions(options, context) { exclude: validator.exclude(options.exclude), }; - const final = { + const final = structuredClone({ root: validator.root(options.root), - ...structuredClone(DEFAULT_OPTIONS), - }; + ...DEFAULT_OPTIONS, + }); for (const [key, value] of Object.entries(checked)) { - // @ts-ignore - if (typeof value !== 'undefined') final[key] = value; + if (typeof value !== 'undefined') { + // @ts-ignore + final[key] = value; + } } return final; diff --git a/lib/page-assets.js b/src/page-assets.ts similarity index 100% rename from lib/page-assets.js rename to src/page-assets.ts diff --git a/lib/pages.js b/src/pages.ts similarity index 70% rename from lib/pages.js rename to src/pages.ts index 7318291..92c4181 100644 --- a/lib/pages.js +++ b/src/pages.ts @@ -1,17 +1,17 @@ import { basename, dirname } from 'node:path'; import { FAVICON_LIST, FAVICON_ERROR, ICONS, STYLES } from './page-assets.js'; +import type { FSLocation } from './types.d.ts'; import { clamp, escapeHtml, trimSlash } from './utils.js'; -/** -@typedef {import('./types.d.ts').FSLocation} FSLocation -*/ - -/** -@param {{ base?: string; body: string; icon?: 'list' | 'error'; title?: string }} data -*/ -async function htmlTemplate({ base, body, icon, title }) { - const svgIcon = { list: FAVICON_LIST, error: FAVICON_ERROR }[String(icon)]; +function htmlTemplate(data: { + base?: string; + body: string; + icon?: 'list' | 'error'; + title?: string; +}) { + const { base, body, title } = data; + const icon = { list: FAVICON_LIST, error: FAVICON_ERROR }[String(data.icon)]; return ` @@ -19,7 +19,7 @@ async function htmlTemplate({ base, body, icon, title }) { ${title ? `${html(title)}` : ''} ${base ? `` : ''} -${svgIcon ? `` : ''} +${icon ? `` : ''} @@ -30,11 +30,8 @@ ${body} `; } -/** -@param {{ status: number; url: string; urlPath: string | null }} data -*/ -export function errorPage({ status, url, urlPath }) { - const displayPath = decodeURIPathSegments(urlPath ?? url); +export function errorPage(data: { status: number; url: string; urlPath: string | null }) { + const displayPath = decodeURIPathSegments(data.urlPath ?? data.url); const pathHtml = `${html(nl2sp(displayPath))}`; const page = (title = '', desc = '') => { @@ -42,7 +39,7 @@ export function errorPage({ status, url, urlPath }) { return htmlTemplate({ icon: 'error', title, body }); }; - switch (status) { + switch (data.status) { case 400: return page('400: Bad request', `Invalid request for ${pathHtml}`); case 403: @@ -58,10 +55,14 @@ export function errorPage({ status, url, urlPath }) { } } -/** -@param {{ root: string; urlPath: string; filePath: string; items: FSLocation[]; ext: string[] }} data -*/ -export function dirListPage({ root, urlPath, filePath, items, ext }) { +export function dirListPage(data: { + root: string; + urlPath: string; + filePath: string; + items: FSLocation[]; + ext: string[]; +}) { + const { root, urlPath, filePath, items, ext } = data; const rootName = basename(root); const trimmedUrl = trimSlash(urlPath); const baseUrl = trimmedUrl ? `/${trimmedUrl}/` : '/'; @@ -87,17 +88,14 @@ export function dirListPage({ root, urlPath, filePath, items, ext }) { Index of ${renderBreadcrumbs(displayPath)}
    -${sorted.map((item) => renderListItem(item, { ext, parentPath })).join('\n')} +${sorted.map((item) => renderListItem({ item, ext, parentPath })).join('\n')}
`.trim(), }); } -/** -@param {FSLocation} item -@param {{ ext: string[]; parentPath: string }} options -*/ -function renderListItem(item, { ext, parentPath }) { +function renderListItem(data: { item: FSLocation; ext: string[]; parentPath: string }) { + const { item, ext, parentPath } = data; const isDir = isDirLike(item); const isParent = isDir && item.filePath === parentPath; @@ -131,8 +129,7 @@ function renderListItem(item, { ext, parentPath }) { ].join(''); } -/** @type {(path: string) => string} */ -function renderBreadcrumbs(path) { +function renderBreadcrumbs(path: string): string { const slash = '/'; return path .split('/') @@ -148,32 +145,26 @@ function renderBreadcrumbs(path) { .join(slash); } -/** @type {(item: FSLocation) => boolean} */ -function isDirLike(item) { +function isDirLike(item: FSLocation): boolean { return item.kind === 'dir' || (item.kind === 'link' && item.target?.kind === 'dir'); } -/** @type {(s: string) => string} */ -function decodeURIPathSegment(s) { +function decodeURIPathSegment(s: string): string { return decodeURIComponent(s).replaceAll('\\', '\\\\').replaceAll('/', '\\/'); } -/** @type {(path: string) => string} */ -function decodeURIPathSegments(path) { +function decodeURIPathSegments(path: string): string { return path.split('/').map(decodeURIPathSegment).join('/'); } -/** @type {(input: string) => string} */ -function attr(str) { - return escapeHtml(str, 'attr'); +function attr(input: string) { + return escapeHtml(input, 'attr'); } -/** @type {(input: string) => string} */ -function html(str) { - return escapeHtml(str, 'text'); +function html(input: string) { + return escapeHtml(input, 'text'); } -/** @type {(input: string) => string} */ -function nl2sp(input) { +function nl2sp(input: string) { return input.replace(/[\u{000A}-\u{000D}\u{2028}]/gu, ' '); } diff --git a/lib/path-matcher.js b/src/path-matcher.ts similarity index 72% rename from lib/path-matcher.js rename to src/path-matcher.ts index 76c4b7a..886364e 100644 --- a/lib/path-matcher.js +++ b/src/path-matcher.ts @@ -1,20 +1,11 @@ import { fwdSlash } from './utils.js'; export class PathMatcher { - /** @type {Array} */ - #positive = []; - - /** @type {Array} */ - #negative = []; - - /** @type {boolean} */ + #positive: Array = []; + #negative: Array = []; #caseSensitive = true; - /** - @param {string[]} patterns - @param {Partial<{ caseSensitive: boolean }>} [options] - */ - constructor(patterns, options) { + constructor(patterns: string[], options?: { caseSensitive: boolean }) { if (typeof options?.caseSensitive === 'boolean') { this.#caseSensitive = options.caseSensitive; } @@ -29,8 +20,7 @@ export class PathMatcher { } } - /** @type {(filePath: string) => boolean} */ - test(filePath) { + test(filePath: string): boolean { if (this.#positive.length === 0) { return false; } @@ -39,8 +29,7 @@ export class PathMatcher { return matched.length > 0; } - /** @type {(input: string) => string | RegExp | null} */ - #parse(input) { + #parse(input: string): string | RegExp | null { if (this.#caseSensitive === false) { input = input.toLowerCase(); } @@ -54,8 +43,7 @@ export class PathMatcher { return input; } - /** @type {(pattern: string | RegExp, value: string) => boolean} */ - #matchPattern(pattern, value) { + #matchPattern(pattern: string | RegExp, value: string): boolean { if (this.#caseSensitive === false) { value = value.toLowerCase(); } @@ -68,8 +56,7 @@ export class PathMatcher { return false; } - /** @type {(segments: string[]) => string[]} */ - #matchSegments(segments) { + #matchSegments(segments: string[]): string[] { return segments.filter((segment) => { const positive = this.#positive.some((pattern) => this.#matchPattern(pattern, segment)); if (!positive) return false; @@ -79,6 +66,9 @@ export class PathMatcher { } data() { - return { positive: this.#positive, negative: this.#negative }; + return structuredClone({ + positive: this.#positive, + negative: this.#negative, + }); } } diff --git a/lib/resolver.js b/src/resolver.ts similarity index 58% rename from lib/resolver.js rename to src/resolver.ts index 19f5cac..0e5e9a3 100644 --- a/lib/resolver.js +++ b/src/resolver.ts @@ -2,59 +2,51 @@ import { isAbsolute, join } from 'node:path'; import { getIndex, getKind, getLocalPath, getRealpath, isReadable, isSubpath } from './fs-utils.js'; import { PathMatcher } from './path-matcher.js'; +import type { FSLocation, ServerOptions } from './types.d.ts'; import { trimSlash } from './utils.js'; -/** -@typedef {import('./types.d.ts').FSLocation} FSLocation -@typedef {import('./types.d.ts').ServerOptions} ServerOptions -*/ - export class FileResolver { - /** @type {string} */ - #root; - - /** @type {string[]} */ - #ext = []; - - /** @type {string[]} */ - #dirFile = []; - - /** @type {boolean} */ + #root: string; + #ext: string[] = []; + #dirFile: string[] = []; #dirList = false; + #excludeMatcher: PathMatcher; - /** @type {PathMatcher} */ - #excludeMatcher; - - /** @param {{root: string } & Partial} options */ - constructor(options) { + constructor(options: { root: string } & Partial) { if (typeof options.root !== 'string') { throw new Error('Missing root directory'); } else if (!isAbsolute(options.root)) { throw new Error('Expected absolute root path'); } this.#root = trimSlash(options.root, { end: true }); - if (Array.isArray(options.ext)) this.#ext = options.ext; - if (Array.isArray(options.dirFile)) this.#dirFile = options.dirFile; - if (typeof options.dirList === 'boolean') this.#dirList = options.dirList; - this.#excludeMatcher = new PathMatcher(options.exclude ?? [], { caseSensitive: true }); - } - /** @param {string} relativePath */ - async find(relativePath) { - const result = { - status: 404, - /** @type {FSLocation | null} */ - file: null, - }; - - const targetPath = this.resolvePath(relativePath); - if (targetPath == null) { - return result; + if (Array.isArray(options.ext)) { + this.#ext = options.ext; } + if (Array.isArray(options.dirFile)) { + this.#dirFile = options.dirFile; + } + if (typeof options.dirList === 'boolean') { + this.#dirList = options.dirList; + } + + this.#excludeMatcher = new PathMatcher(options.exclude ?? [], { + caseSensitive: true, + }); + } - // Locate file (following symlinks) - let file = await this.locateFile(targetPath); - if (file.kind === 'link') { + allowedPath(filePath: string): boolean { + const localPath = getLocalPath(this.#root, filePath); + if (localPath == null) return false; + return this.#excludeMatcher.test(localPath) === false; + } + + async find(localPath: string): Promise<{ status: number; file: FSLocation | null }> { + const targetPath = this.resolvePath(localPath); + let file: FSLocation | null = targetPath != null ? await this.locateFile(targetPath) : null; + + // Resolve symlink + if (file?.kind === 'link') { const realPath = await getRealpath(file.filePath); const real = realPath != null ? await this.locateFile(realPath) : null; if (real?.kind === 'file' || real?.kind === 'dir') { @@ -63,23 +55,20 @@ export class FileResolver { } // We have a match - if (file.kind === 'file' || file.kind === 'dir') { - result.file = file; + if (file?.kind === 'file' || file?.kind === 'dir') { const allowed = file.kind === 'dir' && !this.#dirList ? false : this.allowedPath(file.filePath); const readable = allowed && (await isReadable(file.filePath, file.kind)); - result.status = allowed ? (readable ? 200 : 403) : 404; + return { status: allowed ? (readable ? 200 : 403) : 404, file }; } - return result; + return { status: 404, file: null }; } - /** @type {(dirPath: string) => Promise} */ - async index(dirPath) { + async index(dirPath: string): Promise { if (!this.#dirList) return []; - /** @type {FSLocation[]} */ - const items = (await getIndex(dirPath)).filter( + const items: FSLocation[] = (await getIndex(dirPath)).filter( (item) => item.kind != null && this.allowedPath(item.filePath), ); @@ -100,10 +89,7 @@ export class FileResolver { ); } - /** - @type {(filePath: string[]) => Promise} - */ - async locateAltFiles(filePaths) { + async #locateAltFiles(filePaths: string[]): Promise { for (const filePath of filePaths) { if (!this.withinRoot(filePath)) continue; const kind = await getKind(filePath); @@ -116,9 +102,8 @@ export class FileResolver { /** Locate a file or alternative files that can be served for a resource, using the config for extensions and index file lookup. - @type {(filePath: string) => Promise} */ - async locateFile(filePath) { + async locateFile(filePath: string): Promise { if (!this.withinRoot(filePath)) { return { filePath, kind: null }; } @@ -128,32 +113,23 @@ export class FileResolver { // Try alternates if (kind === 'dir' && this.#dirFile.length) { const paths = this.#dirFile.map((name) => join(filePath, name)); - const match = await this.locateAltFiles(paths); + const match = await this.#locateAltFiles(paths); if (match) return match; } else if (kind === null && this.#ext.length) { const paths = this.#ext.map((ext) => filePath + ext); - const match = await this.locateAltFiles(paths); + const match = await this.#locateAltFiles(paths); if (match) return match; } return { filePath, kind }; } - /** @type {(filePath: string) => boolean} */ - allowedPath(filePath) { - const localPath = getLocalPath(this.#root, filePath); - if (localPath == null) return false; - return this.#excludeMatcher.test(localPath) === false; - } - - /** @type {(relativePath: string) => string | null} */ - resolvePath(relativePath) { - const filePath = join(this.#root, relativePath); + resolvePath(localPath: string): string | null { + const filePath = join(this.#root, localPath); return this.withinRoot(filePath) ? trimSlash(filePath, { end: true }) : null; } - /** @type {(filePath: string) => boolean} */ - withinRoot(filePath) { + withinRoot(filePath: string): boolean { return isSubpath(this.#root, filePath); } } diff --git a/lib/types.d.ts b/src/types.d.ts similarity index 86% rename from lib/types.d.ts rename to src/types.d.ts index e5da4e1..be8cc6c 100644 --- a/lib/types.d.ts +++ b/src/types.d.ts @@ -11,6 +11,19 @@ export interface HttpHeaderRule { headers: Record; } +export type OptionName = + | 'cors' + | 'dirFile' + | 'dirList' + | 'exclude' + | 'ext' + | 'gzip' + | 'header' + | 'help' + | 'host' + | 'port' + | 'version'; + export interface OptionSpec { help: string; names: string[]; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..d67a676 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,96 @@ +import { env, versions } from 'node:process'; + +export function clamp(value: number, min: number, max: number): number { + if (typeof value !== 'number') value = min; + return Math.min(max, Math.max(min, value)); +} + +export function escapeHtml(input: string, context: 'text' | 'attr' = 'text'): string { + if (typeof input !== 'string') return ''; + let result = input.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + if (context === 'attr') result = result.replaceAll(`"`, '"').replaceAll(`'`, '''); + return result; +} + +export function errorList() { + const list: string[] = []; + const fn: { (msg: string): void; list: string[] } = (msg = '') => list.push(msg); + fn.list = list; + return fn; +} + +export function fwdSlash(input: string = ''): string { + return input.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); +} + +export function getEnv(key: string): string { + return env[key] ?? ''; +} + +export const getRuntime = once<'bun' | 'deno' | 'node' | 'webcontainer'>(() => { + if (versions.bun && (globalThis as any).Bun) return 'bun'; + if (versions.deno && (globalThis as any).Deno) return 'deno'; + if (versions.webcontainer && getEnv('SHELL').endsWith('/jsh')) return 'webcontainer'; + return 'node'; +}); + +export function headerCase(name: string): string { + return name.replace(/((^|\b|_)[a-z])/g, (s) => s.toUpperCase()); +} + +export function isPrivateIPv4(address?: string) { + if (!address) return false; + const bytes = address.split('.').map(Number); + if (bytes.length !== 4) return false; + for (const byte of bytes) { + if (!(byte >= 0 && byte <= 255)) return false; + } + return ( + // 10/8 + bytes[0] === 10 || + // 172.16/12 + (bytes[0] === 172 && bytes[1] >= 16 && bytes[1] < 32) || + // 192.168/16 + (bytes[0] === 192 && bytes[1] === 168) + ); +} + +export function intRange(start: number, end: number, limit: number = 1_000): number[] { + for (const [key, val] of Object.entries({ start, end, limit })) { + if (!Number.isSafeInteger(val)) throw new Error(`Invalid ${key} param: ${val}`); + } + const length = Math.min(Math.abs(end - start) + 1, Math.abs(limit)); + const increment = start < end ? 1 : -1; + return Array(length) + .fill(undefined) + .map((_, i) => start + i * increment); +} + +/** Cache a function's result after the first call */ +export function once(fn: () => T): () => T { + let value: T; + return () => { + if (typeof value === 'undefined') value = fn(); + return value; + }; +} + +export function trimSlash( + input: string = '', + config: { start?: boolean; end?: boolean } = { start: true, end: true }, +) { + if (config.start === true) input = input.replace(/^[\/\\]/, ''); + if (config.end === true) input = input.replace(/[\/\\]$/, ''); + return input; +} + +export function withResolvers() { + const noop = () => {}; + let resolve: (value: T | PromiseLike) => void = noop; + let reject: (reason?: any) => void = noop; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/test/args.test.js b/test/args.test.ts similarity index 56% rename from test/args.test.js rename to test/args.test.ts index 9073114..dd8ba4c 100644 --- a/test/args.test.js +++ b/test/args.test.ts @@ -1,5 +1,4 @@ -import { deepStrictEqual, strictEqual } from 'node:assert'; -import { suite, test } from 'node:test'; +import { expect, suite, test } from 'vitest'; import { CLIArgs, @@ -9,18 +8,16 @@ import { splitOptionValue, strToBool, unknownArgs, -} from '../lib/args.js'; -import { errorList, intRange } from '../lib/utils.js'; -import { argify } from './shared.js'; +} from '#src/args.js'; +import { errorList, intRange } from '#src/utils.js'; +import type { HttpHeaderRule } from '#types'; -/** -@typedef {import('../lib/types.d.ts').HttpHeaderRule} HttpHeaderRule -*/ +import { argify } from './shared.js'; suite('CLIArgs', () => { test('returns empty values', () => { const args = new CLIArgs([]); - deepStrictEqual(args.data(), { + expect(args.data()).toEqual({ map: [], list: [], }); @@ -37,33 +34,33 @@ suite('CLIArgs', () => { // 'foo', // ' ' // ] - deepStrictEqual(new CLIArgs(['']).data(), { + expect(new CLIArgs(['']).data()).toEqual({ map: [], list: [''], }); - deepStrictEqual(new CLIArgs(['', ' ', '']).data(), { + expect(new CLIArgs(['', ' ', '']).data()).toEqual({ map: [], list: ['', ' ', ''], }); }); test('treats names starting with 1-2 hyphens as key-value options', () => { - strictEqual(argify`zero`.has('zero'), false); - strictEqual(argify`-one`.has('-one'), true); - strictEqual(argify`--two hello`.has('--two'), true); - strictEqual(argify`---three hello`.has('---three'), false); + expect(argify`zero`.has('zero')).toBe(false); + expect(argify`-one`.has('-one')).toBe(true); + expect(argify`--two hello`.has('--two')).toBe(true); + expect(argify`---three hello`.has('---three')).toBe(false); }); test('maps option to its value when separated by equal sign', () => { - strictEqual(argify`-one=value1`.get('-one'), 'value1'); - strictEqual(argify`--two=value2`.get('--two'), 'value2'); + expect(argify`-one=value1`.get('-one')).toBe('value1'); + expect(argify`--two=value2`.get('--two')).toBe('value2'); }); test('maps option to its value when separated by whitespace', () => { const args = argify`-one value1 --two value2`; - strictEqual(args.get('-one'), 'value1'); - strictEqual(args.get('--two'), 'value2'); - deepStrictEqual(args.data().map, [ + expect(args.get('-one')).toBe('value1'); + expect(args.get('--two')).toBe('value2'); + expect(args.data().map).toEqual([ ['-one', 'value1'], ['--two', 'value2'], ]); @@ -71,59 +68,59 @@ suite('CLIArgs', () => { test('can retrieve args with number indexes', () => { const args = argify`. --foo --bar baz hello`; - strictEqual(args.get(0), '.'); - strictEqual(args.get(1), 'hello'); - strictEqual(args.get(2), undefined); - deepStrictEqual(args.data().list, ['.', 'hello']); + expect(args.get(0)).toBe('.'); + expect(args.get(1)).toBe('hello'); + expect(args.get(2)).toBe(undefined); + expect(args.data().list).toEqual(['.', 'hello']); }); test('can retrieve mapped args', () => { const args = argify`. --foo --bar baz hello -x -test=okay`; - strictEqual(args.get('--foo'), ''); - strictEqual(args.get('--bar'), 'baz'); - strictEqual(args.get('-x'), ''); - strictEqual(args.get('-test'), 'okay'); + expect(args.get('--foo')).toBe(''); + expect(args.get('--bar')).toBe('baz'); + expect(args.get('-x')).toBe(''); + expect(args.get('-test')).toBe('okay'); }); test('last instance of option wins', () => { const args = argify`-t=1 -t=2 --test 3 -t 4 --test 5`; - strictEqual(args.get('-t'), '4'); - strictEqual(args.get('--test'), '5'); - strictEqual(args.get(['--test', '-t']), '5'); - deepStrictEqual(args.all(['-t', '--test']), ['1', '2', '3', '4', '5']); + expect(args.get('-t')).toBe('4'); + expect(args.get('--test')).toBe('5'); + expect(args.get(['--test', '-t'])).toBe('5'); + expect(args.all(['-t', '--test'])).toEqual(['1', '2', '3', '4', '5']); }); test('merges all values for searched options', () => { const args = argify`-c config.js -f one.txt --file two.txt -f=three.txt`; - deepStrictEqual(args.all(['--config', '-c']), ['config.js']); - deepStrictEqual(args.all(['--file', '-f']), ['one.txt', 'two.txt', 'three.txt']); + expect(args.all(['--config', '-c'])).toEqual(['config.js']); + expect(args.all(['--file', '-f'])).toEqual(['one.txt', 'two.txt', 'three.txt']); }); }); suite('parseArgs', () => { test('no errors for empty args', () => { const onError = errorList(); - parseArgs(argify``, { onError }); - deepStrictEqual(onError.list, []); + parseArgs(new CLIArgs([]), { onError }); + expect(onError.list).toEqual([]); }); test('does not validate host and root strings', () => { const onError = errorList(); const args = new CLIArgs(['--host', ' not a hostname!\n', 'https://not-a-valid-root']); const options = parseArgs(args, { onError }); - strictEqual(options.host, 'not a hostname!'); - strictEqual(options.root, 'https://not-a-valid-root'); - deepStrictEqual(onError.list, []); + expect(options.host).toBe('not a hostname!'); + expect(options.root).toBe('https://not-a-valid-root'); + expect(onError.list).toEqual([]); }); test('validates --port syntax', () => { const onError = errorList(); const parse = (str = '') => parseArgs(argify(str), { onError }); - deepStrictEqual(parse(`--port 1000+`), { ports: intRange(1000, 1009) }); - deepStrictEqual(parse(`--port +1000`), {}); - deepStrictEqual(parse(`--port whatever`), {}); - deepStrictEqual(parse(`--port {"some":"json"}`), {}); - deepStrictEqual(onError.list, [ + expect(parse(`--port 1000+`)).toEqual({ ports: intRange(1000, 1009) }); + expect(parse(`--port +1000`)).toEqual({}); + expect(parse(`--port whatever`)).toEqual({}); + expect(parse(`--port {"some":"json"}`)).toEqual({}); + expect(onError.list).toEqual([ `invalid --port value: '+1000'`, `invalid --port value: 'whatever'`, `invalid --port value: '{"some":"json"}'`, @@ -134,14 +131,14 @@ suite('parseArgs', () => { const onError = errorList(); const getRule = (value = '') => parseArgs(new CLIArgs(['--header', value]), { onError }).headers?.at(0); - deepStrictEqual(getRule('x-header-1: true'), { + expect(getRule('x-header-1: true')).toEqual({ headers: { 'x-header-1': 'true' }, }); - deepStrictEqual(getRule('*.md,*.mdown content-type: text/markdown; charset=UTF-8'), { + expect(getRule('*.md,*.mdown content-type: text/markdown; charset=UTF-8')).toEqual({ include: ['*.md', '*.mdown'], headers: { 'content-type': 'text/markdown; charset=UTF-8' }, }); - deepStrictEqual(getRule('{"good": "json"}'), { + expect(getRule('{"good": "json"}')).toEqual({ headers: { good: 'json' }, }); }); @@ -153,9 +150,9 @@ suite('parseArgs', () => { return parseArgs(args, { onError }).headers?.at(0); }; - strictEqual(getRule('basic string'), undefined); - strictEqual(getRule('*.md {"bad": [json]}'), undefined); - deepStrictEqual(onError.list, [ + expect(getRule('basic string')).toBe(undefined); + expect(getRule('*.md {"bad": [json]}')).toBe(undefined); + expect(onError.list).toEqual([ `invalid --header value: 'basic string'`, `invalid --header value: '*.md {"bad": [json]}'`, ]); @@ -165,15 +162,13 @@ suite('parseArgs', () => { const onError = errorList(); const args = argify`--help --port=9999 --never gonna -GiveYouUp`; parseArgs(args, { onError }); - deepStrictEqual(onError.list, [`unknown option '--never'`, `unknown option '-GiveYouUp'`]); + expect(onError.list).toEqual([`unknown option '--never'`, `unknown option '-GiveYouUp'`]); }); }); suite('parseHeaders', () => { - /** @type {(input: string, expected: HttpHeaderRule | undefined) => void} */ - const checkHeaders = (input, expected) => { - const result = parseHeaders(input); - deepStrictEqual(result, expected); + const checkHeaders = (input: string, expected?: HttpHeaderRule) => { + expect(parseHeaders(input)).toEqual(expected); }; test('no header rules for empty inputs', () => { @@ -244,92 +239,106 @@ suite('parseHeaders', () => { }); suite('parsePort', () => { + const checkPort = (input: string, expected?: number[]) => { + expect(parsePort(input)).toEqual(expected); + }; + test('invalid values return undefined', () => { - strictEqual(parsePort(''), undefined); - strictEqual(parsePort('--'), undefined); - strictEqual(parsePort('hello'), undefined); - strictEqual(parsePort('9000!'), undefined); - strictEqual(parsePort('3.1415'), undefined); - strictEqual(parsePort('3141+5'), undefined); - strictEqual(parsePort('31415-'), undefined); + checkPort('', undefined); + checkPort('--', undefined); + checkPort('hello', undefined); + checkPort('9000!', undefined); + checkPort('3.1415', undefined); + checkPort('3141+5', undefined); + checkPort('31415-', undefined); }); test('accepts a single integer number', () => { - deepStrictEqual(parsePort('0'), [0]); - deepStrictEqual(parsePort('10'), [10]); - deepStrictEqual(parsePort('1337'), [1337]); - deepStrictEqual(parsePort('65535'), [65_535]); - deepStrictEqual(parsePort('999999'), [999_999]); + checkPort('0', [0]); + checkPort('10', [10]); + checkPort('1337', [1337]); + checkPort('65535', [65_535]); + checkPort('999999', [999_999]); }); test(`with format: 'int+'`, () => { - deepStrictEqual(parsePort('1+'), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + checkPort('1+', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const res1 = parsePort('80+'); - strictEqual(res1?.length, 10); - strictEqual(res1?.at(0), 80); - strictEqual(res1?.at(-1), 89); + expect(res1?.length).toBe(10); + expect(res1?.at(0)).toBe(80); + expect(res1?.at(-1)).toBe(89); + const res2 = parsePort('1337+'); - strictEqual(res2?.length, 10); - strictEqual(res2?.at(0), 1337); - strictEqual(res2?.at(-1), 1346); + expect(res2?.length).toBe(10); + expect(res2?.at(0)).toBe(1337); + expect(res2?.at(-1)).toBe(1346); }); test(`with format: 'int-int'`, () => { - deepStrictEqual(parsePort('0-5'), [0, 1, 2, 3, 4, 5]); - deepStrictEqual(parsePort('1000000-1000000'), [1_000_000]); - deepStrictEqual(parsePort('1337-1333'), [1337, 1336, 1335, 1334, 1333]); + checkPort('0-5', [0, 1, 2, 3, 4, 5]); + checkPort('1000000-1000000', [1_000_000]); + checkPort('1337-1333', [1337, 1336, 1335, 1334, 1333]); }); test('result is limited to 100 numbers', () => { - const res1 = parsePort('1000-9999'); - strictEqual(res1?.length, 100); - strictEqual(res1?.at(0), 1000); - strictEqual(res1?.at(-1), 1099); - strictEqual(res1?.at(200), undefined); + const res1 = parsePort('1000-9999') ?? []; + expect(res1.length).toBe(100); + expect(res1.at(0)).toBe(1000); + expect(res1.at(-1)).toBe(1099); + expect(res1.at(200)).toBe(undefined); }); }); suite('splitOptionValue', () => { + const checkSplit = (input: string[], expected: string[]) => { + expect(splitOptionValue(input)).toEqual(expected); + }; + test('splits string on commas', () => { - deepStrictEqual(splitOptionValue([]), []); - deepStrictEqual(splitOptionValue(['hello world']), ['hello world']); - deepStrictEqual(splitOptionValue([' hello , world ']), ['hello', 'world']); - deepStrictEqual(splitOptionValue([',,,aaa,,,bbb,,,ccc,,,']), ['aaa', 'bbb', 'ccc']); + checkSplit([], []); + checkSplit(['hello world'], ['hello world']); + checkSplit([' hello , world '], ['hello', 'world']); + checkSplit([',,,aaa,,,bbb,,,ccc,,,'], ['aaa', 'bbb', 'ccc']); }); test('flattens split values', () => { - deepStrictEqual(splitOptionValue(['aaa', 'bbb', 'ccc']), ['aaa', 'bbb', 'ccc']); - deepStrictEqual(splitOptionValue(['a,b,c', 'd,e,f', '1,2,3']), 'abcdef123'.split('')); + checkSplit(['aaa', 'bbb', 'ccc'], ['aaa', 'bbb', 'ccc']); + checkSplit(['a,b,c', 'd,e,f', '1,2,3'], 'abcdef123'.split('')); }); test('drops empty values', () => { - deepStrictEqual(splitOptionValue(['', '']), []); - deepStrictEqual(splitOptionValue(['', ',,,', '']), []); - deepStrictEqual(splitOptionValue([',,,test,,,']), ['test']); + checkSplit(['', ''], []); + checkSplit(['', ',,,', ''], []); + checkSplit([',,,test,,,'], ['test']); }); }); suite('strToBool', () => { test('ignores invalid values', () => { - strictEqual(strToBool(), undefined); - // @ts-expect-error - strictEqual(strToBool(true), undefined); - // @ts-expect-error - strictEqual(strToBool({}, true), undefined); + expect(strToBool()).toBe(undefined); + expect( + // @ts-expect-error + strToBool(true), + ).toBe(undefined); + expect( + // @ts-expect-error + strToBool({}, true), + ).toBe(undefined); }); test('matches non-empty strings', () => { - strictEqual(strToBool('True'), true); - strictEqual(strToBool(' FALSE '), false); - strictEqual(strToBool('1'), true); - strictEqual(strToBool('0'), false); + expect(strToBool('True')).toBe(true); + expect(strToBool(' FALSE ')).toBe(false); + expect(strToBool('1')).toBe(true); + expect(strToBool('0')).toBe(false); }); test('empty string returns emptyValue', () => { - strictEqual(strToBool('', true), true); - strictEqual(strToBool('\t \t', true), true); - strictEqual(strToBool('', false), false); - strictEqual(strToBool('\t \t', false), false); + expect(strToBool('', true)).toBe(true); + expect(strToBool('\t \t', true)).toBe(true); + expect(strToBool('', false)).toBe(false); + expect(strToBool('\t \t', false)).toBe(false); }); }); @@ -348,13 +357,13 @@ suite('unknownArgs', () => { --dir-list --no-dir-list --exclude --no-exclude `); - deepStrictEqual(unknownArgs(args), []); + expect(unknownArgs(args)).toEqual([]); }); test('rejects unknown args', () => { const someKnown = ['--version', '--host', '--header', '--ext']; const unknown = ['-v', '--Host', '--ports', '--headers', '--foobar']; const args = new CLIArgs([...unknown, ...someKnown]); - deepStrictEqual(unknownArgs(args), unknown); + expect(unknownArgs(args)).toEqual(unknown); }); }); diff --git a/test/content-type.test.js b/test/content-type.test.js deleted file mode 100644 index 6d0c291..0000000 --- a/test/content-type.test.js +++ /dev/null @@ -1,153 +0,0 @@ -import { strictEqual } from 'node:assert'; -import { open } from 'node:fs/promises'; -import { join } from 'node:path'; -import { cwd } from 'node:process'; -import { suite, test } from 'node:test'; - -import { - getContentType, - isBinHeader, - isBinDataByte, - typeForFilePath, -} from '../lib/content-type.js'; - -suite('getContentType', () => { - /** @type {(path: string) => Promise} */ - const fromFileName = async (path) => { - const result = await getContentType({ path }); - return result.toString(); - }; - /** @type {(handle: import('node:fs/promises').FileHandle) => Promise} */ - const fromFileHandle = async (handle) => { - const result = await getContentType({ handle }); - return result.toString(); - }; - - test('identifies known extensions', async () => { - strictEqual(await fromFileName('index.html'), 'text/html; charset=UTF-8'); - strictEqual(await fromFileName('vendor.a45f981c.min.js'), 'text/javascript; charset=UTF-8'); - strictEqual(await fromFileName('!!!.css'), 'text/css; charset=UTF-8'); - strictEqual(await fromFileName('image.png'), 'image/png'); - strictEqual(await fromFileName('myfont.woff2'), 'font/woff2'); - }); - - test('sniffs text or bin type from file handle', async () => { - const testCases = [ - { file: 'test/content-type.test.js', expected: 'text/plain; charset=UTF-8' }, - { file: 'doc/changelog.md', expected: 'text/plain; charset=UTF-8' }, - { file: 'doc/example.png', expected: 'application/octet-stream' }, - ]; - for (const { file, expected } of testCases) { - const handle = await open(join(cwd(), file)); - const result = await fromFileHandle(handle); - strictEqual(result, expected); - await handle.close(); - } - }); -}); - -suite('typeForFilePath', () => { - const defaultBin = 'application/octet-stream'; - const defaultText = 'text/plain'; - - const checkType = (fileName = '', expected = '') => { - strictEqual(typeForFilePath(fileName, null).toString(), expected); - }; - - test('defaults to binary content type', () => { - checkType('', defaultBin); - checkType('foo', defaultBin); - checkType('!!!!', defaultBin); - }); - - test('identifies bin types from file name', () => { - checkType('image.png', 'image/png'); - checkType('Photos/DSC_4567.JPEG', 'image/jpg'); - checkType('myfont.woff2', 'font/woff2'); - checkType('MyApp.dng', defaultBin); - checkType('cool-installer.msi', defaultBin); - }); - - test('identifies text types from file name', () => { - checkType('WELCOME.HTM', 'text/html'); - checkType('README.md', 'text/markdown'); - checkType('styles.css', 'text/css'); - checkType('data.json', 'application/json'); - checkType('component.js', 'text/javascript'); - checkType('file.txt', defaultText); - checkType('README', defaultText); - checkType('LICENSE', defaultText); - checkType('dev.log', defaultText); - checkType('.bashrc', defaultText); - checkType('.gitkeep', defaultText); - checkType('.npmignore', defaultText); - }); -}); - -suite('isBinHeader', () => { - const NUL = 0x00; - const SP = 0x20; - - test('empty file is not binary', () => { - strictEqual(isBinHeader(new Uint8Array([])), false); - }); - - test('starts with binary data bytes', () => { - strictEqual(isBinHeader(new Uint8Array([0x00, SP, SP, SP])), true); - strictEqual(isBinHeader(new Uint8Array([0x0b, SP, SP, SP])), true); - strictEqual(isBinHeader(new Uint8Array([0x1f, SP, SP, SP])), true); - }); - - test('binary data byte within header', () => { - const base = new Uint8Array(1500); - base.fill(SP); - - const arr1 = new Uint8Array(base); - const arr2 = new Uint8Array(base); - arr1[750] = NUL; - arr2[arr2.length - 1] = NUL; - - strictEqual(isBinHeader(base), false); - strictEqual(isBinHeader(arr1), true); - strictEqual(isBinHeader(arr2), true); - }); - - test('binary data byte is ignored if after the 2000th byte', () => { - const arr = new Uint8Array(5000); - arr.fill(SP); - arr[arr.length - 1] = NUL; - strictEqual(isBinHeader(arr), false); - }); - - test('UTF-8 BOM', () => { - strictEqual(isBinHeader(new Uint8Array([0xef, 0xbb, 0xbf])), false); - strictEqual(isBinHeader(new Uint8Array([0xef, 0xbb, 0xbf, SP, SP, NUL])), false); - }); - - test('UTF-16 BOM', () => { - strictEqual(isBinHeader(new Uint8Array([0xfe, 0xff])), false); - strictEqual(isBinHeader(new Uint8Array([0xff, 0xfe])), false); - strictEqual(isBinHeader(new Uint8Array([0xfe, 0xff, SP, SP, NUL])), false); - strictEqual(isBinHeader(new Uint8Array([0xff, 0xfe, SP, SP, NUL])), false); - }); - - test('UTF-8 string', () => {}); -}); - -suite('isBinDataByte', () => { - test('identifies binary data bytes', () => { - const bytes = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0b, 0x0e, 0x0f, 0x10, 0x11, 0x12, - 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x1f, - ]; - for (const byte of bytes) { - strictEqual(isBinDataByte(byte), true, `${byte} is a binary data byte`); - } - }); - test('avoids false positives', () => { - const other = [-257, -50, -1, 0x09, 0x0a, 0x0c, 0x0d, 0x1b, 0x20, 0x21, 0x2f, 100, 255, 256]; - for (const num of other) { - strictEqual(isBinDataByte(num), false, `${num} is not a binary data byte`); - } - }); -}); diff --git a/test/content-type.test.ts b/test/content-type.test.ts new file mode 100644 index 0000000..4396a11 --- /dev/null +++ b/test/content-type.test.ts @@ -0,0 +1,142 @@ +import { open } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expect, suite, test } from 'vitest'; + +import { getContentType, isBinHeader, isBinDataByte, typeForFilePath } from '#src/content-type.js'; + +suite('getContentType', () => { + const $path = async (path: string, expected: string) => { + const type = await getContentType({ path }); + expect(type.toString()).toBe(expected); + }; + const $file = async (localPath: string, expected: string) => { + const filePath = join(import.meta.dirname, '..', localPath); + const handle = await open(filePath); + const type = await getContentType({ handle }); + expect(type.toString()).toBe(expected); + await handle.close(); + }; + + test('identifies known extensions', async () => { + await $path('index.html', 'text/html; charset=UTF-8'); + await $path('vendor.a45f981c.min.js', 'text/javascript; charset=UTF-8'); + await $path('!!!.css', 'text/css; charset=UTF-8'); + await $path('image.png', 'image/png'); + await $path('myfont.woff2', 'font/woff2'); + }); + + test('sniffs text or bin type from file handle', async () => { + await $file('test/content-type.test.ts', 'text/plain; charset=UTF-8'); + await $file('doc/changelog.md', 'text/plain; charset=UTF-8'); + await $file('LICENSE', 'text/plain; charset=UTF-8'); + await $file('doc/example.png', 'application/octet-stream'); + }); +}); + +suite('typeForFilePath', () => { + const defaultBin = 'application/octet-stream'; + const defaultText = 'text/plain'; + + const $type = (fileName = '', expected = '') => { + expect(typeForFilePath(fileName, '').toString()).toBe(expected); + }; + + test('defaults to binary content type', () => { + $type('', defaultBin); + $type('foo', defaultBin); + $type('!!!!', defaultBin); + }); + + test('identifies bin types from file name', () => { + $type('image.png', 'image/png'); + $type('Photos/DSC_4567.JPEG', 'image/jpg'); + $type('myfont.woff2', 'font/woff2'); + $type('MyApp.dng', defaultBin); + $type('cool-installer.msi', defaultBin); + }); + + test('identifies text types from file name', () => { + $type('WELCOME.HTM', 'text/html'); + $type('README.md', 'text/markdown'); + $type('styles.css', 'text/css'); + $type('data.json', 'application/json'); + $type('component.js', 'text/javascript'); + $type('file.txt', defaultText); + $type('README', defaultText); + $type('LICENSE', defaultText); + $type('dev.log', defaultText); + $type('.bashrc', defaultText); + $type('.gitkeep', defaultText); + $type('.npmignore', defaultText); + }); +}); + +suite('isBinHeader', () => { + const NUL = 0x00; + const SP = 0x20; + + const $bin = (bytes: Uint8Array, expected: boolean) => { + expect(isBinHeader(bytes)).toBe(expected); + }; + + test('empty file is not binary', () => { + $bin(new Uint8Array([]), false); + }); + + test('starts with binary data bytes', () => { + $bin(new Uint8Array([0x00, SP, SP, SP]), true); + $bin(new Uint8Array([0x0b, SP, SP, SP]), true); + $bin(new Uint8Array([0x1f, SP, SP, SP]), true); + }); + + test('binary data byte within header', () => { + const base = new Uint8Array(1500); + base.fill(SP); + + const arr1 = new Uint8Array(base); + const arr2 = new Uint8Array(base); + arr1[750] = NUL; + arr2[arr2.length - 1] = NUL; + + $bin(base, false); + $bin(arr1, true); + $bin(arr2, true); + }); + + test('binary data byte is ignored if after the 2000th byte', () => { + const arr = new Uint8Array(5000); + arr.fill(SP); + arr[arr.length - 1] = NUL; + $bin(arr, false); + }); + + test('UTF-8 BOM', () => { + $bin(new Uint8Array([0xef, 0xbb, 0xbf]), false); + $bin(new Uint8Array([0xef, 0xbb, 0xbf, SP, SP, NUL]), false); + }); + + test('UTF-16 BOM', () => { + $bin(new Uint8Array([0xfe, 0xff]), false); + $bin(new Uint8Array([0xff, 0xfe]), false); + $bin(new Uint8Array([0xfe, 0xff, SP, SP, NUL]), false); + $bin(new Uint8Array([0xff, 0xfe, SP, SP, NUL]), false); + }); +}); + +suite('isBinDataByte', () => { + test('identifies binary data bytes', () => { + const bytes = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0b, 0x0e, 0x0f, 0x10, 0x11, 0x12, + 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x1f, + ]; + for (const byte of bytes) { + expect(isBinDataByte(byte), `${byte} is a binary data byte`).toBe(true); + } + }); + test('avoids false positives', () => { + const other = [-257, -50, -1, 0x09, 0x0a, 0x0c, 0x0d, 0x1b, 0x20, 0x21, 0x2f, 100, 255, 256]; + for (const num of other) { + expect(isBinDataByte(num), `${num} is not a binary data byte`).toBe(false); + } + }); +}); diff --git a/test/fs-utils.test.js b/test/fs-utils.test.ts similarity index 58% rename from test/fs-utils.test.js rename to test/fs-utils.test.ts index e409d29..a9fc86d 100644 --- a/test/fs-utils.test.js +++ b/test/fs-utils.test.ts @@ -1,9 +1,8 @@ -import { deepStrictEqual, strictEqual } from 'node:assert'; import { platform } from 'node:os'; import { chmod } from 'node:fs/promises'; -import { after, suite, test } from 'node:test'; +import { afterAll, expect, suite, test } from 'vitest'; -import { getIndex, getKind, getRealpath, isReadable, readPkgJson } from '../lib/fs-utils.js'; +import { getIndex, getKind, getRealpath, isReadable } from '#src/fs-utils.js'; import { fsFixture } from './shared.js'; const isWindows = platform() === 'win32'; @@ -23,11 +22,11 @@ suite('fsUtils', async () => { 'section2/link.html': isWindows ? '' : ({ symlink }) => symlink('../page1.html'), }); - after(() => fixture.rm()); + afterAll(() => fixture.rm()); test('getIndex', async () => { const rootIndex = await getIndex(path()); - deepStrictEqual(rootIndex, [ + expect(rootIndex).toEqual([ { filePath: path`blocked`, kind: 'dir' }, { filePath: path`index.html`, kind: 'file' }, { filePath: path`page1.html`, kind: 'file' }, @@ -36,7 +35,7 @@ suite('fsUtils', async () => { ]); const sectionIndex = await getIndex(path`section1`); - deepStrictEqual(sectionIndex, [ + expect(sectionIndex).toEqual([ { filePath: path`section1/index.html`, kind: 'file' }, { filePath: path`section1/other-page.html`, kind: 'file' }, { filePath: path`section1/sub-section`, kind: 'dir' }, @@ -44,40 +43,41 @@ suite('fsUtils', async () => { }); test('getKind', async () => { - strictEqual(await getKind(path``), 'dir'); - strictEqual(await getKind(path`section1`), 'dir'); - strictEqual(await getKind(path`index.html`), 'file'); - strictEqual(await getKind(path`section1/sub-section/index.html`), 'file'); - strictEqual(await getKind(path`section2/link.html`), isWindows ? 'file' : 'link'); + expect(await getKind(path``)).toBe('dir'); + expect(await getKind(path`section1`)).toBe('dir'); + expect(await getKind(path`index.html`)).toBe('file'); + expect(await getKind(path`section1/sub-section/index.html`)).toBe('file'); + if (!isWindows) { + expect(await getKind(path`section2/link.html`)).toBe('link'); + } }); test('getRealpath', async () => { - strictEqual(await getRealpath(path``), path``); - strictEqual(await getRealpath(path`page1.html`), path`page1.html`); - strictEqual( - await getRealpath(path`section2/link.html`), - isWindows ? path`section2/link.html` : path`page1.html`, - ); + expect(await getRealpath(path``)).toBe(path``); + expect(await getRealpath(path`page1.html`)).toBe(path`page1.html`); + if (!isWindows) { + expect(await getRealpath(path`section2/link.html`)).toBe(path`page1.html`); + } }); test('isReadable(file)', async () => { - strictEqual(await isReadable(path``), true); - strictEqual(await isReadable(path`page1.html`), true); - strictEqual(await isReadable(path`section1/sub-section`), true); + expect(await isReadable(path``)).toBe(true); + expect(await isReadable(path`page1.html`)).toBe(true); + expect(await isReadable(path`section1/sub-section`)).toBe(true); // make one file unreadable const blockedPath = path`blocked/file.txt`; - strictEqual(await isReadable(blockedPath), true); + expect(await isReadable(blockedPath)).toBe(true); if (!isWindows) { await chmod(blockedPath, 0o000); - strictEqual(await isReadable(blockedPath), false); + expect(await isReadable(blockedPath)).toBe(false); } // symlinks reflect the readable state of their target // (caveat: does not check +x permission if target is a dir) if (!isWindows) { - strictEqual(await isReadable(path`section2/link.html`), true); - strictEqual(await isReadable(path`blocked/link.txt`), false); + expect(await isReadable(path`section2/link.html`)).toBe(true); + expect(await isReadable(path`blocked/link.txt`)).toBe(false); } }); }); diff --git a/test/handler.test.js b/test/handler.test.ts similarity index 69% rename from test/handler.test.js rename to test/handler.test.ts index 25b2a66..91c293f 100644 --- a/test/handler.test.js +++ b/test/handler.test.ts @@ -1,45 +1,30 @@ -import { deepStrictEqual, match, strictEqual } from 'node:assert'; import { IncomingMessage, ServerResponse } from 'node:http'; import { Duplex } from 'node:stream'; -import { after, suite, test } from 'node:test'; +import { afterAll, expect, suite, test } from 'vitest'; + +import { extractUrlPath, fileHeaders, isValidUrlPath, RequestHandler } from '#src/handler.js'; +import { FileResolver } from '#src/resolver.js'; +import type { HttpHeaderRule, ServerOptions } from '#types'; -import { extractUrlPath, fileHeaders, isValidUrlPath, RequestHandler } from '../lib/handler.js'; -import { FileResolver } from '../lib/resolver.js'; import { fsFixture, getBlankOptions, getDefaultOptions, platformSlash } from './shared.js'; -/** -@typedef {import('../lib/types.d.ts').HttpHeaderRule} HttpHeaderRule -@typedef {import('../lib/types.d.ts').ServerOptions} ServerOptions -@typedef {Record} ResponseHeaders -*/ +type ResponseHeaders = Record; const allowMethods = 'GET, HEAD, OPTIONS, POST'; -/** -@type {(actual: ResponseHeaders, expected: ResponseHeaders) => void} -*/ -function checkHeaders(actual, expected) { - deepStrictEqual(actual, headersObj(expected)); +function checkHeaders(actual: ResponseHeaders, expected: ResponseHeaders) { + expect(actual).toEqual(expected); } -/** -@type {(data: ResponseHeaders) => ResponseHeaders} -*/ -function headersObj(data) { - /** @type {ResponseHeaders} */ - const result = Object.create(null); +function headersObj(data: ResponseHeaders) { + const result: ResponseHeaders = Object.create(null); for (const [key, value] of Object.entries(data)) { result[key.toLowerCase()] = value; } return result; } -/** -@param {string} method -@param {string} url -@param {Record} [headers] -*/ -function mockReqRes(method, url, headers = {}) { +function mockReqRes(method: string, url: string, headers: Record = {}) { const req = new IncomingMessage( // @ts-expect-error (we don't have a socket, hoping this is enough for testing) new Duplex(), @@ -53,11 +38,9 @@ function mockReqRes(method, url, headers = {}) { return { req, res }; } -/** -@param {ServerOptions} options -@returns {(method: string, url: string, headers?: Record) => RequestHandler} -*/ -function handlerContext(options) { +function handlerContext( + options: ServerOptions, +): (method: string, url: string, headers?: Record) => RequestHandler { const resolver = new FileResolver(options); const handlerOptions = { ...options, gzip: false, _noStream: true }; @@ -67,18 +50,17 @@ function handlerContext(options) { }; } -/** -@param {HttpHeaderRule[]} rules -@param {string[]} [blockList] -@returns {(filePath: string) => ReturnType} -*/ -function withHeaderRules(rules, blockList) { +function withHeaderRules( + rules: HttpHeaderRule[], + blockList?: string[], +): (filePath: string) => ReturnType { return (filePath) => fileHeaders(filePath, rules, blockList); } suite('isValidUrlPath', () => { - /** @type {(urlPath: string, expected: boolean) => void} */ - const check = (urlPath, expected = true) => strictEqual(isValidUrlPath(urlPath), expected); + const check = (urlPath: string, expected = true) => { + expect(isValidUrlPath(urlPath)).toBe(expected); + }; test('rejects invalid paths', () => { check('', false); @@ -110,8 +92,9 @@ suite('isValidUrlPath', () => { }); suite('extractUrlPath', () => { - /** @type {(url: string, expected: string | null) => void} */ - const checkUrl = (url, expected) => strictEqual(extractUrlPath(url), expected); + const checkUrl = (url: string, expected: string | null) => { + expect(extractUrlPath(url)).toBe(expected); + }; test('extracts URL pathname', () => { checkUrl('https://example.com/hello/world', '/hello/world'); @@ -149,9 +132,9 @@ suite('fileHeaders', () => { { name: 'x-header1', value: 'three' }, { name: 'X-Header2', value: 'four' }, ]; - deepStrictEqual(headers(''), expected); - deepStrictEqual(headers('file.ext'), expected); - deepStrictEqual(headers('any/thing.ext'), expected); + expect(headers('')).toEqual(expected); + expect(headers('file.ext')).toEqual(expected); + expect(headers('any/thing.ext')).toEqual(expected); }); test('headers matching blocklist are rejected', () => { @@ -162,8 +145,8 @@ suite('fileHeaders', () => { ], ['content-length', 'content-encoding'], ); - deepStrictEqual(headers(''), [{ name: 'X-Header1', value: 'one' }]); - deepStrictEqual(headers('readme.md'), [ + expect(headers('')).toEqual([{ name: 'X-Header1', value: 'one' }]); + expect(headers('readme.md')).toEqual([ { name: 'X-Header1', value: 'one' }, { name: 'X-Header2', value: 'two' }, ]); @@ -174,11 +157,11 @@ suite('fileHeaders', () => { { include: ['path'], headers: { 'x-header1': 'true' } }, { include: ['*.test'], headers: { 'Content-Type': 'test/custom-type' } }, ]); - deepStrictEqual(headers(''), []); - deepStrictEqual(headers('wrong-path/file.test.txt'), []); - deepStrictEqual(headers('path/to/README.md'), [{ name: 'x-header1', value: 'true' }]); - deepStrictEqual(headers('README.test'), [{ name: 'Content-Type', value: 'test/custom-type' }]); - deepStrictEqual(headers('other/path/cool.test/index.html'), [ + expect(headers('')).toEqual([]); + expect(headers('wrong-path/file.test.txt')).toEqual([]); + expect(headers('path/to/README.md')).toEqual([{ name: 'x-header1', value: 'true' }]); + expect(headers('README.test')).toEqual([{ name: 'Content-Type', value: 'test/custom-type' }]); + expect(headers('other/path/cool.test/index.html')).toEqual([ { name: 'x-header1', value: 'true' }, { name: 'Content-Type', value: 'test/custom-type' }, ]); @@ -206,30 +189,30 @@ suite('RequestHandler', async () => { const defaultOptions = getDefaultOptions(path()); const request = handlerContext(defaultOptions); - after(() => fixture.rm()); + afterAll(() => fixture.rm()); test('starts with a 200 status', async () => { const request = handlerContext(blankOptions); const handler = request('GET', '/'); - strictEqual(handler.method, 'GET'); - strictEqual(handler.urlPath, '/'); - strictEqual(handler.status, 200); - strictEqual(handler.file, null); + expect(handler.method).toBe('GET'); + expect(handler.urlPath).toBe('/'); + expect(handler.status).toBe(200); + expect(handler.file).toBe(null); }); for (const method of ['PUT', 'DELETE']) { test(`${method} method is unsupported`, async () => { const handler = request(method, '/README.md'); - strictEqual(handler.method, method); - strictEqual(handler.status, 200); - strictEqual(handler.urlPath, '/README.md'); - strictEqual(handler.file, null); + expect(handler.method).toBe(method); + expect(handler.status).toBe(200); + expect(handler.urlPath).toBe('/README.md'); + expect(handler.file).toBe(null); await handler.process(); - strictEqual(handler.status, 405); - strictEqual(handler.headers['allow'], allowMethods); - strictEqual(handler.headers['content-type'], 'text/html; charset=UTF-8'); - match(`${handler.headers['content-length']}`, /^\d+$/); + expect(handler.status).toBe(405); + expect(handler.headers['allow']).toBe(allowMethods); + expect(handler.headers['content-type']).toBe('text/html; charset=UTF-8'); + expect(`${handler.headers['content-length']}`).toMatch(/^\d+$/); }); } @@ -237,10 +220,10 @@ suite('RequestHandler', async () => { const handler = request('GET', '/'); await handler.process(); - strictEqual(handler.status, 200); - strictEqual(handler.file?.kind, 'file'); - strictEqual(handler.localPath, 'index.html'); - strictEqual(handler.error, undefined); + expect(handler.status).toBe(200); + expect(handler.file?.kind).toBe('file'); + expect(handler.localPath).toBe('index.html'); + expect(handler.error).toBe(undefined); }); test('GET returns a directory listing', async () => { @@ -257,48 +240,46 @@ suite('RequestHandler', async () => { const request = handlerContext({ ...blankOptions, dirList }); const handler = request('GET', url); await handler.process(); - strictEqual(handler.status, status); + expect(handler.status).toBe(status); // both error and list pages are HTML - strictEqual(handler.headers['content-type'], 'text/html; charset=UTF-8'); + expect(handler.headers['content-type']).toBe('text/html; charset=UTF-8'); // folder is still resolved when status is 404, just not used - deepStrictEqual(handler.file, file); + expect(handler.file).toEqual(file); } }); test('GET returns a 404 for an unknown path', async () => { const control = request('GET', '/index.html'); await control.process(); - strictEqual(control.status, 200); - strictEqual(control.localPath, 'index.html'); + expect(control.status).toBe(200); + expect(control.localPath).toBe('index.html'); const noFile = request('GET', '/does/not/exist'); await noFile.process(); - strictEqual(noFile.status, 404); - strictEqual(noFile.file, null); - strictEqual(noFile.localPath, null); + expect(noFile.status).toBe(404); + expect(noFile.file).toBe(null); + expect(noFile.localPath).toBe(null); }); test('GET finds .html files without extension', async () => { const page1 = request('GET', '/section/page'); await page1.process(); - strictEqual(page1.status, 200); - strictEqual(page1.localPath, platformSlash`section/page.html`); + expect(page1.status).toBe(200); + expect(page1.localPath).toBe(platformSlash`section/page.html`); const page2 = request('GET', '/section/other-page'); await page2.process(); - strictEqual(page2.status, 200); - strictEqual(page2.localPath, platformSlash`section/other-page.html`); + expect(page2.status).toBe(200); + expect(page2.localPath).toBe(platformSlash`section/other-page.html`); }); test('GET shows correct content-type', async () => { const checkType = async (url = '', contentType = '') => { const handler = request('GET', url); await handler.process(); - strictEqual(handler.status, 200, `Correct status for GET ${url}`); - strictEqual( - handler.headers['content-type'], + expect(handler.status, `Correct status for GET ${url}`).toBe(200); + expect(handler.headers['content-type'], `Correct content-type for GET ${url}`).toBe( contentType, - `Correct content-type for GET ${url}`, ); }; @@ -319,47 +300,47 @@ suite('RequestHandler', async () => { for (const { url, localPath, status } of cases) { const getReq = request('GET', url); await getReq.process(); - strictEqual(getReq.method, 'GET'); - strictEqual(getReq.status, status); - strictEqual(getReq.localPath, localPath); + expect(getReq.method).toBe('GET'); + expect(getReq.status).toBe(status); + expect(getReq.localPath).toBe(localPath); const postReq = request('POST', url); await postReq.process(); - strictEqual(postReq.method, 'POST'); - strictEqual(postReq.status, status); - strictEqual(postReq.localPath, localPath); + expect(postReq.method).toBe('POST'); + expect(postReq.status).toBe(status); + expect(postReq.localPath).toBe(localPath); // other than method, results are identical - strictEqual(getReq.status, postReq.status); - deepStrictEqual(getReq.file, postReq.file); + expect(getReq.status).toBe(postReq.status); + expect(getReq.file).toEqual(postReq.file); } }); test('HEAD with a 200 response', async () => { const handler = request('HEAD', '/'); await handler.process(); - strictEqual(handler.method, 'HEAD'); - strictEqual(handler.status, 200); - strictEqual(handler.localPath, 'index.html'); - strictEqual(handler.headers['content-type'], 'text/html; charset=UTF-8'); - match(`${handler.headers['content-length']}`, /^\d+$/); + expect(handler.method).toBe('HEAD'); + expect(handler.status).toBe(200); + expect(handler.localPath).toBe('index.html'); + expect(handler.headers['content-type']).toBe('text/html; charset=UTF-8'); + expect(`${handler.headers['content-length']}`).toMatch(/^\d+$/); }); test('HEAD with a 404 response', async () => { const handler = request('HEAD', '/doesnt/exist'); await handler.process(); - strictEqual(handler.method, 'HEAD'); - strictEqual(handler.status, 404); - strictEqual(handler.file, null); - strictEqual(handler.headers['content-type'], 'text/html; charset=UTF-8'); - match(`${handler.headers['content-length']}`, /^\d+$/); + expect(handler.method).toBe('HEAD'); + expect(handler.status).toBe(404); + expect(handler.file).toBe(null); + expect(handler.headers['content-type']).toBe('text/html; charset=UTF-8'); + expect(`${handler.headers['content-length']}`).toMatch(/^\d+$/); }); test('OPTIONS *', async () => { const handler = request('OPTIONS', '*'); await handler.process(); - strictEqual(handler.method, 'OPTIONS'); - strictEqual(handler.status, 204); + expect(handler.method).toBe('OPTIONS'); + expect(handler.status).toBe(204); checkHeaders(handler.headers, { allow: allowMethods, 'content-length': '0', @@ -369,8 +350,8 @@ suite('RequestHandler', async () => { test('OPTIONS for existing file', async () => { const handler = request('OPTIONS', '/section/page'); await handler.process(); - strictEqual(handler.method, 'OPTIONS'); - strictEqual(handler.status, 204); + expect(handler.method).toBe('OPTIONS'); + expect(handler.status).toBe(204); checkHeaders(handler.headers, { allow: allowMethods, 'content-length': '0', @@ -380,7 +361,7 @@ suite('RequestHandler', async () => { test('OPTIONS for missing file', async () => { const handler = request('OPTIONS', '/doesnt/exist'); await handler.process(); - strictEqual(handler.status, 404); + expect(handler.status).toBe(404); checkHeaders(handler.headers, { allow: allowMethods, 'content-length': '0', @@ -395,7 +376,7 @@ suite('RequestHandler', async () => { 'Access-Control-Request-Method': 'GET', }); await getReq.process(); - strictEqual(getReq.status, 200); + expect(getReq.status).toBe(200); checkHeaders(getReq.headers, { 'content-type': 'application/json; charset=UTF-8', 'content-length': '18', @@ -407,7 +388,7 @@ suite('RequestHandler', async () => { 'Access-Control-Request-Headers': 'X-Header1', }); await preflightReq.process(); - strictEqual(preflightReq.status, 204); + expect(preflightReq.status).toBe(204); checkHeaders(preflightReq.headers, { allow: allowMethods, 'content-length': '0', @@ -422,7 +403,7 @@ suite('RequestHandler', async () => { 'Access-Control-Request-Method': 'GET', }); await getReq.process(); - strictEqual(getReq.status, 200); + expect(getReq.status).toBe(200); checkHeaders(getReq.headers, { 'access-control-allow-origin': 'https://example.com', 'content-type': 'application/json; charset=UTF-8', @@ -435,7 +416,7 @@ suite('RequestHandler', async () => { 'Access-Control-Request-Headers': 'X-Header1', }); await preflightReq.process(); - strictEqual(preflightReq.status, 204); + expect(preflightReq.status).toBe(204); checkHeaders(preflightReq.headers, { allow: allowMethods, 'access-control-allow-headers': 'X-Header1', diff --git a/test/logger.test.js b/test/logger.test.ts similarity index 64% rename from test/logger.test.js rename to test/logger.test.ts index a76cbad..206d139 100644 --- a/test/logger.test.js +++ b/test/logger.test.ts @@ -1,62 +1,57 @@ -import { strictEqual } from 'node:assert'; -import { suite, test } from 'node:test'; import { stripVTControlCharacters } from 'node:util'; +import { expect, suite, test } from 'vitest'; -import { ColorUtils, requestLogLine } from '../lib/logger.js'; +import { ColorUtils, requestLogLine } from '#src/logger.js'; +import type { ResMetaData } from '#types'; suite('ColorUtils', () => { const color = new ColorUtils(true); const noColor = new ColorUtils(false); test('.style does nothing for empty format', () => { - strictEqual(color.style('TEST'), 'TEST'); - strictEqual(color.style('TEST', ''), 'TEST'); - strictEqual(noColor.style('TEST', ''), 'TEST'); + expect(color.style('TEST')).toBe('TEST'); + expect(color.style('TEST', '')).toBe('TEST'); + expect(noColor.style('TEST', '')).toBe('TEST'); }); test('.style adds color codes to strings', () => { - strictEqual(color.style('TEST', 'reset'), '\x1B[0mTEST\x1B[0m'); - strictEqual(color.style('TEST', 'red'), '\x1B[31mTEST\x1B[39m'); - strictEqual(color.style('TEST', 'dim underline'), '\x1B[2m\x1B[4mTEST\x1B[24m\x1B[22m'); - strictEqual(noColor.style('TEST', 'reset'), 'TEST'); - strictEqual(noColor.style('TEST', 'red'), 'TEST'); - strictEqual(noColor.style('TEST', 'dim underline'), 'TEST'); + expect(color.style('TEST', 'reset')).toBe('\x1B[0mTEST\x1B[0m'); + expect(color.style('TEST', 'red')).toBe('\x1B[31mTEST\x1B[39m'); + expect(color.style('TEST', 'dim underline')).toBe('\x1B[2m\x1B[4mTEST\x1B[24m\x1B[22m'); + expect(noColor.style('TEST', 'reset')).toBe('TEST'); + expect(noColor.style('TEST', 'red')).toBe('TEST'); + expect(noColor.style('TEST', 'dim underline')).toBe('TEST'); }); test('.sequence applies styles to sequence', () => { - strictEqual(color.sequence(['(', 'TEST', ')']), '(TEST)'); - strictEqual(color.sequence(['TE', 'ST'], 'blue'), '\x1B[34mTE\x1B[39mST'); - strictEqual(color.sequence(['TE', 'ST'], ',blue'), 'TE\x1B[34mST\x1B[39m'); - strictEqual( - color.sequence(['TE', 'ST'], 'blue,red,green'), + expect(color.sequence(['(', 'TEST', ')'])).toBe('(TEST)'); + expect(color.sequence(['TE', 'ST'], 'blue')).toBe('\x1B[34mTE\x1B[39mST'); + expect(color.sequence(['TE', 'ST'], ',blue')).toBe('TE\x1B[34mST\x1B[39m'); + expect(color.sequence(['TE', 'ST'], 'blue,red,green')).toBe( '\x1B[34mTE\x1B[39m\x1B[31mST\x1B[39m', ); - strictEqual(noColor.sequence(['TE', 'ST'], 'blue'), 'TEST'); - strictEqual(noColor.sequence(['TE', 'ST'], 'blue,red,green'), 'TEST'); + expect(noColor.sequence(['TE', 'ST'], 'blue')).toBe('TEST'); + expect(noColor.sequence(['TE', 'ST'], 'blue,red,green')).toBe('TEST'); }); test('.brackets adds characters around input', () => { - strictEqual(color.brackets('TEST', ''), '[TEST]'); - strictEqual(color.brackets('TEST', '', ['<<<', '>>>']), '<<>>'); - strictEqual(color.brackets('TEST', 'blue,,red'), '\x1B[34m[\x1B[39mTEST\x1B[31m]\x1B[39m'); - strictEqual(color.brackets('TEST'), '\x1B[2m[\x1B[22mTEST\x1B[2m]\x1B[22m'); - strictEqual(color.brackets('TEST', ',underline,', ['<<<', '>>>']), '<<<\x1B[4mTEST\x1B[24m>>>'); + expect(color.brackets('TEST', '')).toBe('[TEST]'); + expect(color.brackets('TEST', '', ['<<<', '>>>'])).toBe('<<>>'); + expect(color.brackets('TEST', 'blue,,red')).toBe('\x1B[34m[\x1B[39mTEST\x1B[31m]\x1B[39m'); + expect(color.brackets('TEST')).toBe('\x1B[2m[\x1B[22mTEST\x1B[2m]\x1B[22m'); + expect(color.brackets('TEST', ',underline,', ['<<<', '>>>'])).toBe('<<<\x1B[4mTEST\x1B[24m>>>'); }); }); suite('responseLogLine', () => { - /** - @param {Omit} data - @param {string} expected - */ - const matchLogLine = (data, expected) => { + const matchLogLine = (data: Omit, expected: string) => { const rawLine = requestLogLine({ timing: { start: Date.now() }, url: `http://localhost:8080${data.urlPath}`, ...data, }); const line = stripVTControlCharacters(rawLine).replace(/^\d{2}:\d{2}:\d{2} /, ''); - strictEqual(line, expected); + expect(line).toBe(expected); }; test('basic formatting', () => { diff --git a/test/options.test.js b/test/options.test.ts similarity index 83% rename from test/options.test.js rename to test/options.test.ts index 7be984b..919e3bb 100644 --- a/test/options.test.js +++ b/test/options.test.ts @@ -1,8 +1,7 @@ -import { deepStrictEqual, ok, strictEqual } from 'node:assert'; import { cwd } from 'node:process'; -import { suite, test } from 'node:test'; +import { expect, suite, test } from 'vitest'; -import { DEFAULT_OPTIONS } from '../lib/constants.js'; +import { DEFAULT_OPTIONS } from '#src/constants.js'; import { isValidExt, isValidHeader, @@ -11,16 +10,12 @@ import { isValidPattern, isValidPort, serverOptions, -} from '../lib/options.js'; -import { errorList } from '../lib/utils.js'; - -/** -@param {(input: any) => boolean} isValidFn -@returns {{ valid: (input: any) => void, invalid: (input: any) => void }} -*/ -function makeValidChecks(isValidFn) { - /** @type {(expected: boolean, input: any) => string} */ - const msg = (expected, input) => { +} from '#src/options.js'; +import { errorList } from '#src/utils.js'; +import type { ServerOptions } from '#types'; + +function makeValidChecks(isValidFn: (input: any) => boolean) { + const msg = (expected: boolean, input: any) => { return [ `Expected to be ${expected ? 'valid' : 'invalid'}:`, `${isValidFn.name}(${JSON.stringify(input, null, '\t')})`, @@ -28,11 +23,11 @@ function makeValidChecks(isValidFn) { }; return { - valid(input) { - strictEqual(isValidFn(input), true, msg(true, input)); + valid(input: any) { + expect(isValidFn(input), msg(true, input)).toBe(true); }, - invalid(input) { - strictEqual(isValidFn(input), false, msg(false, input)); + invalid(input: any) { + expect(isValidFn(input), msg(false, input)).toBe(false); }, }; } @@ -197,30 +192,30 @@ suite('isValidPort', () => { }); suite('serverOptions', () => { + type InputOptions = Partial & { root: string }; + test('returns default options with empty input', () => { const onError = errorList(); const { root, ...result } = serverOptions({ root: cwd() }, { onError }); - deepStrictEqual(result, DEFAULT_OPTIONS); - deepStrictEqual(onError.list, []); + expect(result).toEqual(DEFAULT_OPTIONS); + expect(onError.list).toEqual([]); }); test('preserves valid options', () => { const onError = errorList(); - /** @type {Parameters[0]} */ - const testOptions1 = { + const testOptions1: InputOptions = { root: cwd(), dirList: false, gzip: false, cors: true, }; - deepStrictEqual(serverOptions(testOptions1, { onError }), { + expect(serverOptions(testOptions1, { onError })).toEqual({ ...DEFAULT_OPTIONS, ...testOptions1, }); - /** @type {Parameters[0]} */ - const testOptions2 = { + const testOptions2: InputOptions = { root: cwd(), ext: ['.htm', '.TXT'], dirFile: ['page.md', 'Index Page.html'], @@ -228,12 +223,12 @@ suite('serverOptions', () => { headers: [{ include: ['*.md', '*.html'], headers: { dnt: 1 } }], host: '192.168.1.199', }; - deepStrictEqual(serverOptions(testOptions2, { onError }), { + expect(serverOptions(testOptions2, { onError })).toEqual({ ...DEFAULT_OPTIONS, ...testOptions2, }); - deepStrictEqual(onError.list, []); + expect(onError.list).toEqual([]); }); test('rejects invalid values', () => { @@ -255,8 +250,8 @@ suite('serverOptions', () => { inputs, { onError }, ); - ok(typeof root === 'string'); - ok(Object.keys(result).length >= 9); - deepStrictEqual(result, DEFAULT_OPTIONS); + expect(typeof root).toBe('string'); + expect(Object.keys(result).length).toBeGreaterThanOrEqual(9); + expect(result).toEqual(DEFAULT_OPTIONS); }); }); diff --git a/test/package.test.js b/test/package.test.js deleted file mode 100644 index acf9ea0..0000000 --- a/test/package.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { match, strictEqual } from 'node:assert'; -import { suite, test } from 'node:test'; - -import { readPkgJson } from '../lib/fs-utils.js'; - -suite('package.json', async () => { - const pkg = readPkgJson(); - - test('it has a version number', () => { - strictEqual(pkg !== null && typeof pkg, 'object'); - match(pkg.version, /^\d+\.\d+\./); - }); - - test('it has no dependencies', () => { - const keys = Object.keys(pkg); - - // no library dependencies - strictEqual(keys.includes('dependencies'), false); - strictEqual(keys.includes('peerDependencies'), false); - strictEqual(keys.includes('optionalDependencies'), false); - - // only dev dependencies - strictEqual(keys.includes('devDependencies'), true); - }); -}); diff --git a/test/package.test.ts b/test/package.test.ts new file mode 100644 index 0000000..8e61b09 --- /dev/null +++ b/test/package.test.ts @@ -0,0 +1,24 @@ +import { expect, suite, test } from 'vitest'; + +import { readPkgJson } from '#src/fs-utils.js'; + +suite('package.json', async () => { + const pkg = readPkgJson(); + + test('it has a version number', () => { + expect(pkg).toBeInstanceOf(Object); + expect(pkg.version).toMatch(/^\d+\.\d+\./); + }); + + test('it has no dependencies', () => { + const keys = Object.keys(pkg); + + // only dev dependencies + expect(keys).toContain('devDependencies'); + + // no library dependencies + expect(keys).not.toContain('dependencies'); + expect(keys).not.toContain('peerDependencies'); + expect(keys).not.toContain('optionalDependencies'); + }); +}); diff --git a/test/pages.test.js b/test/pages.test.js deleted file mode 100644 index bb9f6db..0000000 --- a/test/pages.test.js +++ /dev/null @@ -1,202 +0,0 @@ -import { parseHTML } from 'linkedom'; -import { deepStrictEqual, ok, strictEqual } from 'node:assert'; -import { suite, test } from 'node:test'; - -import { dirListPage, errorPage } from '../lib/pages.js'; -import { loc } from './shared.js'; - -/** -@type {(doc: Document, selector: string) => string | undefined} -*/ -function textContent(doc, selector) { - return doc.querySelector(selector)?.textContent?.trim(); -} - -/** -@type {(doc: Document, content: { title: string, desc?: string, base?: string }) => void} -*/ -function checkTemplate(doc, content) { - const text = (s = '') => textContent(doc, s); - - strictEqual(typeof doc, 'object'); - strictEqual(doc.doctype?.name, 'html'); - ok(doc.querySelector('link[rel=icon]')); - ok(doc.querySelector('style')); - - strictEqual(text('title'), content.title); - strictEqual(text('h1'), content.title); - if (content.desc) { - strictEqual(text('body > p'), content.desc); - } - if (content.base) { - strictEqual(doc.querySelector('base')?.getAttribute('href'), content.base); - } -} - -suite('dirListPage', () => { - const serverOptions = { root: loc.path(), ext: ['.html'] }; - - /** - @type {(data: Omit[0], 'root' | 'ext'>) => Promise} - */ - async function dirListDoc(data) { - const html = await dirListPage({ - ...serverOptions, - ...data, - }); - return parseHTML(html).document; - } - - /** - @type {(doc: Document, shouldExist: boolean) => void} - */ - function checkParentLink(doc, shouldExist) { - const link = doc.querySelector('ul > li:first-child a'); - if (shouldExist) { - ok(link); - strictEqual(link.getAttribute('aria-label'), 'Parent directory'); - strictEqual(link.getAttribute('href'), '..'); - strictEqual(link.textContent, '../'); - } else { - strictEqual(link, null); - } - } - - test('empty list page (root)', async () => { - const doc = await dirListDoc({ - urlPath: '/', - filePath: loc.path(), - items: [], - }); - const list = doc.querySelector('ul'); - checkTemplate(doc, { base: '/', title: 'Index of _servitsy_test_' }); - strictEqual(list?.nodeName, 'UL'); - strictEqual(list?.childElementCount, 0); - checkParentLink(doc, false); - }); - - test('empty list page (subfolder)', async () => { - const localPath = 'cool/folder'; - const doc = await dirListDoc({ - urlPath: `/${localPath}`, - filePath: loc.path(localPath), - items: [], - }); - const list = doc.querySelector('ul'); - - checkTemplate(doc, { base: '/cool/folder/', title: 'Index of _servitsy_test_/cool/folder' }); - strictEqual(list?.nodeName, 'UL'); - strictEqual(list?.childElementCount, 1); - checkParentLink(doc, true); - }); - - test('list page with items', async () => { - const doc = await dirListDoc({ - urlPath: '/section', - filePath: loc.path('section'), - items: [ - loc.file('section/ I have spaces '), - loc.file('section/.gitignore'), - loc.file('section/CHANGELOG', loc.file('section/docs/changelog.md')), - loc.dir('section/Library'), - loc.file('section/public', loc.dir('section/.vitepress/build')), - loc.file('section/README.md'), - ], - }); - - checkTemplate(doc, { base: '/section/', title: 'Index of _servitsy_test_/section' }); - checkParentLink(doc, true); - - const items = doc.querySelectorAll('ul > li a'); - strictEqual(items.length, 7); - const hrefs = []; - const texts = []; - for (const child of items) { - hrefs.push(child.getAttribute('href')); - texts.push(child.textContent); - } - // Items should be sorted by type: directories first, files second - deepStrictEqual(hrefs, [ - '..', - 'Library', - 'public', - '%20%20I%20have%20spaces%20%20', - '.gitignore', - 'CHANGELOG', - 'README.md', - ]); - deepStrictEqual(texts, [ - '../', - 'Library/', - 'public/', - ' I have spaces ', - '.gitignore', - 'CHANGELOG', - 'README.md', - ]); - }); -}); - -suite('errorPage', () => { - /** - @type {(data: { status: number; urlPath: string | null }) => Promise} - */ - async function errorDoc(data) { - const html = await errorPage({ ...data, url: data.urlPath ?? '' }); - return parseHTML(html).document; - } - - test('same generic error page for unknown status', async () => { - const html1 = await errorPage({ - status: 0, - url: '/error', - urlPath: '/error', - }); - const html2 = await errorPage({ - status: 200, - url: '/some/other/path', - urlPath: '/some/other/path', - }); - strictEqual(html1, html2); - }); - - test('generic error page', async () => { - const doc = await errorDoc({ status: 499, urlPath: '/error' }); - checkTemplate(doc, { - title: 'Error', - desc: 'Something went wrong', - }); - }); - - test('400 error page', async () => { - const doc = await errorDoc({ status: 400, urlPath: null }); - checkTemplate(doc, { - title: '400: Bad request', - desc: 'Invalid request for ', - }); - }); - - test('404 error page', async () => { - const doc = await errorDoc({ status: 404, urlPath: '/does/not/exist' }); - checkTemplate(doc, { - title: '404: Not found', - desc: 'Could not find /does/not/exist', - }); - }); - - test('403 error page', async () => { - const doc = await errorDoc({ status: 403, urlPath: '/no/access' }); - checkTemplate(doc, { - title: '403: Forbidden', - desc: 'Could not access /no/access', - }); - }); - - test('500 error page', async () => { - const doc = await errorDoc({ status: 500, urlPath: '/oh/noes' }); - checkTemplate(doc, { - title: '500: Error', - desc: 'Could not serve /oh/noes', - }); - }); -}); diff --git a/test/pages.test.ts b/test/pages.test.ts new file mode 100644 index 0000000..c89cc2a --- /dev/null +++ b/test/pages.test.ts @@ -0,0 +1,173 @@ +import { parseHTML } from 'linkedom'; +import { expect, suite, test } from 'vitest'; + +import { dirListPage, errorPage } from '#src/pages.js'; + +import { loc } from './shared.js'; + +function $template(doc: Document, content: { title: string; desc?: string; base?: string }) { + const text = (selector: string) => doc.querySelector(selector)?.textContent?.trim(); + + expect(doc ?? undefined).toBeTypeOf('object'); + expect(doc.doctype?.name).toBe('html'); + expect(doc.querySelector('link[rel=icon]')).toBeTruthy(); + expect(doc.querySelector('style')).toBeTruthy(); + + expect(text('title')).toBe(content.title); + expect(text('h1')).toBe(content.title); + if (content.desc) { + expect(text('body > p')).toBe(content.desc); + } + if (content.base) { + expect(doc.querySelector('base')?.getAttribute('href')).toBe(content.base); + } +} + +suite('dirListPage', () => { + const serverOptions = { root: loc.path(), ext: ['.html'] }; + + function dirListDoc(data: Omit[0], 'root' | 'ext'>): Document { + const html = dirListPage({ ...serverOptions, ...data }); + return parseHTML(html).document; + } + + function $list(doc: Document, expectedCount: number) { + const list = doc.querySelector('ul'); + expect(list, 'List exists').toBeTruthy(); + expect(list?.nodeName).toBe('UL'); + expect(list?.childElementCount, `List has ${expectedCount} items`).toBe(expectedCount); + } + + function $parentLink(doc: Document, shouldExist: boolean) { + const link = doc.querySelector('ul > li:first-child a'); + if (shouldExist) { + expect(link).toBeTruthy(); + expect(link?.getAttribute('aria-label')).toBe('Parent directory'); + expect(link?.getAttribute('href')).toBe('..'); + expect(link?.textContent).toBe('../'); + } else { + expect(link).toBe(null); + } + } + + test('empty list page (root)', async () => { + const doc = dirListDoc({ + urlPath: '/', + filePath: loc.path(), + items: [], + }); + + $template(doc, { base: '/', title: 'Index of _servitsy_test_' }); + $list(doc, 0); + $parentLink(doc, false); + }); + + test('empty list page (subfolder)', async () => { + const localPath = 'cool/folder'; + const doc = dirListDoc({ + urlPath: `/${localPath}`, + filePath: loc.path(localPath), + items: [], + }); + + $template(doc, { base: '/cool/folder/', title: 'Index of _servitsy_test_/cool/folder' }); + $list(doc, 1); + $parentLink(doc, true); + }); + + test('list page with items', async () => { + const doc = dirListDoc({ + urlPath: '/section', + filePath: loc.path('section'), + items: [ + loc.file('section/ I have spaces '), + loc.file('section/.gitignore'), + loc.file('section/CHANGELOG', loc.file('section/docs/changelog.md')), + loc.dir('section/Library'), + loc.file('section/public', loc.dir('section/.vitepress/build')), + loc.file('section/README.md'), + ], + }); + + $template(doc, { base: '/section/', title: 'Index of _servitsy_test_/section' }); + $list(doc, 7); + $parentLink(doc, true); + + const links: Record<'href' | 'text', string | null>[] = []; + for (const link of doc.querySelectorAll('ul > li a')) { + links.push({ href: link.getAttribute('href'), text: link.textContent }); + } + + // Items should be sorted by type: directories first, files second + expect(links).toEqual([ + { href: '..', text: '../' }, + { href: 'Library', text: 'Library/' }, + { href: 'public', text: 'public/' }, + { href: '%20%20I%20have%20spaces%20%20', text: ' I have spaces ' }, + { href: '.gitignore', text: '.gitignore' }, + { href: 'CHANGELOG', text: 'CHANGELOG' }, + { href: 'README.md', text: 'README.md' }, + ]); + }); +}); + +suite('errorPage', () => { + function errorDoc(data: { status: number; urlPath: string | null }): Document { + const html = errorPage({ ...data, url: data.urlPath ?? '' }); + return parseHTML(html).document; + } + + test('same generic error page for unknown status', async () => { + const html1 = errorPage({ + status: 0, + url: '/error', + urlPath: '/error', + }); + const html2 = errorPage({ + status: 200, + url: '/some/other/path', + urlPath: '/some/other/path', + }); + expect(html1).toBe(html2); + }); + + test('generic error page', async () => { + const doc = errorDoc({ status: 499, urlPath: '/error' }); + $template(doc, { + title: 'Error', + desc: 'Something went wrong', + }); + }); + + test('400 error page', async () => { + const doc = errorDoc({ status: 400, urlPath: null }); + $template(doc, { + title: '400: Bad request', + desc: 'Invalid request for ', + }); + }); + + test('404 error page', async () => { + const doc = errorDoc({ status: 404, urlPath: '/does/not/exist' }); + $template(doc, { + title: '404: Not found', + desc: 'Could not find /does/not/exist', + }); + }); + + test('403 error page', async () => { + const doc = errorDoc({ status: 403, urlPath: '/no/access' }); + $template(doc, { + title: '403: Forbidden', + desc: 'Could not access /no/access', + }); + }); + + test('500 error page', async () => { + const doc = errorDoc({ status: 500, urlPath: '/oh/noes' }); + $template(doc, { + title: '500: Error', + desc: 'Could not serve /oh/noes', + }); + }); +}); diff --git a/test/path-matcher.test.js b/test/path-matcher.test.js deleted file mode 100644 index 6c4fc65..0000000 --- a/test/path-matcher.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import { deepStrictEqual, strictEqual } from 'node:assert'; -import { suite, test } from 'node:test'; - -import { PathMatcher } from '../lib/path-matcher.js'; - -suite('PathMatcher', () => { - test('does not match strings when no patterns are provided', () => { - const matcher = new PathMatcher([]); - deepStrictEqual(matcher.data(), { positive: [], negative: [] }); - strictEqual(matcher.test('foo'), false); - strictEqual(matcher.test('cool/story/bro.md'), false); - }); - - test('ignores empty patterns and those containing slashes', () => { - const matcher = new PathMatcher(['', '!', 'foo/bar', 'foo\\bar']); - deepStrictEqual(matcher.data(), { positive: [], negative: [] }); - strictEqual(matcher.test(''), false); - strictEqual(matcher.test('foo'), false); - strictEqual(matcher.test('bar'), false); - strictEqual(matcher.test('foo/bar'), false); - strictEqual(matcher.test('foo\\bar'), false); - }); - - test('patterns may match every path segment', () => { - const matcher = new PathMatcher(['yes']); - deepStrictEqual(matcher.data(), { positive: ['yes'], negative: [] }); - strictEqual(matcher.test('yes/nope'), true); - strictEqual(matcher.test('nope\\yes'), true); - // all slashes are treated as path separators - strictEqual(matcher.test('hmm\\nope/never\\maybe/yes\\but/no'), true); - }); - - test('patterns without wildcard must match entire segment', () => { - const matcher = new PathMatcher(['README']); - deepStrictEqual(matcher.data(), { positive: ['README'], negative: [] }); - strictEqual(matcher.test('project/README'), true); - strictEqual(matcher.test('project/README.md'), false); - strictEqual(matcher.test('project/DO_NOT_README'), false); - }); - - test('patterns are optionally case-insensitive', () => { - const patterns = ['*.md', 'TODO']; - - const defaultMatcher = new PathMatcher(patterns); - strictEqual(defaultMatcher.test('test/dir/README.md'), true); - strictEqual(defaultMatcher.test('test/dir/README.MD'), false); - strictEqual(defaultMatcher.test('docs/TODO'), true); - strictEqual(defaultMatcher.test('docs/todo'), false); - - const ciMatcher = new PathMatcher(['*.md', 'TODO'], { caseSensitive: false }); - strictEqual(ciMatcher.test('test/dir/README.md'), true); - strictEqual(ciMatcher.test('test/dir/README.MD'), true); - strictEqual(ciMatcher.test('docs/TODO'), true); - strictEqual(ciMatcher.test('docs/todo'), true); - }); - - test('wildcard character works', () => { - const matcher = new PathMatcher(['.env*', '*.secrets', '*config*', '*a*b*c*d*']); - strictEqual(matcher.test('project/.env'), true); - strictEqual(matcher.test('project/.env.production'), true); - strictEqual(matcher.test('home/.secrets'), true); - strictEqual(matcher.test('home/my.secrets'), true); - strictEqual(matcher.test('home/scrts'), false); - strictEqual(matcher.test('test/config'), true); - strictEqual(matcher.test('test/foo.config.js'), true); - strictEqual(matcher.test('abcd'), true); - strictEqual(matcher.test('abdc'), false); - strictEqual(matcher.test('1(a)[2b]3cccc+4d!!'), true); - }); - - test('patterns starting with ! negate a previous match', () => { - const matcher = new PathMatcher(['.env*', '!.env.development', '!.well-known', '.*', '_*']); - - // matched by positive rules - strictEqual(matcher.test('.env'), true); - strictEqual(matcher.test('.environment'), true); - strictEqual(matcher.test('.env.production'), true); - strictEqual(matcher.test('.htaccess'), true); - strictEqual(matcher.test('.htpasswd'), true); - strictEqual(matcher.test('_static'), true); - - // negated by ! rules - strictEqual(matcher.test('.env.development'), false); - strictEqual(matcher.test('.well-known'), false); - strictEqual(matcher.test('.well-known/security.txt'), false); - - // if only some matched segments are negated, then it's a match anyway - strictEqual(matcher.test('.config/.env.development'), true); - strictEqual(matcher.test('_static/.well-known/security.txt'), true); - }); -}); diff --git a/test/path-matcher.test.ts b/test/path-matcher.test.ts new file mode 100644 index 0000000..85b43a8 --- /dev/null +++ b/test/path-matcher.test.ts @@ -0,0 +1,105 @@ +import { expect, suite, test } from 'vitest'; + +import { PathMatcher } from '#src/path-matcher.js'; + +class TestPathMatcher extends PathMatcher { + $data = (expected: ReturnType) => { + expect(this.data()).toEqual(expected); + }; + $path = (path: string, expected: boolean) => { + expect(this.test(path)).toBe(expected); + }; +} + +suite('PathMatcher', () => { + test('does not match strings when no patterns are provided', () => { + const { $data, $path } = new TestPathMatcher([]); + $data({ positive: [], negative: [] }); + $path('foo', false); + $path('cool/story/bro.md', false); + }); + + test('ignores empty patterns and those containing slashes', () => { + const { $data, $path } = new TestPathMatcher(['', '!', 'foo/bar', 'foo\\bar']); + $data({ positive: [], negative: [] }); + $path('', false); + $path('foo', false); + $path('bar', false); + $path('foo/bar', false); + $path('foo\\bar', false); + }); + + test('patterns may match any path segment', () => { + const { $data, $path } = new TestPathMatcher(['yes']); + $data({ positive: ['yes'], negative: [] }); + $path('yes/nope', true); + // all slashes are treated as path separators + $path('nope\\yes', true); + $path('hmm\\nope/never\\maybe/yes\\but/no', true); + }); + + test('patterns without wildcard must match entire segment', () => { + const { $data, $path } = new TestPathMatcher(['README']); + $data({ positive: ['README'], negative: [] }); + $path('project/README', true); + $path('project/README.md', false); + $path('project/DO_NOT_README', false); + }); + + test('patterns are optionally case-insensitive', () => { + const patterns = ['*.md', 'TODO']; + const cs = new TestPathMatcher(patterns, { caseSensitive: true }); + const ci = new TestPathMatcher(patterns, { caseSensitive: false }); + + cs.$path('test/dir/README.md', true); + cs.$path('test/dir/README.MD', false); + cs.$path('docs/TODO', true); + cs.$path('docs/todo', false); + + ci.$path('test/dir/README.md', true); + ci.$path('test/dir/README.MD', true); + ci.$path('docs/TODO', true); + ci.$path('docs/todo', true); + }); + + test('wildcard character works', () => { + const { $path } = new TestPathMatcher(['.env*', '*.secrets', '*config*', '*a*b*c*d*']); + $path('project/.env', true); + $path('project/.env.production', true); + $path('home/.secrets', true); + $path('home/my.secrets', true); + $path('home/scrts', false); + $path('test/config', true); + $path('test/foo.config.js', true); + $path('abcd', true); + $path('abdc', false); + $path('1(a)[2b]3cccc+4d!!', true); + }); + + test('patterns starting with ! negate a previous match', () => { + const { $path } = new TestPathMatcher([ + '.env*', + '!.env.development', + '!.well-known', + '.*', + '_*', + ]); + + // matched by positive rules + $path('.env', true); + $path('.environment', true); + $path('.env.production', true); + $path('.htaccess', true); + $path('.htpasswd', true); + $path('_static', true); + + // negated by ! rules + $path('.env.development', false); + $path('.well-known', false); + $path('.well-known/security.txt', false); + + // if only some matched segments are negated, then it's a match anyway + $path('.config/.env.development', true); + $path('_static/.well-known/security.txt', true); + }); +}); diff --git a/test/resolver.test.js b/test/resolver.test.js deleted file mode 100644 index bfb4da3..0000000 --- a/test/resolver.test.js +++ /dev/null @@ -1,301 +0,0 @@ -import { deepStrictEqual, strictEqual, throws } from 'node:assert'; -import { after, suite, test } from 'node:test'; - -import { getLocalPath } from '../lib/fs-utils.js'; -import { FileResolver } from '../lib/resolver.js'; -import { fsFixture, getDefaultOptions, loc, platformSlash } from './shared.js'; - -suite('FileResolver.#root', () => { - const { path } = loc; - - test('throws when root is not defined', () => { - throws(() => { - // @ts-expect-error - new FileResolver({}); - }, /Missing root directory/); - }); - - test('withinRoot', () => { - const resolver = new FileResolver({ root: path() }); - strictEqual(resolver.withinRoot(path`index.html`), true); - strictEqual(resolver.withinRoot(path`some/dir`), true); - strictEqual(resolver.withinRoot(path`../../.zshrc`), false); - strictEqual(resolver.withinRoot(path`/etc/hosts`), false); - }); -}); - -suite('FileResolver.locateFile', async () => { - const { fileTree, fixture, path, file, dir } = await fsFixture({ - 'index.html': '

Hello

', - 'page1.html': '

Page 1

', - 'section1/index.html': '', - 'section2/sub-page/hello.txt': 'Hello!', - }); - - after(() => fixture.rm()); - - test('locates exact paths', async () => { - const resolver = new FileResolver({ - root: path(), - ext: [], - dirFile: [], - }); - const locate = (localPath = '') => resolver.locateFile(path(localPath)); - - for (const localFilePath of Object.keys(fileTree)) { - deepStrictEqual(await locate(localFilePath), file(localFilePath)); - } - for (const localDirPath of ['section1', 'section2', 'section2/sub-page']) { - deepStrictEqual(await locate(localDirPath), dir(localDirPath)); - } - }); - - test('locates variants with options.ext', async () => { - const resolver = new FileResolver({ - root: path(), - ext: ['.html', '.txt'], - }); - /** @type {(localPath: string, expected: ReturnType) => Promise} */ - const locate = async (localPath, expected) => { - const filePath = path(localPath); - const result = await resolver.locateFile(filePath); - deepStrictEqual(result, expected); - }; - - await locate('', dir('')); - await locate('section1', dir('section1')); - await locate('section2/sub-page', dir('section2/sub-page')); - await locate('index', file('index.html')); - await locate('page1', file('page1.html')); - await locate('section1/index', file('section1/index.html')); - await locate('section2/sub-page/hello', file('section2/sub-page/hello.txt')); - }); - - test('locates variants with options.dirFile', async () => { - const resolver = new FileResolver({ - root: path(), - dirFile: ['index.html'], - }); - /** @type {(localPath: string, expected: ReturnType) => Promise} */ - const locate = async (localPath, expected) => { - const result = await resolver.locateFile(path(localPath)); - deepStrictEqual(result, expected); - }; - - // finds dirFile - await locate('', file('index.html')); - await locate('section1', file('section1/index.html')); - - // does not add .html or find non-dirFile children - await locate('page1', { - filePath: path`page1`, - kind: null, - }); - await locate('section2/sub-page', { - filePath: path`section2/sub-page`, - kind: 'dir', - }); - }); -}); - -suite('FileResolver.#options', () => { - const { path } = loc; - test('options: exclude', () => { - const resolver = new FileResolver({ - root: path(), - exclude: ['.*', '*.md'], - }); - const allowed = (p = '') => resolver.allowedPath(path(p)); - - // should be allowed - strictEqual(allowed('robots.txt'), true); - strictEqual(allowed('_._'), true); - strictEqual(allowed('README.md.backup'), true); - - // should be blocked - strictEqual(allowed('.env.production'), false); - strictEqual(allowed('src/components/.gitignore'), false); - strictEqual(allowed('README.md'), false); - }); - - test('options: exclude + include (custom)', () => { - const resolver = new FileResolver({ - root: path(), - exclude: ['*.html', '!index.*'], - }); - const allowed = (p = '') => resolver.allowedPath(path(p)); - - strictEqual(allowed('page.html'), false); - strictEqual(allowed('some/dir/hello.html'), false); - strictEqual(allowed('index.html'), true); - strictEqual(allowed('some/dir/index.html'), true); - }); - - test('options: exclude + include (defaults)', async () => { - const resolver = new FileResolver(getDefaultOptions()); - const allowed = (p = '') => resolver.allowedPath(path(p)); - - // paths that should be allowed with defaults - strictEqual(allowed('index.html'), true); - strictEqual(allowed('page1.html'), true); - strictEqual(allowed('some-dir/index.html'), true); - strictEqual(allowed('some/!!!!/(dir)/+[page]2.html'), true); - strictEqual(allowed('.well-known/security.txt'), true); - - // paths that should be blocked with defaults - strictEqual(allowed('.htpasswd'), false); - strictEqual(allowed('.gitignore'), false); - strictEqual(allowed('.git/config'), false); - strictEqual(allowed('some/!!!!/(dir)/.htaccess'), false); - }); -}); - -suite('FileResolver.find', async () => { - const { fixture, path, file, dir } = await fsFixture({ - '.env': '', - '.htpasswd': '', - '.well-known/security.txt': '', - 'about.md': '', - 'index.html': '', - 'page1.html': '', - 'page2.htm': '', - 'section/.gitignore': '', - 'section/index.html': '', - 'section/page.md': '', - }); - const minimalOptions = { root: path() }; - const defaultOptions = getDefaultOptions(path()); - - after(() => fixture.rm()); - - test('finds file with exact path', async () => { - const resolver = new FileResolver(minimalOptions); - - for (const localPath of ['.htpasswd', 'page2.htm', 'section/page.md']) { - deepStrictEqual(await resolver.find(localPath), { - status: 200, - file: file(localPath), - }); - } - }); - - test('finds folder with exact path', async () => { - const resolver = new FileResolver({ ...minimalOptions, dirList: true }); - - for (const localPath of ['section', '/section/']) { - deepStrictEqual(await resolver.find(localPath), { - status: 200, - file: dir('section'), - }); - } - }); - - test('non-existing paths have a 404 status', async () => { - const resolver = new FileResolver(minimalOptions); - - for (const localPath of ['README.md', 'section/other-page']) { - deepStrictEqual(await resolver.find(localPath), { - status: 404, - file: null, - }); - } - }); - - test('default options block dotfiles', async () => { - const resolver = new FileResolver(defaultOptions); - const check = async (url = '', expected = '') => { - const { status, file } = await resolver.find(url); - const result = `${status} ${file ? getLocalPath(defaultOptions.root, file.filePath) : null}`; - strictEqual(result, platformSlash(expected)); - }; - - // non-existing files are always a 404 - await check('doesnt-exist', '404 null'); - await check('.doesnt-exist', '404 null'); - - // existing dotfiles are excluded by default pattern - await check('.env', '404 .env'); - await check('.htpasswd', '404 .htpasswd'); - await check('section/.gitignore', '404 section/.gitignore'); - - // Except the .well-known folder, allowed by default - await check('.well-known', '200 .well-known'); - await check('.well-known/security.txt', '200 .well-known/security.txt'); - }); - - test('default options resolve index.html', async () => { - const resolver = new FileResolver(defaultOptions); - - deepStrictEqual(await resolver.find(''), { - status: 200, - file: file('index.html'), - }); - - for (const localPath of ['section', '/section/']) { - deepStrictEqual(await resolver.find(localPath), { - status: 200, - file: file('section/index.html'), - }); - } - }); - - test('default options resolve .html extension', async () => { - const resolver = new FileResolver(defaultOptions); - - // adds .html - for (const localPath of ['index', 'page1', 'section/index']) { - deepStrictEqual(await resolver.find(localPath), { - status: 200, - file: file(`${localPath}.html`), - }); - } - - // doesn't add other extensions - for (const localPath of ['about', 'page2', 'section/page']) { - deepStrictEqual(await resolver.find(localPath), { - status: 404, - file: null, - }); - } - }); -}); - -suite('FileResolver.index', async () => { - const { fixture, path, file, dir } = await fsFixture({ - '.env': '', - 'index.html': '', - 'products.html': '', - 'about-us.html': '', - '.well-known/security.txt': '', - 'section/.gitignore': '', - 'section/page.md': '', - 'section/forbidden.json': '', - 'section/index.html': '', - }); - const defaultOptions = getDefaultOptions(path()); - - after(() => fixture.rm()); - - test('does not index directories when options.dirList is false', async () => { - const resolver = new FileResolver({ ...defaultOptions, dirList: false }); - deepStrictEqual(await resolver.index(path()), []); - deepStrictEqual(await resolver.index(path`section`), []); - deepStrictEqual(await resolver.index(path`doesnt-exist`), []); - }); - - test('indexes directories when options.dirList is true', async () => { - const resolver = new FileResolver({ ...defaultOptions, dirList: true }); - deepStrictEqual(await resolver.index(path()), [ - dir('.well-known'), - file('about-us.html'), - file('index.html'), - file('products.html'), - dir('section'), - ]); - deepStrictEqual(await resolver.index(path`section`), [ - file('section/forbidden.json'), - file('section/index.html'), - file('section/page.md'), - ]); - }); -}); diff --git a/test/resolver.test.ts b/test/resolver.test.ts new file mode 100644 index 0000000..98f0fc8 --- /dev/null +++ b/test/resolver.test.ts @@ -0,0 +1,280 @@ +import { afterAll, expect, suite, test } from 'vitest'; + +import { getLocalPath } from '#src/fs-utils.js'; +import { FileResolver } from '#src/resolver.js'; +import type { FSLocation } from '#types'; + +import { fsFixture, getDefaultOptions, loc, platformSlash } from './shared.js'; + +class TestFileResolver extends FileResolver { + $allowed = (filePath: string, expected: boolean) => { + expect(this.allowedPath(filePath)).toBe(expected); + }; + $find = async (localPath: string, expected: { status: number; file: FSLocation | null }) => { + expect(await this.find(localPath)).toEqual(expected); + }; + $index = async (dirPath: string, expected: FSLocation[]) => { + expect(await this.index(dirPath)).toEqual(expected); + }; + $locate = async (filePath: string, expected: FSLocation) => { + expect(await this.locateFile(filePath)).toEqual(expected); + }; +} + +suite('FileResolver.#root', () => { + const { path } = loc; + + test('throws when root is not defined', () => { + expect(() => { + // @ts-expect-error + new FileResolver({}); + }).toThrow(/Missing root directory/); + }); + + test('withinRoot', () => { + const resolver = new FileResolver({ root: path() }); + expect(resolver.withinRoot(path`index.html`)).toBe(true); + expect(resolver.withinRoot(path`some/dir`)).toBe(true); + expect(resolver.withinRoot(path`../../.zshrc`)).toBe(false); + expect(resolver.withinRoot(path`/etc/hosts`)).toBe(false); + }); +}); + +suite('FileResolver.locateFile', async () => { + const { fileTree, fixture, path, file, dir } = await fsFixture({ + 'index.html': '

Hello

', + 'page1.html': '

Page 1

', + 'section1/index.html': '', + 'section2/sub-page/hello.txt': 'Hello!', + }); + + afterAll(() => fixture.rm()); + + test('locates exact paths', async () => { + const { $locate } = new TestFileResolver({ + root: path(), + ext: [], + dirFile: [], + }); + + for (const localFilePath of Object.keys(fileTree)) { + await $locate(path(localFilePath), file(localFilePath)); + } + for (const localDirPath of ['section1', 'section2', 'section2/sub-page']) { + await $locate(path(localDirPath), dir(localDirPath)); + } + }); + + test('locates variants with options.ext', async () => { + const { $locate } = new TestFileResolver({ + root: path(), + ext: ['.html', '.txt'], + }); + + await $locate(path``, dir('')); + await $locate(path`section1`, dir('section1')); + await $locate(path`section2/sub-page`, dir('section2/sub-page')); + await $locate(path`index`, file('index.html')); + await $locate(path`page1`, file('page1.html')); + await $locate(path`section1/index`, file('section1/index.html')); + await $locate(path`section2/sub-page/hello`, file('section2/sub-page/hello.txt')); + }); + + test('locates variants with options.dirFile', async () => { + const { $locate } = new TestFileResolver({ + root: path(), + dirFile: ['index.html'], + }); + + // finds dirFile + await $locate(path``, file('index.html')); + await $locate(path`section1`, file('section1/index.html')); + + // does not add .html or find non-dirFile children + await $locate(path`page1`, { + filePath: path`page1`, + kind: null, + }); + await $locate(path`section2/sub-page`, dir('section2/sub-page')); + }); +}); + +suite('FileResolver.#options', () => { + const { path } = loc; + + test('options: exclude', () => { + const { $allowed } = new TestFileResolver({ + root: path(), + exclude: ['.*', '*.md'], + }); + + // should be allowed + $allowed(path`robots.txt`, true); + $allowed(path`_._`, true); + $allowed(path`README.md.backup`, true); + + // should be blocked + $allowed(path`.env.production`, false); + $allowed(path`src/components/.gitignore`, false); + $allowed(path`README.md`, false); + }); + + test('options: exclude + include (custom)', () => { + const { $allowed } = new TestFileResolver({ + root: path(), + exclude: ['*.html', '!index.*'], + }); + + $allowed(path`page.html`, false); + $allowed(path`some/dir/hello.html`, false); + $allowed(path`index.html`, true); + $allowed(path`some/dir/index.html`, true); + }); + + test('options: exclude + include (defaults)', async () => { + const { $allowed } = new TestFileResolver(getDefaultOptions()); + + // paths that should be allowed with defaults + $allowed(path`index.html`, true); + $allowed(path`page1.html`, true); + $allowed(path`some-dir/index.html`, true); + $allowed(path`some/!!!!/(dir)/+[page]2.html`, true); + $allowed(path`.well-known/security.txt`, true); + + // paths that should be blocked with defaults + $allowed(path`.htpasswd`, false); + $allowed(path`.gitignore`, false); + $allowed(path`.git/config`, false); + $allowed(path`some/!!!!/(dir)/.htaccess`, false); + }); +}); + +suite('FileResolver.find', async () => { + const { fixture, path, file, dir } = await fsFixture({ + '.env': '', + '.htpasswd': '', + '.well-known/security.txt': '', + 'about.md': '', + 'index.html': '', + 'page1.html': '', + 'page2.htm': '', + 'section/.gitignore': '', + 'section/index.html': '', + 'section/page.md': '', + }); + const minimalOptions = { root: path() }; + const defaultOptions = getDefaultOptions(path()); + + afterAll(() => fixture.rm()); + + test('finds file with exact path', async () => { + const { $find } = new TestFileResolver(minimalOptions); + for (const localPath of ['.htpasswd', 'page2.htm', 'section/page.md']) { + await $find(localPath, { status: 200, file: file(localPath) }); + } + }); + + test('finds folder with exact path', async () => { + const { $find } = new TestFileResolver({ ...minimalOptions, dirList: true }); + for (const localPath of ['section', '/section/']) { + await $find(localPath, { status: 200, file: dir('section') }); + } + }); + + test('non-existing paths have a 404 status', async () => { + const { $find } = new TestFileResolver(minimalOptions); + for (const localPath of ['README.md', 'section/other-page']) { + await $find(localPath, { status: 404, file: null }); + } + }); + + test('default options block dotfiles', async () => { + const resolver = new TestFileResolver(defaultOptions); + const check = async (url: string, expected: string) => { + const { status, file } = await resolver.find(url); + const result = `${status} ${file ? getLocalPath(defaultOptions.root, file.filePath) : null}`; + expect(result).toBe(platformSlash(expected)); + }; + + // non-existing files are always a 404 + await check('doesnt-exist', '404 null'); + await check('.doesnt-exist', '404 null'); + + // existing dotfiles are excluded by default pattern + await check('.env', '404 .env'); + await check('.htpasswd', '404 .htpasswd'); + await check('section/.gitignore', '404 section/.gitignore'); + + // Except the .well-known folder, allowed by default + await check('.well-known', '200 .well-known'); + await check('.well-known/security.txt', '200 .well-known/security.txt'); + }); + + test('default options resolve index.html', async () => { + const { $find } = new TestFileResolver(defaultOptions); + await $find('', { status: 200, file: file('index.html') }); + for (const localPath of ['section', '/section/']) { + await $find(localPath, { status: 200, file: file('section/index.html') }); + } + }); + + test('default options resolve .html extension', async () => { + const { $find } = new TestFileResolver(defaultOptions); + + // adds .html + for (const localPath of ['index', 'page1', 'section/index']) { + await $find(localPath, { + status: 200, + file: file(`${localPath}.html`), + }); + } + + // doesn't add other extensions + for (const localPath of ['about', 'page2', 'section/page']) { + await $find(localPath, { + status: 404, + file: null, + }); + } + }); +}); + +suite('FileResolver.index', async () => { + const { fixture, path, file, dir } = await fsFixture({ + '.env': '', + 'index.html': '', + 'products.html': '', + 'about-us.html': '', + '.well-known/security.txt': '', + 'section/.gitignore': '', + 'section/page.md': '', + 'section/forbidden.json': '', + 'section/index.html': '', + }); + const defaultOptions = getDefaultOptions(path()); + + afterAll(() => fixture.rm()); + + test('does not index directories when options.dirList is false', async () => { + const { $index } = new TestFileResolver({ ...defaultOptions, dirList: false }); + await $index(path``, []); + await $index(path`section`, []); + await $index(path`doesnt-exist`, []); + }); + + test('indexes directories when options.dirList is true', async () => { + const { $index } = new TestFileResolver({ ...defaultOptions, dirList: true }); + await $index(path``, [ + dir('.well-known'), + file('about-us.html'), + file('index.html'), + file('products.html'), + dir('section'), + ]); + await $index(path`section`, [ + file('section/forbidden.json'), + file('section/index.html'), + file('section/page.md'), + ]); + }); +}); diff --git a/test/shared.js b/test/shared.ts similarity index 52% rename from test/shared.js rename to test/shared.ts index a99d726..19e52dd 100644 --- a/test/shared.js +++ b/test/shared.ts @@ -2,21 +2,14 @@ import { join, resolve, sep as dirSep } from 'node:path'; import { cwd } from 'node:process'; import { createFixture } from 'fs-fixture'; -import { CLIArgs } from '../lib/args.js'; -import { DEFAULT_OPTIONS } from '../lib/constants.js'; -import { trimSlash } from '../lib/utils.js'; - -/** -@typedef {import('../lib/types.d.ts').FSLocation} FSLocation -@typedef {import('../lib/types.d.ts').ServerOptions} ServerOptions -*/ +import { CLIArgs } from '#src/args.js'; +import { DEFAULT_OPTIONS } from '#src/constants.js'; +import { trimSlash } from '#src/utils.js'; +import type { FSLocation, ServerOptions } from '#types'; export const loc = testPathUtils(join(cwd(), '_servitsy_test_')); -/** -@type {(s?: string | TemplateStringsArray, ...v: string[]) => CLIArgs} -*/ -export function argify(strings = '', ...values) { +export function argify(strings: string | TemplateStringsArray = '', ...values: string[]) { return new CLIArgs( String.raw({ raw: strings }, ...values) .trim() @@ -24,18 +17,12 @@ export function argify(strings = '', ...values) { ); } -/** -@param {import('fs-fixture').FileTree} fileTree -*/ -export async function fsFixture(fileTree) { +export async function fsFixture(fileTree: import('fs-fixture').FileTree) { const fixture = await createFixture(fileTree); return { fileTree, fixture, ...testPathUtils(fixture.path) }; } -/** -@type {(root?: string) => ServerOptions} -*/ -export function getBlankOptions(root) { +export function getBlankOptions(root?: string): ServerOptions { return { root: root ?? loc.path(), host: '::', @@ -50,20 +37,14 @@ export function getBlankOptions(root) { }; } -/** -@type {(root?: string) => ServerOptions} -*/ -export function getDefaultOptions(root) { +export function getDefaultOptions(root?: string): ServerOptions { return { root: root ?? loc.path(), ...DEFAULT_OPTIONS, }; } -/** -@type {(path?: string | TemplateStringsArray, ...values: string[]) => string} -*/ -export function platformSlash(path = '', ...values) { +export function platformSlash(path: string | TemplateStringsArray = '', ...values: string[]) { path = String.raw({ raw: path }, ...values); const wrong = dirSep === '/' ? '\\' : '/'; if (path.includes(wrong) && !path.includes(dirSep)) { @@ -72,12 +53,8 @@ export function platformSlash(path = '', ...values) { return path; } -/** -@param {string} root -*/ -function testPathUtils(root) { - /** @type {(localPath?: string | TemplateStringsArray, ...values: string[]) => string} */ - const path = (localPath = '', ...values) => { +function testPathUtils(root: string) { + const path = (localPath: string | TemplateStringsArray = '', ...values: string[]) => { const subpath = String.raw({ raw: localPath }, ...values); const full = resolve(root, subpath); return full.length >= 2 ? trimSlash(full, { start: false, end: true }) : full; @@ -85,12 +62,10 @@ function testPathUtils(root) { return { path, - /** @type {(localPath: string) => FSLocation} */ - dir(localPath) { + dir(localPath: string): FSLocation { return { filePath: path(localPath), kind: 'dir' }; }, - /** @type {(localPath: string, target?: FSLocation) => FSLocation} */ - file(localPath, target) { + file(localPath: string, target?: FSLocation): FSLocation { if (target) { return { filePath: path(localPath), kind: 'link', target }; } diff --git a/test/jsconfig.json b/test/tsconfig.json similarity index 68% rename from test/jsconfig.json rename to test/tsconfig.json index a489513..2d22e61 100644 --- a/test/jsconfig.json +++ b/test/tsconfig.json @@ -1,13 +1,13 @@ { - "include": ["./*.js", "../globals.d.ts"], + "include": ["./*.ts"], "compilerOptions": { "module": "NodeNext", "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node"], + "isolatedModules": true, + "skipLibCheck": true, "strict": true, - "allowJs": true, - "checkJs": true, "noEmit": true } } diff --git a/test/utils.test.js b/test/utils.test.js deleted file mode 100644 index f62f48d..0000000 --- a/test/utils.test.js +++ /dev/null @@ -1,240 +0,0 @@ -import { deepStrictEqual, ok, strictEqual, throws } from 'node:assert'; -import { suite, test } from 'node:test'; - -import { - clamp, - escapeHtml, - fwdSlash, - getRuntime, - headerCase, - intRange, - isPrivateIPv4, - trimSlash, - withResolvers, -} from '../lib/utils.js'; - -suite('clamp', () => { - test('keeps the value when between bounds', () => { - strictEqual(clamp(1, 0, 2), 1); - strictEqual(clamp(Math.PI, -10, 10), Math.PI); - strictEqual(clamp(-50, -Infinity, Infinity), -50); - strictEqual( - clamp(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1, Infinity), - Number.MAX_SAFE_INTEGER, - ); - }); - - test('constrains the value when outside of bounds', () => { - strictEqual(clamp(-1, 0, 1), 0); - strictEqual(clamp(2, -1, 1), 1); - strictEqual(clamp(Infinity, 0, Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER); - strictEqual(clamp(-Infinity, 0, Number.MAX_SAFE_INTEGER), 0); - - // maximum wins over minimum - strictEqual(clamp(5, 10, 0), 0); - strictEqual(clamp(50, 10, 0), 0); - strictEqual(clamp(0, 10, -10), -10); - }); -}); - -suite('escapeHtml', () => { - test('escapes & charachters', () => { - const input = `"Ampersand&Sons"`; - strictEqual(escapeHtml(input, 'text'), `"Ampersand&Sons"`); - strictEqual(escapeHtml(input, 'attr'), `"Ampersand&Sons"`); - }); - - test('escapes HTML comments', () => { - const input = ``; - strictEqual(escapeHtml(input, 'text'), `<!--hmm-->`); - strictEqual(escapeHtml(input, 'attr'), `<!--hmm-->`); - }); - - test('escapes HTML tags', () => { - const input = `