diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e804f18..81ac91a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,9 +10,9 @@ on: workflow_dispatch: inputs: engines: - description: 'Comma-separated engines to benchmark. Supported by this workflow: "http-native,bun"' + description: 'Comma-separated engines to benchmark. Supported by this workflow: "http-native,bun,fiber"' required: false - default: "http-native,bun" + default: "http-native,bun,fiber" scenarios: description: 'Comma-separated scenarios: "static,dynamic,opt"' required: false @@ -62,7 +62,7 @@ jobs: - name: Run benchmarks run: | bun bench/ci.js \ - --engines="${{ github.event.inputs.engines || 'http-native,bun' }}" \ + --engines="${{ github.event.inputs.engines || 'http-native,bun,fiber' }}" \ --scenarios="${{ github.event.inputs.scenarios || 'static,dynamic,opt' }}" \ --connections="${{ github.event.inputs.connections || '200' }}" \ --duration="${{ github.event.inputs.duration || '10s' }}" \ diff --git a/bench/ci.js b/bench/ci.js index 494b579..d328d8d 100644 --- a/bench/ci.js +++ b/bench/ci.js @@ -5,7 +5,7 @@ import { once } from "node:events"; import { resolve } from "node:path"; import { homedir } from "node:os"; -const DEFAULT_ENGINES = ["http-native", "bun"]; +const DEFAULT_ENGINES = ["http-native", "bun", "fiber"]; const DEFAULT_SCENARIOS = ["static", "dynamic", "opt"]; const DEFAULT_CONNECTIONS = 200; const DEFAULT_DURATION = "10s"; @@ -20,6 +20,7 @@ const SERVER_PORTS = Object.freeze({ xitca: { static: 3003, dynamic: 3013, opt: 3023 }, monoio: { static: 3004, dynamic: 3014, opt: 3024 }, zig: { static: 3005, dynamic: 3015, opt: 3025 }, + fiber: { static: 3009, dynamic: 3019, opt: 3029 }, }); const SUPPORTED_SCENARIOS = new Set(DEFAULT_SCENARIOS); @@ -79,6 +80,7 @@ async function main() { console.log(summary); console.log(`[http-native][bench] wrote ${jsonPath}`); console.log(`[http-native][bench] wrote ${markdownPath}`); + process.exit(0); } function parseArgs(argv) { @@ -143,7 +145,7 @@ function printUsage() { console.log("Usage: bun bench/ci.js [options]"); console.log(""); console.log("Options:"); - console.log(` --engines=http-native,bun Comma-separated list. Default: ${DEFAULT_ENGINES.join(",")}`); + console.log(` --engines=http-native,bun,fiber Comma-separated list. Default: ${DEFAULT_ENGINES.join(",")}`); console.log(` --scenarios=static,dynamic,opt Comma-separated list. Default: ${DEFAULT_SCENARIOS.join(",")}`); console.log(` --connections=${DEFAULT_CONNECTIONS} Bombardier concurrency`); console.log(` --duration=${DEFAULT_DURATION} Bombardier duration`); @@ -213,10 +215,8 @@ async function runBenchmarkCase(testCase, options) { const server = spawnServer(testCase); const serverLogs = []; let readyResolve; - let readyReject; - const ready = new Promise((resolve, reject) => { + const ready = new Promise((resolve) => { readyResolve = resolve; - readyReject = reject; }); let stdoutBuffer = ""; @@ -304,10 +304,7 @@ async function runBenchmarkCase(testCase, options) { derived: deriveMetrics(parsed.result), }; } finally { - if (server.exitCode === null) { - server.kill("SIGTERM"); - await once(server, "exit").catch(() => {}); - } + await stopServer(server, `${testCase.engine}/${testCase.scenario}`); } } @@ -315,6 +312,20 @@ function spawnServer(testCase) { if (testCase.engine === "bun" || testCase.engine === "http-native" || testCase.engine === "old") { return spawn("bun", ["bench/target.js", testCase.engine, testCase.scenario, String(testCase.port)], { cwd: process.cwd(), + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + } + + if (testCase.engine === "fiber") { + const cwd = resolve(process.cwd(), "bench/fiber-server"); + if (!existsSync(resolve(cwd, "go.mod"))) { + throw new Error(`Missing Fiber benchmark target at ${cwd}`); + } + + return spawn("go", ["run", ".", testCase.scenario, String(testCase.port)], { + cwd, + detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }); } @@ -346,6 +357,7 @@ function spawnServer(testCase) { ["run", "--release", "--manifest-path", manifestPath, "--", testCase.scenario, String(testCase.port)], { cwd: process.cwd(), + detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }, ); @@ -354,6 +366,63 @@ function spawnServer(testCase) { throw new Error(`Unsupported engine ${testCase.engine}`); } +async function stopServer(server, label) { + if (!server) { + return; + } + + if (server.exitCode !== null) { + cleanupServerStreams(server); + return; + } + + const waitForExit = once(server, "exit"); + const gracefulSignal = "SIGTERM"; + + try { + if (server.pid && process.platform !== "win32") { + process.kill(-server.pid, gracefulSignal); + } else { + server.kill(gracefulSignal); + } + } catch { + cleanupServerStreams(server); + return; + } + + const gracefulExit = await Promise.race([ + waitForExit.then(() => true).catch(() => false), + delay(5000).then(() => false), + ]); + + if (!gracefulExit && server.exitCode === null) { + console.warn(`[http-native][bench] forcing ${label} to exit`); + try { + if (server.pid && process.platform !== "win32") { + process.kill(-server.pid, "SIGKILL"); + } else { + server.kill("SIGKILL"); + } + await once(server, "exit").catch(() => {}); + } catch { + // Ignore kill failures during cleanup + } + } + + cleanupServerStreams(server); +} + +function cleanupServerStreams(server) { + server.stdout?.destroy(); + server.stderr?.destroy(); +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + async function runCommand(command, args, options = {}) { const label = options.label ?? `${command} ${args.join(" ")}`; const child = spawn(command, args, { diff --git a/bench/fiber-server/fiber-server b/bench/fiber-server/fiber-server new file mode 100755 index 0000000..14b3aef Binary files /dev/null and b/bench/fiber-server/fiber-server differ diff --git a/bench/fiber-server/go.mod b/bench/fiber-server/go.mod new file mode 100644 index 0000000..1691ad7 --- /dev/null +++ b/bench/fiber-server/go.mod @@ -0,0 +1,19 @@ +module github.com/nadhi/http-native/bench/fiber-server + +go 1.23 + +require github.com/gofiber/fiber/v2 v2.52.6 + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/bench/fiber-server/go.sum b/bench/fiber-server/go.sum new file mode 100644 index 0000000..edf0334 --- /dev/null +++ b/bench/fiber-server/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= +github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/bench/fiber-server/main.go b/bench/fiber-server/main.go new file mode 100644 index 0000000..21958d4 --- /dev/null +++ b/bench/fiber-server/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "log" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" +) + +func main() { + scenario := "static" + if len(os.Args) > 1 { + scenario = os.Args[1] + } + + port := "3009" + if len(os.Args) > 2 { + port = os.Args[2] + } + + app := fiber.New(fiber.Config{ + DisableStartupMessage: true, + }) + + switch scenario { + case "static": + app.Get("/", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "ok": true, + "engine": "fiber", + "mode": "static", + }) + }) + case "dynamic": + app.Get("/users/:id", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "id": c.Params("id"), + "engine": "fiber", + "mode": "dynamic", + }) + }) + case "opt": + app.Get("/stable", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "ok": true, + "engine": "fiber", + "mode": "opt", + "optimization": "runtime", + }) + }) + default: + log.Fatalf("unsupported scenario: %s", scenario) + } + + app.Use(func(c *fiber.Ctx) error { + c.Status(fiber.StatusNotFound) + return c.JSON(fiber.Map{"error": "Route not found"}) + }) + + listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", port)) + if err != nil { + log.Fatal(err) + } + defer listener.Close() + + fmt.Printf("READY http://127.0.0.1:%s\n", port) + + serverErr := make(chan error, 1) + go func() { + serverErr <- app.Listener(listener) + }() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-signals: + shutdownDone := make(chan struct{}) + go func() { + defer close(shutdownDone) + _ = app.Shutdown() + }() + + select { + case <-shutdownDone: + case <-time.After(5 * time.Second): + log.Print("fiber shutdown timed out") + } + case err := <-serverErr: + if err != nil { + log.Fatal(err) + } + } +} diff --git a/bench/run.js b/bench/run.js index 3eeac5b..828586f 100644 --- a/bench/run.js +++ b/bench/run.js @@ -9,7 +9,7 @@ const port = Number(portArg ?? 3001); function printUsage() { console.log("Usage: bun bench/run.js "); - console.log("Engines: http-native | bun | xitca | monoio | zig"); + console.log("Engines: http-native | bun | fiber | xitca | monoio | zig"); console.log("Scenarios: static | dynamic | opt"); console.log(""); console.log("Example:"); @@ -30,7 +30,7 @@ function benchmarkPathForScenario(activeScenario) { } async function main() { - if (!["http-native", "bun", "xitca", "monoio", "zig"].includes(engine)) { + if (!["http-native", "bun", "fiber", "xitca", "monoio", "zig"].includes(engine)) { printUsage(); process.exit(1); } @@ -69,6 +69,11 @@ async function main() { stdio: ["ignore", "pipe", "inherit"], }, ) + : engine === "fiber" + ? spawn("go", ["run", "./bench/fiber-server", scenario, String(port)], { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "inherit"], + }) : spawn("bun", ["bench/target.js", engine, scenario, String(port)], { cwd: process.cwd(), stdio: ["ignore", "pipe", "inherit"], diff --git a/package.json b/package.json index c86de66..a92e760 100644 --- a/package.json +++ b/package.json @@ -19,18 +19,21 @@ "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", "bench:bun:static": "bun bench/run.js bun static 3000", + "bench:fiber:static": "bun bench/run.js fiber static 3009", "bench:xitca:static": "bun bench/run.js xitca static 3003", "bench:monoio:static": "bun bench/run.js monoio static 3004", "bench:zig:static": "bun bench/run.js zig static 3005", "bench:http-native:dynamic": "bun run build:release && bun bench/run.js http-native dynamic 3011", "bench:bun:dynamic": "bun bench/run.js bun dynamic 3010", + "bench:fiber:dynamic": "bun bench/run.js fiber dynamic 3019", "bench:xitca:dynamic": "bun bench/run.js xitca dynamic 3013", "bench:monoio:dynamic": "bun bench/run.js monoio dynamic 3014", "bench:zig:dynamic": "bun bench/run.js zig dynamic 3015", "bench:http-native:opt": "bun run build:release && bun bench/run.js http-native opt 3021", "bench:bun:opt": "bun bench/run.js bun opt 3020", + "bench:fiber:opt": "bun bench/run.js fiber opt 3029", "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" } -} \ No newline at end of file +} diff --git a/readme.md b/readme.md index 0898bb7..9c2295c 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,7 @@ +

+ Http-native banner +

+ Http-native 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. diff --git a/rust-native/src/lib.rs b/rust-native/src/lib.rs index 9d6eebf..f9c0e58 100644 --- a/rust-native/src/lib.rs +++ b/rust-native/src/lib.rs @@ -21,6 +21,7 @@ use crate::manifest::{HttpServerConfigInput, ManifestInput}; use crate::router::{ExactStaticRoute, MatchedRoute, Router}; // ─── Constants ──────────────────────────────────────────────────────────────── +// Gotta add support for these to be changed. const FALLBACK_DEFAULT_HOST: &str = "127.0.0.1"; const FALLBACK_DEFAULT_BACKLOG: i32 = 2048; @@ -33,6 +34,7 @@ const FALLBACK_HEADER_TRANSFER_ENCODING_PREFIX: &str = "transfer-encoding:"; const BRIDGE_VERSION: u8 = 1; const REQUEST_FLAG_QUERY_PRESENT: u16 = 1 << 0; const REQUEST_FLAG_BODY_PRESENT: u16 = 1 << 1; +const NOT_FOUND_HANDLER_ID: u32 = 0; const NOT_FOUND_BODY: &[u8] = br#"{"error":"Route not found"}"#; /// Security: Maximum number of headers we allow per request @@ -847,15 +849,22 @@ fn build_dispatch_request_owned( return Ok(None); } - let Some(matched_route) = router.match_route(method_code, normalized_path.as_ref()) else { - return Ok(None); - }; - let header_refs: Vec<(&str, &str)> = headers .iter() .map(|(n, v)| (n.as_str(), v.as_str())) .collect(); + let Some(matched_route) = router.match_route(method_code, normalized_path.as_ref()) else { + return build_not_found_dispatch_envelope( + method_code, + path_str, + url_str, + &header_refs, + body, + ) + .map(Some); + }; + build_dispatch_envelope( &matched_route, method_code, @@ -867,6 +876,56 @@ fn build_dispatch_request_owned( .map(Some) } +fn build_not_found_dispatch_envelope( + method_code: u8, + path: &str, + url: &str, + header_entries: &[(&str, &str)], + body: &[u8], +) -> Result { + let url_bytes = url.as_bytes(); + let path_bytes = path.as_bytes(); + let mut flags: u16 = 0; + if url.contains('?') { + flags |= REQUEST_FLAG_QUERY_PRESENT; + } + if !body.is_empty() { + flags |= REQUEST_FLAG_BODY_PRESENT; + } + + if url_bytes.len() > u32::MAX as usize { + return Err(anyhow!("request url too large")); + } + if path_bytes.len() > u16::MAX as usize { + return Err(anyhow!("request path too large")); + } + if header_entries.len() > u16::MAX as usize { + return Err(anyhow!("too many headers")); + } + + let mut frame = Vec::with_capacity( + 20 + url_bytes.len() + path_bytes.len() + header_entries.len() * 16 + body.len(), + ); + frame.push(BRIDGE_VERSION); + frame.push(method_code); + push_u16(&mut frame, flags); + push_u32(&mut frame, NOT_FOUND_HANDLER_ID); + push_u32(&mut frame, url_bytes.len() as u32); + push_u16(&mut frame, path_bytes.len() as u16); + push_u16(&mut frame, 0); + push_u16(&mut frame, header_entries.len() as u16); + push_u32(&mut frame, body.len() as u32); + frame.extend_from_slice(url_bytes); + frame.extend_from_slice(path_bytes); + + for (name, value) in header_entries { + push_string_pair(&mut frame, name, value)?; + } + + frame.extend_from_slice(body); + Ok(Buffer::from(frame)) +} + fn build_dispatch_envelope( matched_route: &MatchedRoute<'_, '_>, method_code: u8, diff --git a/rust-native/src/router.rs b/rust-native/src/router.rs index 070d839..cb9970c 100644 --- a/rust-native/src/router.rs +++ b/rust-native/src/router.rs @@ -407,7 +407,11 @@ fn build_response_bytes( for (name, value) in headers { // Security: skip headers with CRLF injection - if name.contains('\r') || name.contains('\n') || value.contains('\r') || value.contains('\n') { + if name.contains('\r') + || name.contains('\n') + || value.contains('\r') + || value.contains('\n') + { continue; } response.extend_from_slice(name.as_bytes()); diff --git a/src/bridge.js b/src/bridge.js index 86a6d55..abb6238 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -192,7 +192,7 @@ function acquireRequestObject() { return requestPool.pop() || null; } -function releaseRequestObject(req) { +export function releaseRequestObject(req) { if (requestPool.length >= REQUEST_POOL_MAX) { return; } @@ -353,6 +353,27 @@ function createPooledRequest() { return req; } +function methodNameFromCode(methodCode) { + switch (methodCode) { + case METHOD_CODES.GET: + return "GET"; + case METHOD_CODES.POST: + return "POST"; + case METHOD_CODES.PUT: + return "PUT"; + case METHOD_CODES.DELETE: + return "DELETE"; + case METHOD_CODES.PATCH: + return "PATCH"; + case METHOD_CODES.OPTIONS: + return "OPTIONS"; + case METHOD_CODES.HEAD: + return "HEAD"; + default: + return ""; + } +} + export function createRequestFactory( plan, routeParamNames = EMPTY_ARRAY, @@ -369,7 +390,7 @@ export function createRequestFactory( request._routeParamNames = routeParamNames; request._plan = plan; request._routeMethod = routeMethod; - request.method = routeMethod; + request.method = routeMethod ?? methodNameFromCode(decoded.methodCode); request._path = undefined; request._url = undefined; request._params = undefined; diff --git a/src/index.d.ts b/src/index.d.ts index 9bfa5d9..14e0167 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -161,6 +161,9 @@ export interface Application { use(middleware: Middleware): Application; use(path: string, middleware: Middleware): Application; + /** Register a global error / not-found handler */ + error(handler: ErrorHandler): Application; + /** Register a global error handler */ onError(handler: ErrorHandler): Application; diff --git a/src/index.js b/src/index.js index ca8430a..0d8b64e 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ import { decodeRequestEnvelope, encodeResponseEnvelope, mergeRequestAccessPlans, + releaseRequestObject, } from "./bridge.js"; import { loadNativeModule } from "./native.js"; import defaultHttpServerConfig, { @@ -18,6 +19,19 @@ import { createRuntimeOptimizer } from "../opt/runtime.js"; const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; const ACTIVE_NATIVE_SERVERS = new Set(); const EMPTY_BUFFER = Buffer.alloc(0); +const ERROR_REQUEST_PLAN = Object.freeze({ + method: true, + path: true, + url: true, + fullParams: false, + fullQuery: true, + fullHeaders: true, + paramKeys: new Set(), + queryKeys: new Set(), + headerKeys: new Set(), + dispatchKind: "generic_fallback", + jsonFastPath: "fallback", +}); // ─── Path Normalization ─────────────────────────────────────────────────────── @@ -66,7 +80,7 @@ function normalizeContentType(type) { return type; } -// ─── Response Envelope (Pooled) ─────────────────────────────────────────────── + const RESPONSE_POOL_MAX = 512; const responsePool = []; @@ -268,29 +282,92 @@ function createMiddlewareRunner(middlewares) { // ─── Error Handling (Security-Hardened) ─────────────────────────────────────── -function serializeErrorResponse(error) { +function normalizeErrorStatus(error, fallbackStatus = 500) { + const status = Number(error?.status ?? error?.statusCode ?? fallbackStatus); + return Number.isInteger(status) && status >= 400 && status <= 599 + ? status + : fallbackStatus; +} + +function createHttpError(status, message, code) { + const error = new Error(message); + error.status = status; + if (code) { + error.code = code; + } + return error; +} + +function buildDefaultErrorSnapshot(error, fallbackStatus = 500) { // Security: NEVER leak internal error details to the client + const status = normalizeErrorStatus(error, fallbackStatus); const isProduction = process.env.NODE_ENV === "production"; - const body = isProduction - ? { error: "Internal Server Error" } - : { - error: "Internal Server Error", - detail: error instanceof Error ? error.message : String(error), - }; + let body; - return encodeResponseEnvelope({ - status: 500, + if (status === 404) { + body = { + error: error?.message || "Route not found", + }; + } else if (status >= 500) { + body = isProduction + ? { error: "Internal Server Error" } + : { + error: "Internal Server Error", + detail: error instanceof Error ? error.message : String(error), + }; + } else { + body = { + error: + error instanceof Error && error.message + ? error.message + : `HTTP ${status}`, + }; + } + + return { + status, headers: { "content-type": "application/json; charset=utf-8", }, body: Buffer.from(JSON.stringify(body), "utf8"), - }); + }; +} + +function serializeErrorResponse(error, fallbackStatus = 500) { + return encodeResponseEnvelope(buildDefaultErrorSnapshot(error, fallbackStatus)); } // ─── Dispatcher ─────────────────────────────────────────────────────────────── function createDispatcher(compiledRoutes, runtimeOptimizer, errorHandlers = []) { const routesById = new Map(compiledRoutes.map((route) => [route.handlerId, route])); + const errorRequestFactory = createRequestFactory(ERROR_REQUEST_PLAN, [], null); + + async function finalizeError(error, req, res, snapshot, release, fallbackStatus = 500) { + try { + if (!res.finished) { + for (const errorHandler of errorHandlers) { + await errorHandler(error, req, res); + if (res.finished) { + break; + } + } + } + + if (!res.finished) { + return encodeResponseEnvelope(buildDefaultErrorSnapshot(error, fallbackStatus)); + } + + return encodeResponseEnvelope(snapshot()); + } catch (handlerError) { + return serializeErrorResponse(handlerError); + } finally { + if (req) { + releaseRequestObject(req); + } + release(); + } + } return async function dispatch(requestBuffer) { let decoded; @@ -301,6 +378,19 @@ function createDispatcher(compiledRoutes, runtimeOptimizer, errorHandlers = []) return serializeErrorResponse(error); } + if (decoded.handlerId === 0) { + const req = errorRequestFactory(decoded); + const { response: res, snapshot, release } = createResponseEnvelope(); + return finalizeError( + createHttpError(404, "Route not found", "NOT_FOUND"), + req, + res, + snapshot, + release, + 404, + ); + } + const route = routesById.get(decoded.handlerId); if (!route) { return serializeErrorResponse(new Error(`Unknown handler id ${decoded.handlerId}`)); @@ -315,32 +405,13 @@ function createDispatcher(compiledRoutes, runtimeOptimizer, errorHandlers = []) await route.compiledHandler(req, res); } } catch (error) { - if (!res.finished) { - // Try registered error handlers first - for (const errorHandler of errorHandlers) { - try { - await errorHandler(error, req, res); - if (res.finished) { - break; - } - } catch (handlerError) { - // Error handler itself threw — fall through to default - release(); - return serializeErrorResponse(handlerError); - } - } - - // If no error handler responded, use default - if (!res.finished) { - release(); - return serializeErrorResponse(error); - } - } + return finalizeError(error, req, res, snapshot, release, 500); } const responseSnapshot = snapshot(); runtimeOptimizer?.recordDispatch(route, req, responseSnapshot); const encoded = encodeResponseEnvelope(responseSnapshot); + releaseRequestObject(req); release(); return encoded; }; @@ -471,6 +542,10 @@ export function createApp() { return this; }, + error(handler) { + return this.onError(handler); + }, + get: undefined, post: undefined, put: undefined, diff --git a/test/test.js b/test/test.js index a654548..f568c6d 100644 --- a/test/test.js +++ b/test/test.js @@ -119,8 +119,34 @@ async function main() { }, }; const chainOrder = []; + const observedErrors = []; const app = createApp(); + assert.equal(typeof app.error, "function"); + + app.error((error, req, res) => { + observedErrors.push({ + path: req.path, + status: Number(error?.status ?? 500), + code: error?.code ?? null, + message: error?.message ?? "", + }); + + if (Number(error?.status) === 404) { + res.status(404).json({ + handled: true, + path: req.path, + code: error.code, + }); + return; + } + + res.status(Number(error?.status ?? 500)).json({ + handled: true, + path: req.path, + message: error?.message ?? "unknown", + }); + }); app.use("/users", async (req, res, next) => { res.header("x-powered-by", "http-native"); @@ -192,6 +218,10 @@ async function main() { res.status(204).send(); }); + app.get("/explode", () => { + throw new Error("boom"); + }); + const server = await app.listen({ port: 0, serverConfig: { @@ -303,6 +333,41 @@ async function main() { q: "safe", }); + const notFoundResponse = await fetch(new URL("/missing?q=nope", server.url), { + headers: { + accept: "application/json", + }, + }); + assert.equal(notFoundResponse.status, 404); + assert.deepEqual(await notFoundResponse.json(), { + handled: true, + path: "/missing", + code: "NOT_FOUND", + }); + + const explodedResponse = await fetch(new URL("/explode", server.url)); + assert.equal(explodedResponse.status, 500); + assert.deepEqual(await explodedResponse.json(), { + handled: true, + path: "/explode", + message: "boom", + }); + + assert.deepEqual(observedErrors, [ + { + path: "/missing", + status: 404, + code: "NOT_FOUND", + message: "Route not found", + }, + { + path: "/explode", + status: 500, + code: null, + message: "boom", + }, + ]); + for (let index = 0; index < 32; index += 1) { const stableResponse = await fetch(new URL("/stable", server.url)); assert.equal(stableResponse.status, 200);