diff --git a/examples/README.md b/.github/examples/README.md similarity index 100% rename from examples/README.md rename to .github/examples/README.md diff --git a/examples/basic/server.js b/.github/examples/basic/server.js similarity index 100% rename from examples/basic/server.js rename to .github/examples/basic/server.js diff --git a/examples/cors/server.js b/.github/examples/cors/server.js similarity index 100% rename from examples/cors/server.js rename to .github/examples/cors/server.js diff --git a/examples/error-handling/server.js b/.github/examples/error-handling/server.js similarity index 100% rename from examples/error-handling/server.js rename to .github/examples/error-handling/server.js diff --git a/examples/middleware/server.js b/.github/examples/middleware/server.js similarity index 100% rename from examples/middleware/server.js rename to .github/examples/middleware/server.js diff --git a/examples/rest-api/server.js b/.github/examples/rest-api/server.js similarity index 100% rename from examples/rest-api/server.js rename to .github/examples/rest-api/server.js diff --git a/examples/validation/server.js b/.github/examples/validation/server.js similarity index 100% rename from examples/validation/server.js rename to .github/examples/validation/server.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9a8ea48..eba214c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,43 +6,12 @@ on: workflow_dispatch: jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install dependencies - run: bun install - - - name: Build - run: bun run build:release - - - name: Test - run: bun run test - publish: - needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Setup Node.js (for npm publish) + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 @@ -50,10 +19,7 @@ jobs: scope: "@http-native" - name: Install dependencies - run: bun install - - - name: Build (release) - run: bun run build:release + run: npm install - name: Publish to npm run: npm publish --access public diff --git a/noslop/AGENTS.md b/noslop/AGENTS.md deleted file mode 100644 index e69de29..0000000 diff --git a/noslop/CLAUDE.md b/noslop/CLAUDE.md deleted file mode 100644 index e69de29..0000000 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2d4febb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,20 @@ +{ + "name": "@http-native/core", + "version": "0.0.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@http-native/core", + "version": "0.0.2", + "dependencies": { + "@http-native/core": "^0.0.1" + } + }, + "node_modules/@http-native/core": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@http-native/core/-/core-0.0.1.tgz", + "integrity": "sha512-NGdf6gxui1gtcQJ86zSAwuCRMUsIr5C7BBdkD/8SWDP2NJ90cGboOvM6zH3S+QZlKmkKYFmfSBKRspTioKM9Xw==" + } + } +} diff --git a/package.json b/package.json index 941aa2c..73431da 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,23 @@ { "name": "@http-native/core", - "version": "0.1.0", + "version": "0.0.3", "type": "module", "publishConfig": { "access": "public" }, + "bin": { + "http-native": "./src/cli.js" + }, + "files": [ + "src" + ], "exports": { ".": { "types": "./src/index.d.ts", "default": "./src/index.js" }, "./cors": "./src/cors.js", + "./hot": "./src/hot.js", "./session": "./src/session.js", "./validate": "./src/validate.js", "./http-server.config": "./src/http-server.config.js" @@ -18,6 +25,8 @@ "scripts": { "build": "bun scripts/build-native.mjs", "build:release": "bun scripts/build-native.mjs --release", + "dev:hot": "HTTP_NATIVE_HOT_RELOAD=1 bun test/app.ts", + "dev": "bun src/hot.js", "test": "bun run build && bun test/test.js", "bench": "bun run build:release && bun bench/run.js", "bench:http-native:static": "bun run build:release && bun bench/run.js http-native static 3001", @@ -44,5 +53,8 @@ "bench:xitca:opt": "bun bench/run.js xitca opt 3023", "bench:monoio:opt": "bun bench/run.js monoio opt 3024", "bench:zig:opt": "bun bench/run.js zig opt 3025" + }, + "dependencies": { + "@http-native/core": "^0.0.1" } } diff --git a/readme.md b/readme.md index e918ce6..0830b01 100644 --- a/readme.md +++ b/readme.md @@ -1,91 +1,54 @@

- +

