From 147bf9fc3787b8ddba98bebec12ce056a2aaae3e Mon Sep 17 00:00:00 2001 From: Veeravardhan <23bsm037@iiitdmj.ac.in> Date: Mon, 13 Apr 2026 17:06:34 +0530 Subject: [PATCH] Support query-based route rewrites --- package-lock.json | 1532 ++++++++++++++++++++++++ package.json | 3 +- src/app.test.ts | 46 + src/app.ts | 28 + src/rewrite/rewrite-middleware.test.ts | 57 + src/rewrite/rewrite-middleware.ts | 115 ++ src/rewrite/route-pattern.test.ts | 93 ++ src/rewrite/route-pattern.ts | 210 ++++ 8 files changed, 2083 insertions(+), 1 deletion(-) create mode 100644 package-lock.json create mode 100644 src/rewrite/rewrite-middleware.test.ts create mode 100644 src/rewrite/rewrite-middleware.ts create mode 100644 src/rewrite/route-pattern.test.ts create mode 100644 src/rewrite/route-pattern.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..467acc6d3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1532 @@ +{ + "name": "json-server", + "version": "1.0.0-beta.15", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "json-server", + "version": "1.0.0-beta.15", + "license": "MIT", + "dependencies": { + "@tinyhttp/app": "^3.0.1", + "@tinyhttp/cors": "^2.0.1", + "@tinyhttp/logger": "^2.1.0", + "@tinyhttp/url": "^2.1.1", + "chalk": "^5.6.2", + "chokidar": "^5.0.0", + "dot-prop": "^10.1.0", + "eta": "^4.5.0", + "inflection": "^3.0.2", + "json5": "^2.2.3", + "lowdb": "^7.0.1", + "milliparsec": "^5.1.0", + "sirv": "^3.0.2", + "sort-on": "^7.0.0" + }, + "bin": { + "json-server": "lib/bin.js" + }, + "devDependencies": { + "@types/node": "^25.0.8", + "concurrently": "^9.2.1", + "get-port": "^7.1.0", + "husky": "^9.1.7", + "oxfmt": "^0.24.0", + "oxlint": "^1.39.0", + "tempy": "^3.1.0", + "type-fest": "^5.4.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@oxfmt/darwin-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/darwin-arm64/-/darwin-arm64-0.24.0.tgz", + "integrity": "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxfmt/darwin-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/darwin-x64/-/darwin-x64-0.24.0.tgz", + "integrity": "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxfmt/linux-arm64-gnu": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/linux-arm64-gnu/-/linux-arm64-gnu-0.24.0.tgz", + "integrity": "sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxfmt/linux-arm64-musl": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/linux-arm64-musl/-/linux-arm64-musl-0.24.0.tgz", + "integrity": "sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxfmt/linux-x64-gnu": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/linux-x64-gnu/-/linux-x64-gnu-0.24.0.tgz", + "integrity": "sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxfmt/linux-x64-musl": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/linux-x64-musl/-/linux-x64-musl-0.24.0.tgz", + "integrity": "sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxfmt/win32-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/win32-arm64/-/win32-arm64-0.24.0.tgz", + "integrity": "sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxfmt/win32-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@oxfmt/win32-x64/-/win32-x64-0.24.0.tgz", + "integrity": "sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.59.0.tgz", + "integrity": "sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.59.0.tgz", + "integrity": "sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.59.0.tgz", + "integrity": "sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.59.0.tgz", + "integrity": "sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.59.0.tgz", + "integrity": "sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.59.0.tgz", + "integrity": "sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.59.0.tgz", + "integrity": "sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.59.0.tgz", + "integrity": "sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.59.0.tgz", + "integrity": "sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.59.0.tgz", + "integrity": "sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.59.0.tgz", + "integrity": "sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.59.0.tgz", + "integrity": "sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.59.0.tgz", + "integrity": "sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.59.0.tgz", + "integrity": "sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.59.0.tgz", + "integrity": "sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.59.0.tgz", + "integrity": "sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.59.0.tgz", + "integrity": "sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.59.0.tgz", + "integrity": "sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.59.0.tgz", + "integrity": "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@tinyhttp/accepts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tinyhttp/accepts/-/accepts-2.3.0.tgz", + "integrity": "sha512-hdKkMGAUqnagpWO1R8rVBYqbu4sWQ2Fo682gkJmO0nl54DPvnzxx81b2WZtV3VwB7EdLfUoasj2BAkyTcyZ5aw==", + "license": "MIT", + "dependencies": { + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/app": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@tinyhttp/app/-/app-3.0.6.tgz", + "integrity": "sha512-jJYl9mc7nXY4gCyvot3tplCG6hVj+zm7sOZrT91et2Rg8M0oxBCVKVeX1tPhvVfOtYHQsz7mRlC1yk6yNLk79Q==", + "license": "MIT", + "dependencies": { + "@tinyhttp/accepts": "^2.3.0", + "@tinyhttp/cookie": "2.1.1", + "@tinyhttp/proxy-addr": "3.0.1", + "@tinyhttp/req": "2.2.8", + "@tinyhttp/res": "2.2.10", + "@tinyhttp/router": "2.2.5", + "regexparam": "^2.0.2" + }, + "engines": { + "node": ">=16.10.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/content-disposition": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz", + "integrity": "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==", + "license": "MIT", + "engines": { + "node": ">=12.17.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/content-type": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@tinyhttp/content-type/-/content-type-0.1.4.tgz", + "integrity": "sha512-dl6f3SHIJPYbhsW1oXdrqOmLSQF/Ctlv3JnNfXAE22kIP7FosqJHxkz/qj2gv465prG8ODKH5KEyhBkvwrueKQ==", + "license": "MIT", + "engines": { + "node": ">=12.4" + } + }, + "node_modules/@tinyhttp/cookie": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/cookie/-/cookie-2.1.1.tgz", + "integrity": "sha512-h/kL9jY0e0Dvad+/QU3efKZww0aTvZJslaHj3JTPmIPC9Oan9+kYqmh3M6L5JUQRuTJYFK2nzgL2iJtH2S+6dA==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/cookie-signature": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/cookie-signature/-/cookie-signature-2.1.1.tgz", + "integrity": "sha512-VDsSMY5OJfQJIAtUgeQYhqMPSZptehFSfvEEtxr+4nldPA8IImlp3QVcOVuK985g4AFR4Hl1sCbWCXoqBnVWnw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/cors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/cors/-/cors-2.0.1.tgz", + "integrity": "sha512-qrmo6WJuaiCzKWagv2yA/kw6hIISfF/hOqPWwmI6w0o8apeTMmRN3DoCFvQ/wNVuWVdU5J4KU7OX8aaSOEq51A==", + "license": "MIT", + "dependencies": { + "@tinyhttp/vary": "^0.1.3" + }, + "engines": { + "node": ">=12.20 || 14.x || >=16" + } + }, + "node_modules/@tinyhttp/encode-url": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/encode-url/-/encode-url-2.1.1.tgz", + "integrity": "sha512-AhY+JqdZ56qV77tzrBm0qThXORbsVjs/IOPgGCS7x/wWnsa/Bx30zDUU/jPAUcSzNOzt860x9fhdGpzdqbUeUw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/etag": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@tinyhttp/etag/-/etag-2.1.2.tgz", + "integrity": "sha512-j80fPKimGqdmMh6962y+BtQsnYPVCzZfJw0HXjyH70VaJBHLKGF+iYhcKqzI3yef6QBNa8DKIPsbEYpuwApXTw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/forwarded": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@tinyhttp/forwarded/-/forwarded-2.1.2.tgz", + "integrity": "sha512-9H/eulJ68ElY/+zYpTpNhZ7vxGV+cnwaR6+oQSm7bVgZMyuQfgROW/qvZuhmgDTIxnGMXst+Ba4ij6w6Krcs3w==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/logger": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@tinyhttp/logger/-/logger-2.1.0.tgz", + "integrity": "sha512-Ma1fJ9CwUbn9r61/4HW6+nflsVoslpOnCrfQ6UeZq7GGIgwLzofms3HoSVG7M+AyRMJpxlfcDdbH5oFVroDMKA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.20", + "dayjs": "^1.11.13", + "http-status-emojis": "^2.2.0" + }, + "engines": { + "node": ">=14.18 || >=16.20" + } + }, + "node_modules/@tinyhttp/proxy-addr": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/proxy-addr/-/proxy-addr-3.0.1.tgz", + "integrity": "sha512-vP0JVsy9ZMIldsaP/QHdMF+sb3B6wn7e2QXRdqpX/Cqz1ie35Am29DK88DeVmiwdTQle3FtYaVNtU3RgTGYZ+w==", + "license": "MIT", + "dependencies": { + "@tinyhttp/forwarded": "2.1.2" + }, + "engines": { + "node": ">=16.10.0" + } + }, + "node_modules/@tinyhttp/req": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@tinyhttp/req/-/req-2.2.8.tgz", + "integrity": "sha512-HCsceFNgMpssUsnRao16iJyyfdWRwKlhL7OMTPUEjZsZGREnBzpjlrPHA31G5xNNzR7XOWVXDXGyrGgSpcwGSA==", + "license": "MIT", + "dependencies": { + "@tinyhttp/accepts": "2.3.0", + "@tinyhttp/type-is": "2.2.5", + "@tinyhttp/url": "2.1.1", + "header-range-parser": "^1.1.3" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/res": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@tinyhttp/res/-/res-2.2.10.tgz", + "integrity": "sha512-ou+7T+x14xMrGwW0Rv+FOu4zYfXkTRf25yKMMOIuFyPyYOg27pbxtdiDVYJU1D5s6DFFdmenu0iXOSd1KABfYA==", + "license": "MIT", + "dependencies": { + "@tinyhttp/content-disposition": "2.2.4", + "@tinyhttp/cookie": "2.1.1", + "@tinyhttp/cookie-signature": "2.1.1", + "@tinyhttp/encode-url": "2.1.1", + "@tinyhttp/req": "2.2.8", + "@tinyhttp/send": "2.2.4", + "@tinyhttp/vary": "^0.1.3", + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/router": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@tinyhttp/router/-/router-2.2.5.tgz", + "integrity": "sha512-HI9Mpo9+IVpCzx/36okjJvtvifBSh3Ufhl9n1vylAbNLEykceJiMBkr06+W0qqRlo8TiZeUtg2XinEJu+GFcRA==", + "license": "MIT", + "dependencies": { + "regexparam": "^2.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tinyhttp/send": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@tinyhttp/send/-/send-2.2.4.tgz", + "integrity": "sha512-DgEY185oQUd2LTKHM+he/m3sjYWB7yL0WLNrasoSEHaCfzstm0+viltqqOvEV7UtkSB3oRRo+OWiEtYKmd6h5w==", + "license": "MIT", + "dependencies": { + "@tinyhttp/content-type": "^0.1.4", + "@tinyhttp/etag": "2.1.2", + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/type-is": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@tinyhttp/type-is/-/type-is-2.2.5.tgz", + "integrity": "sha512-BCPEB+NV8v/9lzEE9GbfRPAKVsyayp84m6SSWn70j8yFkPBXeuVeq004pwVrjW1CRdmAZz9ZSH147pqqzAdr5g==", + "license": "MIT", + "dependencies": { + "@tinyhttp/content-type": "^0.1.4", + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/url": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/url/-/url-2.1.1.tgz", + "integrity": "sha512-POJeq2GQ5jI7Zrdmj22JqOijB5/GeX+LEX7DUdml1hUnGbJOTWDx7zf2b5cCERj7RoXL67zTgyzVblBJC+NJWg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/vary": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@tinyhttp/vary/-/vary-0.1.3.tgz", + "integrity": "sha512-SoL83sQXAGiHN1jm2VwLUWQSQeDAAl1ywOm6T0b0Cg1CZhVsjoiZadmjhxF6FHCCY7OHHVaLnTgSMxTPIDLxMg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/dot-prop": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eta": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.5.1.tgz", + "integrity": "sha512-EaNCGm+8XEIU7YNcc+THptWAO5NfKBHHARxt+wxZljj9bTr/+arRoOm9/MpGt4n6xn9fLnPFRSoLD0WFYGFUxQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/header-range-parser": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/header-range-parser/-/header-range-parser-1.1.5.tgz", + "integrity": "sha512-n5JOx67HBL0MGqtu6NFoEYWb+xDYAOgBI5dBkyMDff1xHbhGnjCMglj1aiMNPHps6HwXO+2i5jbPU/zJSk7etQ==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/http-status-emojis": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-status-emojis/-/http-status-emojis-2.2.0.tgz", + "integrity": "sha512-ompKtgwpx8ff0hsbpIB7oE4ax1LXoHmftsHHStMELX56ivG3GhofTX8ZHWlUaFKfGjcGjw6G3rPk7dJRXMmbbg==", + "license": "MIT" + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/inflection": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", + "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lowdb": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", + "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", + "license": "MIT", + "dependencies": { + "steno": "^4.0.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/milliparsec": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/milliparsec/-/milliparsec-5.1.1.tgz", + "integrity": "sha512-jkEDaSWZp4/Q3vprqdqukBqUEyNNqC1pwTjZ5cp9YkaR1wv5fvTCd8VFsecbw7i8DNBGjzhJ83MDoPZlcTaPQg==", + "license": "MIT", + "engines": { + "node": ">=18.13 || >=19.20 || >=20" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/oxfmt": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.24.0.tgz", + "integrity": "sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.0.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/darwin-arm64": "0.24.0", + "@oxfmt/darwin-x64": "0.24.0", + "@oxfmt/linux-arm64-gnu": "0.24.0", + "@oxfmt/linux-arm64-musl": "0.24.0", + "@oxfmt/linux-x64-gnu": "0.24.0", + "@oxfmt/linux-x64-musl": "0.24.0", + "@oxfmt/win32-arm64": "0.24.0", + "@oxfmt/win32-x64": "0.24.0" + } + }, + "node_modules/oxlint": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.59.0.tgz", + "integrity": "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.59.0", + "@oxlint/binding-android-arm64": "1.59.0", + "@oxlint/binding-darwin-arm64": "1.59.0", + "@oxlint/binding-darwin-x64": "1.59.0", + "@oxlint/binding-freebsd-x64": "1.59.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", + "@oxlint/binding-linux-arm-musleabihf": "1.59.0", + "@oxlint/binding-linux-arm64-gnu": "1.59.0", + "@oxlint/binding-linux-arm64-musl": "1.59.0", + "@oxlint/binding-linux-ppc64-gnu": "1.59.0", + "@oxlint/binding-linux-riscv64-gnu": "1.59.0", + "@oxlint/binding-linux-riscv64-musl": "1.59.0", + "@oxlint/binding-linux-s390x-gnu": "1.59.0", + "@oxlint/binding-linux-x64-gnu": "1.59.0", + "@oxlint/binding-linux-x64-musl": "1.59.0", + "@oxlint/binding-openharmony-arm64": "1.59.0", + "@oxlint/binding-win32-arm64-msvc": "1.59.0", + "@oxlint/binding-win32-ia32-msvc": "1.59.0", + "@oxlint/binding-win32-x64-msvc": "1.59.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.18.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regexparam": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", + "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sort-on": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/sort-on/-/sort-on-7.0.0.tgz", + "integrity": "sha512-e+4RRxt7jsWdGPp4H5PKOER/ELYlemNB1plvW686Qi3j4WVaCjCpro2zaTD7Cn0VtBImq/hg3x1JfovMNXXfJQ==", + "license": "MIT", + "dependencies": { + "dot-prop": "^10.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/steno": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", + "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinypool": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.0.0.tgz", + "integrity": "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json index a1ac7be2b..d671775a1 100644 --- a/package.json +++ b/package.json @@ -37,13 +37,14 @@ "build": "rm -rf lib && tsc", "prepublishOnly": "rm -rf lib && tsc", "typecheck": "tsc --noEmit", - "test": "node --experimental-strip-types --test src/*.test.ts", + "test": "node --experimental-strip-types --test \"src/**/*.test.ts\"", "lint": "oxlint src", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "prepare": "husky" }, "dependencies": { + "@tinyhttp/url": "^2.1.1", "@tinyhttp/app": "^3.0.1", "@tinyhttp/cors": "^2.0.1", "@tinyhttp/logger": "^2.1.0", diff --git a/src/app.test.ts b/src/app.test.ts index bac286d98..a4a69aae7 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -180,3 +180,49 @@ await test('createApp', async (t) => { assert.deepEqual(data, { error: 'Body must be a JSON object' }) }) }) + +await test('createApp query route rewrites run before API routing', async (t) => { + const rewritePort = await getPort() + const rewriteDb = new Low(new Memory(), {}) + rewriteDb.data = { + posts: [ + { id: 1, title: 'foo', group: 'a' }, + { id: 2, title: 'bar', group: 'b' }, + ], + } + + const rewriteApp = createApp(rewriteDb, { + routes: { + '/blog?customNamedId=:id': '/posts?id=:id', + }, + }) + + await new Promise((resolve, reject) => { + try { + const server = rewriteApp.listen(rewritePort, () => resolve()) + t.after(() => server.close()) + } catch (err) { + reject(err) + } + }) + + await t.test('rewrites /blog?customNamedId=1 to /posts?id=1', async () => { + const response = await fetch(`http://localhost:${rewritePort}/blog?customNamedId=1`) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), [{ id: 1, title: 'foo', group: 'a' }]) + }) + + await t.test('preserves unrelated query params after rewrite', async () => { + const response = await fetch( + `http://localhost:${rewritePort}/blog?customNamedId=1&group=a`, + ) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), [{ id: 1, title: 'foo', group: 'a' }]) + }) + + await t.test('does not break existing routes', async () => { + const response = await fetch(`http://localhost:${rewritePort}/posts?title=bar`) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), [{ id: 2, title: 'bar', group: 'b' }]) + }) +}) diff --git a/src/app.ts b/src/app.ts index 40a70c046..414e1a62d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { dirname, isAbsolute, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +10,12 @@ import { json } from 'milliparsec' import sirv from 'sirv' import { parseWhere } from './parse-where.ts' +import { + compileRewriteRules, + createQueryRewriteMiddleware, + loadRoutesFile, + type RoutesConfig, +} from './rewrite/rewrite-middleware.ts' import type { Data } from './service.ts' import { isItem, Service } from './service.ts' @@ -18,6 +25,18 @@ const isProduction = process.env['NODE_ENV'] === 'production' export type AppOptions = { logger?: boolean static?: string[] + /** Query-based rewrites; overrides same keys from `routes.json` when both exist. */ + routes?: RoutesConfig + /** Defaults to `/routes.json` when that file exists. */ + routesFile?: string + /** Log query rewrites to stdout, or set env `JSON_SERVER_REWRITE_DEBUG=1`. */ + rewriteDebug?: boolean +} + +function resolveRoutesConfig(options: AppOptions): RoutesConfig { + const path = options.routesFile ?? join(process.cwd(), 'routes.json') + const fromFile = existsSync(path) ? (loadRoutesFile(path) ?? {}) : {} + return { ...fromFile, ...options.routes } } const eta = new Eta({ @@ -118,6 +137,15 @@ export function createApp(db: Low, options: AppOptions = {}) { // Body parser app.use(json()) + const rewriteRules = compileRewriteRules(resolveRoutesConfig(options)) + if (rewriteRules.length > 0) { + app.use( + createQueryRewriteMiddleware(rewriteRules, { + debug: options.rewriteDebug, + }), + ) + } + app.get('/', (_req, res) => res.send(eta.render('index.html', { data: db.data }))) app.get('/:name', (req, res, next) => { diff --git a/src/rewrite/rewrite-middleware.test.ts b/src/rewrite/rewrite-middleware.test.ts new file mode 100644 index 000000000..678b87d7e --- /dev/null +++ b/src/rewrite/rewrite-middleware.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { getPathname, getQueryParams } from '@tinyhttp/url' + +import { compileRewriteRules, createQueryRewriteMiddleware } from './rewrite-middleware.ts' + +function runRewrite( + url: string, + query: Record, + routes: Record, +) { + const req: { + method: string + url: string + query: Record + } = { + method: 'GET', + url, + query: query as Record, + } + const mw = createQueryRewriteMiddleware(compileRewriteRules(routes)) + mw(req, {}, () => {}) + return { req } +} + +describe('createQueryRewriteMiddleware', () => { + it('rewrites /blog?customNamedId=1 to /posts?id=1', () => { + const { req } = runRewrite( + '/blog?customNamedId=1', + { customNamedId: '1' }, + { '/blog?customNamedId=:id': '/posts?id=:id' }, + ) + assert.strictEqual(req.url, '/posts?id=1') + assert.strictEqual(getPathname(req.url), '/posts') + assert.strictEqual(getQueryParams(req.url)['id'], '1') + }) + + it('leaves URL unchanged when no rule matches', () => { + const { req } = runRewrite('/posts', {}, { '/blog?customNamedId=:id': '/posts?id=:id' }) + assert.strictEqual(req.url, '/posts') + }) + + it('handles multiple captured query params', () => { + const { req } = runRewrite( + '/search?author=a&tag=js', + { author: 'a', tag: 'js' }, + { '/search?author=:x&tag=:y': '/posts?user=:x&filter=:y' }, + ) + assert.strictEqual(req.url, '/posts?user=a&filter=js') + }) + + it('does not match when a required pattern query param is missing', () => { + const { req } = runRewrite('/blog', {}, { '/blog?customNamedId=:id': '/posts?id=:id' }) + assert.strictEqual(req.url, '/blog') + }) +}) diff --git a/src/rewrite/rewrite-middleware.ts b/src/rewrite/rewrite-middleware.ts new file mode 100644 index 000000000..2173ea199 --- /dev/null +++ b/src/rewrite/rewrite-middleware.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { getPathname, getQueryParams } from '@tinyhttp/url' + +import { + buildRewrittenUrl, + matchRoutePattern, + parseRoutePattern, + type ParsedRoutePattern, +} from './route-pattern.ts' + +export type RoutesConfig = Record + +export type QueryRewriteMiddlewareOptions = { + /** When true, logs incoming URL, matched rule, and rewritten URL. */ + debug?: boolean +} + +function resolveRewriteDebug(options?: QueryRewriteMiddlewareOptions): boolean { + if (options?.debug !== undefined) return options.debug + return process.env['JSON_SERVER_REWRITE_DEBUG'] === '1' +} + +export type CompiledRewriteRule = { + sourcePattern: string + parsed: ParsedRoutePattern + destinationTemplate: string +} + +export function compileRewriteRules(routes: RoutesConfig): CompiledRewriteRule[] { + const rules: CompiledRewriteRule[] = [] + for (const [sourcePattern, destinationTemplate] of Object.entries(routes)) { + rules.push({ + sourcePattern, + parsed: parseRoutePattern(sourcePattern), + destinationTemplate, + }) + } + return rules +} + +function syncRequestUrl(req: { url: string; path?: string; query: Record }) { + req.url = req.url.startsWith('/') ? req.url : `/${req.url}` + req.path = getPathname(req.url) + req.query = getQueryParams(req.url) as Record +} + +/** + * Loads `routes.json` from cwd if present. Invalid JSON throws. + */ +export function loadRoutesFile(path = join(process.cwd(), 'routes.json')): RoutesConfig | undefined { + if (!existsSync(path)) return undefined + const raw = readFileSync(path, 'utf-8') + const data = JSON.parse(raw) as unknown + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('routes.json must be a JSON object') + } + const out: RoutesConfig = {} + for (const [k, v] of Object.entries(data as Record)) { + if (k.startsWith('$')) continue + if (typeof v !== 'string') { + throw new Error(`routes.json: destination for "${k}" must be a string`) + } + out[k] = v + } + return out +} + +/** + * Middleware: applies the first matching rewrite rule. Rules are compiled at startup (see {@link compileRewriteRules}). + * After rewriting, `req.url`, `req.path`, and `req.query` are synced for tinyhttp + json-server. + * + * Debug: set `options.debug`, or env `JSON_SERVER_REWRITE_DEBUG=1`. + */ +export function createQueryRewriteMiddleware( + rules: CompiledRewriteRule[], + options?: QueryRewriteMiddlewareOptions, +) { + const debug = resolveRewriteDebug(options) + + return ( + req: { method?: string; url: string; path?: string; query: Record }, + _res: unknown, + next: () => void, + ) => { + if (debug) console.log('[json-server rewrite] incoming', req.method ?? 'GET', req.url) + + const pathname = getPathname(req.url) + const query = req.query as Record + + for (const rule of rules) { + const result = matchRoutePattern(rule.parsed, pathname, query) + if (!result.match) continue + + const nextUrl = buildRewrittenUrl( + rule.destinationTemplate, + rule.parsed, + result.params, + query, + ) + + if (debug) { + console.log('[json-server rewrite] matched', rule.sourcePattern, '->', rule.destinationTemplate) + console.log('[json-server rewrite] rewritten', nextUrl) + } + + req.url = nextUrl + syncRequestUrl(req) + return next() + } + + next() + } +} diff --git a/src/rewrite/route-pattern.test.ts b/src/rewrite/route-pattern.test.ts new file mode 100644 index 000000000..c2fa90a41 --- /dev/null +++ b/src/rewrite/route-pattern.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { + applyDestinationPattern, + buildRewrittenUrl, + matchRoutePattern, + parseRoutePattern, +} from './route-pattern.ts' + +describe('parseRoutePattern', () => { + it('parses path and single capture query', () => { + assert.deepStrictEqual(parseRoutePattern('/blog?customNamedId=:id'), { + path: '/blog', + query: { customNamedId: ':id' }, + }) + }) + + it('parses multiple query params', () => { + assert.deepStrictEqual(parseRoutePattern('/a?x=:id&y=fixed&z=:other'), { + path: '/a', + query: { x: ':id', y: 'fixed', z: ':other' }, + }) + }) +}) + +describe('matchRoutePattern', () => { + it('matches blog example and extracts id', () => { + const pattern = parseRoutePattern('/blog?customNamedId=:id') + const r = matchRoutePattern(pattern, '/blog', { customNamedId: '1' }) + assert.strictEqual(r.match, true) + if (r.match) assert.deepStrictEqual(r.params, { id: '1' }) + }) + + it('rejects wrong path', () => { + const pattern = parseRoutePattern('/blog?customNamedId=:id') + const r = matchRoutePattern(pattern, '/news', { customNamedId: '1' }) + assert.strictEqual(r.match, false) + }) + + it('matches literal query value', () => { + const pattern = parseRoutePattern('/x?mode=edit&id=:id') + const r = matchRoutePattern(pattern, '/x', { mode: 'edit', id: '99' }) + assert.strictEqual(r.match, true) + if (r.match) assert.deepStrictEqual(r.params, { id: '99' }) + }) + + it('allows extra query keys on request', () => { + const pattern = parseRoutePattern('/blog?customNamedId=:id') + const r = matchRoutePattern(pattern, '/blog', { + customNamedId: '1', + sort: 'title', + }) + assert.strictEqual(r.match, true) + }) +}) + +describe('applyDestinationPattern', () => { + it('builds /posts?id=1 from template and params', () => { + assert.strictEqual(applyDestinationPattern('/posts?id=:id', { id: '1' }), '/posts?id=1') + }) + + it('formats several query params with URLSearchParams', () => { + assert.strictEqual( + applyDestinationPattern('/x?a=:x&b=:y', { x: '1', y: 'two' }), + '/x?a=1&b=two', + ) + }) + + it('encodes special characters in query values', () => { + assert.strictEqual( + applyDestinationPattern('/q?n=:name', { name: 'a&b' }), + '/q?n=a%26b', + ) + }) +}) + +describe('buildRewrittenUrl', () => { + it('rewrites and preserves unrelated query params', () => { + const pattern = parseRoutePattern('/blog?customNamedId=:id') + const r = matchRoutePattern(pattern, '/blog', { + customNamedId: '1', + sort: 'title', + }) + assert.strictEqual(r.match, true) + if (!r.match) return + const url = buildRewrittenUrl('/posts?id=:id', pattern, r.params, { + customNamedId: '1', + sort: 'title', + }) + assert.strictEqual(url, '/posts?id=1&sort=title') + }) +}) diff --git a/src/rewrite/route-pattern.ts b/src/rewrite/route-pattern.ts new file mode 100644 index 000000000..942207661 --- /dev/null +++ b/src/rewrite/route-pattern.ts @@ -0,0 +1,210 @@ +/** + * Query-based route patterns like `/blog?customNamedId=:id`. + * A query value that is exactly `:paramName` captures that segment into `params`. + * Any other value is matched literally against the request. + */ + +const PARAM_VALUE = /^:([a-zA-Z_][a-zA-Z0-9_]*)$/ + +export type ParsedRoutePattern = { + path: string + /** Keys and pattern values; capture values look like `:id`, literals are plain strings */ + query: Record +} + +function leadSlash(p: string): string { + if (p === '' || p === '/') return '/' + return p.startsWith('/') ? p : `/${p}` +} + +/** + * Parse a route pattern such as `/blog?customNamedId=:id` or `/x?a=:id&b=fixed`. + * Returns path plus query map (values are either `:param` or literal strings). + */ +export function parseRoutePattern(pattern: string): ParsedRoutePattern { + const trimmed = pattern.trim() + const q = trimmed.indexOf('?') + const pathPart = (q === -1 ? trimmed : trimmed.slice(0, q)).trim() + const queryPart = q === -1 ? '' : trimmed.slice(q + 1).trim() + + const query: Record = {} + if (queryPart) { + for (const segment of queryPart.split('&')) { + if (segment === '') continue + const eq = segment.indexOf('=') + if (eq === -1) { + throw new Error(`Invalid query segment in route pattern (missing '='): ${segment}`) + } + const key = decodeURIComponent(segment.slice(0, eq).trim()) + const rawVal = segment.slice(eq + 1) + const value = decodeURIComponent(rawVal.trim()) + query[key] = value + } + } + + return { path: leadSlash(pathPart), query } +} + +function firstQueryValue(v: string | string[] | undefined): string | undefined { + if (v === undefined) return undefined + return Array.isArray(v) ? v.at(0) : v +} + +/** + * True if the pattern value denotes a capture (e.g. `:id`). + */ +export function isQueryParamToken(value: string): boolean { + return PARAM_VALUE.test(value) +} + +export function captureNameFromQueryValue(value: string): string | undefined { + const m = PARAM_VALUE.exec(value) + return m?.[1] +} + +export type MatchResult = + | { match: true; params: Record } + | { match: false; params: Record } + +/** + * Match a parsed pattern against a request path and query object. + * Extra query keys on the request do not prevent a match. + */ +export function matchRoutePattern( + pattern: ParsedRoutePattern, + requestPath: string, + requestQuery: Record, +): MatchResult { + const path = leadSlash(requestPath) + if (path !== pattern.path) { + return { match: false, params: {} } + } + + const params: Record = {} + + for (const [key, spec] of Object.entries(pattern.query)) { + const raw = firstQueryValue(requestQuery[key]) + if (raw === undefined) { + return { match: false, params: {} } + } + + const cap = captureNameFromQueryValue(spec) + if (cap !== undefined) { + params[cap] = raw + } else if (raw !== spec) { + return { match: false, params: {} } + } + } + + return { match: true, params } +} + +const DEST_PARAM = /:([a-zA-Z_][a-zA-Z0-9_]*)/g + +function replacePathParams(segment: string, params: Record): string { + return segment.replace(DEST_PARAM, (_, name: string) => { + const v = params[name] + if (v === undefined) { + throw new Error(`Missing param "${name}" for destination path`) + } + return encodeURIComponent(v) + }) +} + +/** Replace `:param` in a query value; values stay decoded — URLSearchParams encodes on serialize. */ +function replaceQueryValueParams(template: string, params: Record): string { + return template.replace(DEST_PARAM, (_, name: string) => { + const v = params[name] + if (v === undefined) { + throw new Error(`Missing param "${name}" for destination query`) + } + return v + }) +} + +/** + * Build path + query from a destination template and captured params. + * Query pairs use URLSearchParams so encoding and multi-value keys are handled consistently. + * + * @example applyDestinationPattern("/posts?id=:id", { id: "1" }) -> "/posts?id=1" + */ +export function applyDestinationPattern( + destinationPattern: string, + params: Record, +): string { + const trimmed = destinationPattern.trim() + const qi = trimmed.indexOf('?') + const pathTemplate = (qi === -1 ? trimmed : trimmed.slice(0, qi)).trim() + const queryTemplate = qi === -1 ? '' : trimmed.slice(qi + 1) + + const path = replacePathParams(pathTemplate, params) + + if (!queryTemplate.trim()) { + return leadSlash(path) + } + + const out = new URLSearchParams() + for (const segment of queryTemplate.split('&')) { + if (segment === '') continue + const eq = segment.indexOf('=') + if (eq === -1) { + throw new Error(`Invalid destination query segment (missing '='): ${segment}`) + } + const key = decodeURIComponent(segment.slice(0, eq).trim()) + const valueTemplate = decodeURIComponent(segment.slice(eq + 1).trim()) + out.append(key, replaceQueryValueParams(valueTemplate, params)) + } + + const search = out.toString() + return search ? `${leadSlash(path)}?${search}` : leadSlash(path) +} + +/** Same as {@link applyDestinationPattern} (kept for callers that used the old name). */ +export function substituteDestinationParams( + destinationTemplate: string, + params: Record, +): string { + return applyDestinationPattern(destinationTemplate, params) +} + +/** + * Merge query params from the original request that were only used for pattern matching + * into the rewritten URL, without clobbering keys already present in the destination. + */ +export function mergePreservedQueryParams( + rewrittenPathAndQuery: string, + patternQueryKeys: string[], + originalQuery: Record, +): string { + const qIndex = rewrittenPathAndQuery.indexOf('?') + const pathOnly = qIndex === -1 ? rewrittenPathAndQuery : rewrittenPathAndQuery.slice(0, qIndex) + const destSearch = qIndex === -1 ? '' : rewrittenPathAndQuery.slice(qIndex + 1) + + const out = new URLSearchParams(destSearch) + const consumed = new Set(patternQueryKeys) + + for (const [key, val] of Object.entries(originalQuery)) { + if (consumed.has(key)) continue + if (out.has(key)) continue + const s = firstQueryValue(val) + if (s === undefined) continue + out.append(key, s) + } + + const search = out.toString() + return search ? `${pathOnly}?${search}` : pathOnly +} + +/** + * Full rewrite: substitute captures, then append non-consumed original query keys. + */ +export function buildRewrittenUrl( + destinationTemplate: string, + pattern: ParsedRoutePattern, + params: Record, + originalQuery: Record, +): string { + const withSubs = applyDestinationPattern(destinationTemplate, params) + const patternKeys = Object.keys(pattern.query) + return mergePreservedQueryParams(withSubs, patternKeys, originalQuery) +}