diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c5161bd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test + +on: + push: + pull_request: + +jobs: + test: + name: Test code + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install packages + run: bun install + + - name: Test + run: bun run test diff --git a/.gitignore b/.gitignore index 7c4dffa..fce08c2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules .pnpm-debug.log dist -build \ No newline at end of file +build +cmueats-build \ No newline at end of file diff --git a/bench/index.d.ts b/bench/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/bench/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/bench/index.ts b/bench/index.ts new file mode 100644 index 0000000..2c3058a --- /dev/null +++ b/bench/index.ts @@ -0,0 +1,33 @@ +import { Bench } from 'tinybench' +import { Elysia } from 'elysia' +import { staticPlugin } from '../src' +import { req } from '../test/utils' + +const bench = new Bench({ time: 10000 }) + +const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: 'public', + indexHTML: true, + bunFullstack: false, + alwaysStatic: true + }) +) +await app.modules +console.log(app.routes) +bench.add('route caching', async () => { + const htmlPaths = [ + '/public/html', + '/public/html/', + '/public/html/index.html', + '/public/html/index.html/' + ] + for (const path of htmlPaths) { + const res = await app.handle(req(path)) + await (await res.blob()).text() + } +}) + +await bench.run() +console.table(bench.table()) diff --git a/bun.lock b/bun.lock index 8ba23f5..d7f1ad1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,10 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@elysiajs/static", "devDependencies": { + "@elysiajs/node": "^1.4.5", "@types/bun": "^1.3.13", "@types/fast-decode-uri-component": "^1.0.0", "@types/node": "^24.12.2", @@ -13,8 +13,11 @@ "esbuild-fix-imports-plugin": "^1.0.23", "eslint": "9.6.0", "fast-decode-uri-component": "^1.0.1", + "tinybench": "^6.0.1", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.9.3", + "vitest": "^4.1.5", }, "peerDependencies": { "elysia": ">= 1.4.0", @@ -24,6 +27,14 @@ "packages": { "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + "@elysiajs/node": ["@elysiajs/node@1.4.5", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.11.2" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-K58HEre7RQWZOkLcMNl2RFnuZFzmUUyxHRBSfC2XaJz4j2AjrpHKu/f26tN9LRi3nx1L0PbH22vgZ4O2Fqppbw=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -102,14 +113,50 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.50.1", "", { "os": "android", "cpu": "arm" }, "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.50.1", "", { "os": "android", "cpu": "arm64" }, "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw=="], @@ -154,12 +201,20 @@ "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/fast-decode-uri-component": ["@types/fast-decode-uri-component@1.0.0", "", {}, "sha512-xhTFpMookjiVEDxI8RBUOxZL8BQiI2+YZZoqzW/G6s3r1h60gDzGnmKP83LNuTmCqk8ZmbY5cYkByKu4t+rKhA=="], @@ -186,6 +241,20 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@6.21.0", "", { "dependencies": { "@typescript-eslint/types": "6.21.0", "eslint-visitor-keys": "^3.4.1" } }, "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A=="], + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], + + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], + + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -202,6 +271,8 @@ "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -216,6 +287,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -232,14 +305,20 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -248,6 +327,8 @@ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "esbuild-fix-imports-plugin": ["esbuild-fix-imports-plugin@1.0.23", "", {}, "sha512-zDn2Mq3OnW9qNm9FrHDeE0FePgIRIaH9EWpHOGsJZFPfyPSNqzvCK/x9EZJ5eHEEyXe8ZGdb5CjDpzqkyAqcCg=="], @@ -268,10 +349,14 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -306,6 +391,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -354,6 +441,30 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -366,7 +477,7 @@ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], @@ -384,10 +495,14 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -418,6 +533,8 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -430,8 +547,12 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], + "rollup": ["rollup@4.50.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.50.1", "@rollup/rollup-android-arm64": "4.50.1", "@rollup/rollup-darwin-arm64": "4.50.1", "@rollup/rollup-darwin-x64": "4.50.1", "@rollup/rollup-freebsd-arm64": "4.50.1", "@rollup/rollup-freebsd-x64": "4.50.1", "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", "@rollup/rollup-linux-arm-musleabihf": "4.50.1", "@rollup/rollup-linux-arm64-gnu": "4.50.1", "@rollup/rollup-linux-arm64-musl": "4.50.1", "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", "@rollup/rollup-linux-ppc64-gnu": "4.50.1", "@rollup/rollup-linux-riscv64-gnu": "4.50.1", "@rollup/rollup-linux-riscv64-musl": "4.50.1", "@rollup/rollup-linux-s390x-gnu": "4.50.1", "@rollup/rollup-linux-x64-gnu": "4.50.1", "@rollup/rollup-linux-x64-musl": "4.50.1", "@rollup/rollup-openharmony-arm64": "4.50.1", "@rollup/rollup-win32-arm64-msvc": "4.50.1", "@rollup/rollup-win32-ia32-msvc": "4.50.1", "@rollup/rollup-win32-x64-msvc": "4.50.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -442,12 +563,22 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -470,10 +601,14 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinybench": ["tinybench@6.0.1", "", {}, "sha512-cMdWsxmysdg8mNWf1pujiWl3TW0cU6m8QuNw55QlnP3I6N96Grb0wnu5N0syHIu3LbiVZCNqlfWzWDq84HZphA=="], + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], @@ -484,8 +619,12 @@ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -498,8 +637,14 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], + + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], @@ -518,6 +663,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fix-dts-default-cjs-exports/magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -528,6 +675,14 @@ "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "vite/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "vitest/tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "vitest/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], diff --git a/example/index.ts b/example/index.ts index 03a730a..7d417d4 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,15 +1,21 @@ import { Elysia } from 'elysia' import { staticPlugin } from '../src/index' - -const app = new Elysia() - .use( - await staticPlugin({ - prefix: '/', - bundleHTML: false - }) - ) - .listen(3000) - -console.log(app.routes) - -await app.modules +import { node } from '@elysiajs/node' +import { isBun } from '../src/utils' +;(async () => { + const app = new Elysia(isBun ? {} : { adapter: node() }) + .use( + await staticPlugin({ + prefix: 'hi', + assets: 'public', + alwaysStatic: true, + bunFullstack: true, + decodeURI: false, + etag: false + // staticLimit: 1 + }) + ) + .listen(3005) + await app.modules + console.log(app.routes) +})() // no top-level awaits allowed for cjs (error triggered by `bun dev:node`) (idk how to fix this) diff --git a/package.json b/package.json index 020f297..d43867c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "license": "MIT", "scripts": { "dev": "bun run --watch example/index.ts", - "test": "bun test && npm run test:node", + "dev:node": "tsx watch example/index.ts", + "bench": "bun run bench/index.ts", + "test": "bun run build && bun test && vitest run && npm run test:node", "test:node": "npm install --prefix ./test/node/cjs/ && npm install --prefix ./test/node/esm/ && node ./test/node/cjs/index.js && node ./test/node/esm/index.js", "build": "bun build.ts", "release": "npm run build && npm run test && npm publish --access public" @@ -48,6 +50,7 @@ } }, "devDependencies": { + "@elysiajs/node": "^1.4.5", "@types/bun": "^1.3.13", "@types/fast-decode-uri-component": "^1.0.0", "@types/node": "^24.12.2", @@ -56,10 +59,13 @@ "esbuild-fix-imports-plugin": "^1.0.23", "eslint": "9.6.0", "fast-decode-uri-component": "^1.0.1", + "tinybench": "^6.0.1", "tsup": "^8.5.1", - "typescript": "^5.9.3" + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.1.5" }, "peerDependencies": { "elysia": ">= 1.4.0" } -} +} \ No newline at end of file diff --git a/public/MGICALCURE_LOVE_SHOT.mp3 b/public/MGICALCURE_LOVE_SHOT.mp3 new file mode 100644 index 0000000..21ffc3e Binary files /dev/null and b/public/MGICALCURE_LOVE_SHOT.mp3 differ diff --git a/public/html/index.html b/public/html/index.html index 411a3ee..5df9ccb 100644 --- a/public/html/index.html +++ b/public/html/index.html @@ -1,4 +1,4 @@ - + Home! diff --git a/public/js/index.js b/public/js/index.js new file mode 100644 index 0000000..0781663 --- /dev/null +++ b/public/js/index.js @@ -0,0 +1 @@ +console.log("hi") \ No newline at end of file diff --git a/public/kyuukurarin.mp4 b/public/kyuukurarin.mp4 new file mode 100644 index 0000000..b1ce213 Binary files /dev/null and b/public/kyuukurarin.mp4 differ diff --git a/src/index.ts b/src/index.ts index 12aaf86..a07b543 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Elysia, NotFoundError } from 'elysia' +import { Elysia, ElysiaFile, NotFoundError, type Context } from 'elysia' import fastDecodeURI from 'fast-decode-uri-component' @@ -8,13 +8,20 @@ import { getBuiltinModule, listFiles, generateETag, - isCached, + alreadyCachedDownstream, getFile, isBun, - listHTMLFiles, - isNotEmpty + getFileStats } from './utils' import type { StaticOptions } from './types' +import { BunFile, HTMLBundle } from 'bun' +import { Stats } from 'fs' + +interface CachedFile { + data: Blob + stats: Stats + etag?: string +} export async function staticPlugin({ assets = 'public', @@ -55,341 +62,248 @@ export async function staticPlugin({ ? (p: string) => p.replace(/\\/g, '/') : (p: string) => p - const fileCache = new LRUCache() + const fileCache = new LRUCache() + + if (prefix.endsWith('/')) { + prefix = prefix.slice(0, -1) as Prefix + } - if (prefix === path.sep) prefix = '' as Prefix const assetsDir = path.resolve(assets) + const shouldIgnore = !ignorePatterns.length ? () => false - : (file: string) => - ignorePatterns.find((pattern) => + : (relativeFilePathFromAssetDir: string) => { + return ignorePatterns?.find((pattern) => typeof pattern === 'string' - ? pattern.includes(file) - : pattern.test(file) + ? pattern.includes(relativeFilePathFromAssetDir) + : pattern.test(relativeFilePathFromAssetDir) ) + } const app = new Elysia({ name: 'static', seed: prefix }) + app.onError(() => {}) + + const files = (await listFiles(assetsDir)).sort((path1, path2) => { + const isHTML1 = path1.endsWith('.html') + const isHTML2 = path2.endsWith('.html') + return +isHTML2 - +isHTML1 + }) // prioritize mounting the html files first, since those must be added (if bunFullstack is true) whether or not we've exceeded staticLimit + let staticRoutesMounted = 0 + /** whether or not the `prefix` url (no trailing slash, unless the whole url is `/`) was mounted in the below for-loop */ + let rootPathAlreadyMounted = false + // mount applicable files (HTML files bundled with Bun, or all files if alwaysStatic is true) as static routes + for (const absoluteFilePath of files) { + const shouldBundleFileWithBun = + isBun && bunFullstack && absoluteFilePath.endsWith('.html') + + if ( + !absoluteFilePath || + shouldIgnore(absoluteFilePath.replace(assetsDir, '')) || + (!alwaysStatic && !shouldBundleFileWithBun) // if shouldBundleFileWithBun, we pre-bundle the HTML files and add them as routes regardless if alwaysStatic is true or not (matches current implementation) + ) + continue - if (alwaysStatic) { - const files = await listFiles(path.resolve(assets)) - - if (files.length <= staticLimit) - for (const absolutePath of files) { - if (!absolutePath || shouldIgnore(absolutePath)) continue - - let relativePath = absolutePath.replace(assetsDir, '') - if (decodeURI) - relativePath = fastDecodeURI(relativePath) ?? relativePath - - let pathName = normalizePath(path.join(prefix, relativePath)) - - if (isBun && absolutePath.endsWith('.html')) { - let htmlFile - try { - htmlFile = bunFullstack - ? (await import(absolutePath)).default - : getFile(absolutePath) - } catch (error) { - if (!silent) - console.error( - `[@elysiajs/static] Failed to load HTML file: ${absolutePath}`, - error - ) - continue - } - - app.get(pathName, htmlFile, { - detail: - typeof detail === 'function' - ? detail(pathName) - : detail - }) - if (indexHTML && pathName.endsWith('/index.html')) - app.get(pathName.replace('/index.html', ''), htmlFile, { - detail: - typeof detail === 'function' - ? detail( - pathName.replace('/index.html', '') - ) - : detail - }) - - continue - } - - if (!extension) - pathName = normalizePath( - pathName.slice(0, pathName.lastIndexOf('.')) - ) - - const file: Awaited> = isBun - ? getFile(absolutePath) - : ((await getFile(absolutePath)) as any) - - if (!file) { - if (!silent) - console.warn( - `[@elysiajs/static] Failed to load file: ${absolutePath}` - ) - - return new Elysia() - } - - const etag = await generateETag(file) - - function handleCache({ - headers: requestHeaders - }: { - headers: Record - }) { - if (etag) { - let cached = isCached( - requestHeaders as any, - etag, - absolutePath - ) - - if (cached === true) - return new Response(null, { - status: 304, - headers: isNotEmpty(initialHeaders) - ? initialHeaders - : undefined - }) - else if (cached !== false) { - const cache = fileCache.get(pathName) - if (cache) return cache.clone() - - return cached.then((cached) => { - if (cached) - return new Response(null, { - status: 304, - headers: initialHeaders - ? initialHeaders - : undefined - }) - - const response = new Response(file, { - headers: Object.assign( - { - 'Cache-Control': maxAge - ? `${directive}, max-age=${maxAge}` - : directive - }, - initialHeaders, - etag ? { Etag: etag } : {} - ) - }) - fileCache.set(prefix, response) - - return response.clone() - }) - } - } - - const cache = fileCache.get(pathName) - if (cache) return cache.clone() - - const response = new Response(file, { - headers: Object.assign( - { - 'Cache-Control': maxAge - ? `${directive}, max-age=${maxAge}` - : directive - }, - initialHeaders, - etag ? { Etag: etag } : {} - ) - }) - - fileCache.set(pathName, response) - - return response.clone() - } - app.get( - pathName, - useETag - ? (handleCache as any) - : new Response( - file, - isNotEmpty(initialHeaders) - ? { - headers: initialHeaders - } - : undefined - ), - { - detail: - typeof detail === 'function' - ? detail(pathName) - : detail - } + if (staticRoutesMounted >= staticLimit && !shouldBundleFileWithBun) { + // we're skipping this asset, so we'll need the wildcard route generated when alwaysStatic is false + alwaysStatic = false // we can't mount any more (non-bun HTML) routes + continue + } + if (!(await fileExists(absoluteFilePath))) { + if (!silent) + console.warn( + `[@elysiajs/static] Failed to load file: ${absoluteFilePath}` ) - if (indexHTML && pathName.endsWith('/index.html')) - app.get( - pathName.replace('/index.html', ''), - useETag - ? (handleCache as any) - : new Response( - file, - isNotEmpty(initialHeaders) - ? { - headers: initialHeaders - } - : undefined - ), - { - detail: - typeof detail === 'function' - ? detail( - pathName.replace('/index.html', '') - ) - : detail - } - ) + return new Elysia() + } + const urlPath = getURLPath(absoluteFilePath) + const prebundledHTML = shouldBundleFileWithBun + ? ((await import(absoluteFilePath)).default as HTMLBundle) + : undefined + + mountRoute({ urlPath, absoluteFilePath, prebundledHTML }) + staticRoutesMounted++ + + if (indexHTML && urlPath.endsWith('/index.html')) { + const strippedURLPath = urlPath.replace(/\/index.html$/, '') + mountRoute({ + urlPath: strippedURLPath === '' ? '/' : strippedURLPath, + absoluteFilePath, + prebundledHTML + }) + staticRoutesMounted++ + + if (strippedURLPath === prefix) { + rootPathAlreadyMounted = true } - - return app + } } + // set up catch-all route for static assets if ( // @ts-ignore private property - !(`GET_${prefix}/*` in app.routeTree) + !(`GET_${prefix}/*` in app.routeTree) && + !alwaysStatic ) { - if (isBun) { - const htmls = await listHTMLFiles(path.resolve(assets)) - - for (const absolutePath of htmls) { - if (!absolutePath || shouldIgnore(absolutePath)) continue - - let relativePath = absolutePath.replace(assetsDir, '') - const pathName = normalizePath(path.join(prefix, relativePath)) - let htmlFile - try { - htmlFile = bunFullstack - ? (await import(absolutePath)).default - : getFile(absolutePath) - } catch (error) { - if (!silent) - console.error( - `[@elysiajs/static] Failed to load HTML file: ${absolutePath}`, - error - ) - continue - } + mountRoute({ + urlPath: `${prefix.endsWith('/') ? prefix.slice(0, -1) : prefix}/*`, + absoluteFilePath: (params) => + path.resolve( + assets, + decodeURI + ? (fastDecodeURI(params['*']) ?? params['*']) + : params['*'] + ) + }) + if (!rootPathAlreadyMounted) { + mountRoute({ + urlPath: prefix === '' ? '/' : prefix, + absoluteFilePath: assetsDir + }) // /public/* does not catch /public (without the trailing /), so we need another route here in case it serves the top-level index.html file (funnily enough, /* captures /, so we are a bit redundant in that case) + } + } - app.get(pathName, htmlFile, { - detail: - typeof detail === 'function' ? detail(pathName) : detail + function mountRoute({ + urlPath, + absoluteFilePath, + prebundledHTML + }: { + urlPath: string + absoluteFilePath: string | ((params: any) => string) + /** if provided, will serve bun HTML file instead of custom handler */ + prebundledHTML?: HTMLBundle + }) { + app.get( + urlPath, + prebundledHTML !== undefined + ? prebundledHTML + : ({ params, headers: requestHeaders, set }) => { + return getFileResponse({ + absoluteFilePath: + typeof absoluteFilePath === 'function' + ? absoluteFilePath(params) + : absoluteFilePath, + requestHeaders, + set + }) + }, + { + detail: typeof detail === 'function' ? detail(urlPath) : detail + } + ) + } + /** Replaces assetsDir with url prefix */ + function getURLPath(absoluteFilePath: string) { + let relativeFilePath = absoluteFilePath.replace(assetsDir, '') + if (decodeURI) + relativeFilePath = + fastDecodeURI(relativeFilePath) ?? relativeFilePath + + let urlPath = normalizePath(path.join(prefix, relativeFilePath)) + + if (!extension) urlPath = urlPath.slice(0, urlPath.lastIndexOf('.')) + return urlPath + } + + /** returns file response given file path and request headers */ + async function getFileResponse({ + absoluteFilePath, + requestHeaders, + set + }: { + absoluteFilePath: string + requestHeaders: Record + set: Context['set'] + }) { + if ( + absoluteFilePath !== assetsDir && + !absoluteFilePath.startsWith(assetsDir + path.sep) + ) + throw new NotFoundError() // prevent file-traversal attacks + + const setInitialHeaders = () => { + for (const [headerName, headerVal] of Object.entries( + initialHeaders ?? {} + )) { + set.headers[headerName] = headerVal + } + } + + const finalizeResponse = async (file: CachedFile) => { + setInitialHeaders() + if (!useETag) return file.data + + if (alreadyCachedDownstream(requestHeaders, file.etag, file.stats)) + return new Response(null, { + status: 304 }) - if (indexHTML && pathName.endsWith('/index.html')) - app.get(pathName.replace('/index.html', ''), htmlFile, { - detail: - typeof detail === 'function' - ? detail(pathName.replace('/index.html', '')) - : detail - }) + + set.headers['etag'] = file.etag + set.headers['cache-control'] = maxAge + ? `${directive}, max-age=${maxAge}` + : directive + return file.data + } + + const cachedFile = + fileCache.get(absoluteFilePath) ?? + (indexHTML + ? fileCache.get(path.join(absoluteFilePath, 'index.html')) // eagerly check for /index.html subpath, regardless of whether absoluteFilePath is a directory or not + : undefined) + + if (cachedFile) { + return finalizeResponse(cachedFile) + } + + let fileStat = await getFileStats(absoluteFilePath) + if (!fileStat) throw new NotFoundError() + + if (fileStat.isDirectory()) { + if (indexHTML) { + absoluteFilePath = path.join(absoluteFilePath, 'index.html') + fileStat = await getFileStats(absoluteFilePath) + } else { + throw new NotFoundError() } } - app.onError(() => {}).get( - `${prefix.endsWith('/') ? prefix.slice(0, -1) : prefix}/*`, - async ({ params, headers: requestHeaders }) => { - const pathName = normalizePath( - path.join( - assets, - decodeURI - ? (fastDecodeURI(params['*']) ?? params['*']) - : params['*'] - ) - ) + if (shouldIgnore(absoluteFilePath.replace(assetsDir, ''))) + throw new NotFoundError() + + if (fileStat === null || fileStat.isDirectory()) { + throw new NotFoundError() + } - if (shouldIgnore(pathName)) throw new NotFoundError() - - const cache = fileCache.get(pathName) - if (cache) return cache.clone() - - try { - const fileStat = await fs.stat(pathName).catch(() => null) - if (!fileStat) throw new NotFoundError() - - if (!indexHTML && fileStat.isDirectory()) - throw new NotFoundError() - - // @ts-ignore - let file: - | NonNullable>> - | undefined - - if (!isBun && indexHTML) { - const htmlPath = path.join(pathName, 'index.html') - const cache = fileCache.get(htmlPath) - if (cache) return cache.clone() - - if (await fileExists(htmlPath)) - file = await getFile(htmlPath) - } - - if ( - !file && - !fileStat.isDirectory() && - (await fileExists(pathName)) - ) - file = await getFile(pathName) - else throw new NotFoundError() - - if (!useETag) - return new Response( - file, - isNotEmpty(initialHeaders) - ? { headers: initialHeaders } - : undefined - ) - - const etag = await generateETag(file) - if ( - etag && - (await isCached(requestHeaders, etag, pathName)) - ) - return new Response(null, { - status: 304 - }) - - const response = new Response(file, { - headers: Object.assign( - { - 'Cache-Control': maxAge - ? `${directive}, max-age=${maxAge}` - : directive - }, - initialHeaders, - etag ? { Etag: etag } : {} - ) - }) - - fileCache.set(pathName, response) - - return response.clone() - } catch (error) { - if (error instanceof NotFoundError) throw error - if (!silent) console.error(`[@elysiajs/static]`, error) - - throw new NotFoundError() + try { + if (absoluteFilePath.endsWith('.html') && isBun && bunFullstack) { + throw new Error( + 'Bun HTML files should be served directly as a static route, not requested dynamically' + ) + } else { + const file = getFile(absoluteFilePath) + const cachedFileResponse = isBun + ? (file.value as BunFile) // bun does its own magic with these lazy blobs, so we don't need to eagerly load them here + : new Blob([await fs.readFile(absoluteFilePath)], { + type: file.type // save the content-type here + }) + + const cachedFile: CachedFile = { + data: cachedFileResponse, + stats: fileStat, + etag: await generateETag(file) } - }, - { - detail: - typeof detail === 'function' - ? detail( - `${prefix.endsWith('/') ? prefix.slice(0, -1) : prefix}/*` - ) - : detail + fileCache.set(absoluteFilePath, cachedFile) + return finalizeResponse(cachedFile) } - ) + } catch (error) { + if (error instanceof NotFoundError) throw error + if (!silent) console.error(`[@elysiajs/static]`, error) + + throw new NotFoundError() + } } return app diff --git a/src/types.ts b/src/types.ts index 8a7ecf2..a80a661 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,7 +24,7 @@ export interface StaticOptions { /** * @default false unless `NODE_ENV` is 'production' * - * Should file always be served statically + * Should file always be served statically (note: if bunFullstack is set to true, any html files will still be served statically as pre-defined routes.) */ alwaysStatic?: boolean /** diff --git a/src/utils.ts b/src/utils.ts index fb0b8cb..6e7920f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import type { BunFile } from 'bun' -import type { Stats } from 'fs' +import { ElysiaFile, file as getElysiaFile } from 'elysia' +import { ReadStream, Stats } from 'fs' let fs: typeof import('fs/promises') let path: typeof import('path') @@ -21,23 +22,6 @@ export function getBuiltinModule() { return [fs, path] as const } - -export async function listHTMLFiles(dir: string) { - if (!fs) getBuiltinModule() - - if (isBun) { - const glob = new Bun.Glob('**/*.html') - const files = [] - - for await (const file of glob.scan(dir)) - files.push(path.join(dir, file)) - - return files - } - - return [] -} - export async function listFiles(dir: string): Promise { if (!fs) getBuiltinModule() @@ -68,14 +52,24 @@ export async function listFiles(dir: string): Promise { return all.flat() } +/** + * File exists and is not a directory + * @param path + * @returns + */ export function fileExists(path: string) { if (!fs) getBuiltinModule() return fs.stat(path).then( - () => true, + (data) => !data.isDirectory(), () => false ) } +export function getFileStats(path: string) { + if (!fs) getBuiltinModule() + + return fs.stat(path).catch(() => null) +} export class LRUCache { private map = new Map() @@ -134,11 +128,10 @@ export class LRUCache { if (this.interval) clearInterval(this.interval) } } - -export function isCached( +export function alreadyCachedDownstream( headers: Record, - etag: string, - filePath: string + etag: string | undefined, + fileStats: Stats ) { // Always return stale when Cache-Control: no-cache // to support end-to-end reload requests @@ -176,13 +169,10 @@ export function isCached( const ifModifiedSince = headers['if-modified-since'] try { - return fs.stat(filePath).then((stat) => { - if ( - stat.mtime !== undefined && - stat.mtime.getTime() <= Date.parse(ifModifiedSince) - ) - return true - }) + return ( + fileStats.mtime !== undefined && + fileStats.mtime.getTime() <= Date.parse(ifModifiedSince) + ) } catch {} } @@ -192,16 +182,13 @@ export function isCached( let Crypto: typeof import('crypto') export function getFile(path: string) { - if (isBun) return Bun.file(path) - - if (!fs) getBuiltinModule() - return fs.readFile(path) + return getElysiaFile(path) } -export async function generateETag(file: BunFile | Buffer) { +export async function generateETag(file: ElysiaFile) { if (isBun) return new Bun.CryptoHasher('md5') - .update(await (file as BunFile).arrayBuffer()) + .update(await (file.value as BunFile).arrayBuffer()) .digest('base64') if (!Crypto) Crypto = process.getBuiltinModule('crypto') @@ -209,10 +196,9 @@ export async function generateETag(file: BunFile | Buffer) { return void console.warn( '[@elysiajs/static] crypto is required to generate etag.' ) - - return Crypto.createHash('md5') - .update(file as Buffer) - .digest('base64') + if (!fs) getBuiltinModule() + const buffer = await fs.readFile(file.path) // redundant read, but the file.value ReadStream can only be read once... + return Crypto.createHash('md5').update(buffer).digest('base64') } export const isNotEmpty = (obj?: Object) => { diff --git a/test/index.test.ts b/test/index.test.ts index 4e4e45e..82ed7fb 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,7 +1,7 @@ import { Elysia } from 'elysia' import { staticPlugin } from '../src' -import { describe, expect, it } from 'bun:test' +import { expect, it, describe } from 'vitest' import { join, sep } from 'path' import { req, takodachi } from './utils' @@ -17,7 +17,7 @@ describe('Static Plugin', () => { .then((r) => r.blob()) .then((r) => r.text()) - expect(res).toBe(takodachi) + expect(res).toBe(takodachi.toString()) }) it('should get nested path', async () => { @@ -27,7 +27,7 @@ describe('Static Plugin', () => { const res = await app.handle(req('/public/nested/takodachi.png')) const blob = await res.blob() - expect(await blob.text()).toBe(takodachi) + expect(await blob.text()).toBe(takodachi.toString()) }) it('should get different path', async () => { @@ -41,7 +41,7 @@ describe('Static Plugin', () => { const res = await app.handle(req('/public/tako.png')) const blob = await res.blob() - expect(await blob.text()).toBe(takodachi) + expect(await blob.text()).toBe(takodachi.toString()) }) it('should handle prefix', async () => { @@ -55,7 +55,7 @@ describe('Static Plugin', () => { const res = await app.handle(req('/static/takodachi.png')) const blob = await res.blob() - expect(await blob.text()).toBe(takodachi) + expect(await blob.text()).toBe(takodachi.toString()) }) it('should handle empty prefix', async () => { @@ -69,7 +69,7 @@ describe('Static Plugin', () => { const res = await app.handle(req('/takodachi.png')) const blob = await res.blob() - expect(await blob.text()).toBe(takodachi) + expect(await blob.text()).toBe(takodachi.toString()) }) it('should supports multiple public', async () => { @@ -105,6 +105,19 @@ describe('Static Plugin', () => { const res = await app.handle(req('/public/takodachi.png')) expect(res.status).toBe(404) }) + it('ignore string pattern (when alwaysStatic is true)', async () => { + const app = new Elysia().use( + staticPlugin({ + ignorePatterns: [`public${sep}takodachi.png`], + alwaysStatic: true + }) + ) + + await app.modules + + const res = await app.handle(req('/public/takodachi.png')) + expect(res.status).toBe(404) + }) it('ignore regex pattern', async () => { const app = new Elysia().use( @@ -132,14 +145,17 @@ describe('Static Plugin', () => { .then((r) => r.blob()) .then((r) => r.text()) - expect(res).toBe(takodachi) + expect(res).toBe(takodachi.toString()) }) it('always static with assets on an absolute path', async () => { const app = new Elysia().use( staticPlugin({ alwaysStatic: true, - assets: join(import.meta.dir, '../public') + assets: join( + import.meta.dir ?? join(expect.getState().testPath!, '../'), + '../public' + ) }) ) @@ -147,7 +163,7 @@ describe('Static Plugin', () => { const res = await app.handle(req('/public/takodachi.png')) const blob = await res.blob() - expect(await blob.text()).toBe(takodachi) + expect(await blob.text()).toEqual(takodachi.toString()) }) it('exclude extension', async () => { @@ -165,7 +181,7 @@ describe('Static Plugin', () => { .then((r) => r.blob()) .then((r) => r.text()) - expect(res).toBe(takodachi) + expect(res).toBe(takodachi.toString()) }) it('return custom headers', async () => { @@ -381,11 +397,111 @@ describe('Static Plugin', () => { for (const path of notFoundPaths) { const res = await app.handle(req(path)) - expect(res.status).toBe(404) } }) + it.each([ + { bunFullstack: true, alwaysStatic: true }, + { bunFullstack: false, alwaysStatic: true }, + { bunFullstack: true, alwaysStatic: false }, + { bunFullstack: false, alwaysStatic: false } + ])( + 'should work on all possible index.html paths', + async ({ bunFullstack, alwaysStatic }) => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: 'public', + indexHTML: true, + bunFullstack, + alwaysStatic + }) + ) + + await app.modules + const htmlPaths = [ + '/public/html', + '/public/html/', + '/public/html/index.html', + '/public/html/index.html/' + ] + + for (const path of htmlPaths) { + // console.log(path) + const res = await app.handle(req(path)) + await (await res.blob()).text() // make sure content is readable and does not lock server-side Response object + + expect(res.status).toBe(200) + } + } + ) + it.each([ + { bunFullstack: true, alwaysStatic: true }, + { bunFullstack: false, alwaysStatic: true }, + { bunFullstack: true, alwaysStatic: false }, + { bunFullstack: false, alwaysStatic: false } + ])( + 'should 404 on root path when indexHTML is false', + async ({ bunFullstack, alwaysStatic }) => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: 'public', + indexHTML: false, + bunFullstack, + alwaysStatic + }) + ) + + await app.modules + const htmlPaths = [ + '/public/html/index.html', + '/public/html/index.html/' + ] + + for (const path of htmlPaths) { + // console.log(path) + const res = await app.handle(req(path)) + await (await res.blob()).text() // make sure content is readable and does not lock server-side Response object + + expect(res.status).toBe(200) + } + for (const invalidPath of ['/public/html', '/public/html/']) { + const res = await app.handle(req(invalidPath)) + + expect(res.status).toBe(404) + } + } + ) + it.each([ + { bunFullstack: true, alwaysStatic: true }, + { bunFullstack: false, alwaysStatic: true }, + { bunFullstack: true, alwaysStatic: false }, + { bunFullstack: false, alwaysStatic: false } + ])( + 'should work on all possible index.html paths (index.html is in root asset dir)', + async ({ bunFullstack, alwaysStatic }) => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public/html', + prefix: '', + indexHTML: true, + bunFullstack, + alwaysStatic + }) + ) + + await app.modules + const htmlPaths = ['', '/', '/index.html', '/index.html/'] + + for (const path of htmlPaths) { + const res = await app.handle(req(path)) + expect(res.status).toBe(200) + } + } + ) + it('serve index.html to default /', async () => { const app = new Elysia().use(staticPlugin()) await app.modules @@ -454,7 +570,356 @@ describe('Static Plugin', () => { await app.modules for (const route of app.routes) { - expect(route.hooks.detail.hide).toBeFalse() + expect(route.hooks.detail.hide).toBe(false) + } + }) + it('should return necessary content-type headers', async () => { + const app = new Elysia().use( + staticPlugin({ + detail: { + hide: false + }, + headers: { + hii: 'uwa' + } + }) + ) + await app.modules + + const jsFile = await app.handle(req('/public/js/index.js')) + expect(jsFile.headers.get('content-type')).toContain('/javascript') // slightly different content-type depending on Bun vs. Node runtime + expect(jsFile.headers.get('hii')).toEqual('uwa') + }) + + it('preserves content-type on cached file responses', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const first = await app.handle(req('/public/js/index.js')) + expect(first.status).toBe(200) + expect(first.headers.get('content-type')).toContain('/javascript') + + const second = await app.handle(req('/public/js/index.js')) + expect(second.status).toBe(200) + expect(second.headers.get('content-type')).toContain('/javascript') + }) + + it('preserves custom headers on cached file responses', async () => { + const app = new Elysia().use( + staticPlugin({ + headers: { + 'x-static-test': 'cached' + } + }) + ) + + await app.modules + + const first = await app.handle(req('/public/takodachi.png')) + expect(first.status).toBe(200) + expect(first.headers.get('x-static-test')).toBe('cached') + + const second = await app.handle(req('/public/takodachi.png')) + expect(second.status).toBe(200) + expect(second.headers.get('x-static-test')).toBe('cached') + }) + + it('preserves etag and cache-control headers on cached file responses', async () => { + const app = new Elysia().use( + staticPlugin({ + maxAge: 3600, + directive: 'private' + }) + ) + + await app.modules + + const first = await app.handle(req('/public/takodachi.png')) + expect(first.status).toBe(200) + + const etag = first.headers.get('etag') + expect(etag).toBeTruthy() + expect(first.headers.get('cache-control')).toBe('private, max-age=3600') + + const second = await app.handle(req('/public/takodachi.png')) + expect(second.status).toBe(200) + expect(second.headers.get('etag')).toBe(etag) + expect(second.headers.get('cache-control')).toBe( + 'private, max-age=3600' + ) + }) + + it('returns 304 for if-none-match after the file has been cached', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const first = await app.handle(req('/public/takodachi.png')) + expect(first.status).toBe(200) + + const etag = first.headers.get('etag') + expect(etag).toBeTruthy() + + const request = req('/public/takodachi.png') + request.headers.set('if-none-match', etag!) + + const second = await app.handle(request) + + expect(second.status).toBe(304) + expect(second.body).toBe(null) + }) + + it('does not return 304 when cache-control no-cache is sent after file is cached', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const first = await app.handle(req('/public/takodachi.png')) + expect(first.status).toBe(200) + + const etag = first.headers.get('etag') + expect(etag).toBeTruthy() + + const request = req('/public/takodachi.png') + request.headers.set('if-none-match', etag!) + request.headers.set('cache-control', 'no-cache') + + const second = await app.handle(request) + + expect(second.status).toBe(200) + expect(await second.blob().then((b) => b.text())).toBe( + takodachi.toString() + ) + }) + + it('returns 304 for if-none-match after cached alwaysStatic route response', async () => { + const app = new Elysia().use( + staticPlugin({ + alwaysStatic: true, + extension: false + }) + ) + + await app.modules + + const first = await app.handle(req('/public/takodachi')) + expect(first.status).toBe(200) + + const etag = first.headers.get('etag') + expect(etag).toBeTruthy() + + const request = req('/public/takodachi') + request.headers.set('if-none-match', etag!) + + const second = await app.handle(request) + + expect(second.status).toBe(304) + expect(second.body).toBe(null) + }) + + it('serves index.html from cache with content-type and cache headers', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const first = await app.handle(req('/public/html')) + expect(first.status).toBe(200) + expect(first.headers.get('content-type')).toContain('text/html') + + const etag = first.headers.get('etag') + expect(etag).toBeTruthy() + expect(first.headers.get('cache-control')).toBe('public, max-age=86400') + + const second = await app.handle(req('/public/html')) + expect(second.status).toBe(200) + expect(second.headers.get('content-type')).toContain('text/html') + expect(second.headers.get('etag')).toBe(etag) + expect(second.headers.get('cache-control')).toBe( + 'public, max-age=86400' + ) + }) + + it('returns 304 for cached index.html default route', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const first = await app.handle(req('/public/html')) + expect(first.status).toBe(200) + + const etag = first.headers.get('etag') + expect(etag).toBeTruthy() + + const request = req('/public/html') + request.headers.set('if-none-match', etag!) + + const second = await app.handle(request) + + expect(second.status).toBe(304) + expect(second.body).toBe(null) + }) + + it('preserves image content-type on cached PNG file responses', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const first = await app.handle(req('/public/takodachi.png')) + expect(first.status).toBe(200) + expect(first.headers.get('content-type')).toContain('image/png') + + const second = await app.handle(req('/public/takodachi.png')) + expect(second.status).toBe(200) + expect(second.headers.get('content-type')).toContain('image/png') + }) + + it.each([ + { bunFullstack: true, alwaysStatic: true }, + { bunFullstack: false, alwaysStatic: true }, + { bunFullstack: true, alwaysStatic: false }, + { bunFullstack: false, alwaysStatic: false } + ])( + 'suppresses etag and cache-control on cached file responses when etag is false', + async ({ bunFullstack, alwaysStatic }) => { + const app = new Elysia().use( + staticPlugin({ etag: false, alwaysStatic, bunFullstack }) + ) + + await app.modules + + const first = await app.handle(req('/public/takodachi.png')) + expect(first.status).toBe(200) + expect(first.headers.get('etag')).toBeNull() + expect(first.headers.get('cache-control')).toBeNull() + + const second = await app.handle(req('/public/takodachi.png')) + expect(second.status).toBe(200) + expect(second.headers.get('etag')).toBeNull() + expect(second.headers.get('cache-control')).toBeNull() + } + ) + describe.each([ + { bunFullstack: true, alwaysStatic: true }, + { bunFullstack: false, alwaysStatic: true }, + { bunFullstack: true, alwaysStatic: false }, + { bunFullstack: false, alwaysStatic: false } + ])('', ({ bunFullstack, alwaysStatic }) => { + let prevRouteCount = 0 // should expect to be non-decreasing as staticLimit increases + it.each([...Array(20)].map((_, i) => i))( + 'test static limit', + async (staticLimit) => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: '', + indexHTML: true, + bunFullstack, + alwaysStatic, + staticLimit + }) + ) + + await app.modules + const allPaths = [ + '/html', + '/html/', + '/html/index.html', + '/html/index.html/', + '/html/a.html/', + '/js/index.js', + '/takodachi.png', + '/takodachi.png/', + '/nested/takodachi.png', + '\\nested\\takodachi.png' + ] + // console.log(staticLimit, app.routes.length) + expect(app.routes.length).toBeGreaterThanOrEqual( + prevRouteCount - 1 + ) // you may end up with a decrease of 1 when the last static route gets mounted and the two catch-all routes are no-longer needed. Bit scuffed test. + prevRouteCount = app.routes.length + for (const path of allPaths) { + const res = await app.handle(req(path)) + expect(res.status).toBe(200) + } + for (const nonExistentPath of ['/', 'hi/ok', 'owo', '///']) { + const res = await app.handle(req(nonExistentPath)) + expect(res.status).toBe(404) + } + } + ) + }) + it.each([{ alwaysStatic: true }, { alwaysStatic: false }])( + 'range request header', + async ({ alwaysStatic }) => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: '', + indexHTML: true, + alwaysStatic + }) + ) + + await app.modules + const request = req('/html') + request.headers.set('range', 'bytes=0-1') + const res = await app.handle(request) + expect(res.status).toBe(206) // partial request + expect((await res.bytes()).length).toBe(2) + } + ) + it.each([{ alwaysStatic: true }, { alwaysStatic: false }])( + 'range request header on video', + async ({ alwaysStatic }) => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: '', + indexHTML: true, + alwaysStatic + }) + ) + + await app.modules + const request = req('/kyuukurarin.mp4') + request.headers.set('range', 'bytes=37158912-') + const res = await app.handle(request) + expect(res.status).toBe(206) // partial request + expect((await res.bytes()).length).toBe(3220118) } + ) + it("doesn't allow path traversal attacks", async () => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: '', + decodeURI: true // does have to be set as true + }) + ) + + await app.modules + const request = req('/..%2Fsrc/index.ts') + const res = await app.handle(request) + expect(res.status).toBe(404) + }) + it('allows custom headers when etag is false and alwaysStatic is true', async () => { + const app = new Elysia().use( + staticPlugin({ + assets: 'public', + prefix: '', + etag: false, + headers: { + custom: 'hi' + }, + alwaysStatic: true + }) + ) + + await app.modules + const request = req('/kyuukurarin.mp4') + const res = await app.handle(request) + expect(res.status).toBe(200) + expect(res.headers.get('custom')).toBe('hi') }) }) diff --git a/test/utils.d.ts b/test/utils.d.ts new file mode 100644 index 0000000..4654eab --- /dev/null +++ b/test/utils.d.ts @@ -0,0 +1 @@ +export declare const req: (path: string) => Request diff --git a/test/utils.ts b/test/utils.ts index e1e2b33..d6d2d72 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,3 +1,7 @@ -export const req = (path: string) => new Request(`http://localhost${path}`) +let fs = process.getBuiltinModule('fs/promises') -export const takodachi = await Bun.file('public/takodachi.png').text() +export const req = (path: string) => new Request(`http://localhost${path}`) +const isBun = typeof Bun !== 'undefined' && !!Bun.file +export const takodachi = isBun + ? await Bun.file('public/takodachi.png').text() + : await fs.readFile('public/takodachi.png') diff --git a/tsconfig.dts.json b/tsconfig.dts.json index cd54e87..2420fbf 100644 --- a/tsconfig.dts.json +++ b/tsconfig.dts.json @@ -101,6 +101,6 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ }, - "exclude": ["node_modules", "test", "example", "dist", "build.ts"] + "exclude": ["node_modules", "test", "example", "dist", "build.ts","bench"] // "include": ["src/**/*"] } diff --git a/tsconfig.json b/tsconfig.json index c2be58c..68c59ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -102,6 +102,5 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ }, - "exclude": ["node_modules"] - // "include": ["src/**/*"] + "exclude": ["node_modules"], }