+# @http-native/core -Http-native +A fast, Express-like HTTP framework for JavaScript powered by a Rust native module via napi-rs. -Http native is a express like server framework for Javascript that uses the Node-compatible framework with Rust native module way, where the rust binary is evoked through napi-rs or something faster. +## Install -You can also import the default server tuning config and override it before `listen()`: - -```js -import httpServerConfig from "http-native/http-server.config"; +```sh +npm install @http-native/core ``` -Rust handler (http) <-> (javascript logic) - -The rust server handles all the http, while the core javascript logic is run sperately (EXREMELY fast) - -Extrat performance features: - - 1) Ahead of time constant data indentification. (If the data in the route's logic isn't manipulated at runtime we directly store it in rust so we don't envoke the javascript logic) - - 2) Faster than bun.server() aswell as fastify. - - 3) Default async handling (Yes rust handles the async for you.) - -So start by just writing +## Usage ```js -import { createApp } from "../src/index.js"; - -const db = { - async getUser(id) { - return { - id, - name: "Ada wong", - role: "admin", - }; - }, -}; +import { createApp } from "@http-native/core"; const app = createApp(); -app.use(async (req, res, next) => { - res.header("x-powered-by", "http-native"); - await next(); +app.get("/", async (req, res) => { + res.json({ ok: true }); }); -app.get("/", (req, res) => { - res.json({ - ok: true, - engine: "rust", - bridge: "napi-rs", - }); +app.get("/user/:id", async (req, res) => { + res.json({ id: req.params.id }); }); -app.get("/users/:id", async (req, res) => { - const user = await db.getUser(req.params.id); - res.json(user); +app.error(async (error, req, res) => { + res.status(500).json({ error: error.message }); }); -const server = await app.listen({ - port: 3001, - serverConfig: { - ...httpServerConfig, - maxHeaderBytes: 32 * 1024, - }, -}); +const server = await app.listen().port(8190); +console.log(`Listening on ${server.url}`); ``` -Runtime optimization reporting: +## Imports ```js -console.log(server.optimizations.summary()); -console.log(server.optimizations.snapshot()); +import { createApp } from "@http-native/core"; +import cors from "@http-native/core/cors"; +import { validate } from "@http-native/core/validate"; +import httpServerConfig from "@http-native/core/http-server.config"; ``` -Pass `opt: { notify: true }` to `listen()` if you want runtime logs when a route is already native static or looks stable enough to cache later. - - -This architecture is designed to outperform previous iterations and provide top-tier performance on par with or exceeding `bun.serve()`. -Run tests via `test.js` and use the benchmark suite to validate performance gains. - - -Since this is designed to be a core library, please ensure strict adherence to API stability and zero-allocation principles where possible. - -Remeber nadhi u moron this will be a library so don't go around doing shit. +## Optimizations +```js +const server = await app.listen().port(8190).opt({ devComments: true }); -bump action +console.log(server.optimizations.summary()); +console.log(server.optimizations.snapshot()); +``` diff --git a/rust-native/Cargo.lock b/rust-native/Cargo.lock index d640ca7..548e3bf 100644 --- a/rust-native/Cargo.lock +++ b/rust-native/Cargo.lock @@ -25,6 +25,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -70,12 +92,33 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "convert_case" version = "0.11.0" @@ -157,6 +200,18 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flume" version = "0.11.1" @@ -178,6 +233,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -298,6 +359,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -320,18 +393,21 @@ dependencies = [ "anyhow", "base64", "bytes", - "getrandom", + "getrandom 0.2.17", "hmac", "httparse", "itoa", "json5", "memchr", "monoio", + "monoio-rustls", "napi", "napi-build", "napi-derive", "parking_lot", "rustc-hash", + "rustls", + "rustls-pemfile", "serde", "serde_json", "sha2", @@ -463,6 +539,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -571,6 +657,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "monoio-io-wrapper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfaa76e5daf87cc4d31b4d1b6bc93c12db59c19df50b9200afdbde42077655" +dependencies = [ + "monoio", +] + [[package]] name = "monoio-macros" version = "0.1.0" @@ -582,13 +677,26 @@ dependencies = [ "syn", ] +[[package]] +name = "monoio-rustls" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e31f422825bd7fb19957af6eaf89d7234ba143fcc0e515f5a2f526e332d1875" +dependencies = [ + "bytes", + "monoio", + "monoio-io-wrapper", + "rustls", + "thiserror", +] + [[package]] name = "nanorand" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -794,6 +902,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -803,12 +917,71 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -881,6 +1054,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.12" @@ -946,6 +1125,26 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "threadpool" version = "1.8.1" @@ -989,6 +1188,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1019,6 +1224,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -1209,6 +1423,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "writeable" version = "0.6.2" @@ -1259,6 +1479,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/rust-native/Cargo.toml b/rust-native/Cargo.toml index 296ef3b..d320e70 100644 --- a/rust-native/Cargo.toml +++ b/rust-native/Cargo.toml @@ -18,9 +18,12 @@ rustc-hash = "2" getrandom = "0.2" hmac = "0.12" monoio = { version = "0.2", features = ["sync", "legacy"] } +monoio-rustls = "0.4" napi = { version = "3", default-features = false, features = ["napi8"] } napi-derive = "3" parking_lot = "0.12" +rustls = "0.23" +rustls-pemfile = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" diff --git a/rust-native/src/lib.rs b/rust-native/src/lib.rs index 7c82b9a..e8509d4 100644 --- a/rust-native/src/lib.rs +++ b/rust-native/src/lib.rs @@ -6,13 +6,17 @@ pub mod session; use anyhow::{anyhow, Context, Result}; use memchr::memmem; use monoio::io::{AsyncReadRent, AsyncWriteRent, AsyncWriteRentExt}; -use monoio::net::{ListenerOpts, TcpListener, TcpStream}; +use monoio::net::{ListenerOpts, TcpListener}; +use monoio_rustls::TlsAcceptor; use napi::bindgen_prelude::{Buffer, Function, Promise}; use napi::threadsafe_function::ThreadsafeFunction; use napi::{Error, Status}; use napi_derive::napi; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::ServerConfig as RustlsServerConfig; use std::borrow::Cow; use std::cell::RefCell; +use std::io::BufReader; use std::net::{SocketAddr, ToSocketAddrs}; use std::sync::atomic::{AtomicBool, Ordering}; use std::rc::Rc; @@ -23,7 +27,7 @@ use crate::analyzer::{ DynamicFastPathResponse, DynamicValueSourceKind, JsonTemplateKind, JsonValueTemplate, TextSegment, }; -use crate::manifest::{HttpServerConfigInput, ManifestInput}; +use crate::manifest::{HttpServerConfigInput, ManifestInput, TlsConfigInput}; use crate::router::{ExactStaticRoute, MatchedRoute, Router}; // ─── Constants ────────────────────────── @@ -341,6 +345,8 @@ pub fn start_server( let server_config = Arc::new(HttpServerConfig::from_manifest(&manifest).map_err(to_napi_error)?); let router = Arc::new(Router::from_manifest(&manifest).map_err(to_napi_error)?); + let tls_acceptor = build_tls_acceptor(&manifest).map_err(to_napi_error)?; + let tls_enabled = tls_acceptor.is_some(); // Build session store if session config is present in manifest let session_store: Option> = manifest.session.as_ref().map(|cfg| { @@ -380,6 +386,7 @@ pub fn start_server( let thread_config = Arc::clone(&server_config); let thread_shutdown = Arc::clone(&shutdown_flag); let thread_session_store = session_store.clone(); + let thread_tls_acceptor = tls_acceptor.clone(); let thread_options = NativeListenOptions { host: options.host.clone(), port: options.port, @@ -405,6 +412,7 @@ pub fn start_server( thread_router, thread_dispatcher, thread_config, + thread_tls_acceptor, thread_shutdown, thread_session_store, ) @@ -464,7 +472,11 @@ pub fn start_server( Ok(NativeServerHandle { host: host.clone(), port, - url: format!("http://{host}:{port}"), + url: if tls_enabled { + format!("https://{host}:{port}") + } else { + format!("http://{host}:{port}") + }, shutdown: Mutex::new(Some(ShutdownHandle { flag: shutdown_flag, wake_addrs, @@ -516,6 +528,7 @@ async fn run_server( router: Arc, dispatcher: Arc, server_config: Arc, + tls_acceptor: Option, shutdown_flag: Arc, session_store: Option>, ) -> Result<()> { @@ -524,6 +537,7 @@ async fn run_server( let router: Rc> = Rc::new(router); let dispatcher: Rc> = Rc::new(dispatcher); let server_config: Rc> = Rc::new(server_config); + let tls_acceptor: Option> = tls_acceptor.map(Rc::new); let session_store: Option>> = session_store.map(Rc::new); @@ -553,6 +567,7 @@ async fn run_server( let router = Rc::clone(&router); let dispatcher = Rc::clone(&dispatcher); let server_config = Rc::clone(&server_config); + let tls_acceptor = tls_acceptor.clone(); let session_store = session_store.clone(); active_connections.set(active_connections.get() + 1); @@ -560,9 +575,25 @@ async fn run_server( let conn_counter = &active_connections as *const std::cell::Cell; monoio::spawn(async move { - if let Err(error) = - handle_connection(stream, router, dispatcher, server_config, session_store).await - { + let connection_result = if let Some(acceptor) = tls_acceptor.as_ref() { + match acceptor.accept(stream).await { + Ok(tls_stream) => { + handle_connection( + tls_stream, + router, + dispatcher, + server_config, + session_store, + ) + .await + } + Err(error) => Err(anyhow!("TLS accept failed: {error}")), + } + } else { + handle_connection(stream, router, dispatcher, server_config, session_store) + .await + }; + if let Err(error) = connection_result { eprintln!("[http-native] connection error: {error}"); } // Safety: single-threaded — pointer is always valid while server runs @@ -611,13 +642,16 @@ const TIMEOUT_BODY_READ: Duration = Duration::from_secs(60); // ─── Connection Handler with Buffer Pool -async fn handle_connection( - mut stream: TcpStream, +async fn handle_connection( + mut stream: S, router: Rc>, dispatcher: Rc>, server_config: Rc>, session_store: Option>>, -) -> Result<()> { +) -> Result<()> +where + S: AsyncReadRent + AsyncWriteRent + Unpin, +{ let mut buffer = acquire_buffer(); let result = handle_connection_inner( @@ -634,14 +668,17 @@ async fn handle_connection( result } -async fn handle_connection_inner( - stream: &mut TcpStream, +async fn handle_connection_inner( + stream: &mut S, buffer: &mut Vec, router: &Router, dispatcher: &JsDispatcher, server_config: &HttpServerConfig, session_store: Option<&session::SessionStore>, -) -> Result<()> { +) -> Result<()> +where + S: AsyncReadRent + AsyncWriteRent + Unpin, +{ let mut is_first_request = true; loop { @@ -1767,11 +1804,14 @@ fn build_response_bytes_fast( // ─── Response Writing ─────────────────── -async fn write_exact_static_response( - stream: &mut TcpStream, +async fn write_exact_static_response( + stream: &mut S, static_route: &ExactStaticRoute, keep_alive: bool, -) -> Result<()> { +) -> Result<()> +where + S: AsyncWriteRent + Unpin, +{ let response = if keep_alive { static_route.keep_alive_response.clone() } else { @@ -1984,8 +2024,8 @@ fn extract_session_trailer(dispatch_bytes: &[u8], start_offset: usize) -> Option }) } -async fn write_dynamic_dispatch_response( - stream: &mut TcpStream, +async fn write_dynamic_dispatch_response( + stream: &mut S, dispatcher: &JsDispatcher, request: Buffer, keep_alive: bool, @@ -1995,7 +2035,10 @@ async fn write_dynamic_dispatch_response( session_store: Option<&session::SessionStore>, session_id: Option<[u8; session::SESSION_ID_BYTES]>, is_new_session: bool, -) -> Result<()> { +) -> Result<()> +where + S: AsyncWriteRent + Unpin, +{ match dispatcher.dispatch(request).await { Ok(response) => { match build_http_response_from_dispatch(response.as_ref(), keep_alive) { @@ -2450,6 +2493,61 @@ fn bind_listener( .with_context(|| format!("failed to bind TCP listener on {bind_addr}")) } +fn build_tls_acceptor(manifest: &ManifestInput) -> Result> { + let Some(tls) = manifest.tls.as_ref() else { + return Ok(None); + }; + + let mut cert_chain = parse_tls_certificates(tls.cert.as_str(), "tls.cert")?; + if let Some(ca_pem) = tls.ca.as_deref() { + let mut ca_chain = parse_tls_certificates(ca_pem, "tls.ca")?; + cert_chain.append(&mut ca_chain); + } + + let private_key = parse_tls_private_key(tls)?; + let mut config = RustlsServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, private_key) + .context("failed to construct rustls server config")?; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + Ok(Some(TlsAcceptor::from(Arc::new(config)))) +} + +fn parse_tls_certificates( + pem: &str, + source_name: &str, +) -> Result>> { + let mut reader = BufReader::new(pem.as_bytes()); + let certs = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .with_context(|| format!("failed to parse {source_name} PEM"))?; + + if certs.is_empty() { + return Err(anyhow!("{source_name} does not contain any certificates")); + } + + Ok(certs) +} + +fn parse_tls_private_key(tls: &TlsConfigInput) -> Result> { + let mut reader = BufReader::new(tls.key.as_bytes()); + let key = rustls_pemfile::private_key(&mut reader) + .context("failed to parse tls.key PEM")?; + + if let Some(private_key) = key { + return Ok(private_key); + } + + if tls.passphrase.is_some() { + return Err(anyhow!( + "encrypted TLS private keys are not supported by this loader; provide an unencrypted PEM key" + )); + } + + Err(anyhow!("tls.key does not contain a supported private key")) +} + fn resolve_socket_addr(host: &str, port: u16) -> Result { (host, port) .to_socket_addrs()? @@ -2462,6 +2560,15 @@ fn validate_manifest(manifest: &ManifestInput) -> Result<()> { return Err(anyhow!("Unsupported manifest version {}", manifest.version)); } + if let Some(tls) = manifest.tls.as_ref() { + if tls.cert.trim().is_empty() { + return Err(anyhow!("tls.cert is required")); + } + if tls.key.trim().is_empty() { + return Err(anyhow!("tls.key is required")); + } + } + Ok(()) } diff --git a/rust-native/src/manifest.rs b/rust-native/src/manifest.rs index 4056977..900304b 100644 --- a/rust-native/src/manifest.rs +++ b/rust-native/src/manifest.rs @@ -5,12 +5,25 @@ use serde::Deserialize; pub struct ManifestInput { pub version: u32, pub server_config: Option, + #[serde(default)] + pub tls: Option, pub middlewares: Vec, pub routes: Vec, #[serde(default)] pub session: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TlsConfigInput { + pub cert: String, + pub key: String, + #[serde(default)] + pub ca: Option, + #[serde(default)] + pub passphrase: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionConfigInput { diff --git a/src/bridge.js b/src/bridge.js index d45ba4b..c5e0ea4 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -100,7 +100,7 @@ export function compileRouteShape(method, path) { }; } -// ─── Request Access Analysis ──────────── + /** * Static-analyze a handler/middleware source string to determine which diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..10a1a83 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +const [, , command] = process.argv; + +if (command === "setup") { + console.log("http-native: setting up native binary..."); + // TODO: download or build the platform-specific .node binary + console.log("http-native: done."); +} else { + console.log(`Usage: http-native setup`); +} diff --git a/src/dev/comments.js b/src/dev/comments.js index c627cab..3b396e5 100644 --- a/src/dev/comments.js +++ b/src/dev/comments.js @@ -4,8 +4,10 @@ const COMMENT_PREFIX = "[http-native optimization]"; const STATUS_DESCRIPTIONS = { "static-fast-path": "This route is served by the static fast path and avoids generic bridge dispatch.", + "native-cache": + "This route is cached natively in Rust after the first response. Subsequent requests are served directly from native memory until the TTL expires.", "bridge-dispatch": - "This route currently runs through bridge dispatch because it depends on runtime request data. ", + "This route currently runs through bridge dispatch because it depends on runtime request data.", "runtime-cache-tracking": "Runtime stability is being tracked to determine whether response caching is safe.", "runtime-cache-promoted": @@ -252,7 +254,7 @@ function buildOptimizationBlock(indent, statuses) { .join(" "); return [ - `${indent}/**`, + `${indent}/** [Auto generated by http-native]`, `${indent} * ${COMMENT_PREFIX} ${dedupedStatuses.join(" | ")}`, `${indent} * ${description}`, `${indent} */`, diff --git a/src/dev/hot-reload.js b/src/dev/hot-reload.js new file mode 100644 index 0000000..646b810 --- /dev/null +++ b/src/dev/hot-reload.js @@ -0,0 +1,169 @@ +import { spawn } from "node:child_process"; +import { existsSync, watch } from "node:fs"; +import path from "node:path"; + +const DEFAULT_WATCH_ROOTS = ["src", "rust-native/src", "test"]; +const DEFAULT_DEBOUNCE_MS = 120; +const WATCHED_EXTENSIONS = new Set([ + ".js", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".rs", + ".toml", + ".json", +]); + +const IGNORED_SEGMENTS = new Set([ + "node_modules", + ".git", + "target", + "bench/results", + ".http-native", +]); + +export function createHotReloadController(options = {}) { + if (options.enabled !== true) { + return { + dispose() {}, + }; + } + + const roots = normalizeWatchRoots(options.roots); + const debounceMs = normalizeDebounceMs(options.debounceMs); + const log = options.log ?? ((message) => console.log(message)); + const beforeRestart = options.beforeRestart ?? (async () => {}); + + const watchers = []; + let restartTimer = null; + let restartPendingFile = ""; + let restarting = false; + + for (const root of roots) { + if (!existsSync(root)) { + continue; + } + + try { + const watcher = watch( + root, + { recursive: true }, + (_eventType, filename) => { + if (restarting) { + return; + } + const changedFile = filename + ? path.resolve(root, String(filename)) + : root; + if (!shouldWatchFile(changedFile)) { + return; + } + restartPendingFile = changedFile; + if (restartTimer) { + clearTimeout(restartTimer); + } + restartTimer = setTimeout(() => { + void restartProcess(); + }, debounceMs); + }, + ); + watchers.push(watcher); + } catch (error) { + log( + `[http-native][hot-reload] failed to watch ${root}: ${error.message}`, + ); + } + } + + if (watchers.length === 0) { + log("[http-native][hot-reload] no watch roots available; disabled"); + return { + dispose() {}, + }; + } + + log( + `[http-native][hot-reload] enabled (${watchers.length} roots, debounce=${debounceMs}ms)`, + ); + + async function restartProcess() { + if (restarting) { + return; + } + restarting = true; + stopWatching(); + + const changedFile = restartPendingFile || "unknown file"; + log(`[http-native][hot-reload] change detected: ${changedFile}`); + log("[http-native][hot-reload] restarting process..."); + + try { + await beforeRestart(changedFile); + } catch (error) { + log(`[http-native][hot-reload] pre-restart cleanup failed: ${error.message}`); + } + + const argv = process.argv.slice(1); + const child = spawn(process.execPath, argv, { + cwd: process.cwd(), + env: { ...process.env, HTTP_NATIVE_HOT_RELOAD: "1" }, + stdio: "inherit", + }); + + child.on("error", (error) => { + log(`[http-native][hot-reload] failed to respawn: ${error.message}`); + }); + + process.exit(0); + } + + function stopWatching() { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } + for (const watcher of watchers) { + try { + watcher.close(); + } catch { + // ignore + } + } + watchers.length = 0; + } + + return { + dispose() { + stopWatching(); + }, + }; +} + +function normalizeWatchRoots(roots) { + const values = + Array.isArray(roots) && roots.length > 0 ? roots : DEFAULT_WATCH_ROOTS; + return values + .map((value) => path.resolve(process.cwd(), String(value))) + .filter((value, index, all) => all.indexOf(value) === index); +} + +function normalizeDebounceMs(value) { + const normalized = Number(value); + if (!Number.isFinite(normalized) || normalized <= 0) { + return DEFAULT_DEBOUNCE_MS; + } + return Math.floor(normalized); +} + +function shouldWatchFile(filePath) { + const normalizedPath = filePath.replaceAll("\\", "/"); + for (const segment of IGNORED_SEGMENTS) { + if (normalizedPath.includes(segment)) { + return false; + } + } + + const extension = path.extname(filePath).toLowerCase(); + return WATCHED_EXTENSIONS.has(extension); +} diff --git a/src/hot.js b/src/hot.js new file mode 100644 index 0000000..64e2b5f --- /dev/null +++ b/src/hot.js @@ -0,0 +1,258 @@ +/** + * http-native hot reloading. + * + * Watches source files for changes, gracefully shuts down the Rust server, + * re-imports the app module, and restarts — preserving the same port. + * + * Usage: + * // server.js + * import { createApp } from "http-native"; + * const app = createApp(); + * app.get("/", (req, res) => res.json({ ok: true })); + * export default app; + * + * // dev.js + * import { hot } from "http-native/hot"; + * hot("./server.js", { port: 3000 }); + * + * Or run directly: + * bun run --hot src/hot.js ./server.js + */ + +import { watch } from "node:fs"; +import { resolve, dirname, extname } from "node:path"; +import { pathToFileURL } from "node:url"; + +const DEBOUNCE_MS = 100; +const WATCHABLE_EXTENSIONS = new Set([".js", ".ts", ".mjs", ".mts", ".json"]); + +/** + * Start a hot-reloading dev server. + * + * @param {string} appModulePath - Path to the module that exports the app (default export) + * @param {Object} [options] + * @param {number} [options.port] - Port to listen on (default 3000) + * @param {string} [options.host] - Host to bind (default "127.0.0.1") + * @param {string|string[]} [options.watch] - Directories/files to watch (default: app module's directory) + * @param {boolean} [options.clear] - Clear console on reload (default true) + * @param {Function} [options.onReload] - Callback after successful reload + * @param {Function} [options.onError] - Callback on reload error + */ +export async function hot(appModulePath, options = {}) { + const absolutePath = resolve(process.cwd(), appModulePath); + const port = options.port ?? 3000; + const host = options.host ?? "127.0.0.1"; + const clearConsole = options.clear ?? true; + const onReload = options.onReload ?? null; + const onError = options.onError ?? null; + + // Determine watch directories + const watchPaths = options.watch + ? Array.isArray(options.watch) + ? options.watch.map((p) => resolve(process.cwd(), p)) + : [resolve(process.cwd(), options.watch)] + : [dirname(absolutePath)]; + + let currentServer = null; + let reloadVersion = 0; + let debounceTimer = null; + let isReloading = false; + + /** + * Load (or reload) the app module and start the server. + */ + async function loadAndStart() { + const version = ++reloadVersion; + + // Gracefully close the existing server and wait for port release + if (currentServer) { + try { + await currentServer.close(); + } catch (err) { + // Server may already be closed + } + currentServer = null; + // Wait for the OS to release the port + await new Promise((r) => setTimeout(r, 200)); + } + + try { + // Intercept self-starting modules: set a global flag that index.js can check + // to override the port and capture the server handle. + globalThis.__HTTP_NATIVE_HOT__ = { port, host, server: null }; + + // Bust the module cache by appending a query param + const moduleUrl = `${pathToFileURL(absolutePath).href}?v=${version}`; + const mod = await import(moduleUrl); + + // Wait for any microtask-queued or setTimeout-deferred app.listen() to complete + await new Promise((r) => setTimeout(r, 100)); + + // Check if the module self-started (called app.listen() during import) + const hotCtx = globalThis.__HTTP_NATIVE_HOT__; + if (hotCtx?.server) { + currentServer = hotCtx.server; + } else { + // Module exports the app without calling listen() + const app = mod.default ?? mod.app ?? mod; + + if (app && typeof app.listen === "function") { + currentServer = await app.listen({ + port, + host, + opt: { notify: false }, + }); + } else { + // Nothing worked — the module probably self-started but we missed + // the capture. This can happen if listen() failed silently. + throw new Error( + `Hot reload failed: the module did not export an app or start a server on port ${port}.\n` + + `Either add \`export default app\` to your file, or check for startup errors above.`, + ); + } + } + + globalThis.__HTTP_NATIVE_HOT__ = null; + + if (clearConsole && version > 1) { + console.clear(); + } + + const reloadTag = version > 1 ? ` (reload #${version - 1})` : ""; + console.log( + `\x1b[32m[hot]\x1b[0m Server running at ${currentServer.url}${reloadTag}`, + ); + + if (onReload && version > 1) { + onReload(currentServer); + } + } catch (error) { + console.error(`\x1b[31m[hot]\x1b[0m Failed to start server:`, error); + if (onError) { + onError(error); + } + // Don't crash — wait for the next file change to retry + } finally { + isReloading = false; + } + } + + /** + * Debounced reload triggered by file changes. + */ + function scheduleReload(filename) { + if (isReloading) return; + + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + isReloading = true; + console.log( + `\x1b[33m[hot]\x1b[0m File changed: ${filename ?? "unknown"} — reloading...`, + ); + loadAndStart(); + }, DEBOUNCE_MS); + } + + // Start watching files + const watchers = []; + for (const watchPath of watchPaths) { + try { + const watcher = watch(watchPath, { recursive: true }, (event, filename) => { + if (!filename) return; + + // Only reload for relevant file types + const ext = extname(filename).toLowerCase(); + if (!WATCHABLE_EXTENSIONS.has(ext)) return; + + // Ignore node_modules and build artifacts + if ( + filename.includes("node_modules") || + filename.includes(".node") || + filename.includes("target/") + ) { + return; + } + + scheduleReload(filename); + }); + + watchers.push(watcher); + } catch (err) { + console.warn(`\x1b[33m[hot]\x1b[0m Could not watch ${watchPath}:`, err.message); + } + } + + // Cleanup on exit + function cleanup() { + for (const w of watchers) { + try { + w.close(); + } catch {} + } + if (currentServer) { + try { + currentServer.close(); + } catch {} + } + } + + process.on("SIGINT", () => { + console.log("\n\x1b[32m[hot]\x1b[0m Shutting down..."); + cleanup(); + process.exit(0); + }); + + process.on("SIGTERM", () => { + cleanup(); + process.exit(0); + }); + + // Initial load + await loadAndStart(); + + return { + /** Manually trigger a reload */ + reload() { + scheduleReload("manual"); + }, + /** Stop watching and close the server */ + close() { + cleanup(); + }, + }; +} + +// ─── CLI Entry Point ────────────────────── +// +// Allow running directly: bun src/hot.js ./server.js [--port 3000] + +const isMain = + typeof process !== "undefined" && + process.argv[1] && + (process.argv[1].endsWith("/hot.js") || process.argv[1].endsWith("\\hot.js")); + +if (isMain) { + const args = process.argv.slice(2); + let appPath = null; + let port = 3000; + let host = "127.0.0.1"; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + port = Number(args[i + 1]); + i++; + } else if (args[i] === "--host" && args[i + 1]) { + host = args[i + 1]; + i++; + } else if (!args[i].startsWith("-")) { + appPath = args[i]; + } + } + + if (!appPath) { + console.error("Usage: bun src/hot.js [--port 3000] [--host 127.0.0.1]"); + process.exit(1); + } + + hot(appPath, { port, host }); +} diff --git a/src/http-server.config.js b/src/http-server.config.js index ee294f9..5d84ceb 100644 --- a/src/http-server.config.js +++ b/src/http-server.config.js @@ -1,3 +1,13 @@ +import { readFileSync } from "node:fs"; + +/** + * @typedef {Object} TlsConfig + * @property {string} cert - Path to PEM certificate file, or PEM string + * @property {string} key - Path to PEM private key file, or PEM string + * @property {string} [ca] - Path to CA bundle file, or PEM string (optional) + * @property {string} [passphrase] - Passphrase for encrypted private key (optional) + */ + /** * @typedef {Object} HttpServerConfig * @property {string} defaultHost - Bind address (default "127.0.0.1") @@ -8,6 +18,7 @@ * @property {string} headerConnectionPrefix - Lowercase "connection:" for matching * @property {string} headerContentLengthPrefix - Lowercase "content-length:" for matching * @property {string} headerTransferEncodingPrefix - Lowercase "transfer-encoding:" for matching + * @property {TlsConfig|null} tls - TLS/SSL configuration (null = plain HTTP) */ /** @type {HttpServerConfig} */ @@ -20,8 +31,46 @@ const httpServerConfig = { headerConnectionPrefix: "connection:", headerContentLengthPrefix: "content-length:", headerTransferEncodingPrefix: "transfer-encoding:", + tls: null, }; +/** + * Resolve a PEM value: if it looks like a file path, read it; otherwise return as-is. + * @param {string} value - PEM string or file path + * @returns {string} PEM content + */ +function resolvePem(value) { + if (!value) return null; + if (value.includes("-----BEGIN ")) return value; + try { + return readFileSync(value, "utf8"); + } catch (err) { + throw new Error(`Failed to read TLS file: ${value} (${err.message})`); + } +} + +/** + * Normalize TLS config — resolve file paths to PEM content and validate. + * @param {TlsConfig|null} tls + * @returns {{ cert: string, key: string, ca: string|null, passphrase: string|null }|null} + */ +function normalizeTlsConfig(tls) { + if (!tls) return null; + + const cert = resolvePem(tls.cert); + const key = resolvePem(tls.key); + + if (!cert) throw new Error("tls.cert is required — provide a PEM string or file path"); + if (!key) throw new Error("tls.key is required — provide a PEM string or file path"); + + return { + cert, + key, + ca: tls.ca ? resolvePem(tls.ca) : null, + passphrase: tls.passphrase ?? null, + }; +} + /** * Merge caller-provided overrides with built-in defaults, coercing * every field to the expected primitive type. @@ -46,6 +95,7 @@ export function normalizeHttpServerConfig(overrides = {}) { overrides.headerTransferEncodingPrefix ?? httpServerConfig.headerTransferEncodingPrefix, ), + tls: normalizeTlsConfig(overrides.tls ?? httpServerConfig.tls), }; } diff --git a/src/index.d.ts b/src/index.d.ts index bd11bce..694b411 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -146,10 +146,23 @@ export type ErrorHandler = ( // ─── Listen Options ───────────────────── +export interface TlsConfig { + /** Path to PEM certificate file, or PEM string */ + cert: string; + /** Path to PEM private key file, or PEM string */ + key: string; + /** Path to CA bundle file, or PEM string (optional) */ + ca?: string; + /** Passphrase for encrypted private key (optional) */ + passphrase?: string; +} + export interface HttpServerConfig { defaultHost?: string; defaultBacklog?: number; maxHeaderBytes?: number; + /** TLS/SSL configuration — set to enable HTTPS */ + tls?: TlsConfig | null; } export interface RuntimeOptimizationOptions { @@ -161,6 +174,12 @@ export interface RuntimeOptimizationOptions { timing?: boolean; /** Enable runtime response cache promotion for deterministic routes */ cache?: boolean; + /** Restart process on JS/Rust source changes (dev only, default: false) */ + hotReload?: boolean; + /** Watch roots for hot reload (default: ["src", "rust-native/src", "test"]) */ + hotReloadPaths?: string[]; + /** Debounce window for restart triggers in ms (default: 120) */ + hotReloadDebounceMs?: number; /** Write dev comments above route declarations with optimization flags (default: true) */ devComments?: boolean; } @@ -191,9 +210,12 @@ export interface ServerHandle { /** Bound port number */ readonly port: number; - /** Full URL (http://host:port) */ + /** Full URL (http(s)://host:port) */ readonly url: string; + /** Whether TLS is enabled */ + readonly tls: boolean; + /** Runtime optimization introspection */ readonly optimizations: { /** Get a snapshot of route optimization state */ @@ -216,6 +238,15 @@ export interface ListenHandle extends Promise { * Supports: await app.listen({ port }).opt({ notify: true }) */ opt(options?: RuntimeOptimizationOptions): ListenHandle; + + /** + * Enable TLS/HTTPS with cert and key. + * Accepts file paths or PEM strings. + * + * @example + * await app.listen().port(443).tls({ cert: "./cert.pem", key: "./key.pem" }) + */ + tls(config: TlsConfig): ListenHandle; } export interface OptimizationSnapshot { @@ -248,7 +279,10 @@ export interface Application { /** Register a global error / not-found handler */ error(handler: ErrorHandler): Application; - /** Register a global error handler */ + /** + * Deprecate soon + * Not very needed rn + * Register a global error handler */ onError(handler: ErrorHandler): Application; /** Group routes under a shared path prefix */ diff --git a/src/index.js b/src/index.js index 63e29db..fd1e150 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ import defaultHttpServerConfig, { } from "./http-server.config.js"; import { buildRouteEntry } from "./opt/entry.js"; import { createRouteDevCommentWriter } from "./dev/comments.js"; +import { createHotReloadController } from "./dev/hot-reload.js"; import { createRuntimeOptimizer } from "./opt/runtime.js"; const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; @@ -888,10 +889,22 @@ function normalizeListenOptions(options = {}) { notify: optionOpt?.notify ?? true, notifyIntervalMs: optionOpt?.notifyIntervalMs, cache: optionOpt?.cache, + hotReload: + optionOpt?.hotReload === true || + process.env.HTTP_NATIVE_HOT_RELOAD === "1", + hotReloadPaths: Array.isArray(optionOpt?.hotReloadPaths) + ? optionOpt.hotReloadPaths + : undefined, + hotReloadDebounceMs: optionOpt?.hotReloadDebounceMs, devComments: optionOpt?.devComments ?? process.env.HTTP_NATIVE_DEV_COMMENTS !== "0", }; + if (normalizedOpt.hotReload) { + // Avoid auto-generated source edits causing restart loops in dev hot-reload mode. + normalizedOpt.devComments = false; + } + return { host: options.host ?? serverConfig.defaultHost, port: Number(options.port ?? 3000), @@ -901,6 +914,7 @@ function normalizeListenOptions(options = {}) { : Number(options.backlog), opt: normalizedOpt, serverConfig, + tls: serverConfig.tls ?? null, }; } @@ -977,6 +991,36 @@ export function createApp() { return this.onError(handler); }, + status(code) { + const methods = {}; + for (const method of [...HTTP_METHODS, "all"]) { + const key = method.toLowerCase(); + methods[key] = (path, handler) => { + return this[key](path, async (req, res) => { + res.status(code); + await handler(req, res); + }); + }; + } + return methods; + }, + + 404(handler) { + return this.onError(async (error, req, res) => { + if (error?.status === 404 || error?.code === "NOT_FOUND") { + await handler(req, res); + } + }); + }, + + 401(handler) { + return this.onError(async (error, req, res) => { + if (error?.status === 401 || error?.code === "UNAUTHORIZED") { + await handler(req, res); + } + }); + }, + group(pathPrefix, registerGroup) { if (typeof registerGroup !== "function") { throw new TypeError("group(path, callback) requires a callback function"); @@ -1006,6 +1050,21 @@ export function createApp() { listen(options = {}) { const startServer = async (listenOptions = options) => { const normalizedOptions = normalizeListenOptions(listenOptions); + + await new Promise((resolve, reject) => { + import("node:net").then(({ createServer }) => { + const tester = createServer(); + tester.once("error", (err) => { + if (err.code === "EADDRINUSE") { + reject(new Error(`Port ${normalizedOptions.port} is already in use`)); + } else { + reject(err); + } + }); + tester.once("listening", () => tester.close(resolve)); + tester.listen(normalizedOptions.port, normalizedOptions.host); + }); + }); const compiledMiddlewares = this._middlewares.map(compileMiddlewareRegistration); const errorHandlerPlans = this._errorHandlers.map((handler) => analyzeRequestAccess(Function.prototype.toString.call(handler)), @@ -1069,6 +1128,15 @@ export function createApp() { })), }; + if (normalizedOptions.tls) { + manifest.tls = { + cert: normalizedOptions.tls.cert, + key: normalizedOptions.tls.key, + ca: normalizedOptions.tls.ca, + passphrase: normalizedOptions.tls.passphrase, + }; + } + // Detect session middleware and add config to manifest const sessionMiddleware = this._middlewares.find((mw) => mw.handler._sessionConfig); if (sessionMiddleware) { @@ -1099,9 +1167,11 @@ export function createApp() { for (const route of compiledRoutes) { const routeEntry = buildRouteEntry(route, compiledMiddlewares); const baseStatus = - routeEntry.staticFastPath === true - ? "static-fast-path" - : "bridge-dispatch"; + routeEntry.nativeCache === true + ? "native-cache" + : routeEntry.staticFastPath === true + ? "static-fast-path" + : "bridge-dispatch"; devRouteCommentWriter.markRoute(route, baseStatus); if (route.runtimeResponseCache) { @@ -1116,17 +1186,45 @@ export function createApp() { this._errorHandlers, devRouteCommentWriter, ); + // Hot reload: override port/host if the hot reloader is active + const hotCtx = globalThis.__HTTP_NATIVE_HOT__; + const listenHost = hotCtx?.host ?? normalizedOptions.host; + const listenPort = hotCtx?.port ?? normalizedOptions.port; + const handle = native.startServer(JSON.stringify(manifest), dispatcher, { - host: normalizedOptions.host, - port: normalizedOptions.port, + host: listenHost, + port: listenPort, backlog: normalizedOptions.backlog, }); ACTIVE_NATIVE_SERVERS.add(handle); - return { + let closing = false; + const closeServerHandle = async () => { + if (closing) { + return; + } + closing = true; + ACTIVE_NATIVE_SERVERS.delete(handle); + runtimeOptimizer?.dispose?.(); + devRouteCommentWriter?.cleanup?.(); + detachDevCommentProcessCleanup(); + await Promise.resolve(handle.close()); + }; + + const hotReloadController = createHotReloadController({ + enabled: normalizedOptions.opt.hotReload === true, + roots: normalizedOptions.opt.hotReloadPaths, + debounceMs: normalizedOptions.opt.hotReloadDebounceMs, + beforeRestart: async () => { + await closeServerHandle(); + }, + }); + + const serverHandle = { host: handle.host, port: handle.port, - url: handle.url, + url: normalizedOptions.tls ? handle.url.replace("http://", "https://") : handle.url, + tls: !!normalizedOptions.tls, _handle: handle, optimizations: { snapshot() { @@ -1137,28 +1235,33 @@ export function createApp() { }, }, close() { - ACTIVE_NATIVE_SERVERS.delete(handle); - runtimeOptimizer?.dispose?.(); - devRouteCommentWriter?.cleanup?.(); - detachDevCommentProcessCleanup(); - return handle.close(); + hotReloadController?.dispose?.(); + return closeServerHandle(); }, }; + + // Hot reload: capture the server handle so hot.js can manage it + if (hotCtx) { + hotCtx.server = serverHandle; + } + + return serverHandle; }; let selectedPort = options.port; let selectedOpt = options.opt; + let selectedTls = options.serverConfig?.tls ?? null; let startPromise = null; const resolveOptions = () => { - if (selectedPort === undefined && selectedOpt === undefined) { - return options; - } - return { ...options, ...(selectedPort === undefined ? {} : { port: selectedPort }), opt: selectedOpt, + serverConfig: { + ...(options.serverConfig ?? {}), + tls: selectedTls, + }, }; }; @@ -1193,6 +1296,52 @@ export function createApp() { return chainableListen; }, + hot(hotOptions = true) { + if (startPromise) { + return startPromise; + } + + const nextOpt = { + ...(selectedOpt ?? {}), + }; + + if (hotOptions === false) { + nextOpt.hotReload = false; + selectedOpt = nextOpt; + return chainableListen; + } + + nextOpt.hotReload = true; + + if (hotOptions && typeof hotOptions === "object") { + const hotReloadPaths = Array.isArray(hotOptions.paths) + ? hotOptions.paths + : Array.isArray(hotOptions.hotReloadPaths) + ? hotOptions.hotReloadPaths + : undefined; + + if (hotReloadPaths) { + nextOpt.hotReloadPaths = hotReloadPaths; + } + + const hotReloadDebounceMs = + hotOptions.debounceMs ?? hotOptions.hotReloadDebounceMs; + if (hotReloadDebounceMs !== undefined) { + nextOpt.hotReloadDebounceMs = hotReloadDebounceMs; + } + } + + selectedOpt = nextOpt; + return chainableListen; + }, + tls(tlsConfig) { + if (startPromise) { + return startPromise; + } + + selectedTls = tlsConfig; + return chainableListen; + }, then(onFulfilled, onRejected) { return start().then(onFulfilled, onRejected); }, diff --git a/src/opt/entry.js b/src/opt/entry.js index 46732da..b72e315 100644 --- a/src/opt/entry.js +++ b/src/opt/entry.js @@ -4,7 +4,8 @@ export function buildRouteEntry(route, middlewares) { pathPrefixMatches(middleware.pathPrefix, route.path), ); const source = route.handlerSource ?? ""; - const staticFastPath = isStaticFastPathCandidate(route, hasMiddleware, source); + const nativeCache = source.includes("res.ncache("); + const staticFastPath = !nativeCache && isStaticFastPathCandidate(route, hasMiddleware, source); const cacheCandidate = !staticFastPath && route.method === "GET" && @@ -15,7 +16,9 @@ export function buildRouteEntry(route, middlewares) { !/Date\.now|new Date|Math\.random|crypto\./.test(source); const reasons = []; - if (staticFastPath) { + if (nativeCache) { + reasons.push("response cached natively in Rust after first call"); + } else if (staticFastPath) { reasons.push("served by static fast path"); } else { reasons.push("served through bridge dispatch"); @@ -38,6 +41,7 @@ export function buildRouteEntry(route, middlewares) { stage: "cold", hits: 0, lastHitAt: null, + nativeCache, staticFastPath, binaryBridge: true, dispatchKind: route.dispatchKind ?? "generic_fallback", diff --git a/test/app.ts b/test/app.ts deleted file mode 100644 index 9458ec1..0000000 --- a/test/app.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createApp } from "../src/index.js"; - -let app = createApp(); - -const db: any = { - getUser: async (id: number) => { - await Promise.resolve(); - return { - id, - name: "Alice" - } - } -} - -app.error(async (error, req, res) => { - await Promise.resolve(); - console.error("Error", error, req, res); -}); - -const db = { - async getUser(id: number) { - return { - id, - name: "Ada Lovelace", - role: "admin", - }; - }, -}; - -app.get("/", async (req, res) => { - - const data = await db.getUser(233242) - res.json({ - ok: true, - data: req.query, - data_2: data, - }); -}); - - -app.get("/stable", async (req, res) => { - - res.json({ - ok: true - }); -}); - - -/** - * [http-native optimization] bridge-dispatch - * This route currently runs through bridge dispatch because it depends on runtime request data. - */ -app.get("/url", async (req, res) => { - const data = await db.getUser(Math.floor(Math.random() * 1000) + 1); - res.status(200).json({ - ok: true, - data: data - }); -}); - -const server = await app.listen().port(8190).opt({ devComments: true}); - - - -console.log(`http-native listening on ${server.url}`); diff --git a/test/test.js b/test/test.js index 38c07b4..3c2f042 100644 --- a/test/test.js +++ b/test/test.js @@ -124,6 +124,11 @@ async function main() { const app = createApp(); assert.equal(typeof app.error, "function"); + httpServerConfig.tls = { + cert: "/tst/cert.pem", + key: "/tst/key.pem" + }; + app.error((error, req, res) => { observedErrors.push({ path: req.path, @@ -153,6 +158,10 @@ async function main() { await next(); }); + /** [Auto generated by http-native] + * [http-native optimization] static-fast-path + * This route is served by the static fast path and avoids generic bridge dispatch. + */ app.get("/", (req, res) => { res.json({ ok: true, @@ -160,10 +169,18 @@ async function main() { }); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/stable", (req, res) => { res.json(stablePayload); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/native/:id", (req, res) => { res.json({ id: req.params.id, @@ -174,6 +191,10 @@ async function main() { }); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/users/:id", async (req, res) => { const user = await db.getUser(req.params.id); res.json(user); @@ -190,6 +211,10 @@ async function main() { await next(); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/chain/:id", (req, res) => { chainOrder.push(`h:${req.params.id}`); res.json({ @@ -199,6 +224,10 @@ async function main() { }); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/fallback", (req, res) => { const { headers, query } = req; res.json({ @@ -207,6 +236,10 @@ async function main() { }); }); + /** [Auto generated by http-native] + * [http-native optimization] static-fast-path + * This route is served by the static fast path and avoids generic bridge dispatch. + */ app.get("/search", (req, res) => { res.json({ q: req.query.q, @@ -216,18 +249,34 @@ async function main() { }); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/text", (req, res) => { res.type("text").send("hello from binary bridge"); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/binary", (req, res) => { res.send(Buffer.from([1, 2, 3, 4])); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/empty", (req, res) => { res.status(204).send(); }); + /** [Auto generated by http-native] + * [http-native optimization] bridge-dispatch + * This route currently runs through bridge dispatch because it depends on runtime request data. + */ app.get("/explode", () => { throw new Error("boom"); }); @@ -475,4 +524,4 @@ async function main() { console.log("[http-native] test suite passed"); } -await main(); +await main(); \ No newline at end of file