├── .editorconfig ├── .github └── workflows │ ├── autofix.yml │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.config.mjs ├── deno.json ├── docs ├── .config │ └── docs.yaml ├── .docs │ ├── .gitignore │ └── public │ │ └── icon.svg ├── .npmrc ├── 1.guide │ ├── 1.index.md │ ├── 2.handler.md │ ├── 3.server.md │ ├── 4.middleware.md │ ├── 5.options.md │ ├── 6.bundler.md │ └── 7.node.md ├── package.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml ├── eslint.config.mjs ├── package.json ├── playground ├── app.mjs ├── package.json └── sw │ ├── index.html │ └── sw.mjs ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── src ├── _middleware.ts ├── _plugins.ts ├── _url.ts ├── _utils.ts ├── adapters │ ├── _node │ │ ├── _common.ts │ │ ├── headers.ts │ │ ├── index.ts │ │ ├── request.ts │ │ ├── response.ts │ │ ├── send.ts │ │ └── url.ts │ ├── bun.ts │ ├── cloudflare.ts │ ├── deno.ts │ ├── generic.ts │ ├── node.ts │ └── service-worker.ts └── types.ts ├── test ├── _fixture.ts ├── _tests.ts ├── _utils.ts ├── bench-node │ ├── _run.mjs │ ├── hono-fast.mjs │ ├── hono.mjs │ ├── node.mjs │ ├── remix.mjs │ ├── srvx-fast.mjs │ ├── srvx.mjs │ ├── whatwg-node-fast.mjs │ └── whatwg-node.mjs ├── bun.test.ts ├── deno.test.ts ├── node.test.ts ├── url.bench.ts ├── url.test.ts └── wpt │ ├── README.md │ ├── url_setters_tests.json │ └── url_tests.json ├── tsconfig.json └── vitest.config.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.{js,ts}] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci # needed to securely identify the workflow 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["main"] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | autofix: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: npm i -fg corepack && corepack enable 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | cache: "pnpm" 21 | - run: pnpm install 22 | - run: pnpm lint:fix 23 | - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef 24 | with: 25 | commit-message: "chore: apply automated updates" 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: npm i -fg corepack && corepack enable 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | cache: "pnpm" 21 | - uses: oven-sh/setup-bun@v2 22 | with: 23 | bun-version: latest 24 | - uses: denoland/setup-deno@v2 25 | with: 26 | deno-version: v2.x 27 | - run: pnpm install 28 | - run: pnpm lint 29 | - run: pnpm test:types 30 | - run: pnpm build 31 | - run: pnpm vitest --coverage 32 | - uses: codecov/codecov-action@v5 33 | with: 34 | token: ${{ secrets.CODECOV_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .vscode 5 | .DS_Store 6 | .eslintcache 7 | *.log* 8 | *.env* 9 | .wrangler 10 | *.crt 11 | *.key 12 | *.pem 13 | .tmp 14 | tsconfig.tsbuildinfo 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github 2 | pnpm-lock.yaml 3 | .docs 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.7.5 4 | 5 | [compare changes](https://github.com/h3js/srvx/compare/v0.7.4...v0.7.5) 6 | 7 | ### 💅 Refactors 8 | 9 | - Remove unnecessary `__PURE__` ([699a100](https://github.com/h3js/srvx/commit/699a100)) 10 | 11 | ### ❤️ Contributors 12 | 13 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 14 | 15 | ## v0.7.4 16 | 17 | [compare changes](https://github.com/h3js/srvx/compare/v0.7.3...v0.7.4) 18 | 19 | ### 🚀 Enhancements 20 | 21 | - Universal `request.waitUntil` ([#81](https://github.com/h3js/srvx/pull/81)) 22 | 23 | ### 📦 Build 24 | 25 | - Remove small side-effects from `service-worker` ([2ed12a9](https://github.com/h3js/srvx/commit/2ed12a9)) 26 | 27 | ### 🏡 Chore 28 | 29 | - Update undocs ([b872621](https://github.com/h3js/srvx/commit/b872621)) 30 | 31 | ### ❤️ Contributors 32 | 33 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 34 | 35 | ## v0.7.3 36 | 37 | [compare changes](https://github.com/h3js/srvx/compare/v0.7.2...v0.7.3) 38 | 39 | ### 🩹 Fixes 40 | 41 | - **node:** Only use `req.headers` in `FastResponse` when initialized ([#79](https://github.com/h3js/srvx/pull/79)) 42 | 43 | ### 💅 Refactors 44 | 45 | - Include invalid header name in error message ([d0bf7dc](https://github.com/h3js/srvx/commit/d0bf7dc)) 46 | 47 | ### 🏡 Chore 48 | 49 | - Add `--watch` to playground scripts ([222580e](https://github.com/h3js/srvx/commit/222580e)) 50 | - Add `erasableSyntaxOnly` option to compiler options ([#77](https://github.com/h3js/srvx/pull/77)) 51 | - Update undocs ([5d263b1](https://github.com/h3js/srvx/commit/5d263b1)) 52 | - Build docs native dep ([7c8b337](https://github.com/h3js/srvx/commit/7c8b337)) 53 | 54 | ### ❤️ Contributors 55 | 56 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 57 | - Dirk De Visser 58 | - Wind 59 | 60 | ## v0.7.2 61 | 62 | [compare changes](https://github.com/h3js/srvx/compare/v0.7.1...v0.7.2) 63 | 64 | ### 🚀 Enhancements 65 | 66 | - **node:** Call request abort signal ([#76](https://github.com/h3js/srvx/pull/76)) 67 | 68 | ### 🩹 Fixes 69 | 70 | - Match `runtime.name` ([3cfbbcb](https://github.com/h3js/srvx/commit/3cfbbcb)) 71 | 72 | ### 📖 Documentation 73 | 74 | - **guide/plugins:** Make middleware functions asynchronous ([#70](https://github.com/h3js/srvx/pull/70)) 75 | - **guide/bundler:** Improve bundle usage explanation ([#73](https://github.com/h3js/srvx/pull/73)) 76 | 77 | ### 📦 Build 78 | 79 | - Update obuild ([85875b9](https://github.com/h3js/srvx/commit/85875b9)) 80 | 81 | ### 🏡 Chore 82 | 83 | - Add bench for `@whatwg-node/server` ([e14b292](https://github.com/h3js/srvx/commit/e14b292)) 84 | - Update deps ([e6896aa](https://github.com/h3js/srvx/commit/e6896aa)) 85 | - Update play:node command ([9da1d3e](https://github.com/h3js/srvx/commit/9da1d3e)) 86 | 87 | ### ❤️ Contributors 88 | 89 | - Colin Ozanne ([@finxol](https://github.com/finxol)) 90 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 91 | - Markthree ([@markthree](https://github.com/markthree)) 92 | 93 | ## v0.7.1 94 | 95 | [compare changes](https://github.com/h3js/srvx/compare/v0.7.0...v0.7.1) 96 | 97 | ### 📦 Build 98 | 99 | - Fix `/types` subpath ([b40c165](https://github.com/h3js/srvx/commit/b40c165)) 100 | 101 | ### ❤️ Contributors 102 | 103 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 104 | 105 | ## v0.7.0 106 | 107 | [compare changes](https://github.com/h3js/srvx/compare/v0.6.0...v0.7.0) 108 | 109 | ### 🚀 Enhancements 110 | 111 | - **node:** Support http2 ([#58](https://github.com/h3js/srvx/pull/58)) 112 | - ⚠️ Top level `middleware` and simplified plugins ([#67](https://github.com/h3js/srvx/pull/67)) 113 | - Clone options and init `middleware: []` ([16798c1](https://github.com/h3js/srvx/commit/16798c1)) 114 | 115 | ### 🩹 Fixes 116 | 117 | - Add missing types for `node.upgrade` ([7f66ac3](https://github.com/h3js/srvx/commit/7f66ac3)) 118 | - **url:** Always invalidate cached values ([059914d](https://github.com/h3js/srvx/commit/059914d)) 119 | 120 | ### 💅 Refactors 121 | 122 | - ⚠️ Remove experimental upgrade ([#68](https://github.com/h3js/srvx/pull/68)) 123 | - ⚠️ Use `process.getBuiltinModule` for node ([#69](https://github.com/h3js/srvx/pull/69)) 124 | - Move node compat to `adapters/_node` ([e594009](https://github.com/h3js/srvx/commit/e594009)) 125 | 126 | ### 📦 Build 127 | 128 | - Migrate to obuild ([ff883cf](https://github.com/h3js/srvx/commit/ff883cf)) 129 | 130 | ### 🏡 Chore 131 | 132 | - Fix lint issues ([f16da88](https://github.com/h3js/srvx/commit/f16da88)) 133 | - Update code style ([167c22c](https://github.com/h3js/srvx/commit/167c22c)) 134 | - Update middleware example ([e72ad59](https://github.com/h3js/srvx/commit/e72ad59)) 135 | - Remove unused import ([2f7a3c5](https://github.com/h3js/srvx/commit/2f7a3c5)) 136 | - Update deps ([b86b092](https://github.com/h3js/srvx/commit/b86b092)) 137 | - Update deno tests ([c5003af](https://github.com/h3js/srvx/commit/c5003af)) 138 | 139 | ### ✅ Tests 140 | 141 | - Add wpt setter tests for `FastURL` ([#66](https://github.com/h3js/srvx/pull/66)) 142 | - Add more coverage for `FastURL` ([7e8ebd2](https://github.com/h3js/srvx/commit/7e8ebd2)) 143 | 144 | #### ⚠️ Breaking Changes 145 | 146 | - ⚠️ Top level `middleware` and simplified plugins ([#67](https://github.com/h3js/srvx/pull/67)) 147 | - ⚠️ Remove experimental upgrade ([#68](https://github.com/h3js/srvx/pull/68)) 148 | - ⚠️ Use `process.getBuiltinModule` for node ([#69](https://github.com/h3js/srvx/pull/69)) 149 | 150 | ### ❤️ Contributors 151 | 152 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 153 | - Oskar Lebuda 154 | 155 | ## v0.6.0 156 | 157 | [compare changes](https://github.com/h3js/srvx/compare/v0.5.2...v0.6.0) 158 | 159 | ### 🚀 Enhancements 160 | 161 | - ⚠️ Fetch middleware via plugins ([#62](https://github.com/h3js/srvx/pull/62)) 162 | - Support `upgrade` hook (experimental) ([#63](https://github.com/h3js/srvx/pull/63)) 163 | 164 | ### 🩹 Fixes 165 | 166 | - **node:** Handle additional response headers ([#64](https://github.com/h3js/srvx/pull/64)) 167 | 168 | ### 💅 Refactors 169 | 170 | - ⚠️ Rename `onError` hook to `error` for consistency ([471fe57](https://github.com/h3js/srvx/commit/471fe57)) 171 | - ⚠️ Rename to `FastURL` and `FastResponse` exports ([0fe9ed4](https://github.com/h3js/srvx/commit/0fe9ed4)) 172 | 173 | ### 🏡 Chore 174 | 175 | - Update bench script ([c0826c1](https://github.com/h3js/srvx/commit/c0826c1)) 176 | 177 | #### ⚠️ Breaking Changes 178 | 179 | - ⚠️ Fetch middleware via plugins ([#62](https://github.com/h3js/srvx/pull/62)) 180 | - ⚠️ Rename `onError` hook to `error` for consistency ([471fe57](https://github.com/h3js/srvx/commit/471fe57)) 181 | - ⚠️ Rename to `FastURL` and `FastResponse` exports ([0fe9ed4](https://github.com/h3js/srvx/commit/0fe9ed4)) 182 | 183 | ### ❤️ Contributors 184 | 185 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 186 | 187 | ## v0.5.2 188 | 189 | [compare changes](https://github.com/h3js/srvx/compare/v0.5.1...v0.5.2) 190 | 191 | ### 🚀 Enhancements 192 | 193 | - Fast `URL` for node, deno and bun ([b5f5239](https://github.com/h3js/srvx/commit/b5f5239)) 194 | 195 | ### ❤️ Contributors 196 | 197 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 198 | 199 | ## v0.5.1 200 | 201 | [compare changes](https://github.com/h3js/srvx/compare/v0.5.0...v0.5.1) 202 | 203 | ### 🩹 Fixes 204 | 205 | - **service-worker:** Minor fixes ([63a42b5](https://github.com/h3js/srvx/commit/63a42b5)) 206 | 207 | ### 🏡 Chore 208 | 209 | - Apply automated updates ([248d0b5](https://github.com/h3js/srvx/commit/248d0b5)) 210 | - Update playground sw example to use cdn ([b333bd4](https://github.com/h3js/srvx/commit/b333bd4)) 211 | - Fix node compat internal types ([7862ab0](https://github.com/h3js/srvx/commit/7862ab0)) 212 | 213 | ### ❤️ Contributors 214 | 215 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 216 | 217 | ## v0.5.0 218 | 219 | [compare changes](https://github.com/h3js/srvx/compare/v0.4.0...v0.5.0) 220 | 221 | ### 🚀 Enhancements 222 | 223 | - Experimental service-worker adapter ([#53](https://github.com/h3js/srvx/pull/53)) 224 | - **service-worker:** Self-register support ([#55](https://github.com/h3js/srvx/pull/55)) 225 | - Generic adapter ([#56](https://github.com/h3js/srvx/pull/56)) 226 | - ⚠️ Print listening url by default ([#57](https://github.com/h3js/srvx/pull/57)) 227 | - Support `HOST` env for node, deno and bun ([2d94e28](https://github.com/h3js/srvx/commit/2d94e28)) 228 | - Add runtime agnostic error handler ([#48](https://github.com/h3js/srvx/pull/48)) 229 | 230 | ### 💅 Refactors 231 | 232 | - Improve types ([89bba05](https://github.com/h3js/srvx/commit/89bba05)) 233 | 234 | ### 🏡 Chore 235 | 236 | - Apply automated updates ([840e3a3](https://github.com/h3js/srvx/commit/840e3a3)) 237 | - Move to h3js org ([255cab1](https://github.com/h3js/srvx/commit/255cab1)) 238 | - Use pnpm for docs ([0c92f55](https://github.com/h3js/srvx/commit/0c92f55)) 239 | - Apply automated updates ([599c786](https://github.com/h3js/srvx/commit/599c786)) 240 | - Update deps ([3f18ddb](https://github.com/h3js/srvx/commit/3f18ddb)) 241 | - Rename `_utils` to `_uitils.node` ([71cbe57](https://github.com/h3js/srvx/commit/71cbe57)) 242 | 243 | #### ⚠️ Breaking Changes 244 | 245 | - ⚠️ Print listening url by default ([#57](https://github.com/h3js/srvx/pull/57)) 246 | 247 | ### ❤️ Contributors 248 | 249 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 250 | - Daniel Perez ([@danielpza](https://github.com/danielpza)) 251 | 252 | ## v0.4.0 253 | 254 | [compare changes](https://github.com/h3dev/srvx/compare/v0.3.0...v0.4.0) 255 | 256 | ### 💅 Refactors 257 | 258 | - ⚠️ Use `request.ip` and `request.runtime` ([#51](https://github.com/h3dev/srvx/pull/51)) 259 | 260 | ### 🏡 Chore 261 | 262 | - Apply automated updates ([59e28fa](https://github.com/h3dev/srvx/commit/59e28fa)) 263 | 264 | #### ⚠️ Breaking Changes 265 | 266 | - ⚠️ Use `request.ip` and `request.runtime` ([#51](https://github.com/h3dev/srvx/pull/51)) 267 | 268 | ### ❤️ Contributors 269 | 270 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 271 | 272 | ## v0.3.0 273 | 274 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.8...v0.3.0) 275 | 276 | ### 💅 Refactors 277 | 278 | - ⚠️ Move extended request context under `request.x.*` ([#50](https://github.com/h3dev/srvx/pull/50)) 279 | 280 | ### 📖 Documentation 281 | 282 | - Improve quick start ([#49](https://github.com/h3dev/srvx/pull/49)) 283 | 284 | ### 🏡 Chore 285 | 286 | - Update editorconfig to include typescript files ([#47](https://github.com/h3dev/srvx/pull/47)) 287 | 288 | #### ⚠️ Breaking Changes 289 | 290 | - ⚠️ Move extended request context under `request.x.*` ([#50](https://github.com/h3dev/srvx/pull/50)) 291 | 292 | ### ❤️ Contributors 293 | 294 | - Daniel Perez 295 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 296 | - Sébastien Chopin 297 | 298 | ## v0.2.8 299 | 300 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.7...v0.2.8) 301 | 302 | ### 🚀 Enhancements 303 | 304 | - **node:** Expose internal proxy classes ([0cdfa22](https://github.com/h3dev/srvx/commit/0cdfa22)) 305 | - **node:** Support Response static methods ([b9976a4](https://github.com/h3dev/srvx/commit/b9976a4)) 306 | 307 | ### 🩹 Fixes 308 | 309 | - **node:** Use `null` for unset headers ([#45](https://github.com/h3dev/srvx/pull/45)) 310 | 311 | ### 💅 Refactors 312 | 313 | - Remove unused symbols ([c726e40](https://github.com/h3dev/srvx/commit/c726e40)) 314 | - Accept node ctx for `NodeResponseHeaders` constructor ([8fe9241](https://github.com/h3dev/srvx/commit/8fe9241)) 315 | 316 | ### 📦 Build 317 | 318 | - Add types condition to top ([82e7fcc](https://github.com/h3dev/srvx/commit/82e7fcc)) 319 | 320 | ### 🏡 Chore 321 | 322 | - Update node tests ([#42](https://github.com/h3dev/srvx/pull/42)) 323 | 324 | ### ❤️ Contributors 325 | 326 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 327 | - Benny Yen ([@benny123tw](https://github.com/benny123tw)) 328 | 329 | ## v0.2.7 330 | 331 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.6...v0.2.7) 332 | 333 | ### 🚀 Enhancements 334 | 335 | - **node:** Expose `node` context to proxy interfaces ([5f20d9e](https://github.com/h3dev/srvx/commit/5f20d9e)) 336 | 337 | ### 🩹 Fixes 338 | 339 | - **node:** Make sure response constructor name is `Response` ([782ee13](https://github.com/h3dev/srvx/commit/782ee13)) 340 | - **node:** Make sure all proxies mimic global name and instance ([5883995](https://github.com/h3dev/srvx/commit/5883995)) 341 | - **node:** Use global Response for cloing ([effa940](https://github.com/h3dev/srvx/commit/effa940)) 342 | - **node:** Avoid conflict with undici prototype ([40cacf2](https://github.com/h3dev/srvx/commit/40cacf2)) 343 | 344 | ### 💅 Refactors 345 | 346 | - **types:** Fix typo for `BunFetchHandler` ([#41](https://github.com/h3dev/srvx/pull/41)) 347 | 348 | ### 📦 Build 349 | 350 | - Add `engines` field ([ea8a9c9](https://github.com/h3dev/srvx/commit/ea8a9c9)) 351 | 352 | ### ❤️ Contributors 353 | 354 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 355 | - Benny Yen ([@benny123tw](https://github.com/benny123tw)) 356 | 357 | ## v0.2.6 358 | 359 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.5...v0.2.6) 360 | 361 | ### 🚀 Enhancements 362 | 363 | - Support `tls` and `protocol` ([#38](https://github.com/h3dev/srvx/pull/38)) 364 | 365 | ### 🔥 Performance 366 | 367 | - **adapters/node:** Check `req._hasBody` once ([978a27d](https://github.com/h3dev/srvx/commit/978a27d)) 368 | 369 | ### 🩹 Fixes 370 | 371 | - **node:** Flatten headers to handle node slow path ([#40](https://github.com/h3dev/srvx/pull/40)) 372 | 373 | ### 🏡 Chore 374 | 375 | - Update readme ([#39](https://github.com/h3dev/srvx/pull/39)) 376 | - Update deps ([2b1f9f7](https://github.com/h3dev/srvx/commit/2b1f9f7)) 377 | 378 | ### ❤️ Contributors 379 | 380 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 381 | - Oskar Lebuda 382 | - Markthree ([@markthree](https://github.com/markthree)) 383 | - Alexander Lichter ([@TheAlexLichter](https://github.com/TheAlexLichter)) 384 | 385 | ## v0.2.5 386 | 387 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.3...v0.2.5) 388 | 389 | ### 🩹 Fixes 390 | 391 | - Fix `Response` type export ([e8d25e9](https://github.com/h3dev/srvx/commit/e8d25e9)) 392 | - **node:** Set `Response` prototype for `NodeFastResponse` ([2e6a8a0](https://github.com/h3dev/srvx/commit/2e6a8a0)) 393 | 394 | ### 🏡 Chore 395 | 396 | - **release:** V0.2.4 ([d001e87](https://github.com/h3dev/srvx/commit/d001e87)) 397 | 398 | ### ❤️ Contributors 399 | 400 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 401 | 402 | ## v0.2.4 403 | 404 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.3...v0.2.4) 405 | 406 | ### 🩹 Fixes 407 | 408 | - Fix `Response` type export ([e8d25e9](https://github.com/h3dev/srvx/commit/e8d25e9)) 409 | 410 | ### ❤️ Contributors 411 | 412 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 413 | 414 | ## v0.2.3 415 | 416 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.2...v0.2.3) 417 | 418 | ### 🩹 Fixes 419 | 420 | - **node:** Use `headers.entries` when full Headers is set as init ([7f8cac8](https://github.com/h3dev/srvx/commit/7f8cac8)) 421 | - **node:** Make `req instanceof Request` working ([24b3f83](https://github.com/h3dev/srvx/commit/24b3f83)) 422 | 423 | ### 📦 Build 424 | 425 | - Fix types export ([#36](https://github.com/h3dev/srvx/pull/36)) 426 | - Add types export for `.` ([#37](https://github.com/h3dev/srvx/pull/37)) 427 | 428 | ### 🏡 Chore 429 | 430 | - **release:** V0.2.2 ([f015aa3](https://github.com/h3dev/srvx/commit/f015aa3)) 431 | - Lint ([f043d58](https://github.com/h3dev/srvx/commit/f043d58)) 432 | 433 | ### ❤️ Contributors 434 | 435 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 436 | - Oskar Lebuda ([@OskarLebuda](https://github.com/OskarLebuda)) 437 | 438 | ## v0.2.2 439 | 440 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.1...v0.2.2) 441 | 442 | ### 🚀 Enhancements 443 | 444 | - **node:** Support node readable stream ([bc72436](https://github.com/h3dev/srvx/commit/bc72436)) 445 | 446 | ### 🩹 Fixes 447 | 448 | - **node:** Don't send headers if already sent ([bbf6b86](https://github.com/h3dev/srvx/commit/bbf6b86)) 449 | - Add `Response` export type ([e63919b](https://github.com/h3dev/srvx/commit/e63919b)) 450 | - **node:** Use `headers.entries` when full Headers is set as init ([7f8cac8](https://github.com/h3dev/srvx/commit/7f8cac8)) 451 | 452 | ### ❤️ Contributors 453 | 454 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 455 | 456 | ## v0.2.1 457 | 458 | [compare changes](https://github.com/h3dev/srvx/compare/v0.2.0...v0.2.1) 459 | 460 | ### 🚀 Enhancements 461 | 462 | - **node:** Export `toNodeHandler` ([5df69b6](https://github.com/h3dev/srvx/commit/5df69b6)) 463 | - Export handler types ([54a01e4](https://github.com/h3dev/srvx/commit/54a01e4)) 464 | 465 | ### 🏡 Chore 466 | 467 | - Apply automated updates ([5a1caf0](https://github.com/h3dev/srvx/commit/5a1caf0)) 468 | 469 | ### ❤️ Contributors 470 | 471 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 472 | 473 | ## v0.2.0 474 | 475 | [compare changes](https://github.com/h3dev/srvx/compare/v0.1.4...v0.2.0) 476 | 477 | ### 🚀 Enhancements 478 | 479 | - Initial cloudflare support ([cab127c](https://github.com/h3dev/srvx/commit/cab127c)) 480 | - Expose `server.node.handler` ([c84d604](https://github.com/h3dev/srvx/commit/c84d604)) 481 | - `manual` mode ([ef6f9ed](https://github.com/h3dev/srvx/commit/ef6f9ed)) 482 | 483 | ### 💅 Refactors 484 | 485 | - ⚠️ Update exports ([7153090](https://github.com/h3dev/srvx/commit/7153090)) 486 | - ⚠️ Overhaul internal implementation ([d444c74](https://github.com/h3dev/srvx/commit/d444c74)) 487 | 488 | ### 📦 Build 489 | 490 | - Remove extra files ([0f655b1](https://github.com/h3dev/srvx/commit/0f655b1)) 491 | 492 | ### 🏡 Chore 493 | 494 | - Update deps ([0b8494a](https://github.com/h3dev/srvx/commit/0b8494a)) 495 | - Update ci ([4b59db0](https://github.com/h3dev/srvx/commit/4b59db0)) 496 | - Apply automated updates ([06d094c](https://github.com/h3dev/srvx/commit/06d094c)) 497 | - Apply automated updates ([0dc2044](https://github.com/h3dev/srvx/commit/0dc2044)) 498 | 499 | ### ✅ Tests 500 | 501 | - Fix coverage report ([1f8ba79](https://github.com/h3dev/srvx/commit/1f8ba79)) 502 | 503 | ### 🤖 CI 504 | 505 | - Update to node 22 ([2e3044e](https://github.com/h3dev/srvx/commit/2e3044e)) 506 | 507 | #### ⚠️ Breaking Changes 508 | 509 | - ⚠️ Update exports ([7153090](https://github.com/h3dev/srvx/commit/7153090)) 510 | - ⚠️ Overhaul internal implementation ([d444c74](https://github.com/h3dev/srvx/commit/d444c74)) 511 | 512 | ### ❤️ Contributors 513 | 514 | - Pooya Parsa ([@pi0](https://github.com/pi0)) 515 | 516 | ## v0.1.4 517 | 518 | [compare changes](https://github.com/h3dev/srvx/compare/v0.1.3...v0.1.4) 519 | 520 | ### 🩹 Fixes 521 | 522 | - **node:** Access req headers with lowerCase ([#21](https://github.com/h3dev/srvx/pull/21)) 523 | 524 | ### 💅 Refactors 525 | 526 | - **node:** Improve body streaming ([#26](https://github.com/h3dev/srvx/pull/26)) 527 | 528 | ### 🏡 Chore 529 | 530 | - Update deps ([b74f68a](https://github.com/h3dev/srvx/commit/b74f68a)) 531 | - Lint ([011d381](https://github.com/h3dev/srvx/commit/011d381)) 532 | 533 | ### ❤️ Contributors 534 | 535 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 536 | - Alex ([@alexfriesen](http://github.com/alexfriesen)) 537 | 538 | ## v0.1.3 539 | 540 | [compare changes](https://github.com/h3dev/srvx/compare/v0.1.1...v0.1.3) 541 | 542 | ### 🚀 Enhancements 543 | 544 | - **node:** Add `NodeFastResponse.bytes()` ([#16](https://github.com/h3dev/srvx/pull/16)) 545 | - **node:** Add `NodeRequestProxy.bytes()` ([07863f6](https://github.com/h3dev/srvx/commit/07863f6)) 546 | 547 | ### 🩹 Fixes 548 | 549 | - **node:** Compute `hasBody` when accessing `req.body` ([a002185](https://github.com/h3dev/srvx/commit/a002185)) 550 | - **node:** Body utils should respect buffer view offset ([5e4ec69](https://github.com/h3dev/srvx/commit/5e4ec69)) 551 | 552 | ### 💅 Refactors 553 | 554 | - **node:** Expose `request._url` ([8eb8f5d](https://github.com/h3dev/srvx/commit/8eb8f5d)) 555 | 556 | ### 📖 Documentation 557 | 558 | - Minor tweaks ([#9](https://github.com/h3dev/srvx/pull/9)) 559 | 560 | ### 🏡 Chore 561 | 562 | - Apply automated updates ([7def381](https://github.com/h3dev/srvx/commit/7def381)) 563 | - Update dev dependencies ([5bc0dce](https://github.com/h3dev/srvx/commit/5bc0dce)) 564 | - **release:** V0.1.2 ([4bf7261](https://github.com/h3dev/srvx/commit/4bf7261)) 565 | 566 | ### ✅ Tests 567 | 568 | - Update ip regex ([6885842](https://github.com/h3dev/srvx/commit/6885842)) 569 | - Add additional tests for req body handling ([e00b4c9](https://github.com/h3dev/srvx/commit/e00b4c9)) 570 | 571 | ### ❤️ Contributors 572 | 573 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 574 | - Emil ([@bergold](http://github.com/bergold)) 575 | - Johann Schopplich ([@johannschopplich](http://github.com/johannschopplich)) 576 | 577 | ## v0.1.2 578 | 579 | [compare changes](https://github.com/h3dev/srvx/compare/v0.1.1...v0.1.2) 580 | 581 | ### 🚀 Enhancements 582 | 583 | - **node:** Add `NodeFastResponse.bytes()` ([#16](https://github.com/h3dev/srvx/pull/16)) 584 | - **node:** Add `NodeRequestProxy.bytes()` ([07863f6](https://github.com/h3dev/srvx/commit/07863f6)) 585 | 586 | ### 📖 Documentation 587 | 588 | - Minor tweaks ([#9](https://github.com/h3dev/srvx/pull/9)) 589 | 590 | ### 🏡 Chore 591 | 592 | - Apply automated updates ([7def381](https://github.com/h3dev/srvx/commit/7def381)) 593 | - Update dev dependencies ([5bc0dce](https://github.com/h3dev/srvx/commit/5bc0dce)) 594 | 595 | ### ❤️ Contributors 596 | 597 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 598 | - Emil ([@bergold](http://github.com/bergold)) 599 | - Johann Schopplich ([@johannschopplich](http://github.com/johannschopplich)) 600 | 601 | ## v0.1.1 602 | 603 | ### 🚀 Enhancements 604 | 605 | - Plugin support ([53874f0](https://github.com/h3dev/srvx/commit/53874f0)) 606 | 607 | ### 🩹 Fixes 608 | 609 | - **node:** Send body with `NodeFastResponse` ([ac689ef](https://github.com/h3dev/srvx/commit/ac689ef)) 610 | 611 | ### 💅 Refactors 612 | 613 | - Update deno types ([9598308](https://github.com/h3dev/srvx/commit/9598308)) 614 | 615 | ### 📖 Documentation 616 | 617 | - Remove extra `await` ([#2](https://github.com/h3dev/srvx/pull/2)) 618 | - Update diff explainer ([fbd81af](https://github.com/h3dev/srvx/commit/fbd81af)) 619 | 620 | ### 🏡 Chore 621 | 622 | - Small fixes ([592b97c](https://github.com/h3dev/srvx/commit/592b97c)) 623 | - Update undocs ([45613b7](https://github.com/h3dev/srvx/commit/45613b7)) 624 | - Update docs ([2b0d96b](https://github.com/h3dev/srvx/commit/2b0d96b)) 625 | - Update deps ([4eb6a8c](https://github.com/h3dev/srvx/commit/4eb6a8c)) 626 | - Update docs ([768075d](https://github.com/h3dev/srvx/commit/768075d)) 627 | - Fix types ([1bd4a38](https://github.com/h3dev/srvx/commit/1bd4a38)) 628 | - Apply automated updates ([98e7af7](https://github.com/h3dev/srvx/commit/98e7af7)) 629 | - Bump to 0.1.0 ([59fa1db](https://github.com/h3dev/srvx/commit/59fa1db)) 630 | - Update playground ([fa1a776](https://github.com/h3dev/srvx/commit/fa1a776)) 631 | - Update playground ([98eb941](https://github.com/h3dev/srvx/commit/98eb941)) 632 | - Fix readme ([00e3f7d](https://github.com/h3dev/srvx/commit/00e3f7d)) 633 | - **playground:** Set charset in content-type header ([#4](https://github.com/h3dev/srvx/pull/4)) 634 | - Fix typo ([#5](https://github.com/h3dev/srvx/pull/5)) 635 | 636 | ### 🤖 CI 637 | 638 | - Update deno to v2 ([2e2245b](https://github.com/h3dev/srvx/commit/2e2245b)) 639 | 640 | ### ❤️ Contributors 641 | 642 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 643 | - Andrei Luca ([@iamandrewluca](http://github.com/iamandrewluca)) 644 | - Florens Verschelde ([@fvsch](http://github.com/fvsch)) 645 | - Sébastien Chopin 646 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Pooya Parsa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💥 srvx 2 | 3 | 4 | 5 | [![npm version](https://img.shields.io/npm/v/srvx?color=yellow)](https://npmjs.com/package/srvx) 6 | [![npm downloads](https://img.shields.io/npm/dm/srvx?color=yellow)](https://npm.chart.dev/srvx) 7 | 8 | 9 | 10 | Universal Server API based on web platform standards. Works with [Deno](https://deno.com/), [Bun](https://bun.sh/) and [Node.js](https://nodejs.org/en). 11 | 12 | - ✅ Seamless runtime integration with identical usage ([handler](https://srvx.h3.dev/guide/handler) and [instance](https://srvx.h3.dev/guide/server)) 13 | - ✅ Zero overhead [Deno](https://deno.com/) and [Bun](https://bun.sh/) support 14 | - ✅ [Node.js compatibility](https://srvx.h3.dev/guide/node) with ~native perf and [fast response](https://srvx.h3.dev/guide/node#fast-response) support 15 | 16 | ## Quick start 17 | 18 | ```js 19 | import { serve } from "srvx"; 20 | 21 | const server = serve({ 22 | port: 3000, 23 | fetch(request) { 24 | return new Response("👋 Hello there!"); 25 | }, 26 | }); 27 | ``` 28 | 29 | 👉 **Visit the 📖 [Documentation](https://srvx.h3.dev/) to learn more.** 30 | 31 | ## Development 32 | 33 |
34 | 35 | local development 36 | 37 | - Clone this repository 38 | - Install the latest LTS version of [Node.js](https://nodejs.org/en/) 39 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` 40 | - Install dependencies using `pnpm install` 41 | - Run interactive tests using `pnpm dev` 42 | 43 |
44 | 45 | ## License 46 | 47 | 48 | 49 | Published under the [MIT](https://github.com/h3js/srvx/blob/main/LICENSE) license. 50 | Made by [@pi0](https://github.com/pi0) and [community](https://github.com/h3js/srvx/graphs/contributors) 💛 51 |

52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | --- 61 | 62 | _🤖 auto updated with [automd](https://automd.unjs.io)_ 63 | 64 | 65 | -------------------------------------------------------------------------------- /build.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from "obuild/config"; 2 | import { rm } from "node:fs/promises"; 3 | import { join } from "node:path"; 4 | 5 | export default defineBuildConfig({ 6 | entries: [ 7 | { 8 | type: "bundle", 9 | input: [ 10 | "src/types.ts", 11 | ...[ 12 | "deno", 13 | "bun", 14 | "node", 15 | "cloudflare", 16 | "generic", 17 | "service-worker", 18 | ].map((adapter) => `src/adapters/${adapter}.ts`), 19 | ], 20 | }, 21 | ], 22 | hooks: { 23 | async end(ctx) { 24 | await rm(join(ctx.pkgDir, "dist/types.mjs")); 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {} 3 | } 4 | -------------------------------------------------------------------------------- /docs/.config/docs.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://unpkg.com/undocs/schema/config.json 2 | 3 | name: srvx 4 | shortDescription: Universal Server API 5 | description: Based on web platform standards and works seamlessly with Deno, Bun and Node.js and more. 6 | github: h3js/srvx 7 | themeColor: red 8 | socials: 9 | discord: "https://discord.h3.dev" 10 | sponsors: 11 | api: "https://sponsors.pi0.io/sponsors.json" 12 | landing: 13 | contributors: true 14 | heroLinks: 15 | playOnline: 16 | label: Play Online 17 | icon: i-heroicons-play 18 | to: https://stackblitz.com/github/h3js/srvx/tree/main/playground?file=app.mjs 19 | heroCode: 20 | lang: js 21 | title: "server.mjs" 22 | content: | 23 | import { serve } from "srvx"; 24 | 25 | serve({ 26 | port: 3000, 27 | fetch(request) { 28 | return new Response("👋 Hello there!"); 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /docs/.docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nuxt 3 | .data 4 | .output 5 | dist 6 | -------------------------------------------------------------------------------- /docs/.docs/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /docs/1.guide/1.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: ph:book-open-duotone 3 | --- 4 | 5 | # Getting Started 6 | 7 | > Get familiar with srvx usage and why it exists. 8 | 9 | srvx provides a unified standard API to create HTTP servers based on the standard web platform primitives ([fetch][fetch], [Request][Request] and [Response][Response]) and works seamlessly with [Deno][Deno], [Bun][Bun], [Node.js][Node.js] and more. 10 | 11 | For [Deno][Deno] and [Bun][Bun], srvx unifies interface with zero overhead and for [Node.js][Node.js], creates a lightweight compatibility layer to wrap [node:IncomingMessage][IncomingMessage] as a standard [Request][Request] object and convert final state of [node:ServerResponse][ServerResponse] to a standard [Response][Response] object. 12 | 13 | ## Quick Start 14 | 15 | Create an HTTP server using the `serve` function from `srvx` package. 16 | 17 | ```js [server.mjs] 18 | import { serve } from "srvx"; 19 | 20 | const server = serve({ 21 | fetch(request) { 22 | return new Response("👋 Hello there!"); 23 | }, 24 | }); 25 | ``` 26 | 27 | Install `srvx` as a dependency: 28 | 29 | :pm-install{name="srvx"} 30 | 31 | Then, run the server using your favorite runtime: 32 | 33 | ::code-group 34 | 35 | ```bash [node] 36 | node server.mjs 37 | ``` 38 | 39 | ```bash [deno] 40 | deno run --allow-env --allow-net server.mjs 41 | ``` 42 | 43 | ```bash [bun] 44 | bun run server.mjs 45 | ``` 46 | 47 | :: 48 | 49 | ## Why using srvx? 50 | 51 | When you want to create a HTTP server using [Node.js][Node.js], you have to use [node:http](https://nodejs.org/api/http.html) module (or a library based on it). 52 | 53 | **Example:** Node.js HTTP server ([learn more](https://nodejs.org/en/learn/getting-started/introduction-to-nodejs)): 54 | 55 | ```js 56 | import { createServer } from "node:http"; 57 | 58 | createServer((req, res) => { 59 | res.end("Hello, Node.js!"); 60 | }).listen(3000); 61 | ``` 62 | 63 | Whenever a new request is received, the request event is called with two objects: a request `req` object ([node:IncomingMessage][IncomingMessage]) to access HTTP request details and a response `res` object ([node:ServerResponse][ServerResponse]) that can be used to prepare and send a HTTP response. Popular framework such as [Express](https://expressjs.com/) and [Fastify](https://fastify.dev/) are also based on Node.js server API. 64 | 65 | :read-more{to="/guide/node" title="Node.js support"} 66 | 67 | Recent JavaScript server runtimes like [Deno][Deno] and [Bun][Bun] have a different way to define a server which is similar to web [fetch][fetch] API. 68 | 69 | **Example:** [Deno][Deno] HTTP server ([learn more](https://docs.deno.com/api/deno/~/Deno.serve)): 70 | 71 | ```js 72 | Deno.serve({ port: 3000 }, (_req, info) => new Response("Hello, Deno!")); 73 | ``` 74 | 75 | **Example:** [Bun][Bun] HTTP server ([learn more](https://bun.sh/docs/api/http)): 76 | 77 | ```js 78 | Bun.serve({ port: 3000, fetch: (req) => new Response("Hello, Bun!") }); 79 | ``` 80 | 81 | As you probably noticed, there is a difference between [Node.js][Node.js] and [Deno][Deno] and [Bun][Bun]. The incoming request is a web [Request][Request] object and server response is a web [Response][Response] object. Accessing headers, request path, and preparing response is completely different between [Node.js][Node.js] and other runtimes. 82 | 83 | While [Deno][Deno] and [Bun][Bun] servers are both based on web standards, There are differences between them. The way to provide options, server lifecycle, access to request info such as client IP which is not part of [Request][Request] standard are some examples. 84 | 85 | Main use-case of this library is for tools and frameworks that want to be runtime agnostic. By using srvx as standard server layer, instead of depending on of the individual runtime APIs, we push JavaScript ecosystem to be more consistent and moving towards web standards! 86 | 87 | ### How is it Different? 88 | 89 | You might ask, what is the difference between srvx and other HTTP frameworks. 90 | 91 | Srvx provides a simple, low-level, and universal API, very similar to [Deno][Deno] and [Bun][Bun]. It has **no conventions**, utilities, or router, and in most cases, using srvx introduces no overhead. 92 | 93 | The core of srvx was extracted from the [h3](https://h3.dev/) v2 early development branch and opened to a broader ecosystem to encourage the adoption of Web platform standards without enforcing it's own conventions. 94 | 95 | [Deno]: https://deno.com/ 96 | [Bun]: https://bun.sh/ 97 | [Node.js]: https://nodejs.org/ 98 | [fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 99 | [Request]: https://developer.mozilla.org/en-US/docs/Web/API/Request 100 | [Response]: https://developer.mozilla.org/en-US/docs/Web/API/Response 101 | [IncomingMessage]: https://nodejs.org/api/http.html#http_class_http_incomingmessage 102 | [ServerResponse]: https://nodejs.org/api/http.html#http_class_http_serverresponse 103 | -------------------------------------------------------------------------------- /docs/1.guide/2.handler.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: i-fluent:target-24-regular 3 | --- 4 | 5 | # Fetch Handler 6 | 7 | > Get familiar with srvx fetch server handler and `ServerRequest`. 8 | 9 | Request handler is defined via `fetch` key since it is similar to [fetch][fetch] API. The input is a [Request][Request] object and handler should return a [Response][Response] or a promise if the server handler is async. 10 | 11 | **Example:** 12 | 13 | ```js 14 | import { serve } from "srvx"; 15 | serve({ 16 | async fetch(request) { 17 | return new Response( 18 | ` 19 |

👋 Hello there

20 |

You are visiting ${request.url} from ${request.ip}

21 | `, 22 | { headers: { "Content-Type": "text/html" } }, 23 | ); 24 | }, 25 | }); 26 | ``` 27 | 28 | ## Extended Request (`ServerRequest`) 29 | 30 | > [!TIP] 31 | > You can use `ServerRequest` type export from `srvx` as type of `request`. 32 | 33 | ### `request.ip?` 34 | 35 | Using `request.ip` allows to access connected client's IP address. 36 | 37 | ```js 38 | import { serve } from "srvx"; 39 | 40 | serve({ 41 | fetch: (request) => new Response(`Your ip address is "${request.ip}"`), 42 | }); 43 | ``` 44 | 45 | ### `request.waitUntil?` 46 | 47 | Tell the runtime about an ongoing operation that shouldn't close until the promise resolves. 48 | 49 | ```js 50 | import { serve } from "srvx"; 51 | 52 | async function logRequest(request) { 53 | await fetch("https://telemetry.example.com", { 54 | method: "POST", 55 | body: JSON.stringify({ 56 | method: request.method, 57 | url: request.url, 58 | ip: request.ip, 59 | }), 60 | }); 61 | } 62 | 63 | serve({ 64 | fetch: (request) => { 65 | request.waitUntil(logRequest(request)); 66 | return "OK"; 67 | }, 68 | }); 69 | ``` 70 | 71 | ### `request.runtime?.name?` 72 | 73 | Runtime name. Can be `"bun"`, `"deno"`, `"node"`, `"cloudflare"` or any other string. 74 | 75 | ### `request.runtime?.bun?` 76 | 77 | Using `request.bun?.server` you can access to the underlying Bun server. 78 | 79 | ### `request.runtime?.deno?` 80 | 81 | Using `request.deno?.server` you can access to the underlying Deno server. 82 | 83 | Using `request.deno?.info` you can access to the extra request information provided by Deno. 84 | 85 | ### `request.runtime?.node?` 86 | 87 | [Node.js][Node.js] is supported through a proxy that wraps [node:IncomingMessage][IncomingMessage] as [Request][Request] and converting final state of [node:ServerResponse][ServerResponse] to [Response][Response]. 88 | 89 | If access to the underlying [Node.js][Node.js] request and response objects is required (only in Node.js runtime), you can access them via `request.runtime?.node?.req` ([node:IncomingMessage][IncomingMessage]) and `request.runtime?.node?.res` ([node:ServerResponse][ServerResponse]). 90 | 91 | ```js 92 | import { serve } from "srvx"; 93 | 94 | serve({ 95 | fetch: (request) => { 96 | if (request.node) { 97 | console.log("Node.js req path:", request.node?.req.path); 98 | req.node.res.statusCode = 418; // I'm a teapot! 99 | } 100 | return new Response("ok"); 101 | }, 102 | }); 103 | ``` 104 | 105 | > [!TIP] 106 | > srvx implementation of [Request][Request] proxy directly uses the underlying [node:IncomingMessage][IncomingMessage] as source of trust. Any changes to [Request][Request] will be reflected to the underlying [node:IncomingMessage][IncomingMessage] and vise-versa. 107 | 108 | :read-more{to="/guide/node" title="Node.js support"} 109 | 110 | [Deno]: https://deno.com/ 111 | [Bun]: https://bun.sh/ 112 | [Node.js]: https://nodejs.org/ 113 | [fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 114 | [Request]: https://developer.mozilla.org/en-US/docs/Web/API/Request 115 | [Response]: https://developer.mozilla.org/en-US/docs/Web/API/Response 116 | [IncomingMessage]: https://nodejs.org/api/http.html#http_class_http_incomingmessage 117 | [ServerResponse]: https://nodejs.org/api/http.html#http_class_http_serverresponse 118 | -------------------------------------------------------------------------------- /docs/1.guide/3.server.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: radix-icons:component-instance 3 | --- 4 | 5 | # Server Instance 6 | 7 | > Control srvx server lifecycle. 8 | 9 | When calling `serve` to start a server, a server instance will be immediately returned in order to control the server instance. 10 | 11 | ```js 12 | import { serve } from "srvx"; 13 | 14 | const server = serve({ 15 | fetch(request) { 16 | return new Response(`🔥 Server is powered by ${server.runtime}.`); 17 | }, 18 | }); 19 | 20 | await server.ready(); 21 | 22 | console.log(`🚀 Server is ready at ${server.url}`); 23 | 24 | // When server is no longer needed 25 | // await server.close(true /* closeActiveConnections */) 26 | ``` 27 | 28 | ## Server Properties 29 | 30 | ### `server.options` 31 | 32 | Access to the sever options set during initialization. 33 | 34 | :read-more{to="/guide/options"} 35 | 36 | ### `server.url` 37 | 38 | Get the computed server listening URL. 39 | 40 | ### `server.addr` 41 | 42 | Listening address (hostname or ipv4/ipv6). 43 | 44 | ### `server.port` 45 | 46 | Listening port number. 47 | 48 | :read-more{to="/guide/options#port-required"} 49 | 50 | ## Server Methods 51 | 52 | ### `server.ready()` 53 | 54 | Returns a promise that will be resolved when server is listening to the port and ready to accept connections. 55 | 56 | **Example:** 57 | 58 | ```js 59 | await server.ready(); 60 | ``` 61 | 62 | ### `server.close(closeActiveConnections?)` 63 | 64 | Stop listening to prevent new connections from being accepted. 65 | 66 | By default, calling `close` does not cancel in-flight requests or websockets. That means it may take some time before all network activity stops. 67 | 68 | If `closeActiveConnections` is set to `true`, it will immediately terminate in-flight requests, websockets, and stop accepting new connections. 69 | 70 | **Example:** 71 | 72 | ```js 73 | // Stop accepting new requests 74 | await server.close(); 75 | 76 | // Stop accepting new requests and cancel all current connections 77 | await server.close(true); 78 | ``` 79 | 80 | ## Access to the Underlying Server 81 | 82 | > [!NOTE] 83 | > srvx tries to translate most common options to op level properties. This is only for advances usage. 84 | 85 | ### `server.bunServer` 86 | 87 | Access to the underlying Bun server instance when running in Bun. 88 | 89 | :read-more{to="https://bun.sh/docs/api/http"} 90 | 91 | ### `server.denoServer` 92 | 93 | Access to the underlying Bun server instance when running in Deno. 94 | 95 | :read-more{to="https://docs.deno.com/api/deno/~/Deno.HttpServer"} 96 | 97 | ### `server.nodeServer` 98 | 99 | Access to the underlying Node.js server instance when running in Node.js 100 | 101 | :read-more{to="https://nodejs.org/api/http.html#class-httpserver"} 102 | -------------------------------------------------------------------------------- /docs/1.guide/4.middleware.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: clarity:plugin-line 3 | --- 4 | 5 | # Middleware 6 | 7 | > Plugins and middleware allow adding reusable server extensions. 8 | 9 | ## Example 10 | 11 | ```ts 12 | import { serve, type ServerMiddleware, type ServerPlugin } from "srvx"; 13 | 14 | const xPoweredBy: ServerMiddleware = async (req, next) => { 15 | const res = await next(); 16 | res.headers.set("X-Powered-By", "srvx"); 17 | return res; 18 | }; 19 | 20 | const devLogs: ServerPlugin = (server) => { 21 | if (process.env.NODE_ENV === "production") { 22 | return; 23 | } 24 | console.log(`Logger plugin enabled!`); 25 | server.options.middleware.push((req, next) => { 26 | console.log(`[request] [${req.method}] ${req.url}`); 27 | return next(); 28 | }); 29 | }; 30 | 31 | serve({ 32 | middleware: [xPoweredBy], 33 | plugins: [devLogs], 34 | fetch(request) { 35 | return new Response(`👋 Hello there.`); 36 | }, 37 | }); 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/1.guide/5.options.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: ri:settings-3-line 3 | --- 4 | 5 | # Server Options 6 | 7 | > Provide additional options to customize listening server. 8 | 9 | When starting a new server, in addition to main `fetch` handler, you can provide additional options to customize listening server. 10 | 11 | ```js 12 | import { serve } from "srvx"; 13 | 14 | serve({ 15 | // Generic options 16 | port: 3000, 17 | hostname: "localhost", 18 | 19 | // Runtime specific options 20 | node: {}, 21 | bun: {}, 22 | deno: {}, 23 | 24 | // Main server handler 25 | fetch: () => new Response("👋 Hello there!"), 26 | }); 27 | ``` 28 | 29 | There are two kind of options: 30 | 31 | - Generic options: Top level options are intended to have exactly same functionality regardless of runtime 32 | - Runtime specific: Allow customizing more runtime specific options 33 | 34 | ## Generic Options 35 | 36 | ### `port` 37 | 38 | The port server should be listening to. 39 | 40 | Default is value of `PORT` environment variable or `3000`. 41 | 42 | > [!TIP] 43 | > You can set the port to `0` to use a random port. 44 | 45 | ### `hostname` 46 | 47 | The hostname (IP or resolvable host) server listener should bound to. 48 | 49 | When not provided, server will **listen to all network interfaces** by default. 50 | 51 | > [!IMPORTANT] 52 | > If you are running a server that should not be exposed to the network, use `localhost`. 53 | 54 | ### `reusePort` 55 | 56 | Enabling this option allows multiple processes to bind to the same port, which is useful for load balancing. 57 | 58 | > [!NOTE] 59 | > Despite Node.js built-in behavior that has `exclusive` flag enabled by default, srvx uses non-exclusive mode for consistency. 60 | 61 | ### `silent` 62 | 63 | If enabled, no server listening message will be printed (enabled by default when `TEST` environment variable is set). 64 | 65 | ### `protocol` 66 | 67 | The protocol to use for the server. 68 | 69 | Possible values are `http` or `https`. 70 | 71 | If `protocol` is not set, Server will use `http` as the default protocol or `https` if both `tls.cert` and `tls.key` options are provided. 72 | 73 | ### `tls` 74 | 75 | TLS server options. 76 | 77 | **Example:** 78 | 79 | ```js 80 | import { serve } from "srvx"; 81 | 82 | serve({ 83 | tls: { cert: "./server.crt", key: "./server.key" }, 84 | fetch: () => new Response("👋 Hello there!"), 85 | }); 86 | ``` 87 | 88 | **Options:** 89 | 90 | - `cert`: Path or inline content for the certificate in PEM format (required). 91 | - `key`: Path or inline content for the private key in PEM format (required). 92 | - `passphrase`: Passphrase for the private key (optional). 93 | 94 | > [!TIP] 95 | > You can pass inline `cert` and `key` values in PEM format starting with `-----BEGIN `. 96 | 97 | > [!IMPORTANT] 98 | > 99 | > - Always keep your SSL private keys secure and never commit them to version control 100 | > - Use environment variables or secure secret management for production deployments 101 | > - Consider using automatic certificate management (e.g., Let's Encrypt) for production 102 | 103 | ### `onError` 104 | 105 | Runtime agnostic error handler. 106 | 107 | > [!NOTE] 108 | > 109 | > This handler will take over the built-in error handlers of Deno and Bun. 110 | 111 | **Example:** 112 | 113 | ```js 114 | import { serve } from "srvx"; 115 | 116 | serve({ 117 | fetch: () => new Response("👋 Hello there!"), 118 | onError(error) { 119 | return new Response(`
${error}\n${error.stack}
`, { 120 | headers: { "Content-Type": "text/html" }, 121 | }); 122 | }, 123 | }); 124 | ``` 125 | 126 | ## Runtime Specific Options 127 | 128 | ### Node.js 129 | 130 | **Example:** 131 | 132 | ```js 133 | import { serve } from "srvx"; 134 | 135 | serve({ 136 | node: { 137 | maxHeadersize: 16384 * 2, // Double default 138 | ipv6Only: true, // Disable dual-stack support 139 | // http2: false // Disable http2 support (enabled by default in TLS mode) 140 | }, 141 | fetch: () => new Response("👋 Hello there!"), 142 | }); 143 | ``` 144 | 145 | ::read-more 146 | See Node.js documentation for [ServerOptions](https://nodejs.org/api/http.html#httpcreateserveroptions-requestlistener) and [ListenOptions](https://nodejs.org/api/net.html#serverlistenoptions-callback) for all available options. 147 | :: 148 | 149 | ### Bun 150 | 151 | **Example:** 152 | 153 | ```js 154 | import { serve } from "srvx"; 155 | 156 | serve({ 157 | bun: { 158 | error(error) { 159 | return new Response(`
${error}\n${error.stack}
`, { 160 | headers: { "Content-Type": "text/html" }, 161 | }); 162 | }, 163 | }, 164 | fetch: () => new Response("👋 Hello there!"), 165 | }); 166 | ``` 167 | 168 | ::read-more{to=https://bun.sh/docs/api/http} 169 | See Bun HTTP documentation for all available options. 170 | :: 171 | 172 | ### Deno 173 | 174 | **Example:** 175 | 176 | ```js 177 | import { serve } from "srvx"; 178 | 179 | serve({ 180 | deno: { 181 | onError(error) { 182 | return new Response(`
${error}\n${error.stack}
`, { 183 | headers: { "Content-Type": "text/html" }, 184 | }); 185 | }, 186 | }, 187 | fetch: () => new Response("👋 Hello there!"), 188 | }); 189 | ``` 190 | 191 | ::read-more{to=https://docs.deno.com/api/deno/~/Deno.ServeOptions} 192 | See Deno serve documentation for all available options. 193 | :: 194 | -------------------------------------------------------------------------------- /docs/1.guide/6.bundler.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: clarity:bundle-line 3 | --- 4 | 5 | # Bundler Usage 6 | 7 | > Tips for using srvx with bundlers. 8 | 9 | Typically `srvx` is to be imported like this. 10 | 11 | ```js 12 | import { serve } from "srvx"; 13 | ``` 14 | 15 | The import above automatically resolves the the correct entrypoint for each runtime. Node.js, Deno, Cloudflare, and Bun use [ESM conditions](https://nodejs.org/api/esm.html#resolution-algorithm-specification) to resolve the correct entrypoint. 16 | 17 | Normally, when you are directly using `srvx` in your project without bundling it should work as expected. 18 | 19 | ## Using Explicit Imports 20 | 21 | Instead of depending on ESM conditions, you can explicitly import `srvx` for specific runtime: 22 | 23 | ```js 24 | import { serve } from "srvx/node"; 25 | import { serve } from "srvx/deno"; 26 | import { serve } from "srvx/bun"; 27 | import { serve } from "srvx/cloudflare"; 28 | ``` 29 | 30 | ## Using Bundlers 31 | 32 | If srvx is being bundled (e.g. by [Rollup](https://rollupjs.org/) or [esbuild](https://esbuild.github.io/)), 33 | the bundler also has to run the ESM resolution algorithm during bundling. 34 | This means the `srvx` in the bundle will only work with one specific runtime (usually Node.js). 35 | 36 | ### External Dependency 37 | 38 | The simplest way to avoid this is to set `srvx` as an [external dependency](https://rollupjs.org/configuration-options/#external) in your bundler. 39 | 40 | ::code-group 41 | 42 | ```js [Rollup] 43 | export default { 44 | //... 45 | external: ["srvx"], 46 | }; 47 | ``` 48 | 49 | ```ts [esbuild] 50 | import { build } from "esbuild"; 51 | 52 | await build({ 53 | //... 54 | external: ["srvx"], // Add this 55 | }); 56 | ``` 57 | 58 | ```bash [esbuild (CLI)] 59 | esbuild main.ts \ 60 | # ... 61 | --external:srvx # Add this 62 | ``` 63 | 64 | :: 65 | 66 | By doing this, srvx won't be included in the final bundle, it needs to be available at runtime. 67 | 68 | ### Conditions 69 | 70 | Another approach is to set the ESM condition manually at bundle time. 71 | 72 | ::code-group 73 | 74 | ```js [Rollup] 75 | import resolve from "@rollup/plugin-node-resolve"; 76 | 77 | export default { 78 | //... 79 | plugins: [ 80 | resolve({ 81 | preferBuiltins: true, 82 | conditions: ["node"], // or "deno", "bun", "workerd", etc. 83 | }), 84 | ], 85 | }; 86 | ``` 87 | 88 | ```ts [esbuild] 89 | import { build } from "esbuild"; 90 | 91 | await build({ 92 | //... 93 | conditions: ["node"], // or "deno", "bun", "workerd", etc. 94 | }); 95 | ``` 96 | 97 | ```bash [esbuild (CLI)] 98 | esbuild main.ts \ 99 | # ... 100 | --conditions:node # or deno, bun, workerd, etc. 101 | ``` 102 | 103 | :: 104 | 105 | By doing this, the bundler will resolve the correct version on srvx for your runtime. 106 | -------------------------------------------------------------------------------- /docs/1.guide/7.node.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: akar-icons:node-fill 3 | --- 4 | 5 | # Node.js Support 6 | 7 | > Learn more about Node.js support with srvx. 8 | 9 | > [!NOTE] 10 | > This is an advanced section, explaining internal mechanism of srvx for Node.js support. 11 | 12 | As explained in the [why srvx](/guide/why) section, [Node.js][Node.js] uses different API for handling incoming http requests and sending responses. 13 | 14 | srvx internally uses a lightweight proxy system that wraps [node:IncomingMessage][IncomingMessage] as [Request][Request] and converts the final state of [node:ServerResponse][ServerResponse] to a [Response][Response] object. 15 | 16 | ## How Node.js Compatibility Works 17 | 18 | For each incoming request, instead of _fully cloning_ [Node.js][Node.js] request object with into a new [Request][Request] instance, srvx creates a proxy that _translates_ all property access and method calls between two interfaces. 19 | 20 | With this method, we add **minimum amount of overhead** and can **optimize** internal implementation to leverage most of the possibilities with [Node.js][Node.js] native primitives. This method also has the advantage that there is **only one source of trust** ([Node.js][Node.js] request instance) and any changes to each interface will reflect the other ([node:IncomingMessage][IncomingMessage] <> [Request][Request]), **maximizing compatibility**. srvx will **never patch of modify** the global [Request][Request] and [Response][Response] constructors, keeping runtime natives untouched. 21 | 22 | Internally, the fetch wrapper looks like this: 23 | 24 | ```ts 25 | function nodeHandler(nodeReq: IncomingMessage, nodeRes: ServerResponse) { 26 | const request = new NodeRequestProxy(nodeReq); 27 | const response = await server.fetch(request); 28 | await sendNodeResponse(nodeRes, response); 29 | } 30 | ``` 31 | 32 | ... `NodeRequestProxy`, wraps [node:IncomingMessage][IncomingMessage] as a standard [Request][Request] interface.
33 | ... On first `request.body` access, it starts reading request body as a [ReadableStream][ReadableStream].
34 | ... `request.headers` is a proxy (`NodeReqHeadersProxy`) around `nodeReq.headers` providing a standard [Headers][Headers] interface.
35 | ... When accessing `request.url` getter, it creates a full URL string (including protocol, hostname and path) from `nodeReq.url` and `nodeReq.headers` (host).
36 | ... Other request APIs are also implemented similarly. 37 | 38 | `sendNodeResponse`, handles the [Response][Response] object returned from server fetch method. 39 | 40 | ... `status`, `statusText`, and `headers` will be set.
41 | ... `set-cookie` header will be properly split (with [cookie-es](https://cookie-es.unjs.io)).
42 | ... If response has body, it will be streamed to node response.
43 | ... The promise will be resolved after the response is sent and callback called by Node.js.
44 | 45 | ## `FastResponse` 46 | 47 | When initializing a new [Response][Response] in Node.js, a lot of extra internals have to be initialized including a [ReadableStream][ReadableStream] object for `response.body` and [Headers][Headers] for `response.headers` which adds significant overhead since Node.js response handling does not need them. 48 | 49 | Until there will be native [Response][Response] handling support in Node.js http module, srvx provides a faster alternative `Response` class. You can use this instead to replace `Response` and improve performance. 50 | 51 | ```js 52 | import { serve, FastResponse } from "srvx"; 53 | 54 | const server = serve({ 55 | port: 3000, 56 | fetch() { 57 | return new FastResponse("Hello!"); 58 | }, 59 | }); 60 | 61 | await server.ready(); 62 | 63 | console.log(`Server running at ${server.url}`); 64 | ``` 65 | 66 | You can locally run benchmarks by cloning [srvx repository](https://github.com/h3js/srvx) and running `npm run bench:node [--all]` script. Speedup in v22.8.0 was roughly **%94**! 67 | 68 | ## Reverse Compatibility 69 | 70 | srvx converts a [fetch][fetch]-like [Request][Request] => [Response][Response] handler to [node:IncomingMessage][IncomingMessage] => [node:ServerResponse][ServerResponse] handler that is compatible **with** Node.js runtime. 71 | 72 | If you want to instead convert a Node.js server handler (like [Express][Express]) with `(req, IncomingMessage, res: ServerResponse)` signature to [fetch][fetch]-like handler ([Request][Request] => [Response][Response]) that can work **without** Node.js runtime you can instead use [node-mock-http](https://github.com/unjs/node-mock-http) or [fetch-to-node](https://github.com/mhart/fetch-to-node) (more mature but currently requires some `node:` polyfills). 73 | 74 | ```js [node-mock-http.mjs] 75 | import { fetchNodeRequestHandler } from "node-mock-http"; 76 | 77 | // Node.js compatible request handler 78 | const nodeHandler = (req, res) => { 79 | res.writeHead(200, { "Content-Type": "application/json" }); 80 | res.end( 81 | JSON.stringify({ 82 | data: "Hello World!", 83 | }), 84 | ); 85 | }; 86 | 87 | // Create a Response object 88 | const webResponse = await fetchNodeRequestHandler(nodeHandler, webRequest); 89 | ``` 90 | 91 | ```js [fetch-to-node.mjs] 92 | import { toReqRes, toFetchResponse } from "fetch-to-node"; 93 | 94 | // Node.js compatible request handler 95 | const nodeHandler = (req, res) => { 96 | res.writeHead(200, { "Content-Type": "application/json" }); 97 | res.end( 98 | JSON.stringify({ 99 | data: "Hello World!", 100 | }), 101 | ); 102 | }; 103 | 104 | // Create Node.js-compatible req and res from request 105 | const { req, res } = toReqRes(webRequest); 106 | 107 | // Create a Response object based on res, and return it 108 | const webResponse = await toFetchResponse(res); 109 | ``` 110 | 111 | [Node.js]: https://nodejs.org/ 112 | [fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 113 | [Request]: https://developer.mozilla.org/en-US/docs/Web/API/Request 114 | [Response]: https://developer.mozilla.org/en-US/docs/Web/API/Response 115 | [Headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers 116 | [ReadableStream]: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream 117 | [IncomingMessage]: https://nodejs.org/api/http.html#http_class_http_incomingmessage 118 | [ServerResponse]: https://nodejs.org/api/http.html#http_class_http_serverresponse 119 | [Express]: https://expressjs.com/ 120 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "undocs dev", 6 | "build": "undocs build" 7 | }, 8 | "devDependencies": { 9 | "undocs": "^0.3.4" 10 | }, 11 | "packageManager": "pnpm@10.7.0" 12 | } 13 | -------------------------------------------------------------------------------- /docs/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: [] 2 | ignoredBuiltDependencies: 3 | - "@parcel/watcher" 4 | - "@tailwindcss/oxide" 5 | - esbuild 6 | - vue-demi 7 | onlyBuiltDependencies: 8 | - better-sqlite3 9 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unjs from "eslint-config-unjs"; 2 | 3 | export default unjs({ 4 | ignores: ["**/.docs"], 5 | rules: { 6 | "unicorn/no-null": "off", 7 | "unicorn/no-nested-ternary": "off", 8 | "unicorn/prefer-top-level-await": "off", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "srvx", 3 | "version": "0.7.5", 4 | "description": "Universal Server API based on web platform standards. Works seamlessly with Deno, Bun and Node.js.", 5 | "homepage": "https://srvx.h3.dev", 6 | "repository": "h3js/srvx", 7 | "license": "MIT", 8 | "sideEffects": false, 9 | "type": "module", 10 | "exports": { 11 | "./types": "./dist/types.d.mts", 12 | "./deno": "./dist/adapters/deno.mjs", 13 | "./bun": "./dist/adapters/bun.mjs", 14 | "./node": "./dist/adapters/node.mjs", 15 | "./cloudflare": "./dist/adapters/cloudflare.mjs", 16 | "./generic": "./dist/adapters/generic.mjs", 17 | "./service-worker": "./dist/adapters/service-worker.mjs", 18 | ".": { 19 | "types": "./dist/types.d.mts", 20 | "deno": "./dist/adapters/deno.mjs", 21 | "bun": "./dist/adapters/bun.mjs", 22 | "workerd": "./dist/adapters/cloudflare.mjs", 23 | "browser": "./dist/adapters/service-worker.mjs", 24 | "node": "./dist/adapters/node.mjs", 25 | "default": "./dist/adapters/generic.mjs" 26 | } 27 | }, 28 | "types": "./dist/types.d.mts", 29 | "files": [ 30 | "dist" 31 | ], 32 | "scripts": { 33 | "bench:node": "node test/bench-node/_run.mjs", 34 | "bench:url:bun": "bun run ./test/url.bench.ts", 35 | "bench:url:deno": "deno run -A ./test/url.bench.ts", 36 | "bench:url:node": "pnpm node-ts --expose-gc --allow-natives-syntax ./test/url.bench.ts", 37 | "build": "obuild", 38 | "dev": "vitest dev", 39 | "lint": "eslint . && prettier -c .", 40 | "lint:fix": "automd && eslint . --fix && prettier -w .", 41 | "node-ts": "node --disable-warning=ExperimentalWarning --experimental-strip-types", 42 | "prepack": "pnpm build", 43 | "play:bun": "bun --watch playground/app.mjs", 44 | "play:cf": "pnpx wrangler dev playground/app.mjs", 45 | "play:deno": "deno run -A --watch playground/app.mjs", 46 | "play:mkcert": "openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365 -subj /CN=srvx.local", 47 | "play:node": "pnpm node-ts --watch playground/app.mjs", 48 | "play:sw": "pnpm build && pnpx serve playground", 49 | "release": "pnpm test && changelogen --release && npm publish && git push --follow-tags", 50 | "test": "pnpm lint && pnpm test:types && vitest run --coverage", 51 | "test:types": "tsc --noEmit --skipLibCheck" 52 | }, 53 | "resolutions": { 54 | "srvx": "link:." 55 | }, 56 | "dependencies": { 57 | "cookie-es": "^2.0.0" 58 | }, 59 | "devDependencies": { 60 | "@cloudflare/workers-types": "^4.20250525.0", 61 | "@hono/node-server": "^1.14.3", 62 | "@mitata/counters": "^0.0.8", 63 | "@mjackson/node-fetch-server": "^0.6.1", 64 | "@types/bun": "^1.2.14", 65 | "@types/deno": "^2.3.0", 66 | "@types/node": "^22.15.21", 67 | "@types/node-forge": "^1.3.11", 68 | "@types/serviceworker": "^0.0.135", 69 | "@vitest/coverage-v8": "^3.1.4", 70 | "@whatwg-node/server": "^0.10.10", 71 | "automd": "^0.4.0", 72 | "changelogen": "^0.6.1", 73 | "eslint": "^9.27.0", 74 | "eslint-config-unjs": "^0.4.2", 75 | "execa": "^9.6.0", 76 | "get-port-please": "^3.1.2", 77 | "mitata": "^1.0.34", 78 | "node-forge": "^1.3.1", 79 | "obuild": "^0.2.0", 80 | "prettier": "^3.5.3", 81 | "tslib": "^2.8.1", 82 | "typescript": "^5.8.3", 83 | "undici": "^7.10.0", 84 | "vitest": "^3.1.4" 85 | }, 86 | "packageManager": "pnpm@10.11.0", 87 | "engines": { 88 | "node": ">=20.16.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /playground/app.mjs: -------------------------------------------------------------------------------- 1 | import { serve } from "srvx"; 2 | 3 | serve({ 4 | // tls: { cert: "server.crt", key: "server.key" }, 5 | fetch(_request) { 6 | return new Response( 7 | /*html */ ` 8 |

👋 Hello there

9 | Learn more: srvx.h3.dev 10 | `, 11 | { 12 | headers: { 13 | "Content-Type": "text/html; charset=UTF-8", 14 | }, 15 | }, 16 | ); 17 | }, 18 | error(error) { 19 | return new Response( 20 | /*html */ `
${error.stack || error}
`, 21 | { 22 | headers: { "Content-Type": "text/html" }, 23 | }, 24 | ); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "srvx-playground", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "start": "node --watch ./app.mjs", 6 | "bun": "bun --watch ./app.mjs", 7 | "deno": "deno run -A --watch ./app.mjs", 8 | "node": "node --watch ./app.mjs" 9 | }, 10 | "devDependencies": { 11 | "srvx": "latest" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /playground/sw/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SRVX Worker Example 5 | 6 | 7 |

Loading service worker...

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /playground/sw/sw.mjs: -------------------------------------------------------------------------------- 1 | import { serve } from "../node_modules/srvx/dist/adapters/service-worker.mjs"; 2 | // import { serve } from "https://esm.sh/srvx@0.5"; 3 | 4 | serve({ 5 | serviceWorker: { url: import.meta.url }, 6 | fetch(_request) { 7 | return new Response(`

👋 Hello there!

`, { 8 | headers: { "Content-Type": "text/html; charset=UTF-8" }, 9 | }); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "playground" 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /src/_middleware.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Server, 3 | ServerRequest, 4 | ServerHandler, 5 | ServerMiddleware, 6 | } from "./types.ts"; 7 | 8 | export function wrapFetch(server: Server): ServerHandler { 9 | const fetchHandler = server.options.fetch; 10 | const middleware = server.options.middleware || []; 11 | return middleware.length === 0 12 | ? fetchHandler 13 | : (request) => callMiddleware(request, fetchHandler, middleware, 0); 14 | } 15 | 16 | function callMiddleware( 17 | request: ServerRequest, 18 | fetchHandler: ServerHandler, 19 | middleware: ServerMiddleware[], 20 | index: number, 21 | ): Response | Promise { 22 | if (index === middleware.length) { 23 | return fetchHandler(request); 24 | } 25 | return middleware[index](request, () => 26 | callMiddleware(request, fetchHandler, middleware, index + 1), 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/_plugins.ts: -------------------------------------------------------------------------------- 1 | import type { ServerPlugin } from "./types.ts"; 2 | 3 | export const errorPlugin: ServerPlugin = (server) => { 4 | const errorHandler = server.options.error; 5 | if (!errorHandler) return; 6 | server.options.middleware.unshift((_req, next) => { 7 | try { 8 | const res = next(); 9 | return res instanceof Promise 10 | ? res.catch((error) => errorHandler(error)) 11 | : res; 12 | } catch (error) { 13 | return errorHandler(error); 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/_url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper for URL with fast path access to `.pathname` and `.search` props. 3 | * 4 | * **NOTE:** It is assumed that the input URL is already ecoded and formatted from an HTTP request and contains no hash. 5 | * 6 | * **NOTE:** Triggering the setters or getters on other props will deoptimize to full URL parsing. 7 | */ 8 | export const FastURL: { new (url: string): URL } = /* @__PURE__ */ (() => { 9 | const FastURL = class URL implements globalThis.URL { 10 | #originalURL: string; 11 | #parsedURL: globalThis.URL | undefined; 12 | 13 | _pathname?: string; 14 | _urlqindex?: number; 15 | _query?: URLSearchParams; 16 | _search?: string; 17 | 18 | constructor(url: string) { 19 | this.#originalURL = url; 20 | } 21 | 22 | get _url(): globalThis.URL { 23 | if (!this.#parsedURL) { 24 | this.#parsedURL = new globalThis.URL(this.#originalURL); 25 | } 26 | return this.#parsedURL; 27 | } 28 | 29 | toString(): string { 30 | return this._url.toString(); 31 | } 32 | 33 | toJSON(): string { 34 | return this.toString(); 35 | } 36 | 37 | get pathname() { 38 | if (this.#parsedURL) { 39 | return this.#parsedURL.pathname; 40 | } 41 | if (this._pathname === undefined) { 42 | const url = this.#originalURL; 43 | const protoIndex = url.indexOf("://"); 44 | if (protoIndex === -1) { 45 | return this._url.pathname; // deoptimize 46 | } 47 | const pIndex = url.indexOf("/", protoIndex + 4 /* :// */); 48 | if (pIndex === -1) { 49 | return this._url.pathname; // deoptimize 50 | } 51 | const qIndex = (this._urlqindex = url.indexOf("?", pIndex)); 52 | this._pathname = url.slice(pIndex, qIndex === -1 ? undefined : qIndex); 53 | } 54 | return this._pathname!; 55 | } 56 | 57 | set pathname(value: string) { 58 | this._pathname = undefined; // invalidate cache 59 | this._url.pathname = value; 60 | } 61 | 62 | get searchParams() { 63 | if (this.#parsedURL) { 64 | return this.#parsedURL.searchParams; 65 | } 66 | if (!this._query) { 67 | this._query = new URLSearchParams(this.search); 68 | } 69 | return this._query; 70 | } 71 | 72 | get search() { 73 | if (this.#parsedURL) { 74 | return this.#parsedURL.search; 75 | } 76 | if (this._search === undefined) { 77 | const qIndex = this._urlqindex; 78 | if (qIndex === -1 || qIndex === this.#originalURL.length - 1) { 79 | this._search = ""; 80 | } else { 81 | this._search = 82 | qIndex === undefined 83 | ? this._url.search // deoptimize (mostly unlikely unless pathname is not accessed) 84 | : this.#originalURL.slice(qIndex); 85 | } 86 | } 87 | return this._search; 88 | } 89 | 90 | set search(value: string) { 91 | this._search = undefined; // invalidate cache 92 | this._query = undefined; // invalidate cache 93 | this._url.search = value; 94 | } 95 | 96 | declare hash: string; 97 | declare host: string; 98 | declare hostname: string; 99 | declare href: string; 100 | declare origin: string; 101 | declare password: string; 102 | declare port: string; 103 | declare protocol: string; 104 | declare username: string; 105 | }; 106 | 107 | // prettier-ignore 108 | const slowProps = [ 109 | "hash", "host", "hostname", "href", "origin", 110 | "password", "port", "protocol", "username" 111 | ] as const; 112 | 113 | for (const prop of slowProps) { 114 | Object.defineProperty(FastURL.prototype, prop, { 115 | get() { 116 | return this._url[prop]; 117 | }, 118 | set(value) { 119 | this._url[prop] = value; 120 | }, 121 | }); 122 | } 123 | 124 | Object.setPrototypeOf(FastURL, globalThis.URL); 125 | 126 | return FastURL as unknown as { new (url: string): URL }; 127 | })(); 128 | -------------------------------------------------------------------------------- /src/_utils.ts: -------------------------------------------------------------------------------- 1 | // *** This file should be only imported in the runtime adapters with Node.js compatibility. *** 2 | 3 | import type { ServerOptions } from "./types.ts"; 4 | 5 | export function resolvePortAndHost(opts: ServerOptions): { 6 | port: number; 7 | hostname: string | undefined; 8 | } { 9 | const _port = opts.port ?? globalThis.process?.env.PORT ?? 3000; 10 | const port = typeof _port === "number" ? _port : Number.parseInt(_port, 10); 11 | const hostname = opts.hostname ?? globalThis.process?.env.HOST; 12 | return { port, hostname }; 13 | } 14 | 15 | export function fmtURL( 16 | host: string | undefined, 17 | port: number | undefined, 18 | secure: boolean, 19 | ): string | undefined { 20 | if (!host || !port) { 21 | return undefined; 22 | } 23 | if (host.includes(":")) { 24 | host = `[${host}]`; 25 | } 26 | return `http${secure ? "s" : ""}://${host}:${port}/`; 27 | } 28 | 29 | export function printListening( 30 | opts: ServerOptions, 31 | url: string | undefined, 32 | ): void { 33 | if (!url || (opts.silent ?? globalThis.process?.env?.TEST)) { 34 | return; 35 | } 36 | 37 | const _url = new URL(url); 38 | const allInterfaces = _url.hostname === "[::]" || _url.hostname === "0.0.0.0"; 39 | if (allInterfaces) { 40 | _url.hostname = "localhost"; 41 | url = _url.href; 42 | } 43 | 44 | let listeningOn = `➜ Listening on:`; 45 | let additionalInfo = allInterfaces ? " (all interfaces)" : ""; 46 | 47 | if (globalThis.process.stdout?.isTTY) { 48 | listeningOn = `\u001B[32m${listeningOn}\u001B[0m`; // ANSI green 49 | url = `\u001B[36m${url}\u001B[0m`; // ANSI cyan 50 | additionalInfo = `\u001B[2m${additionalInfo}\u001B[0m`; // ANSI dim 51 | } 52 | 53 | console.log(` ${listeningOn} ${url}${additionalInfo}`); 54 | } 55 | 56 | export function resolveTLSOptions(opts: ServerOptions): 57 | | { 58 | cert: string; 59 | key: string; 60 | passphrase: any; 61 | } 62 | | undefined { 63 | if (!opts.tls || opts.protocol === "http") { 64 | return; 65 | } 66 | const cert = resolveCertOrKey(opts.tls.cert); 67 | const key = resolveCertOrKey(opts.tls.key); 68 | if (!cert && !key) { 69 | if (opts.protocol === "https") { 70 | throw new TypeError( 71 | "TLS `cert` and `key` must be provided for `https` protocol.", 72 | ); 73 | } 74 | return; 75 | } 76 | if (!cert || !key) { 77 | throw new TypeError("TLS `cert` and `key` must be provided together."); 78 | } 79 | return { 80 | cert, 81 | key, 82 | passphrase: opts.tls.passphrase, 83 | }; 84 | } 85 | 86 | function resolveCertOrKey(value?: unknown): undefined | string { 87 | if (!value) { 88 | return; 89 | } 90 | if (typeof value !== "string") { 91 | throw new TypeError( 92 | "TLS certificate and key must be strings in PEM format or file paths.", 93 | ); 94 | } 95 | if (value.startsWith("-----BEGIN ")) { 96 | return value; 97 | } 98 | const { readFileSync } = process.getBuiltinModule("node:fs"); 99 | return readFileSync(value, "utf8"); 100 | } 101 | 102 | export function createWaitUntil() { 103 | const promises = new Set>(); 104 | return { 105 | waitUntil: (promise: Promise): void => { 106 | promises.add( 107 | promise.catch(console.error).finally(() => { 108 | promises.delete(promise); 109 | }), 110 | ); 111 | }, 112 | wait: (): Promise => { 113 | return Promise.all(promises); 114 | }, 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /src/adapters/_node/_common.ts: -------------------------------------------------------------------------------- 1 | export const kNodeInspect: symbol = /* @__PURE__ */ Symbol.for( 2 | "nodejs.util.inspect.custom", 3 | ); 4 | -------------------------------------------------------------------------------- /src/adapters/_node/headers.ts: -------------------------------------------------------------------------------- 1 | import { splitSetCookieString } from "cookie-es"; 2 | import { kNodeInspect } from "./_common.ts"; 3 | 4 | import type { NodeServerRequest, NodeServerResponse } from "../../types.ts"; 5 | 6 | export const NodeRequestHeaders: { 7 | new (nodeCtx: { 8 | req: NodeServerRequest; 9 | res?: NodeServerResponse; 10 | }): globalThis.Headers; 11 | } = /* @__PURE__ */ (() => { 12 | const _Headers = class Headers implements globalThis.Headers { 13 | _node: { 14 | req: NodeServerRequest; 15 | res?: NodeServerResponse; 16 | }; 17 | 18 | constructor(nodeCtx: { req: NodeServerRequest; res?: NodeServerResponse }) { 19 | this._node = nodeCtx; 20 | } 21 | 22 | append(name: string, value: string): void { 23 | name = validateHeader(name); 24 | const _headers = this._node.req.headers; 25 | const _current = _headers[name]; 26 | if (_current) { 27 | if (Array.isArray(_current)) { 28 | _current.push(value); 29 | } else { 30 | _headers[name] = [_current as string, value]; 31 | } 32 | } else { 33 | _headers[name] = value; 34 | } 35 | } 36 | 37 | delete(name: string): void { 38 | name = validateHeader(name); 39 | this._node.req.headers[name] = undefined; 40 | } 41 | 42 | get(name: string): string | null { 43 | name = validateHeader(name); 44 | const rawValue = this._node.req.headers[name]; 45 | if (rawValue === undefined) { 46 | return null; 47 | } 48 | return _normalizeValue(this._node.req.headers[name]); 49 | } 50 | 51 | getSetCookie(): string[] { 52 | const setCookie = this._node.req.headers["set-cookie"]; 53 | if (!setCookie || setCookie.length === 0) { 54 | return []; 55 | } 56 | return splitSetCookieString(setCookie); 57 | } 58 | 59 | has(name: string): boolean { 60 | name = validateHeader(name); 61 | return !!this._node.req.headers[name]; 62 | } 63 | 64 | set(name: string, value: string): void { 65 | name = validateHeader(name); 66 | this._node.req.headers[name] = value; 67 | } 68 | 69 | get count(): number { 70 | // Bun-specific addon 71 | throw new Error("Method not implemented."); 72 | } 73 | 74 | getAll(_name: "set-cookie" | "Set-Cookie"): string[] { 75 | // Bun-specific addon 76 | throw new Error("Method not implemented."); 77 | } 78 | 79 | toJSON(): Record { 80 | const _headers = this._node.req.headers; 81 | const result: Record = {}; 82 | for (const key in _headers) { 83 | if (_headers[key]) { 84 | result[key] = _normalizeValue(_headers[key]); 85 | } 86 | } 87 | return result; 88 | } 89 | 90 | forEach( 91 | cb: (value: string, key: string, parent: Headers) => void, 92 | thisArg?: any, 93 | ): void { 94 | const _headers = this._node.req.headers; 95 | for (const key in _headers) { 96 | if (_headers[key]) { 97 | cb.call( 98 | thisArg, 99 | _normalizeValue(_headers[key]), 100 | key, 101 | this as unknown as Headers, 102 | ); 103 | } 104 | } 105 | } 106 | 107 | *entries(): HeadersIterator<[string, string]> { 108 | const headers = this._node.req.headers; 109 | const isHttp2 = this._node.req.httpVersion === "2.0"; 110 | 111 | for (const key in headers) { 112 | if (!isHttp2 || key[0] !== ":") { 113 | yield [key, _normalizeValue(headers[key])]; 114 | } 115 | } 116 | } 117 | 118 | *keys(): HeadersIterator { 119 | const keys = Object.keys(this._node.req.headers); 120 | for (const key of keys) { 121 | yield key; 122 | } 123 | } 124 | 125 | *values(): HeadersIterator { 126 | const values = Object.values(this._node.req.headers); 127 | for (const value of values) { 128 | yield _normalizeValue(value); 129 | } 130 | } 131 | 132 | [Symbol.iterator](): HeadersIterator<[string, string]> { 133 | return this.entries()[Symbol.iterator](); 134 | } 135 | 136 | get [Symbol.toStringTag]() { 137 | return "Headers"; 138 | } 139 | 140 | [kNodeInspect]() { 141 | return Object.fromEntries(this.entries()); 142 | } 143 | }; 144 | 145 | Object.setPrototypeOf(_Headers.prototype, globalThis.Headers.prototype); 146 | 147 | return _Headers; 148 | })(); 149 | 150 | export const NodeResponseHeaders: { 151 | new (nodeCtx: { 152 | req?: NodeServerRequest; 153 | res: NodeServerResponse; 154 | }): globalThis.Headers; 155 | } = /* @__PURE__ */ (() => { 156 | const _Headers = class Headers implements globalThis.Headers { 157 | _node: { req?: NodeServerRequest; res: NodeServerResponse }; 158 | 159 | constructor(nodeCtx: { req?: NodeServerRequest; res: NodeServerResponse }) { 160 | this._node = nodeCtx; 161 | } 162 | 163 | append(name: string, value: string): void { 164 | this._node.res.appendHeader(name, value); 165 | } 166 | 167 | delete(name: string): void { 168 | this._node.res.removeHeader(name); 169 | } 170 | 171 | get(name: string): string | null { 172 | const rawValue = this._node.res.getHeader(name); 173 | if (rawValue === undefined) { 174 | return null; 175 | } 176 | return _normalizeValue(rawValue); 177 | } 178 | 179 | getSetCookie(): string[] { 180 | const setCookie = _normalizeValue(this._node.res.getHeader("set-cookie")); 181 | if (!setCookie) { 182 | return []; 183 | } 184 | return splitSetCookieString(setCookie); 185 | } 186 | 187 | has(name: string): boolean { 188 | return this._node.res.hasHeader(name); 189 | } 190 | 191 | set(name: string, value: string): void { 192 | this._node.res.setHeader(name, value); 193 | } 194 | 195 | get count(): number { 196 | // Bun-specific addon 197 | throw new Error("Method not implemented."); 198 | } 199 | 200 | getAll(_name: "set-cookie" | "Set-Cookie"): string[] { 201 | // Bun-specific addon 202 | throw new Error("Method not implemented."); 203 | } 204 | 205 | toJSON(): Record { 206 | const _headers = this._node.res.getHeaders(); 207 | const result: Record = {}; 208 | for (const key in _headers) { 209 | if (_headers[key]) { 210 | result[key] = _normalizeValue(_headers[key]); 211 | } 212 | } 213 | return result; 214 | } 215 | 216 | forEach( 217 | cb: (value: string, key: string, parent: Headers) => void, 218 | thisArg?: any, 219 | ): void { 220 | const _headers = this._node.res.getHeaders(); 221 | for (const key in _headers) { 222 | if (_headers[key]) { 223 | cb.call( 224 | thisArg, 225 | _normalizeValue(_headers[key]), 226 | key, 227 | this as unknown as Headers, 228 | ); 229 | } 230 | } 231 | } 232 | 233 | *entries(): HeadersIterator<[string, string]> { 234 | const _headers = this._node.res.getHeaders(); 235 | for (const key in _headers) { 236 | yield [key, _normalizeValue(_headers[key])]; 237 | } 238 | } 239 | 240 | *keys(): HeadersIterator { 241 | const keys = this._node.res.getHeaderNames(); 242 | for (const key of keys) { 243 | yield key; 244 | } 245 | } 246 | 247 | *values(): HeadersIterator { 248 | const values = Object.values(this._node.res.getHeaders()); 249 | for (const value of values) { 250 | yield _normalizeValue(value); 251 | } 252 | } 253 | 254 | [Symbol.iterator](): HeadersIterator<[string, string]> { 255 | return this.entries()[Symbol.iterator](); 256 | } 257 | 258 | get [Symbol.toStringTag]() { 259 | return "Headers"; 260 | } 261 | 262 | [kNodeInspect]() { 263 | return Object.fromEntries(this.entries()); 264 | } 265 | }; 266 | 267 | Object.setPrototypeOf(_Headers.prototype, globalThis.Headers.prototype); 268 | 269 | return _Headers; 270 | })(); 271 | 272 | function _normalizeValue( 273 | value: string | string[] | number | undefined, 274 | ): string { 275 | if (Array.isArray(value)) { 276 | return value.join(", "); 277 | } 278 | return typeof value === "string" ? value : String(value ?? ""); 279 | } 280 | 281 | function validateHeader(name: string): string { 282 | if (name[0] === ":") { 283 | throw new TypeError(`${JSON.stringify(name)} is an invalid header name.`); 284 | } 285 | return name.toLowerCase(); 286 | } 287 | -------------------------------------------------------------------------------- /src/adapters/_node/index.ts: -------------------------------------------------------------------------------- 1 | export { NodeResponse } from "./response.ts"; 2 | export { NodeRequest } from "./request.ts"; 3 | export { NodeRequestHeaders, NodeResponseHeaders } from "./headers.ts"; 4 | export { NodeRequestURL } from "./url.ts"; 5 | -------------------------------------------------------------------------------- /src/adapters/_node/request.ts: -------------------------------------------------------------------------------- 1 | import { kNodeInspect } from "./_common.ts"; 2 | import { NodeRequestHeaders } from "./headers.ts"; 3 | import { NodeRequestURL } from "./url.ts"; 4 | 5 | import type { 6 | NodeServerRequest, 7 | NodeServerResponse, 8 | ServerRequest, 9 | ServerRuntimeContext, 10 | } from "../../types.ts"; 11 | 12 | export type NodeRequestContext = { 13 | req: NodeServerRequest; 14 | res?: NodeServerResponse; 15 | }; 16 | 17 | export const NodeRequest = /* @__PURE__ */ (() => { 18 | const _Request = class Request { 19 | #url?: InstanceType; 20 | #headers?: InstanceType; 21 | #bodyUsed: boolean = false; 22 | #abortSignal?: AbortController; 23 | #hasBody: boolean | undefined; 24 | #bodyBytes?: Promise; 25 | #blobBody?: Promise; 26 | #formDataBody?: Promise; 27 | #jsonBody?: Promise; 28 | #textBody?: Promise; 29 | #bodyStream?: undefined | ReadableStream; 30 | 31 | _node: { req: NodeServerRequest; res?: NodeServerResponse }; 32 | runtime: ServerRuntimeContext; 33 | 34 | constructor(nodeCtx: NodeRequestContext) { 35 | this._node = nodeCtx; 36 | this.runtime = { 37 | name: "node", 38 | node: nodeCtx, 39 | }; 40 | } 41 | 42 | get ip() { 43 | return this._node.req.socket?.remoteAddress; 44 | } 45 | 46 | get headers() { 47 | if (!this.#headers) { 48 | this.#headers = new NodeRequestHeaders(this._node); 49 | } 50 | return this.#headers; 51 | } 52 | 53 | clone() { 54 | return new _Request({ ...this._node }); 55 | } 56 | 57 | get _url() { 58 | if (!this.#url) { 59 | this.#url = new NodeRequestURL(this._node); 60 | } 61 | return this.#url; 62 | } 63 | 64 | get url() { 65 | return this._url.href; 66 | } 67 | 68 | get method() { 69 | return this._node.req.method || "GET"; 70 | } 71 | 72 | get signal() { 73 | if (!this.#abortSignal) { 74 | this.#abortSignal = new AbortController(); 75 | this._node.req.once("close", () => { 76 | this.#abortSignal?.abort(); 77 | }); 78 | } 79 | return this.#abortSignal.signal; 80 | } 81 | 82 | get bodyUsed() { 83 | return this.#bodyUsed; 84 | } 85 | 86 | get _hasBody() { 87 | if (this.#hasBody !== undefined) { 88 | return this.#hasBody; 89 | } 90 | // Check if request method requires a payload 91 | const method = this._node.req.method?.toUpperCase(); 92 | if ( 93 | !method || 94 | !( 95 | method === "PATCH" || 96 | method === "POST" || 97 | method === "PUT" || 98 | method === "DELETE" 99 | ) 100 | ) { 101 | this.#hasBody = false; 102 | return false; 103 | } 104 | 105 | // Make sure either content-length or transfer-encoding/chunked is set 106 | if (!Number.parseInt(this._node.req.headers["content-length"] || "")) { 107 | const isChunked = (this._node.req.headers["transfer-encoding"] || "") 108 | .split(",") 109 | .map((e) => e.trim()) 110 | .filter(Boolean) 111 | .includes("chunked"); 112 | if (!isChunked) { 113 | this.#hasBody = false; 114 | return false; 115 | } 116 | } 117 | this.#hasBody = true; 118 | return true; 119 | } 120 | 121 | get body() { 122 | if (!this._hasBody) { 123 | return null; 124 | } 125 | if (!this.#bodyStream) { 126 | this.#bodyUsed = true; 127 | this.#bodyStream = new ReadableStream({ 128 | start: (controller) => { 129 | this._node.req 130 | .on("data", (chunk) => { 131 | controller.enqueue(chunk); 132 | }) 133 | .once("error", (error) => { 134 | controller.error(error); 135 | this.#abortSignal?.abort(); 136 | }) 137 | .once("close", () => { 138 | this.#abortSignal?.abort(); 139 | }) 140 | .once("end", () => { 141 | controller.close(); 142 | }); 143 | }, 144 | }); 145 | } 146 | return this.#bodyStream; 147 | } 148 | 149 | bytes(): Promise { 150 | if (!this.#bodyBytes) { 151 | const _bodyStream = this.body; 152 | this.#bodyBytes = _bodyStream 153 | ? _readStream(_bodyStream) 154 | : Promise.resolve(new Uint8Array()); 155 | } 156 | return this.#bodyBytes; 157 | } 158 | 159 | arrayBuffer(): Promise { 160 | return this.bytes().then((buff) => { 161 | return buff.buffer.slice( 162 | buff.byteOffset, 163 | buff.byteOffset + buff.byteLength, 164 | ) as ArrayBuffer; 165 | }); 166 | } 167 | 168 | blob(): Promise { 169 | if (!this.#blobBody) { 170 | this.#blobBody = this.bytes().then((bytes) => { 171 | return new Blob([bytes], { 172 | type: this._node.req.headers["content-type"], 173 | }); 174 | }); 175 | } 176 | return this.#blobBody; 177 | } 178 | 179 | formData(): Promise { 180 | if (!this.#formDataBody) { 181 | this.#formDataBody = new Response(this.body, { 182 | headers: this.headers as unknown as Headers, 183 | }).formData(); 184 | } 185 | return this.#formDataBody; 186 | } 187 | 188 | text(): Promise { 189 | if (!this.#textBody) { 190 | this.#textBody = this.bytes().then((bytes) => { 191 | return new TextDecoder().decode(bytes); 192 | }); 193 | } 194 | return this.#textBody; 195 | } 196 | 197 | json(): Promise { 198 | if (!this.#jsonBody) { 199 | this.#jsonBody = this.text().then((txt) => { 200 | return JSON.parse(txt); 201 | }); 202 | } 203 | return this.#jsonBody; 204 | } 205 | 206 | get [Symbol.toStringTag]() { 207 | return "Request"; 208 | } 209 | 210 | [kNodeInspect]() { 211 | return { 212 | method: this.method, 213 | url: this.url, 214 | headers: this.headers, 215 | }; 216 | } 217 | }; 218 | 219 | Object.setPrototypeOf(_Request.prototype, globalThis.Request.prototype); 220 | 221 | return _Request; 222 | })() as unknown as { 223 | new (nodeCtx: NodeRequestContext): ServerRequest; 224 | }; 225 | 226 | async function _readStream(stream: ReadableStream) { 227 | const chunks: Uint8Array[] = []; 228 | await stream.pipeTo( 229 | new WritableStream({ 230 | write(chunk) { 231 | chunks.push(chunk); 232 | }, 233 | }), 234 | ); 235 | return Buffer.concat(chunks); 236 | } 237 | -------------------------------------------------------------------------------- /src/adapters/_node/response.ts: -------------------------------------------------------------------------------- 1 | import { splitSetCookieString } from "cookie-es"; 2 | 3 | import type NodeHttp from "node:http"; 4 | import type { Readable as NodeReadable } from "node:stream"; 5 | 6 | export type NodeResponse = InstanceType; 7 | 8 | /** 9 | * Fast Response for Node.js runtime 10 | * 11 | * It is faster because in most cases it doesn't create a full Response instance. 12 | */ 13 | export const NodeResponse: { 14 | new ( 15 | body?: BodyInit | null, 16 | init?: ResponseInit, 17 | ): globalThis.Response & { 18 | readonly nodeResponse: () => { 19 | status: number; 20 | statusText: string; 21 | headers: NodeHttp.OutgoingHttpHeader[]; 22 | body: 23 | | string 24 | | Buffer 25 | | Uint8Array 26 | | DataView 27 | | ReadableStream 28 | | NodeReadable 29 | | undefined 30 | | null; 31 | }; 32 | }; 33 | } = /* @__PURE__ */ (() => { 34 | const CONTENT_TYPE = "content-type"; 35 | const JSON_TYPE = "application/json"; 36 | const JSON_HEADER = [[CONTENT_TYPE, JSON_TYPE]] as HeadersInit; 37 | 38 | const _Response = class Response implements globalThis.Response { 39 | #body?: BodyInit | null; 40 | #init?: ResponseInit; 41 | 42 | constructor(body?: BodyInit | null, init?: ResponseInit) { 43 | this.#body = body; 44 | this.#init = init; 45 | } 46 | 47 | static json(data: any, init?: ResponseInit): Response { 48 | if (init?.headers) { 49 | if (!(init.headers as Record)[CONTENT_TYPE]) { 50 | const initHeaders = new Headers(init.headers); 51 | if (!initHeaders.has(CONTENT_TYPE)) { 52 | initHeaders.set(CONTENT_TYPE, JSON_TYPE); 53 | } 54 | init = { ...init, headers: initHeaders }; 55 | } 56 | } else { 57 | init = init ? { ...init } : {}; 58 | init.headers = JSON_HEADER; 59 | } 60 | return new _Response(JSON.stringify(data), init); 61 | } 62 | 63 | static error(): globalThis.Response { 64 | return globalThis.Response.error(); 65 | } 66 | 67 | static redirect(url: string | URL, status?: number): globalThis.Response { 68 | return globalThis.Response.redirect(url, status); 69 | } 70 | 71 | /** 72 | * Prepare Node.js response object 73 | */ 74 | nodeResponse() { 75 | const status = this.#init?.status ?? 200; 76 | const statusText = this.#init?.statusText ?? ""; 77 | 78 | const headers: NodeHttp.OutgoingHttpHeader[] = []; 79 | 80 | const headersInit = this.#init?.headers; 81 | if (this.#headersObj) { 82 | for (const [key, value] of this.#headersObj) { 83 | if (key === "set-cookie") { 84 | for (const setCookie of splitSetCookieString(value)) { 85 | headers.push(["set-cookie", setCookie]); 86 | } 87 | } else { 88 | headers.push([key, value]); 89 | } 90 | } 91 | } else if (headersInit) { 92 | const headerEntries = Array.isArray(headersInit) 93 | ? headersInit 94 | : headersInit.entries 95 | ? (headersInit as Headers).entries() 96 | : Object.entries(headersInit); 97 | for (const [key, value] of headerEntries) { 98 | if (key === "set-cookie") { 99 | for (const setCookie of splitSetCookieString(value)) { 100 | headers.push(["set-cookie", setCookie]); 101 | } 102 | } else { 103 | headers.push([key, value]); 104 | } 105 | } 106 | } 107 | 108 | const bodyInit = this.#body as BodyInit | null | undefined | NodeReadable; 109 | // prettier-ignore 110 | let body: string | Buffer | Uint8Array | DataView | ReadableStream | NodeReadable | undefined | null; 111 | if (bodyInit) { 112 | if (typeof bodyInit === "string") { 113 | body = bodyInit; 114 | } else if (bodyInit instanceof ReadableStream) { 115 | body = bodyInit; 116 | } else if (bodyInit instanceof ArrayBuffer) { 117 | body = Buffer.from(bodyInit); 118 | } else if (bodyInit instanceof Uint8Array) { 119 | body = Buffer.from(bodyInit); 120 | } else if (bodyInit instanceof DataView) { 121 | body = Buffer.from(bodyInit.buffer); 122 | } else if (bodyInit instanceof Blob) { 123 | body = bodyInit.stream(); 124 | if (bodyInit.type) { 125 | headers.push(["content-type", bodyInit.type]); 126 | } 127 | } else if (typeof (bodyInit as NodeReadable).pipe === "function") { 128 | body = bodyInit as NodeReadable; 129 | } else { 130 | const res = new globalThis.Response(bodyInit as BodyInit); 131 | body = res.body; 132 | for (const [key, value] of res.headers) { 133 | headers.push([key, value]); 134 | } 135 | } 136 | } 137 | 138 | // Free up memory 139 | this.#body = undefined; 140 | this.#init = undefined; 141 | this.#headersObj = undefined; 142 | this.#responseObj = undefined; 143 | 144 | return { 145 | status, 146 | statusText, 147 | headers, 148 | body, 149 | }; 150 | } 151 | 152 | // ... the rest is for interface compatibility only and usually not to be used ... 153 | 154 | /** Lazy initialized response instance */ 155 | #responseObj?: globalThis.Response; 156 | 157 | /** Lazy initialized headers instance */ 158 | #headersObj?: Headers; 159 | 160 | clone(): globalThis.Response { 161 | if (this.#responseObj) { 162 | return this.#responseObj.clone(); 163 | } 164 | if (this.#headersObj) { 165 | return new globalThis.Response(this.#body, { 166 | ...this.#init, 167 | headers: new Headers(this.#headersObj), 168 | }); 169 | } 170 | return new globalThis.Response(this.#body, this.#init); 171 | } 172 | 173 | get #response(): globalThis.Response { 174 | if (!this.#responseObj) { 175 | this.#responseObj = this.#headersObj 176 | ? new globalThis.Response(this.#body, { 177 | ...this.#init, 178 | headers: new Headers(this.#headersObj), 179 | }) 180 | : new globalThis.Response(this.#body, this.#init); 181 | // Free up memory 182 | this.#body = undefined; 183 | this.#init = undefined; 184 | this.#headersObj = undefined; 185 | } 186 | return this.#responseObj; 187 | } 188 | 189 | get headers(): Headers { 190 | if (this.#responseObj) { 191 | return this.#responseObj.headers; // Reuse instance 192 | } 193 | if (!this.#headersObj) { 194 | this.#headersObj = new Headers(this.#init?.headers); 195 | } 196 | return this.#headersObj; 197 | } 198 | 199 | get ok(): boolean { 200 | if (this.#responseObj) { 201 | return this.#responseObj.ok; 202 | } 203 | const status = this.#init?.status ?? 200; 204 | return status >= 200 && status < 300; 205 | } 206 | 207 | get redirected(): boolean { 208 | if (this.#responseObj) { 209 | return this.#responseObj.redirected; 210 | } 211 | return false; 212 | } 213 | 214 | get status(): number { 215 | if (this.#responseObj) { 216 | return this.#responseObj.status; 217 | } 218 | return this.#init?.status ?? 200; 219 | } 220 | 221 | get statusText(): string { 222 | if (this.#responseObj) { 223 | return this.#responseObj.statusText; 224 | } 225 | return this.#init?.statusText ?? ""; 226 | } 227 | 228 | get type(): ResponseType { 229 | if (this.#responseObj) { 230 | return this.#responseObj.type; 231 | } 232 | return "default"; 233 | } 234 | 235 | get url(): string { 236 | if (this.#responseObj) { 237 | return this.#responseObj.url; 238 | } 239 | return ""; 240 | } 241 | 242 | // --- body --- 243 | 244 | #fastBody( 245 | as: new (...args: any[]) => T, 246 | ): T | null | false { 247 | const bodyInit = this.#body; 248 | if (bodyInit === null || bodyInit === undefined) { 249 | return null; // No body 250 | } 251 | if (bodyInit instanceof as) { 252 | return bodyInit; // Fast path 253 | } 254 | return false; // Not supported 255 | } 256 | 257 | get body(): ReadableStream | null { 258 | if (this.#responseObj) { 259 | return this.#responseObj.body; // Reuse instance 260 | } 261 | const fastBody = this.#fastBody(ReadableStream); 262 | if (fastBody !== false) { 263 | return fastBody as ReadableStream; // Fast path 264 | } 265 | return this.#response.body; // Slow path 266 | } 267 | 268 | get bodyUsed(): boolean { 269 | if (this.#responseObj) { 270 | return this.#responseObj.bodyUsed; 271 | } 272 | return false; 273 | } 274 | 275 | arrayBuffer(): Promise { 276 | if (this.#responseObj) { 277 | return this.#responseObj.arrayBuffer(); // Reuse instance 278 | } 279 | const fastBody = this.#fastBody(ArrayBuffer); 280 | if (fastBody !== false) { 281 | return Promise.resolve(fastBody || new ArrayBuffer(0)); // Fast path 282 | } 283 | return this.#response.arrayBuffer(); // Slow path 284 | } 285 | 286 | blob(): Promise { 287 | if (this.#responseObj) { 288 | return this.#responseObj.blob(); // Reuse instance 289 | } 290 | const fastBody = this.#fastBody(Blob); 291 | if (fastBody !== false) { 292 | return Promise.resolve(fastBody || new Blob()); // Fast path 293 | } 294 | return this.#response.blob(); // Slow path 295 | } 296 | 297 | bytes(): Promise> { 298 | if (this.#responseObj) { 299 | return this.#responseObj.bytes(); // Reuse instance 300 | } 301 | const fastBody = this.#fastBody(Uint8Array); 302 | if (fastBody !== false) { 303 | return Promise.resolve(fastBody || new Uint8Array()); // Fast path 304 | } 305 | return this.#response.bytes(); // Slow path 306 | } 307 | 308 | formData(): Promise { 309 | if (this.#responseObj) { 310 | return this.#responseObj.formData(); // Reuse instance 311 | } 312 | const fastBody = this.#fastBody(FormData); 313 | if (fastBody !== false) { 314 | // TODO: Content-Type should be one of "multipart/form-data" or "application/x-www-form-urlencoded" 315 | return Promise.resolve(fastBody || new FormData()); // Fast path 316 | } 317 | return this.#response.formData(); // Slow path 318 | } 319 | 320 | text(): Promise { 321 | if (this.#responseObj) { 322 | return this.#responseObj.text(); // Reuse instance 323 | } 324 | const bodyInit = this.#body; 325 | if (bodyInit === null || bodyInit === undefined) { 326 | return Promise.resolve(""); // No body 327 | } 328 | if (typeof bodyInit === "string") { 329 | return Promise.resolve(bodyInit); // Fast path 330 | } 331 | return this.#response.text(); // Slow path 332 | } 333 | 334 | json(): Promise { 335 | if (this.#responseObj) { 336 | return this.#responseObj.json(); // Reuse instance 337 | } 338 | return this.text().then((text) => JSON.parse(text)); 339 | } 340 | }; 341 | 342 | Object.setPrototypeOf(_Response.prototype, globalThis.Response.prototype); 343 | 344 | return _Response; 345 | })(); 346 | -------------------------------------------------------------------------------- /src/adapters/_node/send.ts: -------------------------------------------------------------------------------- 1 | import { splitSetCookieString } from "cookie-es"; 2 | 3 | import type { Readable as NodeReadable } from "node:stream"; 4 | import type NodeHttp from "node:http"; 5 | import type { NodeServerResponse } from "../../types.ts"; 6 | import type { NodeResponse } from "./response.ts"; 7 | 8 | export async function sendNodeResponse( 9 | nodeRes: NodeServerResponse, 10 | webRes: Response | NodeResponse, 11 | ): Promise { 12 | if (!webRes) { 13 | nodeRes.statusCode = 500; 14 | return endNodeResponse(nodeRes); 15 | } 16 | 17 | // Fast path for NodeResponse 18 | if ((webRes as NodeResponse).nodeResponse) { 19 | const res = (webRes as NodeResponse).nodeResponse(); 20 | writeHead(nodeRes, res.status, res.statusText, res.headers.flat()); 21 | if (res.body) { 22 | if (res.body instanceof ReadableStream) { 23 | return streamBody(res.body, nodeRes); 24 | } else if (typeof (res.body as NodeReadable)?.pipe === "function") { 25 | (res.body as NodeReadable).pipe(nodeRes); 26 | return new Promise((resolve) => nodeRes.on("close", resolve)); 27 | } 28 | // Note: NodeHttp2ServerResponse.write() body type declared as string | Uint8Array 29 | // We explicitly test other types in runtime. 30 | (nodeRes as NodeHttp.ServerResponse).write(res.body); 31 | } 32 | return endNodeResponse(nodeRes); 33 | } 34 | 35 | const headerEntries: NodeHttp.OutgoingHttpHeader[] = []; 36 | for (const [key, value] of webRes.headers) { 37 | if (key === "set-cookie") { 38 | for (const setCookie of splitSetCookieString(value)) { 39 | headerEntries.push(["set-cookie", setCookie]); 40 | } 41 | } else { 42 | headerEntries.push([key, value]); 43 | } 44 | } 45 | 46 | writeHead(nodeRes, webRes.status, webRes.statusText, headerEntries.flat()); 47 | 48 | return webRes.body 49 | ? streamBody(webRes.body, nodeRes) 50 | : endNodeResponse(nodeRes); 51 | } 52 | 53 | function writeHead( 54 | nodeRes: NodeServerResponse, 55 | status: number, 56 | statusText: string, 57 | headers: NodeHttp.OutgoingHttpHeader[], 58 | ): void { 59 | if (!nodeRes.headersSent) { 60 | if (nodeRes.req?.httpVersion === "2.0") { 61 | nodeRes.writeHead(status, headers.flat() as any); 62 | } else { 63 | nodeRes.writeHead(status, statusText, headers.flat() as any); 64 | } 65 | } 66 | } 67 | 68 | function endNodeResponse(nodeRes: NodeServerResponse) { 69 | return new Promise((resolve) => nodeRes.end(resolve)); 70 | } 71 | 72 | export function streamBody( 73 | stream: ReadableStream, 74 | nodeRes: NodeServerResponse, 75 | ): Promise | void { 76 | // stream is already destroyed 77 | if (nodeRes.destroyed) { 78 | stream.cancel(); 79 | return; 80 | } 81 | 82 | const reader = stream.getReader(); 83 | 84 | // Cancel the stream and destroy the response 85 | function streamCancel(error?: Error) { 86 | reader.cancel(error).catch(() => {}); 87 | if (error) { 88 | nodeRes.destroy(error); 89 | } 90 | } 91 | 92 | function streamHandle({ 93 | done, 94 | value, 95 | }: ReadableStreamReadResult): void | Promise { 96 | try { 97 | if (done) { 98 | // End the response 99 | nodeRes.end(); 100 | } else if ((nodeRes as NodeHttp.ServerResponse).write(value)) { 101 | // Continue reading recursively 102 | reader.read().then(streamHandle, streamCancel); 103 | } else { 104 | // Wait for the drain event to continue reading 105 | nodeRes.once("drain", () => 106 | reader.read().then(streamHandle, streamCancel), 107 | ); 108 | } 109 | } catch (error) { 110 | streamCancel(error instanceof Error ? error : undefined); 111 | } 112 | } 113 | 114 | // Listen for close and error events to cancel the stream 115 | nodeRes.on("close", streamCancel); 116 | nodeRes.on("error", streamCancel); 117 | reader.read().then(streamHandle, streamCancel); 118 | 119 | // Return a promise that resolves when the stream is closed 120 | return reader.closed.finally(() => { 121 | // cleanup listeners 122 | nodeRes.off("close", streamCancel); 123 | nodeRes.off("error", streamCancel); 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /src/adapters/_node/url.ts: -------------------------------------------------------------------------------- 1 | import { kNodeInspect } from "./_common.ts"; 2 | 3 | import type { NodeServerRequest, NodeServerResponse } from "../../types.ts"; 4 | 5 | export const NodeRequestURL: { 6 | new (nodeCtx: { req: NodeServerRequest; res?: NodeServerResponse }): URL; 7 | } = /* @__PURE__ */ (() => { 8 | const _URL = class URL implements Partial { 9 | _node: { 10 | req: NodeServerRequest; 11 | res?: NodeServerResponse; 12 | }; 13 | 14 | _hash = ""; 15 | _username = ""; 16 | _password = ""; 17 | _protocol?: string; 18 | _hostname?: string; 19 | _port?: string; 20 | 21 | _pathname?: string; 22 | _search?: string; 23 | _searchParams?: URLSearchParams; 24 | 25 | constructor(nodeCtx: { req: NodeServerRequest; res?: NodeServerResponse }) { 26 | this._node = nodeCtx; 27 | } 28 | 29 | get hash() { 30 | return this._hash; 31 | } 32 | 33 | set hash(value: string) { 34 | this._hash = value; 35 | } 36 | 37 | get username() { 38 | return this._username; 39 | } 40 | 41 | set username(value: string) { 42 | this._username = value; 43 | } 44 | 45 | get password() { 46 | return this._password; 47 | } 48 | 49 | set password(value: string) { 50 | this._password = value; 51 | } 52 | 53 | // host 54 | get host() { 55 | return ( 56 | this._node.req.headers.host || 57 | (this._node.req.headers[":authority"] as string) || 58 | "" 59 | ); 60 | } 61 | set host(value: string) { 62 | this._hostname = undefined; 63 | this._port = undefined; 64 | this._node.req.headers.host = value; 65 | } 66 | 67 | // hostname 68 | get hostname() { 69 | if (this._hostname === undefined) { 70 | const [hostname, port] = parseHost(this._node.req.headers.host); 71 | if (this._port === undefined && port) { 72 | this._port = String(Number.parseInt(port) || ""); 73 | } 74 | this._hostname = hostname || "localhost"; 75 | } 76 | return this._hostname; 77 | } 78 | set hostname(value: string) { 79 | this._hostname = value; 80 | } 81 | 82 | // port 83 | get port() { 84 | if (this._port === undefined) { 85 | const [hostname, port] = parseHost(this._node.req.headers.host); 86 | if (this._hostname === undefined && hostname) { 87 | this._hostname = hostname; 88 | } 89 | this._port = port || String(this._node.req.socket?.localPort || ""); 90 | } 91 | return this._port; 92 | } 93 | set port(value: string) { 94 | this._port = String(Number.parseInt(value) || ""); 95 | } 96 | 97 | // pathname 98 | get pathname() { 99 | if (this._pathname === undefined) { 100 | const [pathname, search] = parsePath(this._node.req.url || "/"); 101 | this._pathname = pathname; 102 | if (this._search === undefined) { 103 | this._search = search; 104 | } 105 | } 106 | return this._pathname; 107 | } 108 | set pathname(value: string) { 109 | if (value[0] !== "/") { 110 | value = "/" + value; 111 | } 112 | if (value === this._pathname) { 113 | return; 114 | } 115 | this._pathname = value; 116 | this._node.req.url = value + this.search; 117 | } 118 | 119 | // search 120 | get search() { 121 | if (this._search === undefined) { 122 | const [pathname, search] = parsePath(this._node.req.url || "/"); 123 | this._search = search; 124 | if (this._pathname === undefined) { 125 | this._pathname = pathname; 126 | } 127 | } 128 | return this._search; 129 | } 130 | set search(value: string) { 131 | if (value === "?") { 132 | value = ""; 133 | } else if (value && value[0] !== "?") { 134 | value = "?" + value; 135 | } 136 | if (value === this._search) { 137 | return; 138 | } 139 | this._search = value; 140 | this._searchParams = undefined; 141 | this._node.req.url = this.pathname + value; 142 | } 143 | 144 | // searchParams 145 | get searchParams() { 146 | if (!this._searchParams) { 147 | this._searchParams = new URLSearchParams(this.search); 148 | } 149 | return this._searchParams; 150 | } 151 | set searchParams(value: URLSearchParams) { 152 | this._searchParams = value; 153 | this._search = value.toString(); 154 | } 155 | 156 | // protocol 157 | get protocol() { 158 | if (!this._protocol) { 159 | this._protocol = 160 | (this._node.req.socket as any)?.encrypted || 161 | this._node.req.headers["x-forwarded-proto"] === "https" 162 | ? "https:" 163 | : "http:"; 164 | } 165 | return this._protocol; 166 | } 167 | set protocol(value) { 168 | this._protocol = value; 169 | } 170 | 171 | // origin 172 | get origin() { 173 | return `${this.protocol}//${this.host}`; 174 | } 175 | set origin(_value) { 176 | // ignore 177 | } 178 | 179 | // href 180 | get href() { 181 | return `${this.protocol}//${this.host}${this.pathname}${this.search}`; 182 | } 183 | set href(value: string) { 184 | const _url = new globalThis.URL(value); 185 | this._protocol = _url.protocol; 186 | this.username = _url.username; 187 | this.password = _url.password; 188 | this._hostname = _url.hostname; 189 | this._port = _url.port; 190 | this.pathname = _url.pathname; 191 | this.search = _url.search; 192 | this.hash = _url.hash; 193 | } 194 | 195 | toString(): string { 196 | return this.href; 197 | } 198 | 199 | toJSON(): string { 200 | return this.href; 201 | } 202 | 203 | get [Symbol.toStringTag]() { 204 | return "URL"; 205 | } 206 | 207 | [kNodeInspect]() { 208 | return this.href; 209 | } 210 | }; 211 | 212 | Object.setPrototypeOf(_URL.prototype, globalThis.URL.prototype); 213 | 214 | return _URL; 215 | })(); 216 | 217 | function parsePath(input: string): [pathname: string, search: string] { 218 | const url = (input || "/").replace(/\\/g, "/"); 219 | const qIndex = url.indexOf("?"); 220 | if (qIndex === -1) { 221 | return [url, ""]; 222 | } 223 | return [url.slice(0, qIndex), url.slice(qIndex)]; 224 | } 225 | 226 | function parseHost(host: string | undefined): [hostname: string, port: string] { 227 | const s = (host || "").split(":"); 228 | return [s[0], String(Number.parseInt(s[1]) || "")]; 229 | } 230 | -------------------------------------------------------------------------------- /src/adapters/bun.ts: -------------------------------------------------------------------------------- 1 | import type { BunFetchHandler, Server, ServerOptions } from "../types.ts"; 2 | import type * as bun from "bun"; 3 | import { 4 | fmtURL, 5 | printListening, 6 | resolvePortAndHost, 7 | resolveTLSOptions, 8 | createWaitUntil, 9 | } from "../_utils.ts"; 10 | import { wrapFetch } from "../_middleware.ts"; 11 | 12 | export { FastURL } from "../_url.ts"; 13 | export const FastResponse: typeof globalThis.Response = Response; 14 | 15 | export function serve(options: ServerOptions): BunServer { 16 | return new BunServer(options); 17 | } 18 | 19 | // https://bun.sh/docs/api/http 20 | 21 | class BunServer implements Server { 22 | readonly runtime = "bun"; 23 | readonly options: Server["options"]; 24 | readonly bun: Server["bun"] = {}; 25 | readonly serveOptions: bun.ServeOptions | bun.TLSServeOptions; 26 | readonly fetch: BunFetchHandler; 27 | 28 | #wait: ReturnType; 29 | 30 | constructor(options: ServerOptions) { 31 | this.options = { ...options, middleware: [...(options.middleware || [])] }; 32 | 33 | for (const plugin of options.plugins || []) plugin(this); 34 | 35 | const fetchHandler = wrapFetch(this); 36 | 37 | this.#wait = createWaitUntil(); 38 | 39 | this.fetch = (request, server) => { 40 | Object.defineProperties(request, { 41 | waitUntil: { value: this.#wait.waitUntil }, 42 | runtime: { 43 | enumerable: true, 44 | value: { name: "bun", bun: { server } }, 45 | }, 46 | ip: { 47 | enumerable: true, 48 | get() { 49 | return server?.requestIP(request as Request)?.address; 50 | }, 51 | }, 52 | }); 53 | return fetchHandler(request); 54 | }; 55 | 56 | const tls = resolveTLSOptions(this.options); 57 | this.serveOptions = { 58 | ...resolvePortAndHost(this.options), 59 | reusePort: this.options.reusePort, 60 | error: this.options.error, 61 | ...this.options.bun, 62 | tls: { 63 | cert: tls?.cert, 64 | key: tls?.key, 65 | passphrase: tls?.passphrase, 66 | ...(this.options.bun as bun.TLSServeOptions)?.tls, 67 | }, 68 | fetch: this.fetch, 69 | }; 70 | 71 | if (!options.manual) { 72 | this.serve(); 73 | } 74 | } 75 | 76 | serve(): Promise { 77 | if (!this.bun!.server) { 78 | this.bun!.server = Bun.serve(this.serveOptions); 79 | } 80 | printListening(this.options, this.url); 81 | return Promise.resolve(this); 82 | } 83 | 84 | get url(): string | undefined { 85 | const server = this.bun?.server; 86 | if (!server) { 87 | return; 88 | } 89 | // Prefer address since server.url hostname is not reliable 90 | const address = ( 91 | server as { address?: { address: string; family: string; port: number } } 92 | ).address; 93 | if (address) { 94 | return fmtURL( 95 | address.address, 96 | address.port, 97 | (server as any).protocol === "https", 98 | ); 99 | } 100 | return server.url.href; 101 | } 102 | 103 | ready(): Promise { 104 | return Promise.resolve(this); 105 | } 106 | 107 | async close(closeAll?: boolean): Promise { 108 | await Promise.all([ 109 | this.#wait.wait(), 110 | Promise.resolve(this.bun?.server?.stop(closeAll)), 111 | ]); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/adapters/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CloudflareFetchHandler, 3 | Server, 4 | ServerOptions, 5 | } from "../types.ts"; 6 | import type * as CF from "@cloudflare/workers-types"; 7 | import { wrapFetch } from "../_middleware.ts"; 8 | import { errorPlugin } from "../_plugins.ts"; 9 | 10 | export const FastURL: typeof globalThis.URL = URL; 11 | export const FastResponse: typeof globalThis.Response = Response; 12 | 13 | export function serve( 14 | options: ServerOptions, 15 | ): Server { 16 | return new CloudflareServer(options); 17 | } 18 | 19 | class CloudflareServer implements Server { 20 | readonly runtime = "cloudflare"; 21 | readonly options: Server["options"]; 22 | readonly serveOptions: CF.ExportedHandler; 23 | readonly fetch: CF.ExportedHandlerFetchHandler; 24 | 25 | constructor(options: ServerOptions) { 26 | this.options = { ...options, middleware: [...(options.middleware || [])] }; 27 | 28 | for (const plugin of options.plugins || []) plugin(this as any as Server); 29 | errorPlugin(this as unknown as Server); 30 | 31 | const fetchHandler = wrapFetch(this as unknown as Server); 32 | 33 | this.fetch = (request, env, context) => { 34 | Object.defineProperties(request, { 35 | waitUntil: { value: context.waitUntil.bind(context) }, 36 | runtime: { 37 | enumerable: true, 38 | value: { name: "cloudflare", cloudflare: { env, context } }, 39 | }, 40 | // TODO 41 | // ip: { 42 | // enumerable: true, 43 | // get() { 44 | // return; 45 | // }, 46 | // }, 47 | }); 48 | return fetchHandler(request as unknown as Request) as unknown as 49 | | CF.Response 50 | | Promise; 51 | }; 52 | 53 | this.serveOptions = { 54 | fetch: this.fetch, 55 | }; 56 | 57 | if (!options.manual) { 58 | this.serve(); 59 | } 60 | } 61 | 62 | serve() { 63 | addEventListener("fetch", (event) => { 64 | // @ts-expect-error 65 | event.respondWith(this.fetch(event.request, {}, event)); 66 | }); 67 | } 68 | 69 | ready(): Promise> { 70 | return Promise.resolve().then(() => this); 71 | } 72 | 73 | close() { 74 | return Promise.resolve(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/adapters/deno.ts: -------------------------------------------------------------------------------- 1 | import type { DenoFetchHandler, Server, ServerOptions } from "../types.ts"; 2 | import { 3 | createWaitUntil, 4 | fmtURL, 5 | printListening, 6 | resolvePortAndHost, 7 | resolveTLSOptions, 8 | } from "../_utils.ts"; 9 | import { wrapFetch } from "../_middleware.ts"; 10 | 11 | export { FastURL } from "../_url.ts"; 12 | export const FastResponse: typeof globalThis.Response = Response; 13 | 14 | export function serve(options: ServerOptions): DenoServer { 15 | return new DenoServer(options); 16 | } 17 | 18 | // https://docs.deno.com/api/deno/~/Deno.serve 19 | 20 | class DenoServer implements Server { 21 | readonly runtime = "deno"; 22 | readonly options: Server["options"]; 23 | readonly deno: Server["deno"] = {}; 24 | readonly serveOptions: 25 | | Deno.ServeTcpOptions 26 | | (Deno.ServeTcpOptions & Deno.TlsCertifiedKeyPem); 27 | readonly fetch: DenoFetchHandler; 28 | 29 | #listeningPromise?: Promise; 30 | #listeningInfo?: { hostname: string; port: number }; 31 | 32 | #wait: ReturnType; 33 | 34 | constructor(options: ServerOptions) { 35 | this.options = { ...options, middleware: [...(options.middleware || [])] }; 36 | 37 | for (const plugin of options.plugins || []) plugin(this); 38 | 39 | const fetchHandler = wrapFetch(this); 40 | 41 | this.#wait = createWaitUntil(); 42 | 43 | this.fetch = (request, info) => { 44 | Object.defineProperties(request, { 45 | waitUntil: { value: this.#wait.waitUntil }, 46 | runtime: { 47 | enumerable: true, 48 | value: { name: "deno", deno: { info, server: this.deno?.server } }, 49 | }, 50 | ip: { 51 | enumerable: true, 52 | get() { 53 | return (info?.remoteAddr as Deno.NetAddr)?.hostname; 54 | }, 55 | }, 56 | }); 57 | return fetchHandler(request); 58 | }; 59 | 60 | const tls = resolveTLSOptions(this.options); 61 | this.serveOptions = { 62 | ...resolvePortAndHost(this.options), 63 | reusePort: this.options.reusePort, 64 | onError: this.options.error, 65 | ...(tls 66 | ? { key: tls.key, cert: tls.cert, passphrase: tls.passphrase } 67 | : {}), 68 | ...this.options.deno, 69 | }; 70 | 71 | if (!options.manual) { 72 | this.serve(); 73 | } 74 | } 75 | 76 | serve(): Promise { 77 | if (this.deno?.server) { 78 | return Promise.resolve(this.#listeningPromise).then(() => this); 79 | } 80 | const onListenPromise = Promise.withResolvers(); 81 | this.#listeningPromise = onListenPromise.promise; 82 | this.deno!.server = Deno.serve( 83 | { 84 | ...this.serveOptions, 85 | onListen: (info) => { 86 | this.#listeningInfo = info; 87 | if (this.options.deno?.onListen) { 88 | this.options.deno.onListen(info); 89 | } 90 | printListening(this.options, this.url); 91 | onListenPromise.resolve(); 92 | }, 93 | }, 94 | this.fetch, 95 | ); 96 | return Promise.resolve(this.#listeningPromise).then(() => this); 97 | } 98 | 99 | get url(): string | undefined { 100 | return this.#listeningInfo 101 | ? fmtURL( 102 | this.#listeningInfo.hostname, 103 | this.#listeningInfo.port, 104 | !!(this.serveOptions as { cert: string }).cert, 105 | ) 106 | : undefined; 107 | } 108 | 109 | ready(): Promise { 110 | return Promise.resolve(this.#listeningPromise).then(() => this); 111 | } 112 | 113 | async close(): Promise { 114 | await Promise.all([ 115 | this.#wait.wait(), 116 | Promise.resolve(this.deno?.server?.shutdown()), 117 | ]); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/adapters/generic.ts: -------------------------------------------------------------------------------- 1 | import type { Server, ServerHandler, ServerOptions } from "../types.ts"; 2 | import { wrapFetch } from "../_middleware.ts"; 3 | import { errorPlugin } from "../_plugins.ts"; 4 | import { createWaitUntil } from "../_utils.ts"; 5 | 6 | export const FastURL: typeof globalThis.URL = URL; 7 | export const FastResponse: typeof globalThis.Response = Response; 8 | 9 | export function serve(options: ServerOptions): Server { 10 | return new GenericServer(options); 11 | } 12 | 13 | class GenericServer implements Server { 14 | readonly runtime = "generic"; 15 | readonly options: Server["options"]; 16 | readonly fetch: ServerHandler; 17 | 18 | #wait: ReturnType; 19 | 20 | constructor(options: ServerOptions) { 21 | this.options = { ...options, middleware: [...(options.middleware || [])] }; 22 | 23 | for (const plugin of options.plugins || []) plugin(this); 24 | errorPlugin(this); 25 | 26 | this.#wait = createWaitUntil(); 27 | 28 | const fetchHandler = wrapFetch(this as unknown as Server); 29 | 30 | this.fetch = (request: Request) => { 31 | Object.defineProperties(request, { 32 | waitUntil: { value: this.#wait.waitUntil }, 33 | }); 34 | return Promise.resolve(fetchHandler(request)); 35 | }; 36 | } 37 | 38 | serve(): void {} 39 | 40 | ready(): Promise { 41 | return Promise.resolve(this); 42 | } 43 | 44 | async close(): Promise { 45 | await this.#wait.wait(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/adapters/node.ts: -------------------------------------------------------------------------------- 1 | import { sendNodeResponse } from "./_node/send.ts"; 2 | import { NodeRequest } from "./_node/request.ts"; 3 | import { 4 | fmtURL, 5 | resolveTLSOptions, 6 | printListening, 7 | resolvePortAndHost, 8 | createWaitUntil, 9 | } from "../_utils.ts"; 10 | import { wrapFetch } from "../_middleware.ts"; 11 | import { errorPlugin } from "../_plugins.ts"; 12 | 13 | import type NodeHttp from "node:http"; 14 | import type NodeHttps from "node:https"; 15 | import type NodeHttp2 from "node:http2"; 16 | import type { 17 | FetchHandler, 18 | NodeHttpHandler, 19 | NodeServerRequest, 20 | NodeServerResponse, 21 | Server, 22 | ServerHandler, 23 | ServerOptions, 24 | } from "../types.ts"; 25 | 26 | export { FastURL } from "../_url.ts"; 27 | 28 | export { NodeRequest } from "./_node/request.ts"; 29 | export { NodeRequestHeaders, NodeResponseHeaders } from "./_node/headers.ts"; 30 | export { 31 | NodeResponse, 32 | NodeResponse as FastResponse, 33 | } from "./_node/response.ts"; 34 | 35 | export function serve(options: ServerOptions): Server { 36 | return new NodeServer(options); 37 | } 38 | 39 | export function toNodeHandler(fetchHandler: FetchHandler): NodeHttpHandler { 40 | return (nodeReq, nodeRes) => { 41 | const request = new NodeRequest({ req: nodeReq, res: nodeRes }); 42 | const res = fetchHandler(request); 43 | return res instanceof Promise 44 | ? res.then((resolvedRes) => sendNodeResponse(nodeRes, resolvedRes)) 45 | : sendNodeResponse(nodeRes, res); 46 | }; 47 | } 48 | 49 | // https://nodejs.org/api/http.html 50 | // https://nodejs.org/api/https.html 51 | // https://nodejs.org/api/http2.html 52 | class NodeServer implements Server { 53 | readonly runtime = "node"; 54 | readonly options: Server["options"]; 55 | readonly node: Server["node"]; 56 | readonly serveOptions: ServerOptions["node"]; 57 | readonly fetch: ServerHandler; 58 | readonly #isSecure: boolean; 59 | 60 | #listeningPromise?: Promise; 61 | 62 | #wait: ReturnType; 63 | 64 | constructor(options: ServerOptions) { 65 | this.options = { ...options, middleware: [...(options.middleware || [])] }; 66 | 67 | for (const plugin of options.plugins || []) plugin(this); 68 | errorPlugin(this); 69 | 70 | const fetchHandler = (this.fetch = wrapFetch(this)); 71 | 72 | this.#wait = createWaitUntil(); 73 | 74 | const handler = ( 75 | nodeReq: NodeServerRequest, 76 | nodeRes: NodeServerResponse, 77 | ) => { 78 | const request = new NodeRequest({ req: nodeReq, res: nodeRes }); 79 | request.waitUntil = this.#wait.waitUntil; 80 | const res = fetchHandler(request); 81 | return res instanceof Promise 82 | ? res.then((resolvedRes) => sendNodeResponse(nodeRes, resolvedRes)) 83 | : sendNodeResponse(nodeRes, res); 84 | }; 85 | 86 | const tls = resolveTLSOptions(this.options); 87 | const { port, hostname: host } = resolvePortAndHost(this.options); 88 | this.serveOptions = { 89 | port, 90 | host, 91 | exclusive: !this.options.reusePort, 92 | ...(tls 93 | ? { cert: tls.cert, key: tls.key, passphrase: tls.passphrase } 94 | : {}), 95 | ...this.options.node, 96 | }; 97 | 98 | // prettier-ignore 99 | let server: NodeHttp.Server | NodeHttps.Server | NodeHttp2.Http2SecureServer; 100 | 101 | // prettier-ignore 102 | this.#isSecure = !!(this.serveOptions as { cert?: string }).cert && this.options.protocol !== "http"; 103 | const isHttp2 = this.options.node?.http2 ?? this.#isSecure; 104 | 105 | if (isHttp2) { 106 | if (this.#isSecure) { 107 | const { createSecureServer } = process.getBuiltinModule("node:http2"); 108 | server = createSecureServer( 109 | { allowHTTP1: true, ...this.serveOptions }, 110 | handler, 111 | ); 112 | } else { 113 | throw new Error("node.http2 option requires tls certificate!"); 114 | } 115 | } else if (this.#isSecure) { 116 | const { createServer } = process.getBuiltinModule("node:https"); 117 | server = createServer( 118 | this.serveOptions as NodeHttps.ServerOptions, 119 | handler, 120 | ); 121 | } else { 122 | const { createServer } = process.getBuiltinModule("node:http"); 123 | server = createServer( 124 | this.serveOptions as NodeHttp.ServerOptions, 125 | handler, 126 | ); 127 | } 128 | 129 | this.node = { server, handler }; 130 | 131 | if (!options.manual) { 132 | this.serve(); 133 | } 134 | } 135 | 136 | serve() { 137 | if (this.#listeningPromise) { 138 | return Promise.resolve(this.#listeningPromise).then(() => this); 139 | } 140 | this.#listeningPromise = new Promise((resolve) => { 141 | this.node!.server!.listen(this.serveOptions, () => { 142 | printListening(this.options, this.url); 143 | resolve(); 144 | }); 145 | }); 146 | } 147 | 148 | get url() { 149 | const addr = this.node?.server?.address(); 150 | if (!addr) { 151 | return; 152 | } 153 | 154 | return typeof addr === "string" 155 | ? addr /* socket */ 156 | : fmtURL(addr.address, addr.port, this.#isSecure); 157 | } 158 | 159 | ready(): Promise { 160 | return Promise.resolve(this.#listeningPromise).then(() => this); 161 | } 162 | 163 | async close(closeAll?: boolean): Promise { 164 | await Promise.all([ 165 | this.#wait.wait(), 166 | new Promise((resolve, reject) => { 167 | const server = this.node?.server; 168 | if (!server) { 169 | return resolve(); 170 | } 171 | if (closeAll && "closeAllConnections" in server) { 172 | server.closeAllConnections(); 173 | } 174 | server.close((error?: Error) => (error ? reject(error) : resolve())); 175 | }), 176 | ]); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/adapters/service-worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-global-this */ 2 | import type { Server, ServerOptions, ServerRequest } from "../types.ts"; 3 | import { wrapFetch } from "../_middleware.ts"; 4 | import { errorPlugin } from "../_plugins.ts"; 5 | 6 | export const FastURL: typeof globalThis.URL = URL; 7 | export const FastResponse: typeof globalThis.Response = Response; 8 | 9 | export type ServiceWorkerHandler = ( 10 | request: ServerRequest, 11 | event: FetchEvent, 12 | ) => Response | Promise; 13 | 14 | const isBrowserWindow = 15 | typeof window !== "undefined" && typeof navigator !== "undefined"; 16 | 17 | const isServiceWorker = /* @__PURE__ */ (() => 18 | typeof self !== "undefined" && "skipWaiting" in self)(); 19 | 20 | export function serve(options: ServerOptions): Server { 21 | return new ServiceWorkerServer(options); 22 | } 23 | 24 | class ServiceWorkerServer implements Server { 25 | readonly runtime = "service-worker"; 26 | readonly options: Server["options"]; 27 | readonly fetch: ServiceWorkerHandler; 28 | 29 | #fetchListener?: (event: FetchEvent) => void | Promise; 30 | #listeningPromise?: Promise; 31 | 32 | constructor(options: ServerOptions) { 33 | this.options = { ...options, middleware: [...(options.middleware || [])] }; 34 | 35 | for (const plugin of options.plugins || []) plugin(this as any as Server); 36 | errorPlugin(this as unknown as Server); 37 | 38 | const fetchHandler = wrapFetch(this as unknown as Server); 39 | 40 | this.fetch = (request: Request, event: FetchEvent) => { 41 | Object.defineProperties(request, { 42 | runtime: { 43 | enumerable: true, 44 | value: { name: "service-worker", serviceWorker: { event } }, 45 | }, 46 | }); 47 | return Promise.resolve(fetchHandler(request)); 48 | }; 49 | 50 | if (!options.manual) { 51 | this.serve(); 52 | } 53 | } 54 | 55 | serve() { 56 | if (isBrowserWindow) { 57 | if (!navigator.serviceWorker) { 58 | throw new Error( 59 | "Service worker is not supported in the current window.", 60 | ); 61 | } 62 | const swURL = this.options.serviceWorker?.url; 63 | if (!swURL) { 64 | throw new Error( 65 | "Service worker URL is not provided. Please set the `serviceWorker.url` serve option or manually register.", 66 | ); 67 | } 68 | // Self-register the service worker 69 | this.#listeningPromise = navigator.serviceWorker 70 | .register(swURL, { 71 | type: "module", 72 | scope: this.options.serviceWorker?.scope, 73 | }) 74 | .then((registration) => { 75 | if (registration.active) { 76 | location.replace(location.href); 77 | } else { 78 | registration.addEventListener("updatefound", () => { 79 | location.replace(location.href); 80 | }); 81 | } 82 | }); 83 | } else if (isServiceWorker) { 84 | // Listen for the 'fetch' event to handle requests 85 | this.#fetchListener = async (event) => { 86 | // skip if event url ends with file with extension 87 | if ( 88 | /\/[^/]*\.[a-zA-Z0-9]+$/.test(new URL(event.request.url).pathname) 89 | ) { 90 | return; 91 | } 92 | Object.defineProperty(event.request, "waitUntil", { 93 | value: event.waitUntil.bind(event), 94 | }); 95 | const response = await this.fetch(event.request, event); 96 | if (response.status !== 404) { 97 | event.respondWith(response); 98 | } 99 | }; 100 | 101 | addEventListener("fetch", this.#fetchListener); 102 | 103 | self.addEventListener("install", () => { 104 | self.skipWaiting(); 105 | }); 106 | 107 | self.addEventListener("activate", () => { 108 | self.clients?.claim?.(); 109 | }); 110 | } 111 | } 112 | 113 | ready(): Promise> { 114 | return Promise.resolve(this.#listeningPromise).then(() => this); 115 | } 116 | 117 | async close() { 118 | if (this.#fetchListener) { 119 | removeEventListener("fetch", this.#fetchListener!); 120 | } 121 | 122 | // unregister the service worker 123 | if (isBrowserWindow) { 124 | const registrations = await navigator.serviceWorker.getRegistrations(); 125 | for (const registration of registrations) { 126 | if (registration.active) { 127 | await registration.unregister(); 128 | } 129 | } 130 | } else if (isServiceWorker) { 131 | await self.registration.unregister(); 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type * as NodeHttp from "node:http"; 2 | import type * as NodeHttps from "node:https"; 3 | import type * as NodeHttp2 from "node:http2"; 4 | import type * as NodeNet from "node:net"; 5 | import type * as Bun from "bun"; 6 | import type * as CF from "@cloudflare/workers-types"; 7 | 8 | type MaybePromise = T | Promise; 9 | 10 | // ---------------------------------------------------------------------------- 11 | // srvx API 12 | // ---------------------------------------------------------------------------- 13 | 14 | /** 15 | * Faster URL constructor with lazy access to pathname and search params (For Node, Deno, and Bun). 16 | */ 17 | export declare const FastURL: typeof globalThis.URL; 18 | 19 | /** 20 | * Faster Response constructor optimized for Node.js (same as Response for other runtimes). 21 | */ 22 | export declare const FastResponse: typeof globalThis.Response; 23 | 24 | /** 25 | * Create a new server instance. 26 | */ 27 | export declare function serve(options: ServerOptions): Server; 28 | 29 | /** 30 | * Web fetch compatible request handler 31 | */ 32 | export type ServerHandler = (request: ServerRequest) => MaybePromise; 33 | 34 | export type ServerMiddleware = ( 35 | request: ServerRequest, 36 | next: () => Response | Promise, 37 | ) => Response | Promise; 38 | 39 | export type ServerPlugin = (server: Server) => void; 40 | 41 | /** 42 | * Server options 43 | */ 44 | export interface ServerOptions { 45 | /** 46 | * The fetch handler handles incoming requests. 47 | */ 48 | fetch: ServerHandler; 49 | 50 | /** 51 | * Handle lifecycle errors. 52 | * 53 | * @note This handler will set built-in Bun and Deno error handler. 54 | */ 55 | error?: ErrorHandler; 56 | 57 | /** 58 | * Server middleware handlers to run before the main fetch handler. 59 | */ 60 | middleware?: ServerMiddleware[]; 61 | 62 | /** 63 | * Server plugins. 64 | */ 65 | plugins?: ServerPlugin[]; 66 | 67 | /** 68 | * If set to `true`, server will not start listening automatically. 69 | */ 70 | manual?: boolean; 71 | 72 | /** 73 | * The port server should be listening to. 74 | * 75 | * Default is read from `PORT` environment variable or will be `3000`. 76 | * 77 | * **Tip:** You can set the port to `0` to use a random port. 78 | */ 79 | port?: string | number; 80 | 81 | /** 82 | * The hostname (IP or resolvable host) server listener should bound to. 83 | * 84 | * When not provided, server with listen to all network interfaces by default. 85 | * 86 | * **Important:** If you are running a server that is not expected to be exposed to the network, use `hostname: "localhost"`. 87 | */ 88 | hostname?: string; 89 | 90 | /** 91 | * Enabling this option allows multiple processes to bind to the same port, which is useful for load balancing. 92 | * 93 | * **Note:** Despite Node.js built-in behavior that has `exclusive` flag (opposite of `reusePort`) enabled by default, srvx uses non-exclusive mode for consistency. 94 | */ 95 | reusePort?: boolean; 96 | 97 | /** 98 | * The protocol to use for the server. 99 | * 100 | * Possible values are `http` and `https`. 101 | * 102 | * If `protocol` is not set, Server will use `http` as the default protocol or `https` if both `tls.cert` and `tls.key` options are provided. 103 | */ 104 | protocol?: "http" | "https"; 105 | 106 | /** 107 | * If set to `true`, server will not print the listening address. 108 | */ 109 | silent?: boolean; 110 | 111 | /** 112 | * TLS server options. 113 | */ 114 | tls?: { 115 | /** 116 | * File path or inlined TLS certificate in PEM format (required). 117 | */ 118 | cert?: string; 119 | 120 | /** 121 | * File path or inlined TLS private key in PEM format (required). 122 | */ 123 | key?: string; 124 | 125 | /** 126 | * Passphrase for the private key (optional). 127 | */ 128 | passphrase?: string; 129 | }; 130 | 131 | /** 132 | * Node.js server options. 133 | */ 134 | node?: ( 135 | | NodeHttp.ServerOptions 136 | | NodeHttps.ServerOptions 137 | | NodeHttp2.ServerOptions 138 | ) & 139 | NodeNet.ListenOptions & { http2?: boolean }; 140 | 141 | /** 142 | * Bun server options 143 | * 144 | * @docs https://bun.sh/docs/api/http 145 | */ 146 | bun?: Omit; 147 | 148 | /** 149 | * Deno server options 150 | * 151 | * @docs https://docs.deno.com/api/deno/~/Deno.serve 152 | */ 153 | deno?: Deno.ServeOptions; 154 | 155 | /** 156 | * Service worker options 157 | */ 158 | serviceWorker?: { 159 | /** 160 | * The path to the service worker file to be registered. 161 | */ 162 | url?: string; 163 | 164 | /** 165 | * The scope of the service worker. 166 | * 167 | */ 168 | scope?: string; 169 | }; 170 | } 171 | 172 | export interface Server { 173 | /** 174 | * Current runtime name 175 | */ 176 | readonly runtime: 177 | | "node" 178 | | "deno" 179 | | "bun" 180 | | "cloudflare" 181 | | "service-worker" 182 | | "generic"; 183 | 184 | /** 185 | * Server options 186 | */ 187 | readonly options: ServerOptions & { middleware: ServerMiddleware[] }; 188 | 189 | /** 190 | * Server URL address. 191 | */ 192 | readonly url?: string; 193 | 194 | /** 195 | * Node.js context. 196 | */ 197 | readonly node?: { 198 | server?: NodeHttp.Server | NodeHttp2.Http2Server; 199 | handler: ( 200 | req: NodeServerRequest, 201 | res: NodeServerResponse, 202 | ) => void | Promise; 203 | }; 204 | 205 | /** 206 | * Bun context. 207 | */ 208 | readonly bun?: { server?: Bun.Server }; 209 | 210 | /** 211 | * Deno context. 212 | */ 213 | readonly deno?: { server?: Deno.HttpServer }; 214 | 215 | /** 216 | * Server fetch handler 217 | */ 218 | readonly fetch: Handler; 219 | 220 | /** 221 | * Returns a promise that resolves when the server is ready. 222 | */ 223 | ready(): Promise>; 224 | 225 | /** 226 | * Stop listening to prevent new connections from being accepted. 227 | * 228 | * By default, it does not cancel in-flight requests or websockets. That means it may take some time before all network activity stops. 229 | * 230 | * @param closeActiveConnections Immediately terminate in-flight requests, websockets, and stop accepting new connections. 231 | * @default false 232 | */ 233 | close(closeActiveConnections?: boolean): Promise; 234 | } 235 | 236 | // ---------------------------------------------------------------------------- 237 | // Request with runtime addons. 238 | // ---------------------------------------------------------------------------- 239 | 240 | export interface ServerRuntimeContext { 241 | name: "node" | "deno" | "bun" | "cloudflare" | (string & {}); 242 | 243 | /** 244 | * Underlying Node.js server request info. 245 | */ 246 | node?: { 247 | req: NodeServerRequest; 248 | res?: NodeServerResponse; 249 | }; 250 | 251 | /** 252 | * Underlying Deno server request info. 253 | */ 254 | deno?: { 255 | info: Deno.ServeHandlerInfo; 256 | }; 257 | 258 | /** 259 | * Underlying Bun server request context. 260 | */ 261 | bun?: { 262 | server: Bun.Server; 263 | }; 264 | 265 | /** 266 | * Underlying Cloudflare request context. 267 | */ 268 | cloudflare?: { 269 | env: unknown; 270 | context: CF.ExecutionContext; 271 | }; 272 | } 273 | 274 | export interface ServerRequest extends Request { 275 | /** 276 | * Runtime specific request context. 277 | */ 278 | runtime?: ServerRuntimeContext; 279 | 280 | /** 281 | * IP address of the client. 282 | */ 283 | ip?: string | undefined; 284 | 285 | /** 286 | * Tell the runtime about an ongoing operation that shouldn't close until the promise resolves. 287 | */ 288 | waitUntil?: (promise: Promise) => void | Promise; 289 | } 290 | 291 | // ---------------------------------------------------------------------------- 292 | // Different handler types 293 | // ---------------------------------------------------------------------------- 294 | 295 | export type FetchHandler = (request: Request) => Response | Promise; 296 | 297 | export type ErrorHandler = (error: unknown) => Response | Promise; 298 | 299 | export type BunFetchHandler = ( 300 | request: Request, 301 | server?: Bun.Server, 302 | ) => Response | Promise; 303 | 304 | export type DenoFetchHandler = ( 305 | request: Request, 306 | info?: Deno.ServeHandlerInfo, 307 | ) => Response | Promise; 308 | 309 | export type NodeServerRequest = 310 | | NodeHttp.IncomingMessage 311 | | NodeHttp2.Http2ServerRequest; 312 | 313 | export type NodeServerResponse = 314 | | NodeHttp.ServerResponse 315 | | NodeHttp2.Http2ServerResponse; 316 | 317 | export type NodeHttpHandler = ( 318 | req: NodeServerRequest, 319 | res: NodeServerResponse, 320 | ) => void | Promise; 321 | 322 | export type CloudflareFetchHandler = CF.ExportedHandlerFetchHandler; 323 | -------------------------------------------------------------------------------- /test/_fixture.ts: -------------------------------------------------------------------------------- 1 | import type { ServerOptions } from "../src/types.ts"; 2 | 3 | // prettier-ignore 4 | const runtime = (globalThis as any).Deno ? "deno" : (globalThis.Bun ? "bun" : "node"); 5 | const { serve } = (await import( 6 | `../src/adapters/${runtime}.ts` 7 | )) as typeof import("../src/types.ts"); 8 | 9 | export const fixture: ( 10 | opts?: Partial, 11 | _Response?: typeof globalThis.Response, 12 | ) => ServerOptions = (opts, _Response = globalThis.Response) => { 13 | let abortCount = 0; 14 | 15 | return { 16 | ...opts, 17 | hostname: "localhost", 18 | middleware: [ 19 | (req, next) => { 20 | if (req.headers.has("X-plugin-req")) { 21 | return new _Response("response from req plugin"); 22 | } 23 | return next(); 24 | }, 25 | ], 26 | plugins: [ 27 | (server) => { 28 | server.options.middleware ??= []; 29 | server.options.middleware.unshift(async (req, next) => { 30 | if (!req.headers.has("X-plugin-res")) { 31 | return next(); 32 | } 33 | const res = await next(); 34 | res.headers.set("x-plugin-header", "1"); 35 | return res; 36 | }); 37 | }, 38 | ], 39 | 40 | async error(err) { 41 | return new _Response(`error: ${(err as Error).message}`, { status: 500 }); 42 | }, 43 | 44 | async fetch(req) { 45 | const url = new URL(req.url); 46 | switch (url.pathname) { 47 | case "/": { 48 | return new _Response("ok"); 49 | } 50 | case "/headers": { 51 | // Trigger Node.js writeHead slowpath to reproduce https://github.com/h3js/srvx/pull/40 52 | req.runtime?.node?.res?.setHeader("x-set-with-node", ""); 53 | const resHeaders = new Headers(); 54 | for (const [key, value] of req.headers) { 55 | resHeaders.append(`x-req-${key}`, value); 56 | } 57 | return Response.json( 58 | { 59 | ...Object.fromEntries(req.headers.entries()), 60 | unsetHeader: req.headers.get("" + Math.random()), // #44 61 | }, 62 | { 63 | headers: resHeaders, 64 | }, 65 | ); 66 | } 67 | case "/headers/response/mutation": { 68 | const headers: Record = { 69 | "x-test-header-1": "1", 70 | }; 71 | const res = new _Response("", { 72 | headers: headers, 73 | }); 74 | 75 | res.headers.set("x-test-header-2", "2"); 76 | headers["x-ignored-mutation"] = "true"; 77 | 78 | return res; 79 | } 80 | case "/body/binary": { 81 | return new _Response(req.body); 82 | } 83 | case "/body/text": { 84 | return new _Response(await req.text()); 85 | } 86 | case "/ip": { 87 | return new _Response(`ip: ${req.ip}`); 88 | } 89 | case "/req-instanceof": { 90 | return new _Response(req instanceof Request ? "yes" : "no"); 91 | } 92 | case "/req-headers-instanceof": { 93 | return new _Response(req.headers instanceof Headers ? "yes" : "no"); 94 | } 95 | case "/error": { 96 | throw new Error("test error"); 97 | } 98 | case "/response/ArrayBuffer": { 99 | const data = new TextEncoder().encode("hello!"); 100 | return new _Response(data.buffer); 101 | } 102 | case "/response/Uint8Array": { 103 | const data = new TextEncoder().encode("hello!"); 104 | return new _Response(data); 105 | } 106 | case "/response/ReadableStream": { 107 | return new _Response( 108 | new ReadableStream({ 109 | start(controller) { 110 | const count = +url.searchParams.get("count")! || 3; 111 | for (let i = 0; i < count; i++) { 112 | controller.enqueue(new TextEncoder().encode(`chunk${i}\n`)); 113 | } 114 | controller.close(); 115 | }, 116 | }), 117 | { 118 | headers: { 119 | "content-type": "text/plain", 120 | }, 121 | }, 122 | ); 123 | } 124 | case "/response/NodeReadable": { 125 | const { Readable } = process.getBuiltinModule("node:stream"); 126 | return new _Response( 127 | new Readable({ 128 | read() { 129 | for (let i = 0; i < 3; i++) { 130 | this.push(`chunk${i}\n`); 131 | } 132 | this.push(null /* end stream */); 133 | }, 134 | }) as any, 135 | ); 136 | } 137 | case "/clone-response": { 138 | const res = new _Response("", {}); 139 | if (req.headers.get("x-clone-with-headers") === "true") { 140 | res.headers.set("x-clone-with-headers", "true"); 141 | } 142 | return res.clone(); 143 | } 144 | case "/abort": { 145 | req.signal.addEventListener("abort", () => { 146 | abortCount++; 147 | }); 148 | return new _Response( 149 | new ReadableStream({ 150 | async start(controller) { 151 | while (!req.signal.aborted) { 152 | controller.enqueue( 153 | new TextEncoder().encode(new Date().toISOString() + "\n"), 154 | ); 155 | await new Promise((resolve) => setTimeout(resolve, 100)); 156 | } 157 | controller.close(); 158 | }, 159 | }), 160 | { 161 | headers: { 162 | "content-type": "text/plain", 163 | }, 164 | }, 165 | ); 166 | } 167 | case "/abort-count": { 168 | return _Response.json({ abortCount }); 169 | } 170 | } 171 | return new _Response("404", { status: 404 }); 172 | }, 173 | }; 174 | }; 175 | 176 | if (import.meta.main) { 177 | const server = serve(fixture({})); 178 | await server.ready(); 179 | } 180 | -------------------------------------------------------------------------------- /test/_tests.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | export function addTests(opts: { 4 | url: (path: string) => string; 5 | runtime: string; 6 | fetch?: typeof globalThis.fetch; 7 | }): void { 8 | const { url, fetch = globalThis.fetch } = opts; 9 | 10 | test("GET works", async () => { 11 | const response = await fetch(url("/")); 12 | expect(response.status).toBe(200); 13 | expect(await response.text()).toMatch("ok"); 14 | }); 15 | 16 | test("request instanceof Request", async () => { 17 | const response = await fetch(url("/req-instanceof")); 18 | expect(response.status).toBe(200); 19 | expect(await response.text()).toMatch("yes"); 20 | }); 21 | 22 | test("request.headers instanceof Headers", async () => { 23 | const response = await fetch(url("/req-headers-instanceof")); 24 | expect(response.status).toBe(200); 25 | expect(await response.text()).toMatch("yes"); 26 | }); 27 | 28 | test("headers", async () => { 29 | const response = await fetch(url("/headers"), { 30 | headers: { foo: "bar", bar: "baz" }, 31 | }); 32 | expect(response.status).toBe(200); 33 | expect(await response.json()).toMatchObject({ 34 | foo: "bar", 35 | bar: "baz", 36 | unsetHeader: null, 37 | }); 38 | expect(response.headers.get("content-type")).toMatch(/^application\/json/); 39 | expect(response.headers.get("x-req-foo")).toBe("bar"); 40 | expect(response.headers.get("x-req-bar")).toBe("baz"); 41 | }); 42 | 43 | test("response headers mutated", async () => { 44 | const response = await fetch(url("/headers/response/mutation")); 45 | expect(response.status).toBe(200); 46 | expect(response.headers.get("x-ignored")).toBeNull(); 47 | expect(response.headers.get("x-test-header-1")).toBe("1"); 48 | expect(response.headers.get("x-test-header-2")).toBe("2"); 49 | }); 50 | 51 | test("POST works (binary body)", async () => { 52 | const response = await fetch(url("/body/binary"), { 53 | method: "POST", 54 | body: new Uint8Array([1, 2, 3]), 55 | }); 56 | expect(response.status).toBe(200); 57 | expect(new Uint8Array(await response.arrayBuffer())).toEqual( 58 | new Uint8Array([1, 2, 3]), 59 | ); 60 | }); 61 | 62 | test("POST works (text body)", async () => { 63 | const response = await fetch(url("/body/text"), { 64 | method: "POST", 65 | body: "hello world", 66 | }); 67 | expect(response.status).toBe(200); 68 | expect(await response.text()).toBe("hello world"); 69 | }); 70 | 71 | test("ip", async () => { 72 | const response = await fetch(url("/ip")); 73 | expect(response.status).toBe(200); 74 | expect(await response.text()).toMatch(/ip: ::1|ip: 127.0.0.1/); 75 | }); 76 | 77 | test("runtime agnostic error handler", async () => { 78 | const response = await fetch(url("/error")); 79 | expect(response.status).toBe(500); 80 | expect(await response.text()).toBe("error: test error"); 81 | }); 82 | 83 | test("abort request", async () => { 84 | const controller = new AbortController(); 85 | const response = await fetch(url("/abort"), { 86 | signal: controller.signal, 87 | }); 88 | controller.abort(); 89 | expect(response.status).toBe(200); 90 | await expect(response.text()).rejects.toThrow("aborted"); 91 | 92 | // Node.js http1 variant needs a bit of time to process the abort 93 | if (opts.runtime === "node") { 94 | await new Promise((resolve) => setTimeout(resolve, 100)); 95 | } 96 | 97 | const { abortCount } = await fetch(url("/abort-count")).then((res) => 98 | res.json(), 99 | ); 100 | expect(abortCount).toBe(1); 101 | }); 102 | 103 | describe("plugin", () => { 104 | test("intercept before handler", async () => { 105 | const response = await fetch(url("/"), { 106 | headers: { "X-plugin-req": "1" }, 107 | }); 108 | expect(response.status).toBe(200); 109 | expect(await response.text()).toBe("response from req plugin"); 110 | }); 111 | 112 | test("intercept response headers", async () => { 113 | const response = await fetch(url("/"), { 114 | headers: { "X-plugin-res": "1" }, 115 | }); 116 | expect(response.status).toBe(200); 117 | expect(await response.text()).toBe("ok"); 118 | expect(response.headers.get("x-plugin-header")).toBe("1"); 119 | }); 120 | }); 121 | 122 | describe("response types", () => { 123 | test("ReadableStream", async () => { 124 | const res = await fetch(url("/response/ReadableStream")); 125 | expect(res.status).toBe(200); 126 | expect(await res.text()).toBe("chunk0\nchunk1\nchunk2\n"); 127 | }); 128 | 129 | test("NodeReadable", async () => { 130 | const res = await fetch(url("/response/NodeReadable")); 131 | expect(res.status).toBe(200); 132 | expect(await res.text()).toBe("chunk0\nchunk1\nchunk2\n"); 133 | }); 134 | 135 | test("ArrayBuffer", async () => { 136 | const res = await fetch(url("/response/ArrayBuffer")); 137 | expect(res.status).toBe(200); 138 | expect(await res.text()).toEqual("hello!"); 139 | }); 140 | 141 | test("Uint8Array", async () => { 142 | const res = await fetch(url("/response/Uint8Array")); 143 | expect(res.status).toBe(200); 144 | expect(await res.text()).toEqual("hello!"); 145 | }); 146 | }); 147 | 148 | describe("response cloning", () => { 149 | test("clone simple response", async () => { 150 | const response = await fetch(url("/clone-response")); 151 | expect(response.status).toBe(200); 152 | }); 153 | 154 | test("clone with headers", async () => { 155 | const response = await fetch(url("/clone-response"), { 156 | headers: { 157 | "x-clone-with-headers": "true", 158 | }, 159 | }); 160 | expect(response.status).toBe(200); 161 | expect(response.headers.get("x-clone-with-headers")).toBe("true"); 162 | }); 163 | }); 164 | } 165 | -------------------------------------------------------------------------------- /test/_utils.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { join } from "node:path"; 3 | import { existsSync } from "node:fs"; 4 | import { readFile, mkdir, writeFile } from "node:fs/promises"; 5 | import { afterAll, beforeAll } from "vitest"; 6 | import { execa, type ResultPromise as ExecaRes } from "execa"; 7 | import { getRandomPort, waitForPort } from "get-port-please"; 8 | import { addTests } from "./_tests.ts"; 9 | 10 | const testDir = fileURLToPath(new URL(".", import.meta.url)); 11 | 12 | export function testsExec( 13 | cmd: string, 14 | opts: { runtime: string; silent?: boolean }, 15 | ): void { 16 | let childProc: ExecaRes; 17 | let baseURL: string; 18 | 19 | beforeAll(async () => { 20 | const port = await getRandomPort("localhost"); 21 | baseURL = `http://localhost:${port}/`; 22 | const [bin, ...args] = cmd.replace("./", testDir).split(" "); 23 | if (process.env.TEST_DEBUG) { 24 | console.log(`$ ${bin} ${args.join(" ")}`); 25 | } 26 | childProc = execa(bin, args, { env: { PORT: port.toString() } }); 27 | childProc.catch((error) => { 28 | if (error.signal !== "SIGTERM") { 29 | console.error(error); 30 | } 31 | }); 32 | if (process.env.TEST_DEBUG || !opts.silent) { 33 | childProc.stderr!.on("data", (chunk) => { 34 | console.log(chunk.toString()); 35 | }); 36 | } 37 | if (process.env.TEST_DEBUG) { 38 | childProc.stdout!.on("data", (chunk) => { 39 | console.log(chunk.toString()); 40 | }); 41 | } 42 | await waitForPort(port, { host: "localhost", delay: 50, retries: 100 }); 43 | }); 44 | 45 | afterAll(async () => { 46 | await childProc.kill(); 47 | }); 48 | 49 | addTests({ 50 | url: (path) => baseURL + path.slice(1), 51 | ...opts, 52 | }); 53 | } 54 | 55 | export async function getTLSCert(): Promise<{ 56 | ca: string; 57 | cert: string; 58 | key: string; 59 | }> { 60 | const certDir = join(testDir, ".tmp/tls"); 61 | const caFile = join(certDir, "ca.crt"); 62 | const certFile = join(certDir, "server.crt"); 63 | const keyFile = join(certDir, "server.key"); 64 | 65 | if (existsSync(caFile) && existsSync(certFile) && existsSync(keyFile)) { 66 | return { 67 | ca: await readFile(caFile, "utf8"), 68 | cert: await readFile(certFile, "utf8"), 69 | key: await readFile(keyFile, "utf8"), 70 | }; 71 | } 72 | 73 | const { pki, md } = await import("node-forge"); 74 | 75 | // Generate keys 76 | const caKeys = pki.rsa.generateKeyPair(2048); 77 | const serverKeys = pki.rsa.generateKeyPair(2048); 78 | 79 | // Create CA cert 80 | const caCert = pki.createCertificate(); 81 | caCert.publicKey = caKeys.publicKey; 82 | caCert.serialNumber = "01"; 83 | caCert.validity.notBefore = new Date(); 84 | caCert.validity.notAfter = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); 85 | caCert.setSubject([{ name: "commonName", value: "Test CA" }]); 86 | caCert.setIssuer(caCert.subject.attributes); 87 | caCert.setExtensions([ 88 | { name: "basicConstraints", cA: true }, 89 | { name: "keyUsage", keyCertSign: true, digitalSignature: true }, 90 | { name: "subjectKeyIdentifier" }, 91 | ]); 92 | caCert.sign(caKeys.privateKey, md.sha256.create()); 93 | 94 | // Create server cert 95 | const serverCert = pki.createCertificate(); 96 | serverCert.publicKey = serverKeys.publicKey; 97 | serverCert.serialNumber = "02"; 98 | serverCert.validity.notBefore = new Date(); 99 | serverCert.validity.notAfter = new Date( 100 | Date.now() + 365 * 24 * 60 * 60 * 1000, 101 | ); 102 | serverCert.setSubject([{ name: "commonName", value: "localhost" }]); 103 | serverCert.setIssuer(caCert.subject.attributes); 104 | serverCert.setExtensions([ 105 | { name: "basicConstraints", cA: false }, 106 | { name: "keyUsage", digitalSignature: true, keyEncipherment: true }, 107 | { name: "extKeyUsage", serverAuth: true }, 108 | { 109 | name: "subjectAltName", 110 | altNames: [ 111 | { type: 2, value: "localhost" }, 112 | { type: 7, ip: "127.0.0.1" }, 113 | { type: 7, ip: "::1" }, 114 | ], 115 | }, 116 | ]); 117 | serverCert.sign(caKeys.privateKey, md.sha256.create()); 118 | 119 | const ca = pki.certificateToPem(caCert); 120 | const key = pki.privateKeyToPem(serverKeys.privateKey); 121 | const cert = pki.certificateToPem(serverCert); 122 | 123 | await mkdir(certDir, { recursive: true }); 124 | await writeFile(caFile, ca); 125 | await writeFile(certFile, cert); 126 | await writeFile(keyFile, key); 127 | 128 | return { ca, cert, key }; 129 | } 130 | -------------------------------------------------------------------------------- /test/bench-node/_run.mjs: -------------------------------------------------------------------------------- 1 | import { Worker } from "node:worker_threads"; 2 | import { execSync } from "node:child_process"; 3 | 4 | let ohaVersion; 5 | try { 6 | ohaVersion = execSync("oha --version", { encoding: "utf8" }).split(" ")[1]; 7 | } catch { 8 | console.error("Please install `oha` first: https://github.com/hatoo/oha"); 9 | } 10 | 11 | console.log(` 12 | Node.js:\t ${process.version} 13 | OS:\t\t ${process.platform} ${process.arch} 14 | OHA:\t\t ${ohaVersion} 15 | `); 16 | 17 | const results = []; 18 | 19 | const all = process.argv.includes("--all"); 20 | 21 | for (const name of [ 22 | "node", 23 | "srvx", 24 | "srvx-fast", 25 | all && "whatwg-node", 26 | all && "whatwg-node-fast", 27 | all && "hono", 28 | all && "hono-fast", 29 | all && "remix", 30 | ].filter(Boolean)) { 31 | process.stdout.write(`${name}...`); 32 | const entry = new URL(`${name}.mjs`, import.meta.url); 33 | const worker = new Worker(entry, { type: "module" }); 34 | await new Promise((resolve) => setTimeout(resolve, 200)); 35 | const stdout = execSync("oha http://localhost:3000 --no-tui -j -z 3sec", { 36 | encoding: "utf8", 37 | }); 38 | worker.terminate(); 39 | const result = JSON.parse(stdout); 40 | const statusCodes = Object.keys(result.statusCodeDistribution); 41 | if (statusCodes.length > 1 || statusCodes[0] !== "200") { 42 | throw new Error(`Unexpected status codes: ${statusCodes}`); 43 | } 44 | const rps = Math.round(result.rps.mean); 45 | results.push([name, `${rps} req/sec`]); 46 | console.log(` ${rps} req/sec`); 47 | } 48 | 49 | results.sort((a, b) => b[1].split(" ")[0] - a[1].split(" ")[0]); 50 | 51 | console.table(Object.fromEntries(results)); 52 | -------------------------------------------------------------------------------- /test/bench-node/hono-fast.mjs: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | 3 | serve({ 4 | overrideGlobalObjects: true, 5 | fetch() { 6 | return new Response("Hello!"); 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /test/bench-node/hono.mjs: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | 3 | serve({ 4 | overrideGlobalObjects: false, 5 | fetch() { 6 | return new Response("Hello!"); 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /test/bench-node/node.mjs: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | 3 | const server = createServer((_req, res) => { 4 | res.end("Hello!"); 5 | }); 6 | 7 | server.listen(3000); 8 | -------------------------------------------------------------------------------- /test/bench-node/remix.mjs: -------------------------------------------------------------------------------- 1 | import * as http from "node:http"; 2 | import { createRequestListener } from "@mjackson/node-fetch-server"; 3 | 4 | let server = http.createServer( 5 | createRequestListener(() => { 6 | return new Response("Hello!"); 7 | }), 8 | ); 9 | 10 | server.listen(3000); 11 | -------------------------------------------------------------------------------- /test/bench-node/srvx-fast.mjs: -------------------------------------------------------------------------------- 1 | import { serve, FastResponse } from "srvx"; 2 | 3 | serve({ 4 | port: 3000, 5 | silent: true, 6 | fetch() { 7 | return new FastResponse("Hello!"); 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /test/bench-node/srvx.mjs: -------------------------------------------------------------------------------- 1 | import { serve } from "srvx"; 2 | 3 | const server = await serve({ 4 | port: 3000, 5 | silent: true, 6 | fetch() { 7 | return new Response("Hello!"); 8 | }, 9 | }); 10 | 11 | await server.ready(); 12 | -------------------------------------------------------------------------------- /test/bench-node/whatwg-node-fast.mjs: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | import { createServerAdapter, Response } from "@whatwg-node/server"; 3 | 4 | const nodeServer = createServer( 5 | createServerAdapter((_req) => { 6 | return new Response("Hello!"); 7 | }), 8 | ); 9 | 10 | nodeServer.listen(3000); 11 | -------------------------------------------------------------------------------- /test/bench-node/whatwg-node.mjs: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | import { createServerAdapter } from "@whatwg-node/server"; 3 | 4 | const nodeServer = createServer( 5 | createServerAdapter((_req) => { 6 | return new Response("Hello!"); 7 | }), 8 | ); 9 | 10 | nodeServer.listen(3000); 11 | -------------------------------------------------------------------------------- /test/bun.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { testsExec } from "./_utils.ts"; 3 | 4 | describe("bun", () => { 5 | testsExec("bun run ./_fixture.ts", { runtime: "bun" }); 6 | }); 7 | -------------------------------------------------------------------------------- /test/deno.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { testsExec } from "./_utils.ts"; 3 | 4 | describe("deno", () => { 5 | testsExec("deno run -A ./_fixture.ts", { 6 | runtime: "deno", 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /test/node.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, afterAll } from "vitest"; 2 | import { fetch, Agent } from "undici"; 3 | import { addTests } from "./_tests.ts"; 4 | import { serve, FastResponse } from "../src/adapters/node.ts"; 5 | import { getTLSCert } from "./_utils.ts"; 6 | import { fixture } from "./_fixture.ts"; 7 | 8 | const tls = await getTLSCert(); 9 | 10 | const testConfigs = [ 11 | { 12 | name: "http1", 13 | Response: globalThis.Response, 14 | }, 15 | { 16 | name: "http1, FastResponse", 17 | Response: FastResponse, 18 | }, 19 | { 20 | name: "http2", 21 | Response: globalThis.Response, 22 | http2: true, 23 | serveOptions: { tls, node: { http2: true, allowHTTP1: false } }, 24 | }, 25 | { 26 | name: "http2, FastResponse", 27 | Response: FastResponse, 28 | http2: true, 29 | serveOptions: { tls, node: { http2: true, allowHTTP1: false } }, 30 | }, 31 | ]; 32 | 33 | for (const config of testConfigs) { 34 | describe.sequential(`node (${config.name})`, () => { 35 | const client = getHttpClient(config.http2); 36 | let server: ReturnType | undefined; 37 | 38 | beforeAll(async () => { 39 | server = serve( 40 | fixture( 41 | { 42 | port: 0, 43 | ...config.serveOptions, 44 | }, 45 | config.Response as unknown as typeof Response, // TODO: fix type incompatibility 46 | ), 47 | ); 48 | await server!.ready(); 49 | }); 50 | 51 | afterAll(async () => { 52 | await client.agent?.close(); 53 | await server!.close(); 54 | }); 55 | 56 | addTests({ 57 | url: (path) => server!.url! + path.slice(1), 58 | runtime: "node", 59 | fetch: client.fetch, 60 | }); 61 | }); 62 | } 63 | 64 | function getHttpClient(h2?: boolean) { 65 | if (!h2) { 66 | return { 67 | fetch: globalThis.fetch, 68 | agent: undefined, 69 | }; 70 | } 71 | const h2Agent = new Agent({ allowH2: true, connect: { ...tls } }); 72 | const fetchWithHttp2 = ((input: any, init?: any) => 73 | fetch(input, { 74 | ...init, 75 | dispatcher: h2Agent, 76 | })) as unknown as typeof globalThis.fetch; 77 | 78 | return { fetch: fetchWithHttp2, agent: h2Agent }; 79 | } 80 | -------------------------------------------------------------------------------- /test/url.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench, compact, summary, group, run, do_not_optimize } from "mitata"; 2 | import { FastURL } from "../src/_url.ts"; 3 | 4 | const input = "https://user:password@example.com/path/to/resource?query=string"; 5 | 6 | const scenarios = { 7 | pathname: (url: URL) => do_not_optimize([url.pathname]), 8 | params: (url: URL) => do_not_optimize([url.searchParams.get("query")]), 9 | "pathname+params": (url: URL) => 10 | do_not_optimize([url.pathname, url.searchParams.get("query")]), 11 | "pathname+params+username": (url: URL) => 12 | do_not_optimize([ 13 | url.pathname, 14 | url.searchParams.get("query"), 15 | url.username, 16 | ]), 17 | }; 18 | 19 | for (const [name, fn] of Object.entries(scenarios)) { 20 | group(name, () => { 21 | summary(() => { 22 | compact(() => { 23 | bench("globalThis.URL", () => do_not_optimize(fn(new URL(input)))).gc( 24 | "inner", 25 | ); 26 | bench("FastURL", () => do_not_optimize(fn(new FastURL(input)))).gc( 27 | "inner", 28 | ); 29 | }); 30 | }); 31 | }); 32 | } 33 | 34 | await run({ throw: true }); 35 | -------------------------------------------------------------------------------- /test/url.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | import { FastURL } from "../src/_url.ts"; 3 | 4 | const urlTests = await import("./wpt/url_tests.json", { 5 | with: { type: "json" }, 6 | }).then((m) => m.default); 7 | 8 | const urlSettersTests = await import("./wpt/url_setters_tests.json", { 9 | with: { type: "json" }, 10 | }); 11 | 12 | // prettier-ignore 13 | type URLPropName = 14 | "pathname" | "search" | "origin" | "protocol" | "username" | 15 | "password" | "host" | "hostname" | "port" | "hash" | "href" 16 | 17 | // prettier-ignore 18 | const urlProps = [ 19 | "pathname", "search", "origin", "protocol", "username", 20 | "password", "host", "hostname", "port", "hash", "href" 21 | ] as URLPropName[]; 22 | 23 | describe("FastURL", () => { 24 | test("invalid protocol", () => { 25 | expect(new FastURL("http:/example.com/foo").pathname).toBe("/foo"); 26 | }); 27 | 28 | test("no trailing slash", () => { 29 | expect(new FastURL("http://example.com").pathname).toBe("/"); 30 | }); 31 | 32 | test(".toString() and .toJSON()", () => { 33 | const url = new FastURL("http://example.com"); 34 | expect(url.toString()).toBe(url.href); 35 | expect(url.toJSON()).toBe(url.href); 36 | }); 37 | 38 | test(".search (slopw path)", () => { 39 | const url = new FastURL("http:/example.com/foo?search"); 40 | expect(url.search).toBe("?search"); 41 | }); 42 | 43 | test(".searchParams (fast path)", () => { 44 | const url = new FastURL("http:/example.com/foo?search"); 45 | expect(url.searchParams).toEqual(new URLSearchParams("?search")); 46 | }); 47 | 48 | test(".searchParams (slow path)", () => { 49 | const url = new FastURL("http:/example.com/foo?search"); 50 | expect(url.href).toBe(url.href); // trigger slow path 51 | expect(url.searchParams).toEqual(new URLSearchParams("?search")); 52 | }); 53 | 54 | describe("WPT tests", () => { 55 | for (const t of urlTests) { 56 | if (typeof t === "string") { 57 | continue; // Section comment 58 | } 59 | if (t.hash || t.href?.endsWith("#")) { 60 | continue; // Skip tests with hash 61 | } 62 | if (!["http:", "https:"].includes(t.protocol!)) { 63 | continue; // Skip tests with non-http(s) protocols 64 | } 65 | 66 | // Check if native URL itself passes the test 67 | let nativePasses = true; 68 | try { 69 | const url = new URL(t.input, t.base || undefined); 70 | for (const prop of urlProps) { 71 | if (url[prop] !== t[prop]) { 72 | nativePasses = false; 73 | break; 74 | } 75 | } 76 | 77 | // NOTE: We assume input is already formatted (from incoming HTTP request) 78 | url.hash = ""; 79 | t.input = url.href; 80 | } catch { 81 | nativePasses = false; 82 | } 83 | 84 | test.skipIf(!nativePasses)(`new FastURL("${t.input}")`, () => { 85 | const url = new FastURL(t.input); 86 | for (const prop of urlProps) { 87 | expect(url[prop], `.${prop}`).toBe(t[prop]); 88 | } 89 | }); 90 | } 91 | }); 92 | 93 | describe("setters", async () => { 94 | for (const [prop, tests] of Object.entries(urlSettersTests)) { 95 | if (prop === "comment" || prop === "default") continue; 96 | describe(prop, () => { 97 | for (const t of tests) { 98 | const title = `new FastURL("${t.href}").${prop} = "${t.new_value}" ${t.comment ? `// ${t.comment}` : ""}`; 99 | 100 | // Check if native URL itself passes the test 101 | let nativePasses = true; 102 | try { 103 | const url = new URL(t.href); 104 | url[prop as Exclude] = t.new_value; 105 | for (const [prop, value] of Object.entries(t.expected)) { 106 | if (url[prop as URLPropName] !== value) { 107 | nativePasses = false; 108 | break; 109 | } 110 | } 111 | } catch { 112 | nativePasses = false; 113 | } 114 | 115 | test.skipIf(!nativePasses)(title, () => { 116 | const url = new FastURL(t.href); 117 | url[prop as Exclude] = t.new_value; 118 | for (const [prop, value] of Object.entries(t.expected)) { 119 | expect(url[prop as URLPropName], `.${prop}`).toBe(value); 120 | } 121 | }); 122 | } 123 | }); 124 | } 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /test/wpt/README.md: -------------------------------------------------------------------------------- 1 | # WPT Test data 2 | 3 | Docs: https://github.com/web-platform-tests/wpt/tree/master/url 4 | 5 | Sources: 6 | 7 | - https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json 8 | - https://github.com/web-platform-tests/wpt/blob/master/url/resources/setters_tests.json 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "resolveJsonModule": true, 7 | "esModuleInterop": false, 8 | "allowSyntheticDefaultImports": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "verbatimModuleSyntax": true, 12 | "isolatedModules": true, 13 | "composite": true, 14 | "allowImportingTsExtensions": true, 15 | "isolatedDeclarations": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noImplicitOverride": true, 18 | "noEmit": true, 19 | "erasableSyntaxOnly": true 20 | }, 21 | "include": ["src", "test"] 22 | } 23 | -------------------------------------------------------------------------------- /vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | typecheck: { enabled: true }, 6 | coverage: { 7 | include: ["src/**/*.ts"], 8 | exclude: [ 9 | "src/adapters/bun.ts", 10 | "src/adapters/cloudflare.ts", 11 | "src/adapters/deno.ts", 12 | "src/types.ts", 13 | ], 14 | reporter: ["text", "clover", "json", "html"], 15 | }, 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------