diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81ac91a..182d250 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,fiber"' + description: 'Comma-separated engines to benchmark. Supported by this workflow default run: "http-native,bun"' required: false - default: "http-native,bun,fiber" + default: "http-native,bun" scenarios: description: 'Comma-separated scenarios: "static,dynamic,opt"' required: false @@ -29,7 +29,6 @@ on: description: "Bombardier timeout" required: false default: "2s" - jobs: benchmark: runs-on: ubuntu-latest @@ -46,6 +45,11 @@ jobs: with: bun-version: latest + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -59,14 +63,27 @@ jobs: go install github.com/codesenberg/bombardier@latest echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - - name: Run benchmarks + - name: Run benchmarks (http-native on Bun + selected engines) + run: | + bun bench/ci.js \ + --engines="${{ github.event.inputs.engines || 'http-native,bun' }}" \ + --scenarios="${{ github.event.inputs.scenarios || 'static,dynamic,opt' }}" \ + --connections="${{ github.event.inputs.connections || '200' }}" \ + --duration="${{ github.event.inputs.duration || '10s' }}" \ + --timeout="${{ github.event.inputs.timeout || '2s' }}" \ + --http-native-runtime="bun" \ + --output-dir="bench/results/bun" + + - name: Run benchmarks (http-native on Node) run: | bun bench/ci.js \ - --engines="${{ github.event.inputs.engines || 'http-native,bun,fiber' }}" \ + --engines="http-native" \ --scenarios="${{ github.event.inputs.scenarios || 'static,dynamic,opt' }}" \ --connections="${{ github.event.inputs.connections || '200' }}" \ --duration="${{ github.event.inputs.duration || '10s' }}" \ - --timeout="${{ github.event.inputs.timeout || '2s' }}" + --timeout="${{ github.event.inputs.timeout || '2s' }}" \ + --http-native-runtime="node" \ + --output-dir="bench/results/node" - name: Upload benchmark artifacts if: always() @@ -74,6 +91,8 @@ jobs: with: name: benchmark-results path: | - bench/results/results.json - bench/results/summary.md + bench/results/bun/results.json + bench/results/bun/summary.md + bench/results/node/results.json + bench/results/node/summary.md if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a7b0502 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Http-native is opensource and freely available for everyone. + +When commiting code please mention what type of commit it is via the prefix. Example, + +opt: This flag is used to show optimizations
+chore: Fix / chore
+rm: removal
+other: Everything else.
+ +A commit would like this, + +opt: Fix benchmarks to be more optimized. +chore: removed ugly comments +rm: Removed all deprecated code +other: added a contributing.md + +You are allowed to mix commit types via & as seen in 'opt&chore:'. + +Please don't push code that's below the current benchmarks. Don't push testing code. + +Contact Us +You can find us in a couple places online. First and foremost, we're active right here on GitHub. If you encounter a bug or other problems, open an issue on here for us to take a look at it. We also accept feature requests here as well. + +- Http-native (Nadhi) diff --git a/bench/ci.js b/bench/ci.js index d328d8d..c9c07ff 100644 --- a/bench/ci.js +++ b/bench/ci.js @@ -5,13 +5,15 @@ import { once } from "node:events"; import { resolve } from "node:path"; import { homedir } from "node:os"; -const DEFAULT_ENGINES = ["http-native", "bun", "fiber"]; +const DEFAULT_ENGINES = ["http-native", "bun"]; const DEFAULT_SCENARIOS = ["static", "dynamic", "opt"]; const DEFAULT_CONNECTIONS = 200; const DEFAULT_DURATION = "10s"; const DEFAULT_TIMEOUT = "2s"; const DEFAULT_OUTPUT_DIR = "bench/results"; +const DEFAULT_HTTP_NATIVE_RUNTIME = "bun"; const DEFAULT_BOMBARDIER_BIN = resolveBombardierBin(); +const SUPPORTED_HTTP_NATIVE_RUNTIMES = new Set(["bun", "node"]); const SERVER_PORTS = Object.freeze({ bun: { static: 3000, dynamic: 3010, opt: 3020 }, @@ -59,6 +61,7 @@ async function main() { connections: options.connections, duration: options.duration, timeout: options.timeout, + httpNativeRuntime: options.httpNativeRuntime, }, results, }; @@ -130,6 +133,15 @@ function parseArgs(argv) { const duration = values.get("duration") ?? DEFAULT_DURATION; const timeout = values.get("timeout") ?? DEFAULT_TIMEOUT; const outputDir = values.get("output-dir") ?? DEFAULT_OUTPUT_DIR; + const httpNativeRuntime = (values.get("http-native-runtime") ?? DEFAULT_HTTP_NATIVE_RUNTIME).trim(); + + if (!SUPPORTED_HTTP_NATIVE_RUNTIMES.has(httpNativeRuntime)) { + throw new Error( + `Unsupported --http-native-runtime \"${httpNativeRuntime}\". Supported runtimes: ${[ + ...SUPPORTED_HTTP_NATIVE_RUNTIMES, + ].join(", ")}`, + ); + } return { engines, @@ -138,6 +150,7 @@ function parseArgs(argv) { duration, timeout, outputDir, + httpNativeRuntime, }; } @@ -145,11 +158,14 @@ function printUsage() { console.log("Usage: bun bench/ci.js [options]"); console.log(""); console.log("Options:"); - console.log(` --engines=http-native,bun,fiber Comma-separated list. Default: ${DEFAULT_ENGINES.join(",")}`); + console.log(` --engines=http-native,bun 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`); console.log(` --timeout=${DEFAULT_TIMEOUT} Bombardier timeout`); + console.log( + ` --http-native-runtime=${DEFAULT_HTTP_NATIVE_RUNTIME} Runtime for http-native/old: bun | node`, + ); console.log(` --output-dir=${DEFAULT_OUTPUT_DIR} Where to write results.json and summary.md`); } @@ -212,7 +228,7 @@ function portFor(engine, scenario) { async function runBenchmarkCase(testCase, options) { console.log(`[http-native][bench] starting ${testCase.engine}/${testCase.scenario} on :${testCase.port}`); - const server = spawnServer(testCase); + const server = spawnServer(testCase, options); const serverLogs = []; let readyResolve; const ready = new Promise((resolve) => { @@ -308,9 +324,10 @@ async function runBenchmarkCase(testCase, options) { } } -function spawnServer(testCase) { +function spawnServer(testCase, options) { if (testCase.engine === "bun" || testCase.engine === "http-native" || testCase.engine === "old") { - return spawn("bun", ["bench/target.js", testCase.engine, testCase.scenario, String(testCase.port)], { + const runtime = testCase.engine === "bun" ? "bun" : options.httpNativeRuntime; + return spawn(runtime, ["bench/target.js", testCase.engine, testCase.scenario, String(testCase.port)], { cwd: process.cwd(), detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], diff --git a/bench/run.js b/bench/run.js index 828586f..cd81d59 100644 --- a/bench/run.js +++ b/bench/run.js @@ -1,19 +1,22 @@ import { spawn } from "node:child_process"; import { once } from "node:events"; -const [, , engineArg, scenarioArg, portArg] = process.argv; +const [, , engineArg, scenarioArg, portArg, runtimeArg] = process.argv; const engine = engineArg ?? "http-native"; const scenario = scenarioArg ?? "static"; const port = Number(portArg ?? 3001); +const httpNativeRuntime = runtimeArg ?? "bun"; function printUsage() { - console.log("Usage: bun bench/run.js "); + console.log("Usage: bun bench/run.js [httpNativeRuntime]"); console.log("Engines: http-native | bun | fiber | xitca | monoio | zig"); console.log("Scenarios: static | dynamic | opt"); + console.log("httpNativeRuntime: bun | node (only applies to http-native and old)"); console.log(""); console.log("Example:"); - console.log(" bun bench/run.js http-native static 3001"); + console.log(" bun bench/run.js http-native static 3001 bun"); + console.log(" bun bench/run.js http-native static 3001 node"); console.log(" bombardier -c 200 -d 10s http://127.0.0.1:3001/"); } @@ -40,6 +43,13 @@ async function main() { process.exit(1); } + if (!["bun", "node"].includes(httpNativeRuntime)) { + printUsage(); + process.exit(1); + } + + const targetRuntime = engine === "http-native" || engine === "old" ? httpNativeRuntime : "bun"; + const child = engine === "xitca" || engine === "monoio" ? spawn( @@ -74,7 +84,7 @@ async function main() { cwd: process.cwd(), stdio: ["ignore", "pipe", "inherit"], }) - : spawn("bun", ["bench/target.js", engine, scenario, String(port)], { + : spawn(targetRuntime, ["bench/target.js", engine, scenario, String(port)], { cwd: process.cwd(), stdio: ["ignore", "pipe", "inherit"], }); diff --git a/bench/target.js b/bench/target.js index 3a3ed34..6c88a48 100644 --- a/bench/target.js +++ b/bench/target.js @@ -1,6 +1,7 @@ import { resolve } from "node:path"; process.env.HTTP_NATIVE_NODE_PATH ??= resolve(process.cwd(), "http-native.release.node"); +process.env.HTTP_NATIVE_NATIVE_PATH ??= process.env.HTTP_NATIVE_NODE_PATH; const { createApp: createHttpNativeApp } = await import("../src/index.js"); @@ -124,7 +125,7 @@ async function startFrameworkServer(createApp, label, activeScenario) { port, opt: label === "http-native" && activeScenario === "opt" - ? { notify: true } + ? { notify: true, cache: true } : undefined, }); diff --git a/examples/cors/server.js b/examples/cors/server.js index f1a200c..c085c34 100644 --- a/examples/cors/server.js +++ b/examples/cors/server.js @@ -3,13 +3,13 @@ import { cors } from "http-native/cors"; const app = createApp(); -// ─── Example 1: Allow all origins ───────────────────────────────────────────── +// ─── Example 1: Allow all origins ─────── // app.use(cors()); -// ─── Example 2: Allow specific origin ───────────────────────────────────────── +// ─── Example 2: Allow specific origin ─── // app.use(cors({ origin: "https://myapp.com" })); -// ─── Example 3: Allow multiple origins ──────────────────────────────────────── +// ─── Example 3: Allow multiple origins ── // app.use(cors({ origin: ["https://myapp.com", "https://admin.myapp.com"] })); // ─── Example 4: Dynamic origin with credentials ────────────────────────────── @@ -29,7 +29,7 @@ app.use( }), ); -// ─── Routes ─────────────────────────────────────────────────────────────────── +// ─── Routes ───────────────────────────── app.get("/api/data", (req, res) => { res.set("X-Request-Id", crypto.randomUUID()).json({ diff --git a/examples/error-handling/server.js b/examples/error-handling/server.js index f219d36..bd79a99 100644 --- a/examples/error-handling/server.js +++ b/examples/error-handling/server.js @@ -2,7 +2,6 @@ import { createApp } from "http-native"; const app = createApp(); -// ─── Custom Error Classes ───────────────────────────────────────────────────── class AppError extends Error { constructor(message, statusCode = 500, code = "INTERNAL_ERROR") { @@ -30,7 +29,7 @@ class UnauthorizedError extends AppError { } } -// ─── Global Error Handler ───────────────────────────────────────────────────── +// Global error handler app.onError((err, req, res) => { // Known application errors @@ -58,7 +57,7 @@ app.onError((err, req, res) => { }); }); -// ─── Routes ─────────────────────────────────────────────────────────────────── +// Routes. app.get("/", (req, res) => { res.json({ status: "ok" }); diff --git a/examples/middleware/server.js b/examples/middleware/server.js index a7eade9..2816167 100644 --- a/examples/middleware/server.js +++ b/examples/middleware/server.js @@ -2,7 +2,7 @@ import { createApp } from "http-native"; const app = createApp(); -// ─── Logging Middleware (global) ────────────────────────────────────────────── +// ─── Logging Middleware (global) ──────── app.use(async (req, res, next) => { const start = performance.now(); @@ -14,7 +14,7 @@ app.use(async (req, res, next) => { console.log(`← ${req.method} ${req.path} [${duration}ms]`); }); -// ─── Auth Middleware (scoped to /api) ───────────────────────────────────────── +// ─── Auth Middleware (scoped to /api) ─── app.use("/api", async (req, res, next) => { const token = req.header("authorization"); @@ -39,7 +39,7 @@ app.use("/api", async (req, res, next) => { await next(); }); -// ─── Request ID Middleware (global) ──────────────────────────────────────────── +// ─── Request ID Middleware (global) ────── app.use(async (req, res, next) => { const requestId = crypto.randomUUID(); @@ -48,7 +48,7 @@ app.use(async (req, res, next) => { await next(); }); -// ─── Routes ─────────────────────────────────────────────────────────────────── +// ─── Routes ───────────────────────────── // Public route (only logging + request ID middlewares run) app.get("/", (req, res) => { @@ -70,7 +70,7 @@ app.get("/api/secret", (req, res) => { }); }); -// ─── Error Handler ──────────────────────────────────────────────────────────── +// ─── Error Handler ────────────────────── app.onError((err, req, res) => { console.error(`❌ Error on ${req.method} ${req.path}:`, err.message); diff --git a/examples/rest-api/server.js b/examples/rest-api/server.js index 2e0a144..636d3f5 100644 --- a/examples/rest-api/server.js +++ b/examples/rest-api/server.js @@ -12,14 +12,14 @@ todos.set(2, { id: 2, title: "Build something awesome", completed: false }); todos.set(3, { id: 3, title: "Deploy to production", completed: false }); nextId = 4; -// ─── Error Handler ──────────────────────────────────────────────────────────── +// ─── Error Handler ────────────────────── app.onError((err, req, res) => { console.error(`Error: ${err.message}`); res.status(500).json({ error: "Internal server error" }); }); -// ─── List all todos ─────────────────────────────────────────────────────────── +// ─── List all todos ───────────────────── app.get("/todos", (req, res) => { const { completed } = req.query; @@ -34,7 +34,7 @@ app.get("/todos", (req, res) => { res.json({ todos: items, count: items.length }); }); -// ─── Get single todo ────────────────────────────────────────────────────────── +// ─── Get single todo ──────────────────── app.get("/todos/:id", (req, res) => { const id = Number(req.params.id); @@ -48,7 +48,7 @@ app.get("/todos/:id", (req, res) => { res.json(todo); }); -// ─── Create todo ────────────────────────────────────────────────────────────── +// ─── Create todo ──────────────────────── app.post("/todos", (req, res) => { const body = req.json(); @@ -68,7 +68,7 @@ app.post("/todos", (req, res) => { res.status(201).json(todo); }); -// ─── Update todo ────────────────────────────────────────────────────────────── +// ─── Update todo ──────────────────────── app.put("/todos/:id", (req, res) => { const id = Number(req.params.id); @@ -90,7 +90,7 @@ app.put("/todos/:id", (req, res) => { res.json(updated); }); -// ─── Delete todo ────────────────────────────────────────────────────────────── +// ─── Delete todo ──────────────────────── app.delete("/todos/:id", (req, res) => { const id = Number(req.params.id); @@ -104,7 +104,7 @@ app.delete("/todos/:id", (req, res) => { res.sendStatus(204); }); -// ─── Toggle completion ──────────────────────────────────────────────────────── +// ─── Toggle completion ────────────────── app.patch("/todos/:id/toggle", (req, res) => { const id = Number(req.params.id); diff --git a/examples/validation/server.js b/examples/validation/server.js index a3e25dc..d75cec7 100644 --- a/examples/validation/server.js +++ b/examples/validation/server.js @@ -3,7 +3,7 @@ import { validate } from "http-native/validate"; const app = createApp(); -// ─── Manual Schema (no external deps) ───────────────────────────────────────── +// ─── Manual Schema (no external deps) ─── // // Works with any object that has .parse() or .safeParse(). // Below is a minimal hand-rolled schema — in production, use Zod: @@ -59,14 +59,14 @@ const QuerySchema = createSchema((data) => { return errors; }); -// ─── Error Handler ──────────────────────────────────────────────────────────── +// ─── Error Handler ────────────────────── app.onError((err, req, res) => { console.error(`Error: ${err.message}`); res.status(500).json({ error: "Internal server error" }); }); -// ─── Routes with Validation ────────────────────────────────────────────────── +// ─── Routes with Validation ──────────── // Validates body against CreateUserSchema app.post( diff --git a/noslop/AGENTS.md b/noslop/AGENTS.md new file mode 100644 index 0000000..e69de29 diff --git a/noslop/CLAUDE.md b/noslop/CLAUDE.md new file mode 100644 index 0000000..e69de29 diff --git a/opt/runtime.js b/opt/runtime.js index 9246da1..1f64d0b 100644 --- a/opt/runtime.js +++ b/opt/runtime.js @@ -11,26 +11,36 @@ export function createRuntimeOptimizer(routes, middlewares, options = {}) { return { recordDispatch(route, _request, snapshot) { const entry = routesByHandlerId.get(route.handlerId); - if (!entry) { + if (!entry || entry.settled) { return; } entry.hits += 1; - entry.lastHitAt = new Date().toISOString(); entry.bridgeObserved = true; - if (entry.stage === "cold" && entry.hits >= HOT_HIT_THRESHOLD) { - entry.stage = "hot"; - maybeNotify( - notify, - entry, - entry.staticFastPath - ? `${entry.label} is serving from the static fast path` - : `${entry.label} is hot on bridge dispatch`, - ); + if (entry.stage === "cold") { + if (entry.hits >= HOT_HIT_THRESHOLD) { + entry.stage = "hot"; + entry.lastHitAt = Date.now(); + maybeNotify( + notify, + entry, + entry.staticFastPath + ? `${entry.label} is serving from the static fast path` + : `${entry.label} is hot on bridge dispatch`, + ); + + // Non-cache candidates: no more recording needed + if (!entry.cacheCandidate) { + entry.settled = true; + } + } + return; } + // Only cache candidates reach here in "hot" stage if (!entry.cacheCandidate) { + entry.settled = true; return; } @@ -47,6 +57,8 @@ export function createRuntimeOptimizer(routes, middlewares, options = {}) { entry.stableResponses >= STABLE_RESPONSE_THRESHOLD ) { entry.recommendation = "cache-candidate"; + entry.settled = true; + entry.lastHitAt = Date.now(); maybeNotify( notify, entry, @@ -156,6 +168,7 @@ function buildRouteEntry(route, middlewares) { reasons, stableResponses: 0, lastResponseKey: null, + settled: false, }; } diff --git a/package.json b/package.json index a92e760..224abce 100644 --- a/package.json +++ b/package.json @@ -18,18 +18,24 @@ "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", + "bench:http-native:bun:static": "bun run build:release && bun bench/run.js http-native static 3001 bun", + "bench:http-native:node:static": "bun run build:release && bun bench/run.js http-native static 3001 node", "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:http-native:bun:dynamic": "bun run build:release && bun bench/run.js http-native dynamic 3011 bun", + "bench:http-native:node:dynamic": "bun run build:release && bun bench/run.js http-native dynamic 3011 node", "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:http-native:bun:opt": "bun run build:release && bun bench/run.js http-native opt 3021 bun", + "bench:http-native:node:opt": "bun run build:release && bun bench/run.js http-native opt 3021 node", "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", diff --git a/readme.md b/readme.md index 9c2295c..d446991 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,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/build.rs b/rust-native/build.rs index 0f1b010..45f48ad 100644 --- a/rust-native/build.rs +++ b/rust-native/build.rs @@ -1,3 +1,6 @@ +// Napi rs build stuff +// OMG why do we even have this yuh. + fn main() { napi_build::setup(); } diff --git a/rust-native/src/analyzer.rs b/rust-native/src/analyzer.rs index 74e908f..9e53c82 100644 --- a/rust-native/src/analyzer.rs +++ b/rust-native/src/analyzer.rs @@ -3,10 +3,6 @@ use std::collections::HashMap; use crate::manifest::{MiddlewareInput, RouteInput}; -/// Optimize static stuff -/// Its not very fun but we don't need to call js again and again for -/// static responses, we can just return them from rust - #[derive(Clone)] pub struct StaticResponseSpec { pub status: u16, @@ -14,6 +10,66 @@ pub struct StaticResponseSpec { pub body: Vec, } +#[derive(Clone)] +pub struct DynamicFastPathSpec { + pub status: u16, + pub headers: Box<[(Box, Box)]>, + pub response: DynamicFastPathResponse, +} + +#[derive(Clone)] +pub enum DynamicFastPathResponse { + Json(JsonTemplate), + Text(TextTemplate), +} + +#[derive(Clone)] +pub struct JsonTemplate { + pub kind: JsonTemplateKind, +} + +#[derive(Clone)] +pub enum JsonTemplateKind { + Object(Box<[JsonObjectField]>), + Literal(Box<[u8]>), +} + +#[derive(Clone)] +pub struct JsonObjectField { + pub key_prefix: Box<[u8]>, + pub value: JsonValueTemplate, +} + +#[derive(Clone)] +pub enum JsonValueTemplate { + Literal(Box<[u8]>), + Dynamic(DynamicValueSource), +} + +#[derive(Clone)] +pub struct TextTemplate { + pub segments: Box<[TextSegment]>, +} + +#[derive(Clone)] +pub enum TextSegment { + Literal(Box), + Dynamic(DynamicValueSource), +} + +#[derive(Clone)] +pub struct DynamicValueSource { + pub kind: DynamicValueSourceKind, + pub key: Box, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum DynamicValueSourceKind { + Param, + Query, + Header, +} + pub enum AnalysisResult { ExactStaticFastPath(StaticResponseSpec), Dynamic, @@ -65,6 +121,51 @@ pub fn analyze_route(route: &RouteInput, middlewares: &[MiddlewareInput]) -> Ana AnalysisResult::Dynamic } +pub fn analyze_dynamic_fast_path( + route: &RouteInput, + middlewares: &[MiddlewareInput], +) -> Option { + if has_applicable_middleware(route.path.as_str(), middlewares) { + return None; + } + + let source = route.handler_source.as_str(); + if source.contains("await") { + return None; + } + + let body = trim_return_and_semicolon(extract_function_body(source)); + if body.is_empty() { + return None; + } + + if let Some((status, payload)) = parse_status_call(body, "json") { + return build_dynamic_json_response(status, payload); + } + + if let Some((status, payload)) = parse_status_call(body, "send") { + return build_dynamic_send_response(status, payload, None); + } + + if let Some((status, content_type, payload)) = parse_status_type_send_call(body) { + return build_dynamic_send_response(status, payload, Some(content_type)); + } + + if let Some((content_type, payload)) = parse_type_send_call(body) { + return build_dynamic_send_response(200, payload, Some(content_type)); + } + + if let Some(payload) = strip_call(body, "res.json(") { + return build_dynamic_json_response(200, payload); + } + + if let Some(payload) = strip_call(body, "res.send(") { + return build_dynamic_send_response(200, payload, None); + } + + None +} + pub fn normalize_path(path: &str) -> String { if path == "/" { return "/".to_string(); @@ -167,6 +268,42 @@ fn parse_status_call<'a>(body: &'a str, method: &str) -> Option<(u16, &'a str)> Some((status, payload.trim())) } +fn parse_status_type_send_call(body: &str) -> Option<(u16, String, &str)> { + let status_prefix = "res.status("; + let type_separator = ").type("; + let send_separator = ").send("; + + if !body.starts_with(status_prefix) || !body.ends_with(')') { + return None; + } + + let after_status = &body[status_prefix.len()..]; + let type_index = after_status.find(type_separator)?; + let status = after_status[..type_index].trim().parse::().ok()?; + let after_type = &after_status[type_index + type_separator.len()..]; + let send_index = after_type.find(send_separator)?; + let content_type = parse_string_literal(after_type[..send_index].trim())?; + let payload_start = send_index + send_separator.len(); + let payload = &after_type[payload_start..after_type.len() - 1]; + Some((status, content_type, payload.trim())) +} + +fn parse_type_send_call(body: &str) -> Option<(String, &str)> { + let type_prefix = "res.type("; + let send_separator = ").send("; + + if !body.starts_with(type_prefix) || !body.ends_with(')') { + return None; + } + + let after_type = &body[type_prefix.len()..]; + let send_index = after_type.find(send_separator)?; + let content_type = parse_string_literal(after_type[..send_index].trim())?; + let payload_start = send_index + send_separator.len(); + let payload = &after_type[payload_start..after_type.len() - 1]; + Some((content_type, payload.trim())) +} + fn strip_call<'a>(body: &'a str, prefix: &str) -> Option<&'a str> { if !body.starts_with(prefix) || !body.ends_with(')') { return None; @@ -191,6 +328,90 @@ fn build_json_response(status: u16, payload: &str) -> Option }) } +fn build_dynamic_json_response(status: u16, payload: &str) -> Option { + let template = parse_json_template(payload)?; + let headers = [( + "content-type".to_string(), + "application/json; charset=utf-8".to_string(), + )]; + + Some(DynamicFastPathSpec { + status, + headers: headers + .into_iter() + .map(|(name, value)| (name.into_boxed_str(), value.into_boxed_str())) + .collect::>() + .into_boxed_slice(), + response: DynamicFastPathResponse::Json(template), + }) +} + +fn build_dynamic_send_response( + status: u16, + payload: &str, + forced_content_type: Option, +) -> Option { + if let Some(text_template) = parse_text_template(payload) { + let content_type = forced_content_type + .map(normalize_content_type) + .unwrap_or_else(|| "text/plain; charset=utf-8".to_string()); + let headers = [("content-type".to_string(), content_type)]; + + return Some(DynamicFastPathSpec { + status, + headers: headers + .into_iter() + .map(|(name, value)| (name.into_boxed_str(), value.into_boxed_str())) + .collect::>() + .into_boxed_slice(), + response: DynamicFastPathResponse::Text(text_template), + }); + } + + let value = parse_literal(payload)?; + match value { + Value::String(text) => { + let content_type = forced_content_type + .map(normalize_content_type) + .unwrap_or_else(|| "text/plain; charset=utf-8".to_string()); + let headers = [("content-type".to_string(), content_type)]; + Some(DynamicFastPathSpec { + status, + headers: headers + .into_iter() + .map(|(name, value)| (name.into_boxed_str(), value.into_boxed_str())) + .collect::>() + .into_boxed_slice(), + response: DynamicFastPathResponse::Text(TextTemplate { + segments: vec![TextSegment::Literal(text.into_boxed_str())].into_boxed_slice(), + }), + }) + } + Value::Null => None, + other => { + if forced_content_type.is_some() { + return None; + } + let body = serde_json::to_vec(&other).ok()?.into_boxed_slice(); + let headers = [( + "content-type".to_string(), + "application/json; charset=utf-8".to_string(), + )]; + Some(DynamicFastPathSpec { + status, + headers: headers + .into_iter() + .map(|(name, value)| (name.into_boxed_str(), value.into_boxed_str())) + .collect::>() + .into_boxed_slice(), + response: DynamicFastPathResponse::Json(JsonTemplate { + kind: JsonTemplateKind::Literal(body), + }), + }) + } + } +} + fn build_send_response(status: u16, payload: &str) -> Option { let value = parse_literal(payload)?; match value { @@ -224,6 +445,414 @@ fn build_send_response(status: u16, payload: &str) -> Option } } +fn parse_json_template(payload: &str) -> Option { + let payload = payload.trim(); + + if payload.starts_with('{') && payload.ends_with('}') { + let fields = parse_json_object_fields(payload)?; + return Some(JsonTemplate { + kind: JsonTemplateKind::Object(fields.into_boxed_slice()), + }); + } + + let literal = parse_literal(payload)?; + let literal_bytes = serde_json::to_vec(&literal).ok()?.into_boxed_slice(); + Some(JsonTemplate { + kind: JsonTemplateKind::Literal(literal_bytes), + }) +} + +fn parse_json_object_fields(payload: &str) -> Option> { + let inner = payload[1..payload.len() - 1].trim(); + if inner.is_empty() { + return Some(Vec::new()); + } + + let entries = split_top_level(inner, ',')?; + let mut fields = Vec::with_capacity(entries.len()); + + for entry in entries { + let trimmed = entry.trim(); + if trimmed.is_empty() { + continue; + } + + let separator = find_top_level(trimmed, ':')?; + let key = parse_object_key(trimmed[..separator].trim())?; + let value_source = trimmed[separator + 1..].trim(); + let value = if let Some(source) = parse_dynamic_value_source(value_source) { + JsonValueTemplate::Dynamic(source) + } else { + let literal = parse_literal(value_source)?; + JsonValueTemplate::Literal(serde_json::to_vec(&literal).ok()?.into_boxed_slice()) + }; + + let key_prefix = format!("{}:", serde_json::to_string(&key).ok()?).into_bytes(); + fields.push(JsonObjectField { + key_prefix: key_prefix.into_boxed_slice(), + value, + }); + } + + Some(fields) +} + +fn parse_object_key(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + + if let Some(value) = parse_string_literal(raw) { + return Some(value); + } + + if is_identifier(raw) { + return Some(raw.to_string()); + } + + None +} + +fn parse_text_template(payload: &str) -> Option { + let payload = payload.trim(); + + if let Some(string_value) = parse_string_literal(payload) { + return Some(TextTemplate { + segments: vec![TextSegment::Literal(string_value.into_boxed_str())].into_boxed_slice(), + }); + } + + parse_template_literal(payload) +} + +fn parse_template_literal(payload: &str) -> Option { + if !payload.starts_with('`') || !payload.ends_with('`') || payload.len() < 2 { + return None; + } + + let inner = &payload[1..payload.len() - 1]; + if !inner.contains("${") { + return Some(TextTemplate { + segments: vec![TextSegment::Literal( + unescape_template_literal(inner)?.into_boxed_str(), + )] + .into_boxed_slice(), + }); + } + + let mut segments = Vec::new(); + let mut cursor = 0usize; + let bytes = inner.as_bytes(); + + while cursor < bytes.len() { + let mut expr_start = None; + let mut index = cursor; + while index + 1 < bytes.len() { + if bytes[index] == b'$' && bytes[index + 1] == b'{' { + expr_start = Some(index); + break; + } + index += 1; + } + + let Some(start) = expr_start else { + let tail = unescape_template_literal(&inner[cursor..])?; + if !tail.is_empty() { + segments.push(TextSegment::Literal(tail.into_boxed_str())); + } + break; + }; + + if start > cursor { + let literal = unescape_template_literal(&inner[cursor..start])?; + if !literal.is_empty() { + segments.push(TextSegment::Literal(literal.into_boxed_str())); + } + } + + let expr_end = find_template_expr_end(inner, start + 2)?; + let expression = inner[start + 2..expr_end].trim(); + let source = parse_dynamic_value_source(expression)?; + segments.push(TextSegment::Dynamic(source)); + cursor = expr_end + 1; + } + + Some(TextTemplate { + segments: segments.into_boxed_slice(), + }) +} + +fn find_template_expr_end(input: &str, start: usize) -> Option { + let bytes = input.as_bytes(); + let mut depth = 0usize; + let mut index = start; + let mut string_delim: Option = None; + let mut escaped = false; + + while index < bytes.len() { + let byte = bytes[index]; + if let Some(delim) = string_delim { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == delim { + string_delim = None; + } + index += 1; + continue; + } + + match byte { + b'\'' | b'"' | b'`' => { + string_delim = Some(byte); + } + b'{' => { + depth += 1; + } + b'}' => { + if depth == 0 { + return Some(index); + } + depth -= 1; + } + _ => {} + } + + index += 1; + } + + None +} + +fn parse_dynamic_value_source(raw: &str) -> Option { + let source = raw.trim(); + if let Some(key) = source.strip_prefix("req.params.") { + if is_identifier(key) { + return Some(DynamicValueSource { + kind: DynamicValueSourceKind::Param, + key: key.to_string().into_boxed_str(), + }); + } + } + + if let Some(key) = parse_bracket_access(source, "req.params[") { + return Some(DynamicValueSource { + kind: DynamicValueSourceKind::Param, + key: key.into_boxed_str(), + }); + } + + if let Some(key) = source.strip_prefix("req.query.") { + if is_identifier(key) { + return Some(DynamicValueSource { + kind: DynamicValueSourceKind::Query, + key: key.to_string().into_boxed_str(), + }); + } + } + + if let Some(key) = parse_bracket_access(source, "req.query[") { + return Some(DynamicValueSource { + kind: DynamicValueSourceKind::Query, + key: key.into_boxed_str(), + }); + } + + if let Some(key) = source.strip_prefix("req.headers.") { + if is_identifier(key) { + return Some(DynamicValueSource { + kind: DynamicValueSourceKind::Header, + key: key.to_ascii_lowercase().into_boxed_str(), + }); + } + } + + if let Some(key) = parse_bracket_access(source, "req.headers[") { + return Some(DynamicValueSource { + kind: DynamicValueSourceKind::Header, + key: key.to_ascii_lowercase().into_boxed_str(), + }); + } + + if let Some(key) = parse_function_call_string_arg(source, "req.header(") { + return Some(DynamicValueSource { + kind: DynamicValueSourceKind::Header, + key: key.to_ascii_lowercase().into_boxed_str(), + }); + } + + None +} + +fn parse_function_call_string_arg(source: &str, prefix: &str) -> Option { + if !source.starts_with(prefix) || !source.ends_with(')') { + return None; + } + + parse_string_literal(source[prefix.len()..source.len() - 1].trim()) +} + +fn parse_bracket_access(source: &str, prefix: &str) -> Option { + if !source.starts_with(prefix) || !source.ends_with(']') { + return None; + } + + parse_string_literal(source[prefix.len()..source.len() - 1].trim()) +} + +fn parse_string_literal(source: &str) -> Option { + let value = parse_literal(source)?; + match value { + Value::String(text) => Some(text), + _ => None, + } +} + +fn split_top_level<'a>(input: &'a str, separator: char) -> Option> { + let mut values = Vec::new(); + let mut start = 0usize; + let mut brace_depth = 0usize; + let mut bracket_depth = 0usize; + let mut paren_depth = 0usize; + let mut string_delimiter: Option = None; + let mut escaped = false; + + for (index, ch) in input.char_indices() { + if let Some(delimiter) = string_delimiter { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == delimiter { + string_delimiter = None; + } + continue; + } + + match ch { + '"' | '\'' | '`' => string_delimiter = Some(ch), + '{' => brace_depth += 1, + '}' => brace_depth = brace_depth.checked_sub(1)?, + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.checked_sub(1)?, + '(' => paren_depth += 1, + ')' => paren_depth = paren_depth.checked_sub(1)?, + _ if ch == separator && brace_depth == 0 && bracket_depth == 0 && paren_depth == 0 => { + values.push(&input[start..index]); + start = index + ch.len_utf8(); + } + _ => {} + } + } + + if string_delimiter.is_some() || brace_depth != 0 || bracket_depth != 0 || paren_depth != 0 { + return None; + } + + values.push(&input[start..]); + Some(values) +} + +fn find_top_level(input: &str, target: char) -> Option { + let mut brace_depth = 0usize; + let mut bracket_depth = 0usize; + let mut paren_depth = 0usize; + let mut string_delimiter: Option = None; + let mut escaped = false; + + for (index, ch) in input.char_indices() { + if let Some(delimiter) = string_delimiter { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == delimiter { + string_delimiter = None; + } + continue; + } + + match ch { + '"' | '\'' | '`' => string_delimiter = Some(ch), + '{' => brace_depth += 1, + '}' => brace_depth = brace_depth.checked_sub(1)?, + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.checked_sub(1)?, + '(' => paren_depth += 1, + ')' => paren_depth = paren_depth.checked_sub(1)?, + _ if ch == target && brace_depth == 0 && bracket_depth == 0 && paren_depth == 0 => { + return Some(index); + } + _ => {} + } + } + + None +} + +fn is_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + + chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$') +} + +fn normalize_content_type(value: String) -> String { + if value.contains('/') { + return value; + } + + match value.as_str() { + "json" => "application/json; charset=utf-8".to_string(), + "html" => "text/html; charset=utf-8".to_string(), + "text" => "text/plain; charset=utf-8".to_string(), + _ => value, + } +} + +fn unescape_template_literal(input: &str) -> Option { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + output.push(ch); + continue; + } + + let Some(next) = chars.next() else { + return None; + }; + match next { + '\\' => output.push('\\'), + '`' => output.push('`'), + '"' => output.push('"'), + '\'' => output.push('\''), + 'n' => output.push('\n'), + 'r' => output.push('\r'), + 't' => output.push('\t'), + '$' => output.push('$'), + other => output.push(other), + } + } + Some(output) +} + fn parse_literal(source: &str) -> Option { let normalized = normalize_js_literal(source); json5::from_str::(normalized.as_str()).ok() diff --git a/rust-native/src/lib.rs b/rust-native/src/lib.rs index f9c0e58..69f5487 100644 --- a/rust-native/src/lib.rs +++ b/rust-native/src/lib.rs @@ -16,11 +16,16 @@ use std::cell::RefCell; use std::net::{SocketAddr, ToSocketAddrs}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex}; +use url::form_urlencoded; +use crate::analyzer::{ + DynamicFastPathResponse, DynamicValueSourceKind, JsonTemplateKind, JsonValueTemplate, + TextSegment, +}; use crate::manifest::{HttpServerConfigInput, ManifestInput}; use crate::router::{ExactStaticRoute, MatchedRoute, Router}; -// ─── Constants ──────────────────────────────────────────────────────────────── +// ─── Constants ────────────────────────── // Gotta add support for these to be changed. const FALLBACK_DEFAULT_HOST: &str = "127.0.0.1"; @@ -34,8 +39,9 @@ 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 UNKNOWN_METHOD_CODE: u8 = 0; +/// Sentinel handler ID dispatched to JS when no route matches — JS treats this as 404. 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 const MAX_HEADER_COUNT: usize = 64; @@ -55,7 +61,7 @@ const BUFFER_POOL_MAX_RECYCLE_SIZE: usize = 65536; type DispatchTsfn = ThreadsafeFunction, Buffer, Status, false, false, 0>; -// ─── Thread-Local Buffer Pool ───────────────────────────────────────────────── +// ─── Thread-Local Buffer Pool ─────────── // // Eliminates per-connection Vec allocations by recycling buffers. @@ -84,7 +90,7 @@ fn release_buffer(mut buf: Vec) { }); } -// ─── Server Configuration ───────────────────────────────────────────────────── +// ─── Server Configuration ─────────────── #[derive(Clone)] struct HttpServerConfig { @@ -162,7 +168,7 @@ impl HttpServerConfig { } } -// ─── NAPI Interface ─────────────────────────────────────────────────────────── +// ─── NAPI Interface ───────────────────── #[napi(object)] pub struct NativeListenOptions { @@ -360,7 +366,7 @@ fn worker_count_for(options: &NativeListenOptions) -> usize { .unwrap_or(1) } -// ─── JS Dispatcher ──────────────────────────────────────────────────────────── +// ─── JS Dispatcher ────────────────────── struct JsDispatcher { callback: DispatchTsfn, @@ -380,7 +386,7 @@ impl JsDispatcher { } } -// ─── Server Loop ────────────────────────────────────────────────────────────── +// ─── Server Loop ──────────────────────── async fn run_server( listener: TcpListener, @@ -429,7 +435,7 @@ async fn run_server( Ok(()) } -// ─── Parsed Request (from httparse) ─────────────────────────────────────────── +// ─── Parsed Request (from httparse) ───── struct ParsedRequest<'a> { method: &'a [u8], @@ -443,7 +449,7 @@ struct ParsedRequest<'a> { headers: Vec<(&'a str, &'a str)>, } -// ─── Connection Handler with Buffer Pool ────────────────────────────────────── +// ─── Connection Handler with Buffer Pool async fn handle_connection( mut stream: TcpStream, @@ -522,22 +528,11 @@ async fn handle_connection_inner( let has_body = parsed.has_body; let content_length = parsed.content_length; - // Extract owned copies from parsed (which borrows buffer) before we mutate buffer - let method_owned: Vec = parsed.method.to_vec(); - let target_owned: Vec = parsed.target.to_vec(); - let path_owned: Vec = parsed.path.to_vec(); - let headers_owned: Vec<(String, String)> = parsed - .headers - .iter() - .map(|(n, v)| (n.to_string(), v.to_string())) - .collect(); - - drop(parsed); - - // ── Fast path: static routes (GET /) ── - if !has_body && method_owned == b"GET" { - if path_owned == b"/" { + // ── Fast path: static routes (zero-copy from borrowed parse data) ── + if !has_body && parsed.method == b"GET" { + if parsed.path == b"/" { if let Some(static_route) = router.exact_get_root() { + drop(parsed); drain_consumed_bytes(buffer, header_bytes); write_exact_static_response(stream, static_route, keep_alive).await?; if !keep_alive { @@ -547,7 +542,8 @@ async fn handle_connection_inner( continue; } } - if let Some(static_route) = router.exact_static_route(&method_owned, &path_owned) { + if let Some(static_route) = router.exact_static_route(parsed.method, parsed.path) { + drop(parsed); drain_consumed_bytes(buffer, header_bytes); write_exact_static_response(stream, static_route, keep_alive).await?; if !keep_alive { @@ -558,12 +554,48 @@ async fn handle_connection_inner( } } - // ── Read request body if present ────────────────────────────── - let body_bytes: Vec = if has_body { + // ── Zero-copy path: non-body requests ── + // Build dispatch envelope directly from borrowed parse data, avoiding + // String/Vec allocations for method, target, path, and headers. + if !has_body { + let dispatch_decision = build_dispatch_decision_zero_copy(router, &parsed, &[])?; + drop(parsed); + drain_consumed_bytes(buffer, header_bytes); + + match dispatch_decision { + DispatchDecision::BridgeRequest(request) => { + write_dynamic_dispatch_response(stream, dispatcher, request, keep_alive) + .await?; + } + DispatchDecision::SpecializedResponse(response) => { + let (write_result, _) = stream.write_all(response).await; + write_result?; + } + } + + if !keep_alive { + stream.shutdown().await?; + return Ok(()); + } + continue; + } + + // ── Body requests: need owned copies to release buffer for body read ── + let method_owned: Vec = parsed.method.to_vec(); + let target_owned: Vec = parsed.target.to_vec(); + let path_owned: Vec = parsed.path.to_vec(); + let headers_owned: Vec<(String, String)> = parsed + .headers + .iter() + .map(|(n, v)| (n.to_string(), v.to_string())) + .collect(); + drop(parsed); + + // ── Read request body + let body_bytes: Vec = { let content_length = match content_length { Some(len) => len, None => { - // Chunked or unknown body length — reject for now let response = build_error_response_bytes(411, b"{\"error\":\"Length Required\"}", false); let (write_result, _) = stream.write_all(response).await; @@ -573,7 +605,6 @@ async fn handle_connection_inner( } }; - // Security: enforce max body size if content_length > MAX_BODY_BYTES { let response = build_error_response_bytes(413, b"{\"error\":\"Payload Too Large\"}", false); @@ -583,7 +614,6 @@ async fn handle_connection_inner( return Ok(()); } - // Some body bytes may already be in the buffer after the headers let already_in_buffer = if buffer.len() > header_bytes { buffer.len() - header_bytes } else { @@ -591,12 +621,10 @@ async fn handle_connection_inner( }; if already_in_buffer >= content_length { - // Entire body is already in the buffer let body = buffer[header_bytes..header_bytes + content_length].to_vec(); drain_consumed_bytes(buffer, header_bytes + content_length); body } else { - // Need to read more bytes from the stream let mut body = Vec::with_capacity(content_length); if already_in_buffer > 0 { body.extend_from_slice(&buffer[header_bytes..]); @@ -609,19 +637,15 @@ async fn handle_connection_inner( let (read_result, returned_buf) = stream.read(chunk_buf).await; let bytes_read = read_result?; if bytes_read == 0 { - return Ok(()); // Connection closed mid-body + return Ok(()); } body.extend_from_slice(&returned_buf[..bytes_read]); } body.truncate(content_length); body } - } else { - drain_consumed_bytes(buffer, header_bytes); - Vec::new() }; - // Dynamic path: build bridge envelope and dispatch to JS let dispatch_request = build_dispatch_request_owned( router, &method_owned, @@ -631,14 +655,7 @@ async fn handle_connection_inner( &body_bytes, )?; - match dispatch_request { - Some(request) => { - write_dynamic_dispatch_response(stream, dispatcher, request, keep_alive).await?; - } - None => { - write_not_found_response(stream, keep_alive).await?; - } - } + write_dynamic_dispatch_response(stream, dispatcher, dispatch_request, keep_alive).await?; if !keep_alive { stream.shutdown().await?; @@ -647,7 +664,7 @@ async fn handle_connection_inner( } } -// ─── httparse-based Request Parsing ─────────────────────────────────────────── +// ─── httparse-based Request Parsing ───── // // Uses the battle-tested `httparse` crate for RFC-compliant zero-copy parsing. // Single-pass: parses headers once and stores them for reuse by both the @@ -740,7 +757,7 @@ fn parse_request_httparse(bytes: &[u8]) -> Option> { }) } -// ─── Hot Root Path (GET /) ──────────────────────────────────────────────────── +// ─── Hot Root Path (GET /) ────────────── // // Ultra-fast path for the most common benchmark case. Falls back to httparse // if the request doesn't exactly match the expected prefix. @@ -816,12 +833,77 @@ fn parse_hot_root_request( }) } -// ─── Routing ────────────────────────────────────────────────────────────────── +// ─── Routing ──────────────────────────── // ─── Bridge Envelope Building (Single-Pass Headers) ─────────────────────────── // // Uses the pre-parsed headers from httparse — no second scan of the raw bytes. +/// Zero-copy dispatch: builds the bridge envelope directly from borrowed parse data, +/// avoiding all String/Vec allocations for method, target, path, and headers. +/// Used for non-body requests (GET, DELETE without body, etc.). +enum DispatchDecision { + BridgeRequest(Buffer), + SpecializedResponse(Vec), +} + +fn build_dispatch_decision_zero_copy( + router: &Router, + parsed: &ParsedRequest<'_>, + body: &[u8], +) -> Result { + let method_code = method_code_from_bytes(parsed.method).unwrap_or(UNKNOWN_METHOD_CODE); + let path_cow = String::from_utf8_lossy(parsed.path); + let path_str = path_cow.as_ref(); + let url_cow = String::from_utf8_lossy(parsed.target); + let url_str = url_cow.as_ref(); + + let normalized_path = normalize_runtime_path(path_str); + if contains_path_traversal(&normalized_path) { + return build_not_found_dispatch_envelope( + method_code, + path_str, + url_str, + &parsed.headers, + body, + ) + .map(DispatchDecision::BridgeRequest); + } + + let matched_route = if method_code == UNKNOWN_METHOD_CODE { + None + } else { + router.match_route(method_code, normalized_path.as_ref()) + }; + + let Some(matched_route) = matched_route else { + return build_not_found_dispatch_envelope( + method_code, + path_str, + url_str, + &parsed.headers, + body, + ) + .map(DispatchDecision::BridgeRequest); + }; + + if let Some(response) = + build_dynamic_fast_path_response(&matched_route, url_str, &parsed.headers, parsed.keep_alive)? + { + return Ok(DispatchDecision::SpecializedResponse(response)); + }; + + build_dispatch_envelope( + &matched_route, + method_code, + path_str, + url_str, + &parsed.headers, + body, + ) + .map(DispatchDecision::BridgeRequest) +} + fn build_dispatch_request_owned( router: &Router, method: &[u8], @@ -829,40 +911,45 @@ fn build_dispatch_request_owned( path: &[u8], headers: &[(String, String)], body: &[u8], -) -> Result> { - let Some(method_code) = method_code_from_bytes(method) else { - return Ok(None); - }; +) -> Result { + let method_code = method_code_from_bytes(method).unwrap_or(UNKNOWN_METHOD_CODE); - let path_str = match std::str::from_utf8(path) { - Ok(path_str) => path_str, - Err(_) => return Ok(None), - }; - let url_str = match std::str::from_utf8(target) { - Ok(url_str) => url_str, - Err(_) => return Ok(None), - }; + let path_cow = String::from_utf8_lossy(path); + let path_str = path_cow.as_ref(); + let url_cow = String::from_utf8_lossy(target); + let url_str = url_cow.as_ref(); + + let header_refs: Vec<(&str, &str)> = headers + .iter() + .map(|(n, v)| (n.as_str(), v.as_str())) + .collect(); // Security: strict path validation let normalized_path = normalize_runtime_path(path_str); if contains_path_traversal(&normalized_path) { - return Ok(None); + return build_not_found_dispatch_envelope( + method_code, + path_str, + url_str, + &header_refs, + body, + ); } - let header_refs: Vec<(&str, &str)> = headers - .iter() - .map(|(n, v)| (n.as_str(), v.as_str())) - .collect(); + let matched_route = if method_code == UNKNOWN_METHOD_CODE { + None + } else { + router.match_route(method_code, normalized_path.as_ref()) + }; - let Some(matched_route) = router.match_route(method_code, normalized_path.as_ref()) else { + let Some(matched_route) = matched_route else { return build_not_found_dispatch_envelope( method_code, path_str, url_str, &header_refs, body, - ) - .map(Some); + ); }; build_dispatch_envelope( @@ -873,7 +960,6 @@ fn build_dispatch_request_owned( &header_refs, body, ) - .map(Some) } fn build_not_found_dispatch_envelope( @@ -934,10 +1020,12 @@ fn build_dispatch_envelope( header_entries: &[(&str, &str)], body: &[u8], ) -> Result { - let url_bytes = url.as_bytes(); - let path_bytes = path.as_bytes(); + let include_url = matched_route.needs_url || matched_route.needs_query; + let include_path = matched_route.needs_path; + let url_bytes = if include_url { url.as_bytes() } else { b"" }; + let path_bytes = if include_path { path.as_bytes() } else { b"" }; let mut flags: u16 = 0; - if url.contains('?') { + if matched_route.needs_query && url.contains('?') { flags |= REQUEST_FLAG_QUERY_PRESENT; } if !body.is_empty() { @@ -953,13 +1041,13 @@ fn build_dispatch_envelope( if matched_route.param_values.len() > u16::MAX as usize { return Err(anyhow!("too many params")); } - let selected_headers = select_header_entries(header_entries, matched_route); - if selected_headers.len() > u16::MAX as usize { + let selected_header_count = count_selected_headers(header_entries, matched_route); + if selected_header_count > u16::MAX as usize { return Err(anyhow!("too many headers")); } let mut frame = Vec::with_capacity( - 20 + url_bytes.len() + path_bytes.len() + selected_headers.len() * 16 + body.len(), + 20 + url_bytes.len() + path_bytes.len() + selected_header_count * 16 + body.len(), ); frame.push(BRIDGE_VERSION); frame.push(method_code); @@ -968,7 +1056,7 @@ fn build_dispatch_envelope( push_u32(&mut frame, url_bytes.len() as u32); push_u16(&mut frame, path_bytes.len() as u16); push_u16(&mut frame, matched_route.param_values.len() as u16); - push_u16(&mut frame, selected_headers.len() as u16); + push_u16(&mut frame, selected_header_count as u16); push_u32(&mut frame, body.len() as u32); // NEW: body length frame.extend_from_slice(url_bytes); frame.extend_from_slice(path_bytes); @@ -977,8 +1065,12 @@ fn build_dispatch_envelope( push_string_value(&mut frame, value)?; } - for (name, value) in selected_headers { - push_string_pair(&mut frame, name, value)?; + if selected_header_count > 0 { + for (name, value) in header_entries { + if should_include_header(name, matched_route) { + push_string_pair(&mut frame, name, value)?; + } + } } frame.extend_from_slice(body); // NEW: body bytes at end @@ -986,33 +1078,334 @@ fn build_dispatch_envelope( Ok(Buffer::from(frame)) } -fn select_header_entries<'a>( - header_entries: &[(&'a str, &'a str)], +fn count_selected_headers( + header_entries: &[(&str, &str)], matched_route: &MatchedRoute<'_, '_>, -) -> Vec<(&'a str, &'a str)> { +) -> usize { if matched_route.full_headers { - return header_entries.to_vec(); + return header_entries.len(); } if matched_route.header_keys.is_empty() { - return Vec::new(); + return 0; } - let mut selected = Vec::with_capacity(matched_route.header_keys.len()); - for (name, value) in header_entries { - if matched_route - .header_keys - .iter() - .any(|target| target.as_ref().eq_ignore_ascii_case(name)) + header_entries + .iter() + .filter(|(name, _)| should_include_header(name, matched_route)) + .count() +} + +fn should_include_header(name: &str, matched_route: &MatchedRoute<'_, '_>) -> bool { + if matched_route.full_headers { + return true; + } + matched_route + .header_keys + .iter() + .any(|target| target.as_ref().eq_ignore_ascii_case(name)) +} + +enum ResolvedDynamicValue { + Missing, + Single(String), + Multi(Vec), +} + +fn build_dynamic_fast_path_response( + matched_route: &MatchedRoute<'_, '_>, + url: &str, + headers: &[(&str, &str)], + keep_alive: bool, +) -> Result>> { + let Some(fast_path) = matched_route.fast_path else { + return Ok(None); + }; + + let mut query_cache: Option> = None; + let body = match &fast_path.response { + DynamicFastPathResponse::Json(template) => { + render_dynamic_json_body(template, matched_route, url, headers, &mut query_cache)? + } + DynamicFastPathResponse::Text(template) => { + render_dynamic_text_body(template, matched_route, url, headers, &mut query_cache) + } + }; + + Ok(Some(build_response_bytes_fast( + fast_path.status, + fast_path.headers.as_ref(), + &body, + keep_alive, + ))) +} + +fn render_dynamic_json_body( + template: &crate::analyzer::JsonTemplate, + matched_route: &MatchedRoute<'_, '_>, + url: &str, + headers: &[(&str, &str)], + query_cache: &mut Option>, +) -> Result> { + match &template.kind { + JsonTemplateKind::Literal(bytes) => Ok(bytes.to_vec()), + JsonTemplateKind::Object(fields) => { + let mut output = Vec::with_capacity(fields.len() * 24 + 16); + output.push(b'{'); + let mut wrote_field = false; + + for field in fields.iter() { + match &field.value { + JsonValueTemplate::Literal(value_bytes) => { + if wrote_field { + output.push(b','); + } + output.extend_from_slice(field.key_prefix.as_ref()); + output.extend_from_slice(value_bytes.as_ref()); + wrote_field = true; + } + JsonValueTemplate::Dynamic(source) => { + let resolved = + resolve_dynamic_value(source, matched_route, url, headers, query_cache); + match resolved { + ResolvedDynamicValue::Missing => {} + ResolvedDynamicValue::Single(value) => { + if wrote_field { + output.push(b','); + } + output.extend_from_slice(field.key_prefix.as_ref()); + append_json_string(&mut output, value.as_str()); + wrote_field = true; + } + ResolvedDynamicValue::Multi(values) => { + if wrote_field { + output.push(b','); + } + output.extend_from_slice(field.key_prefix.as_ref()); + output.push(b'['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + output.push(b','); + } + append_json_string(&mut output, value.as_str()); + } + output.push(b']'); + wrote_field = true; + } + } + } + } + } + + output.push(b'}'); + Ok(output) + } + } +} + +fn render_dynamic_text_body( + template: &crate::analyzer::TextTemplate, + matched_route: &MatchedRoute<'_, '_>, + url: &str, + headers: &[(&str, &str)], + query_cache: &mut Option>, +) -> Vec { + let mut output = String::new(); + for segment in template.segments.iter() { + match segment { + TextSegment::Literal(value) => output.push_str(value.as_ref()), + TextSegment::Dynamic(source) => match resolve_dynamic_value( + source, + matched_route, + url, + headers, + query_cache, + ) { + ResolvedDynamicValue::Missing => output.push_str("undefined"), + ResolvedDynamicValue::Single(value) => output.push_str(value.as_str()), + ResolvedDynamicValue::Multi(values) => { + for (index, value) in values.iter().enumerate() { + if index > 0 { + output.push(','); + } + output.push_str(value.as_str()); + } + } + }, + } + } + + output.into_bytes() +} + +fn resolve_dynamic_value( + source: &crate::analyzer::DynamicValueSource, + matched_route: &MatchedRoute<'_, '_>, + url: &str, + headers: &[(&str, &str)], + query_cache: &mut Option>, +) -> ResolvedDynamicValue { + match source.kind { + DynamicValueSourceKind::Param => { + if let Some(value) = lookup_param_value(matched_route, source.key.as_ref()) { + return ResolvedDynamicValue::Single(value.to_string()); + } + ResolvedDynamicValue::Missing + } + DynamicValueSourceKind::Header => { + if let Some(value) = lookup_header_value(headers, source.key.as_ref()) { + return ResolvedDynamicValue::Single(value.to_string()); + } + ResolvedDynamicValue::Missing + } + DynamicValueSourceKind::Query => { + let entries = query_entries(url, query_cache); + lookup_query_value(entries.as_slice(), source.key.as_ref()) + } + } +} + +fn lookup_param_value<'m, 'r, 'p>( + matched_route: &'m MatchedRoute<'r, 'p>, + key: &str, +) -> Option<&'p str> { + for (index, name) in matched_route.param_names.iter().enumerate() { + if name.as_ref() == key { + return matched_route.param_values.get(index).copied(); + } + } + None +} + +fn lookup_header_value<'a>(headers: &[(&'a str, &'a str)], key: &str) -> Option<&'a str> { + headers + .iter() + .find_map(|(name, value)| name.eq_ignore_ascii_case(key).then_some(*value)) +} + +fn query_entries<'a>( + url: &str, + cache: &'a mut Option>, +) -> &'a Vec<(String, String)> { + if cache.is_none() { + let parsed = if let Some(query_start) = url.find('?') { + let query = &url[query_start + 1..]; + form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>() + } else { + Vec::new() + }; + *cache = Some(parsed); + } + + cache.as_ref().expect("query cache must be initialized") +} + +fn lookup_query_value(entries: &[(String, String)], key: &str) -> ResolvedDynamicValue { + let mut values: Vec = Vec::new(); + for (entry_key, entry_value) in entries.iter() { + if entry_key == key { + values.push(entry_value.clone()); + } + } + + match values.len() { + 0 => ResolvedDynamicValue::Missing, + 1 => ResolvedDynamicValue::Single(values.pop().unwrap_or_default()), + _ => ResolvedDynamicValue::Multi(values), + } +} + +fn append_json_string(output: &mut Vec, value: &str) { + output.push(b'"'); + for ch in value.chars() { + match ch { + '"' => output.extend_from_slice(br#"\""#), + '\\' => output.extend_from_slice(br#"\\"#), + '\n' => output.extend_from_slice(br#"\n"#), + '\r' => output.extend_from_slice(br#"\r"#), + '\t' => output.extend_from_slice(br#"\t"#), + '\x08' => output.extend_from_slice(br#"\b"#), + '\x0C' => output.extend_from_slice(br#"\f"#), + other if other.is_control() => { + let escaped = format!("\\u{:04x}", other as u32); + output.extend_from_slice(escaped.as_bytes()); + } + other => { + let mut buf = [0u8; 4]; + output.extend_from_slice(other.encode_utf8(&mut buf).as_bytes()); + } + } + } + output.push(b'"'); +} + +fn build_response_bytes_fast( + status: u16, + headers: &[(Box, Box)], + body: &[u8], + keep_alive: bool, +) -> Vec { + let reason = status_reason(status); + let connection = if keep_alive { "keep-alive" } else { "close" }; + let body_len = body.len(); + + let mut total_size = + 9 + 3 + 1 + reason.len() + 2 + 16 + count_digits(body_len) + 2 + 12 + connection.len() + 2; + + for (name, value) in headers { + if name.eq_ignore_ascii_case("content-length") || name.eq_ignore_ascii_case("connection") { + continue; + } + if name.contains('\r') + || name.contains('\n') + || value.contains('\r') + || value.contains('\n') { - selected.push((*name, *value)); + continue; } + total_size += name.len() + 2 + value.len() + 2; } - selected + total_size += 2 + body_len; + + let mut output = Vec::with_capacity(total_size); + output.extend_from_slice(b"HTTP/1.1 "); + write_u16(&mut output, status); + output.push(b' '); + output.extend_from_slice(reason.as_bytes()); + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(b"content-length: "); + write_usize(&mut output, body_len); + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(b"connection: "); + output.extend_from_slice(connection.as_bytes()); + output.extend_from_slice(b"\r\n"); + + for (name, value) in headers { + if name.eq_ignore_ascii_case("content-length") || name.eq_ignore_ascii_case("connection") { + continue; + } + if name.contains('\r') + || name.contains('\n') + || value.contains('\r') + || value.contains('\n') + { + continue; + } + output.extend_from_slice(name.as_bytes()); + output.extend_from_slice(b": "); + output.extend_from_slice(value.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(body); + output } -// ─── Response Writing ───────────────────────────────────────────────────────── +// ─── Response Writing ─────────────────── async fn write_exact_static_response( stream: &mut TcpStream, @@ -1030,62 +1423,117 @@ async fn write_exact_static_response( Ok(()) } -#[derive(Clone)] -struct DispatchResponseEnvelope { - status: u16, - headers: Vec<(String, String)>, - body: Bytes, -} - async fn write_dynamic_dispatch_response( stream: &mut TcpStream, dispatcher: &JsDispatcher, request: Buffer, keep_alive: bool, ) -> Result<()> { - let parsed = match dispatcher.dispatch(request).await { - Ok(response) => match parse_dispatch_response(response.as_ref()) { - Ok(parsed) => parsed, - Err(_) => DispatchResponseEnvelope { - status: 500, - headers: vec![( - "content-type".to_string(), - "application/json; charset=utf-8".to_string(), - )], - // Security: sanitized error — no internal details - body: Bytes::from_static(b"{\"error\":\"Internal Server Error\"}"), - }, - }, - Err(_) => DispatchResponseEnvelope { - status: 502, - headers: vec![( - "content-type".to_string(), - "application/json; charset=utf-8".to_string(), - )], + match dispatcher.dispatch(request).await { + Ok(response) => { + match build_http_response_from_dispatch(response.as_ref(), keep_alive) { + Ok(http_response) => { + let (write_result, _) = stream.write_all(http_response).await; + write_result?; + } + Err(_) => { + // Security: sanitized error — no internal details + let response = build_error_response_bytes( + 500, + b"{\"error\":\"Internal Server Error\"}", + keep_alive, + ); + let (write_result, _) = stream.write_all(response).await; + write_result?; + } + } + } + Err(_) => { // Security: sanitized error — no internal details - body: Bytes::from_static(b"{\"error\":\"Bad Gateway\"}"), - }, - }; - - let response_bytes = build_dispatch_response_bytes(parsed, keep_alive); - let (write_result, _) = stream.write_all(response_bytes).await; - write_result?; + let response = build_error_response_bytes( + 502, + b"{\"error\":\"Bad Gateway\"}", + keep_alive, + ); + let (write_result, _) = stream.write_all(response).await; + write_result?; + } + } Ok(()) } -async fn write_not_found_response(stream: &mut TcpStream, keep_alive: bool) -> Result<()> { - let response = build_response_bytes( - 404, - &[( - "content-type".to_string(), - "application/json; charset=utf-8".to_string(), - )], - Bytes::from_static(NOT_FOUND_BODY), - keep_alive, - ); - let (write_result, _) = stream.write_all(response).await; - write_result?; - Ok(()) +/// Build HTTP response bytes directly from the binary dispatch envelope, +/// avoiding all intermediate String/Bytes allocations. +fn build_http_response_from_dispatch(dispatch_bytes: &[u8], keep_alive: bool) -> Result> { + let mut offset = 0usize; + let status = read_u16(dispatch_bytes, &mut offset)?; + let header_count = read_u16(dispatch_bytes, &mut offset)? as usize; + let body_length = read_u32(dispatch_bytes, &mut offset)? as usize; + + let reason = status_reason(status); + let connection = if keep_alive { "keep-alive" } else { "close" }; + + // Conservative estimate: framing overhead + all dispatch bytes + let mut output = Vec::with_capacity(dispatch_bytes.len() + 128); + + // Status line + output.extend_from_slice(b"HTTP/1.1 "); + write_u16(&mut output, status); + output.push(b' '); + output.extend_from_slice(reason.as_bytes()); + output.extend_from_slice(b"\r\n"); + + // Mandatory headers + output.extend_from_slice(b"content-length: "); + write_usize(&mut output, body_length); + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(b"connection: "); + output.extend_from_slice(connection.as_bytes()); + output.extend_from_slice(b"\r\n"); + + // User headers — read directly from binary without String allocation + for _ in 0..header_count { + let name_len = read_u8(dispatch_bytes, &mut offset)? as usize; + let value_len = read_u16(dispatch_bytes, &mut offset)? as usize; + + if offset + name_len + value_len > dispatch_bytes.len() { + return Err(anyhow!("response envelope truncated")); + } + + let name_bytes = &dispatch_bytes[offset..offset + name_len]; + offset += name_len; + let value_bytes = &dispatch_bytes[offset..offset + value_len]; + offset += value_len; + + // Skip headers we already wrote + if name_bytes.eq_ignore_ascii_case(b"content-length") + || name_bytes.eq_ignore_ascii_case(b"connection") + { + continue; + } + + // Security: CRLF injection check + if name_bytes.iter().any(|&b| b == b'\r' || b == b'\n') + || value_bytes.iter().any(|&b| b == b'\r' || b == b'\n') + { + continue; + } + + output.extend_from_slice(name_bytes); + output.extend_from_slice(b": "); + output.extend_from_slice(value_bytes); + output.extend_from_slice(b"\r\n"); + } + + output.extend_from_slice(b"\r\n"); + + // Body + if offset + body_length > dispatch_bytes.len() { + return Err(anyhow!("response body truncated")); + } + output.extend_from_slice(&dispatch_bytes[offset..offset + body_length]); + + Ok(output) } /// Build a simple error response without going through the JS bridge @@ -1101,15 +1549,6 @@ fn build_error_response_bytes(status: u16, body: &[u8], keep_alive: bool) -> Vec ) } -fn build_dispatch_response_bytes(response: DispatchResponseEnvelope, keep_alive: bool) -> Vec { - build_response_bytes( - response.status, - &response.headers, - response.body, - keep_alive, - ) -} - /// Optimized response builder: pre-calculates size and writes in a single pass fn build_response_bytes( status: u16, @@ -1183,36 +1622,7 @@ fn build_response_bytes( output } -// ─── Response Parsing (from JS bridge) ──────────────────────────────────────── - -fn parse_dispatch_response(bytes: &[u8]) -> Result { - let mut offset = 0; - let status = read_u16(bytes, &mut offset)?; - let header_count = read_u16(bytes, &mut offset)? as usize; - let body_length = read_u32(bytes, &mut offset)? as usize; - - let mut headers = Vec::with_capacity(header_count); - for _ in 0..header_count { - let name_length = read_u8(bytes, &mut offset)? as usize; - let value_length = read_u16(bytes, &mut offset)? as usize; - let name = read_utf8(bytes, &mut offset, name_length)?; - let value = read_utf8(bytes, &mut offset, value_length)?; - headers.push((name, value)); - } - - if offset + body_length > bytes.len() { - return Err(anyhow!("response body truncated")); - } - - let body = Bytes::copy_from_slice(&bytes[offset..offset + body_length]); - Ok(DispatchResponseEnvelope { - status, - headers, - body, - }) -} - -// ─── Security Utilities ─────────────────────────────────────────────────────── +// ─── Security Utilities ───────────────── /// Check for path traversal attempts (../, ..\, etc.) fn contains_path_traversal(path: &str) -> bool { @@ -1251,7 +1661,7 @@ pub(crate) fn escape_json(value: &str) -> String { output } -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ─── Helpers ──────────────────────────── fn method_code_from_bytes(method: &[u8]) -> Option { match method { @@ -1466,18 +1876,6 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Result { Ok(value) } -fn read_utf8(bytes: &[u8], offset: &mut usize, length: usize) -> Result { - if *offset + length > bytes.len() { - return Err(anyhow!("response envelope truncated")); - } - - let value = std::str::from_utf8(&bytes[*offset..*offset + length]) - .context("response envelope contained invalid utf-8")? - .to_string(); - *offset += length; - Ok(value) -} - fn to_napi_error(error: E) -> Error where E: std::fmt::Display, diff --git a/rust-native/src/manifest.rs b/rust-native/src/manifest.rs index c0d45f0..1c6c538 100644 --- a/rust-native/src/manifest.rs +++ b/rust-native/src/manifest.rs @@ -42,4 +42,10 @@ pub struct RouteInput { pub segment_count: u16, pub header_keys: Vec, pub full_headers: bool, + #[serde(default)] + pub needs_path: bool, + #[serde(default)] + pub needs_url: bool, + #[serde(default)] + pub needs_query: bool, } diff --git a/rust-native/src/router.rs b/rust-native/src/router.rs index cb9970c..630636f 100644 --- a/rust-native/src/router.rs +++ b/rust-native/src/router.rs @@ -3,14 +3,16 @@ use bytes::Bytes; use std::collections::HashMap; use crate::analyzer::{ - analyze_route, normalize_path, parse_segments, AnalysisResult, RouteSegment, + analyze_dynamic_fast_path, analyze_route, normalize_path, parse_segments, AnalysisResult, + DynamicFastPathSpec, RouteSegment, }; -use crate::manifest::{ManifestInput, RouteInput}; +use crate::manifest::{ManifestInput, MiddlewareInput, RouteInput}; const ROUTE_KIND_EXACT: u8 = 1; const ROUTE_KIND_PARAM: u8 = 2; +const MAX_STACK_SEGMENTS: usize = 16; -// ─── Public Types ───────────────────────────────────────────────────────────── +// ─── Public Types ─────────────────────── #[derive(Clone)] pub struct Router { @@ -32,19 +34,27 @@ pub struct ExactStaticRoute { pub struct MatchedRoute<'a, 'b> { pub handler_id: u32, pub param_values: Vec<&'b str>, + pub param_names: &'a [Box], pub header_keys: &'a [Box], pub full_headers: bool, + pub needs_path: bool, + pub needs_url: bool, + pub needs_query: bool, + pub fast_path: Option<&'a DynamicFastPathSpec>, } -// ─── Internal Types ─────────────────────────────────────────────────────────── +// ─── Internal Types ───────────────────── #[derive(Clone)] struct DynamicRouteSpec { handler_id: u32, - #[allow(dead_code)] param_names: Box<[Box]>, header_keys: Box<[Box]>, full_headers: bool, + needs_path: bool, + needs_url: bool, + needs_query: bool, + fast_path: Option, } #[derive(Clone, Copy, Eq, Hash, PartialEq)] @@ -58,7 +68,7 @@ enum MethodKey { Put, } -// ─── Radix Tree ─────────────────────────────────────────────────────────────── +// ─── Radix Tree ───────────────────────── // // Each node represents either a static prefix or a parameter capture. // Matching is O(M) where M is the number of path segments, not O(N) routes. @@ -170,7 +180,7 @@ impl RadixNode { } } -// ─── Router Implementation ──────────────────────────────────────────────────── +// ─── Router Implementation ────────────── impl Router { pub fn from_manifest(manifest: &ManifestInput) -> Result { @@ -225,13 +235,13 @@ impl Router { .or_insert_with(HashMap::new) .insert( Box::<[u8]>::from(path.as_bytes()), - compile_dynamic_route_spec(route), + compile_dynamic_route_spec(route, &manifest.middlewares), ); } ROUTE_KIND_PARAM => { // Insert into radix tree instead of linear Vec let segments = parse_segments(path.as_str()); - let spec = compile_dynamic_route_spec(route); + let spec = compile_dynamic_route_spec(route, &manifest.middlewares); radix_trees .entry(method_key) .or_insert_with(RadixNode::new) @@ -265,22 +275,38 @@ impl Router { return Some(MatchedRoute { handler_id: route_spec.handler_id, param_values: Vec::new(), + param_names: route_spec.param_names.as_ref(), header_keys: route_spec.header_keys.as_ref(), full_headers: route_spec.full_headers, + needs_path: route_spec.needs_path, + needs_url: route_spec.needs_url, + needs_query: route_spec.needs_query, + fast_path: route_spec.fast_path.as_ref(), }); } // Radix tree match (O(M) where M = segment count) - let segments = split_request_segments(path); let tree = self.radix_trees.get(&method_key)?; - let mut param_values = Vec::new(); - let spec = tree.match_path(&segments, &mut param_values)?; + let mut seg_buf = [""; MAX_STACK_SEGMENTS]; + let seg_count = split_segments_stack(path, &mut seg_buf); + let mut param_values = Vec::with_capacity(4); + let spec = if seg_count <= MAX_STACK_SEGMENTS { + tree.match_path(&seg_buf[..seg_count], &mut param_values)? + } else { + let segments = split_request_segments(path); + tree.match_path(&segments, &mut param_values)? + }; Some(MatchedRoute { handler_id: spec.handler_id, param_values, + param_names: spec.param_names.as_ref(), header_keys: spec.header_keys.as_ref(), full_headers: spec.full_headers, + needs_path: spec.needs_path, + needs_url: spec.needs_url, + needs_query: spec.needs_query, + fast_path: spec.fast_path.as_ref(), }) } @@ -300,7 +326,7 @@ impl Router { } } -// ─── MethodKey ──────────────────────────────────────────────────────────────── +// ─── MethodKey ────────────────────────── impl MethodKey { fn from_method_str(method: &str) -> Option { @@ -343,9 +369,9 @@ impl MethodKey { } } -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ─── Helpers ──────────────────────────── -fn compile_dynamic_route_spec(route: &RouteInput) -> DynamicRouteSpec { +fn compile_dynamic_route_spec(route: &RouteInput, middlewares: &[MiddlewareInput]) -> DynamicRouteSpec { let param_names = route .param_names .iter() @@ -364,6 +390,10 @@ fn compile_dynamic_route_spec(route: &RouteInput) -> DynamicRouteSpec { param_names, header_keys, full_headers: route.full_headers, + needs_path: route.needs_path, + needs_url: route.needs_url, + needs_query: route.needs_query, + fast_path: analyze_dynamic_fast_path(route, middlewares), } } @@ -378,6 +408,27 @@ fn split_request_segments(path: &str) -> Vec<&str> { .collect() } +/// Stack-allocated segment splitting — avoids heap Vec for paths with ≤ MAX_STACK_SEGMENTS segments. +/// Returns the number of segments written into `buf`. If the path has more segments than `buf.len()`, +/// returns `buf.len() + 1` as an overflow sentinel. +fn split_segments_stack<'a>(path: &'a str, buf: &mut [&'a str]) -> usize { + if path == "/" { + return 0; + } + let mut count = 0; + for segment in path.trim_start_matches('/').split('/') { + if segment.is_empty() { + continue; + } + if count >= buf.len() { + return count + 1; // overflow sentinel + } + buf[count] = segment; + count += 1; + } + count +} + fn build_keep_alive_response( status: u16, headers: &HashMap, @@ -425,6 +476,8 @@ fn build_response_bytes( response } +// Todo: Are these expensive? if so remove them. + fn status_reason(status: u16) -> &'static str { match status { 200 => "OK", diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index 9833979..c028ff8 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -1,6 +1,9 @@ import { copyFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; +// unused right? +// need to double check and delete this + const release = process.argv.includes("--release"); const profile = release ? "release" : "debug"; diff --git a/src/bridge.js b/src/bridge.js index abb6238..7cf0217 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -4,6 +4,8 @@ const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const PLAIN_OBJECT_PROTOTYPE = Object.prototype; const EMPTY_ARRAY = Object.freeze([]); +const HEADER_LOOKUP_CACHE_MAX = 128; +const headerLookupNameCache = new Map(); export const BRIDGE_VERSION = 1; export const REQUEST_FLAG_QUERY_PRESENT = 1 << 0; @@ -27,7 +29,7 @@ export const ROUTE_KIND = Object.freeze({ // Security: use null-prototype objects for user-facing data to prevent prototype pollution const EMPTY_OBJECT = Object.freeze(Object.create(null)); -// ─── Regex patterns for source analysis ─────────────────────────────────────── +// ─── Regex patterns for source analysis ─ const PARAM_DOT_RE = /\breq\.params\.([A-Za-z_$][\w$]*)\b/g; const PARAM_BRACKET_RE = /\breq\.params\[(['"])([^"'\\]+)\1\]/g; @@ -59,7 +61,7 @@ const DANGEROUS_KEYS = new Set([ "__lookupSetter__", ]); -// ─── Route Compilation ──────────────────────────────────────────────────────── +// ─── Route Compilation ────────────────── export function compileRouteShape(method, path) { const methodCode = METHOD_CODES[method]; @@ -90,7 +92,7 @@ export function compileRouteShape(method, path) { }; } -// ─── Request Access Analysis ────────────────────────────────────────────────── +// ─── Request Access Analysis ──────────── export function analyzeRequestAccess(source) { const plan = createEmptyAccessPlan(); @@ -203,6 +205,7 @@ export function releaseRequestObject(req) { req._params = undefined; req._query = undefined; req._headers = undefined; + req._headerLookup = undefined; req._decoded = null; req._routeParamNames = null; req._plan = null; @@ -219,6 +222,7 @@ function createPooledRequest() { req._params = undefined; req._query = undefined; req._headers = undefined; + req._headerLookup = undefined; req._bodyParsed = undefined; req._decoded = null; req._routeParamNames = null; @@ -288,26 +292,30 @@ function createPooledRequest() { get() { if (req._headers === undefined) { const needsHeaders = req._plan.fullHeaders || req._plan.headerKeys.size > 0; - req._headers = needsHeaders - ? materializeHeaderObject(req._decoded.rawHeaders, req._plan) - : EMPTY_OBJECT; + if (!needsHeaders) { + req._headers = EMPTY_OBJECT; + } else if (req._plan.fullHeaders) { + req._headers = ensureHeaderLookup(req); + } else { + req._headers = materializeSelectedHeadersFromLookup( + ensureHeaderLookup(req), + req._plan.headerKeys, + ); + } } return req._headers; }, }); req.header = function header(name) { - const lookup = normalizeHeaderLookup(name); + const lookup = normalizeHeaderLookupCached(name); if (req._headers && lookup in req._headers) { return req._headers[lookup]; } - if (req._decoded.rawHeaders.length === 0) { - return undefined; - } - return lookupHeaderValue(req._decoded.rawHeaders, lookup); + return ensureHeaderLookup(req)[lookup]; }; - // ─── Body APIs ──────────────────────────────────────────────────────── + // ─── Body APIs ────────────────── Object.defineProperty(req, "body", { configurable: true, @@ -396,13 +404,14 @@ export function createRequestFactory( request._params = undefined; request._query = undefined; request._headers = undefined; + request._headerLookup = undefined; request._bodyParsed = undefined; return request; }; } -// ─── JSON Serialization ─────────────────────────────────────────────────────── +// ─── JSON Serialization ───────────────── export function createJsonSerializer(mode = "fallback") { // Performance: V8's native JSON.stringify is heavily optimized and almost always @@ -415,35 +424,34 @@ export function createJsonSerializer(mode = "fallback") { return serializer; } -// ─── Binary Protocol Codec ──────────────────────────────────────────────────── +// ─── Binary Protocol Codec ────────────── export function decodeRequestEnvelope(buffer) { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let offset = 0; - const version = readU8(view, offset); + const version = readU8(bytes, offset); offset += 1; if (version !== BRIDGE_VERSION) { throw new Error(`Unsupported request envelope version ${version}`); } - const methodCode = readU8(view, offset); + const methodCode = readU8(bytes, offset); offset += 1; - const flags = readU16(view, offset); + const flags = readU16(bytes, offset); offset += 2; - const handlerId = readU32(view, offset); + const handlerId = readU32(bytes, offset); offset += 4; - const urlLength = readU32(view, offset); + const urlLength = readU32(bytes, offset); offset += 4; - const pathLength = readU16(view, offset); + const pathLength = readU16(bytes, offset); offset += 2; - const paramCount = readU16(view, offset); + const paramCount = readU16(bytes, offset); offset += 2; - const headerCount = readU16(view, offset); + const headerCount = readU16(bytes, offset); offset += 2; - const bodyLength = readU32(view, offset); + const bodyLength = readU32(bytes, offset); offset += 4; const urlBytes = readBytes(bytes, offset, urlLength); @@ -451,20 +459,20 @@ export function decodeRequestEnvelope(buffer) { const pathBytes = readBytes(bytes, offset, pathLength); offset += pathLength; - const paramValues = new Array(paramCount); + const paramValues = paramCount === 0 ? EMPTY_ARRAY : new Array(paramCount); for (let index = 0; index < paramCount; index += 1) { - const valueLength = readU16(view, offset); + const valueLength = readU16(bytes, offset); offset += 2; const valueBytes = readBytes(bytes, offset, valueLength); offset += valueLength; paramValues[index] = valueBytes; } - const rawHeaders = new Array(headerCount); + const rawHeaders = headerCount === 0 ? EMPTY_ARRAY : new Array(headerCount); for (let index = 0; index < headerCount; index += 1) { - const nameLength = readU8(view, offset); + const nameLength = readU8(bytes, offset); offset += 1; - const valueLength = readU16(view, offset); + const valueLength = readU16(bytes, offset); offset += 2; const nameBytes = readBytes(bytes, offset, nameLength); offset += nameLength; @@ -477,17 +485,19 @@ export function decodeRequestEnvelope(buffer) { const bodyBytes = bodyLength > 0 ? readBytes(bytes, offset, bodyLength) : null; offset += bodyLength; - switch (methodCode) { - case METHOD_CODES.GET: - case METHOD_CODES.POST: - case METHOD_CODES.PUT: - case METHOD_CODES.DELETE: - case METHOD_CODES.PATCH: - case METHOD_CODES.OPTIONS: - case METHOD_CODES.HEAD: - break; - default: - throw new Error(`Unknown method code ${methodCode}`); + if (methodCode !== 0) { + switch (methodCode) { + case METHOD_CODES.GET: + case METHOD_CODES.POST: + case METHOD_CODES.PUT: + case METHOD_CODES.DELETE: + case METHOD_CODES.PATCH: + case METHOD_CODES.OPTIONS: + case METHOD_CODES.HEAD: + break; + default: + throw new Error(`Unknown method code ${methodCode}`); + } } return { @@ -502,43 +512,62 @@ export function decodeRequestEnvelope(buffer) { }; } +// Header name encoding cache — avoids re-encoding common names like "content-type" +const headerNameCache = new Map(); + +function getCachedHeaderNameBytes(name) { + let bytes = headerNameCache.get(name); + if (bytes === undefined) { + bytes = textEncoder.encode(name); + if (headerNameCache.size < 64) { + headerNameCache.set(name, bytes); + } + } + return bytes; +} + export function encodeResponseEnvelope(snapshot) { - const headers = Object.entries(snapshot.headers ?? {}).map(([name, value]) => [ - encodeUtf8(name), - encodeUtf8(String(value)), - ]); + const rawHeaders = snapshot.headers; const body = Buffer.isBuffer(snapshot.body) ? snapshot.body : snapshot.body instanceof Uint8Array ? Buffer.from(snapshot.body) : Buffer.alloc(0); - let totalLength = 2 + 2 + 4 + body.length; - for (const [nameBytes, valueBytes] of headers) { + // Encode headers inline — avoids Object.entries().map() intermediate arrays + const headerKeys = rawHeaders ? Object.keys(rawHeaders) : EMPTY_ARRAY; + const headerCount = headerKeys.length; + const encodedHeaders = new Array(headerCount); + let totalLength = 8 + body.length; // status(2) + count(2) + bodylen(4) + body + + for (let i = 0; i < headerCount; i++) { + const name = headerKeys[i]; + const nameBytes = getCachedHeaderNameBytes(name); + const valueBytes = textEncoder.encode(String(rawHeaders[name])); if (nameBytes.length > 0xff) { throw new Error(`Response header name too long: ${nameBytes.length}`); } if (valueBytes.length > 0xffff) { throw new Error(`Response header value too long: ${valueBytes.length}`); } - totalLength += 1 + 2 + nameBytes.length + valueBytes.length; + encodedHeaders[i] = [nameBytes, valueBytes]; + totalLength += 3 + nameBytes.length + valueBytes.length; } const output = Buffer.allocUnsafe(totalLength); - const view = new DataView(output.buffer, output.byteOffset, output.byteLength); let offset = 0; - writeU16(view, offset, Number(snapshot.status ?? 200)); + writeU16(output, offset, Number(snapshot.status ?? 200)); offset += 2; - writeU16(view, offset, headers.length); + writeU16(output, offset, headerCount); offset += 2; - writeU32(view, offset, body.length); + writeU32(output, offset, body.length); offset += 4; - for (const [nameBytes, valueBytes] of headers) { - writeU8(view, offset, nameBytes.length); - offset += 1; - writeU16(view, offset, valueBytes.length); + for (let i = 0; i < headerCount; i++) { + const [nameBytes, valueBytes] = encodedHeaders[i]; + output[offset++] = nameBytes.length; + writeU16(output, offset, valueBytes.length); offset += 2; output.set(nameBytes, offset); offset += nameBytes.length; @@ -562,14 +591,6 @@ function materializeParamObject(entries, paramNames, plan) { return materializeSelectedParamPairs(entries, paramNames, plan.paramKeys); } -function materializeHeaderObject(entries, plan) { - if (plan.fullHeaders) { - return materializePairs(entries, true); - } - - return materializeSelectedPairs(entries, plan.headerKeys, true); -} - function materializeQueryObject(url, flags, plan) { if (!(flags & REQUEST_FLAG_QUERY_PRESENT)) { return Object.create(null); @@ -582,23 +603,6 @@ function materializeQueryObject(url, flags, plan) { return parseSelectedQuery(url, plan.queryKeys); } -function materializePairs(entries, lowerCaseKeys = false) { - // Security: null-prototype object prevents prototype pollution - const result = Object.create(null); - - for (const [rawName, rawValue] of entries) { - const name = textDecoder.decode(rawName); - const key = lowerCaseKeys ? name.toLowerCase() : name; - // Security: skip dangerous prototype keys - if (DANGEROUS_KEYS.has(key)) { - continue; - } - result[key] = textDecoder.decode(rawValue); - } - - return result; -} - function materializeParamPairs(entries, paramNames) { const result = Object.create(null); @@ -629,20 +633,21 @@ function materializeSelectedParamPairs(entries, paramNames, selectedKeys) { return result; } -function materializeSelectedPairs(entries, selectedKeys, lowerCaseKeys = false) { - if (selectedKeys.size === 0) { - return Object.create(null); +function materializeSelectedHeadersFromLookup(headerLookup, selectedKeys) { + if (selectedKeys.size === 0 || headerLookup === EMPTY_OBJECT) { + return EMPTY_OBJECT; } const result = Object.create(null); - for (const [rawName, rawValue] of entries) { - const name = textDecoder.decode(rawName); - const key = lowerCaseKeys ? name.toLowerCase() : name; - if (selectedKeys.has(key) && !DANGEROUS_KEYS.has(key)) { - result[key] = textDecoder.decode(rawValue); + for (const key of selectedKeys) { + if (DANGEROUS_KEYS.has(key)) { + continue; + } + const value = headerLookup[key]; + if (value !== undefined) { + result[key] = value; } } - return result; } @@ -701,18 +706,31 @@ function pushQueryEntry(result, key, value) { result[key] = value; } -function lookupHeaderValue(entries, targetName) { +function ensureHeaderLookup(req) { + if (req._headerLookup !== undefined) { + return req._headerLookup; + } + + const entries = req._decoded.rawHeaders; + if (entries.length === 0) { + req._headerLookup = EMPTY_OBJECT; + return req._headerLookup; + } + + const result = Object.create(null); for (const [rawName, rawValue] of entries) { const name = textDecoder.decode(rawName).toLowerCase(); - if (name === targetName) { - return textDecoder.decode(rawValue); + if (DANGEROUS_KEYS.has(name)) { + continue; } + result[name] = textDecoder.decode(rawValue); } - return undefined; + req._headerLookup = result; + return result; } -// ─── Access Plan ────────────────────────────────────────────────────────────── +// ─── Access Plan ──────────────────────── function createEmptyAccessPlan() { return { @@ -752,6 +770,23 @@ function normalizeHeaderLookup(value) { return String(value).toLowerCase(); } +function normalizeHeaderLookupCached(value) { + if (typeof value !== "string") { + return normalizeHeaderLookup(value); + } + + const cached = headerLookupNameCache.get(value); + if (cached !== undefined) { + return cached; + } + + const normalized = value.toLowerCase(); + if (headerLookupNameCache.size < HEADER_LOOKUP_CACHE_MAX) { + headerLookupNameCache.set(value, normalized); + } + return normalized; +} + function detectJsonFastPath(source) { if (!source.includes("res.json(")) { return "fallback"; @@ -782,7 +817,7 @@ function encodeUtf8(value) { return textEncoder.encode(String(value)); } -// ─── Binary Protocol Helpers ────────────────────────────────────────────────── +// ─── Binary Protocol Helpers ──────────── function readBytes(bytes, offset, length) { if (offset + length > bytes.byteLength) { @@ -792,35 +827,40 @@ function readBytes(bytes, offset, length) { return bytes.subarray(offset, offset + length); } -function readU8(view, offset) { - if (offset + 1 > view.byteLength) { +function readU8(bytes, offset) { + if (offset + 1 > bytes.byteLength) { throw new Error("Request envelope truncated"); } - return view.getUint8(offset); + return bytes[offset]; } -function readU16(view, offset) { - if (offset + 2 > view.byteLength) { +function readU16(bytes, offset) { + if (offset + 2 > bytes.byteLength) { throw new Error("Request envelope truncated"); } - return view.getUint16(offset, true); + return bytes[offset] | (bytes[offset + 1] << 8); } -function readU32(view, offset) { - if (offset + 4 > view.byteLength) { +function readU32(bytes, offset) { + if (offset + 4 > bytes.byteLength) { throw new Error("Request envelope truncated"); } - return view.getUint32(offset, true); -} - -function writeU8(view, offset, value) { - view.setUint8(offset, value); + return ( + bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24 >>> 0) + ) >>> 0; } -function writeU16(view, offset, value) { - view.setUint16(offset, value, true); +function writeU16(bytes, offset, value) { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; } -function writeU32(view, offset, value) { - view.setUint32(offset, value, true); +function writeU32(bytes, offset, value) { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; + bytes[offset + 3] = (value >>> 24) & 0xff; } diff --git a/src/index.d.ts b/src/index.d.ts index 14e0167..b1b32b6 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,6 +1,9 @@ import { Buffer } from "node:buffer"; -// ─── Core Types ─────────────────────────────────────────────────────────────── +// Types by rishi +// Just simple types for now, we can expand as needed. The main goal is to provide a good developer +// experience with TypeScript and IDEs, +// so we want to be careful about adding too much complexity here. export interface Request { /** HTTP method (GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD) */ @@ -88,7 +91,7 @@ export type ErrorHandler = ( res: Response, ) => void | Promise; -// ─── Listen Options ─────────────────────────────────────────────────────────── +// ─── Listen Options ───────────────────── export interface HttpServerConfig { defaultHost?: string; @@ -113,7 +116,7 @@ export interface ListenOptions { opt?: Record; } -// ─── Server Handle ──────────────────────────────────────────────────────────── +// ─── Server Handle ────────────────────── export interface ServerHandle { /** Bound hostname */ @@ -154,7 +157,7 @@ export interface RouteOptimizationInfo { jsonFastPath?: string; } -// ─── Application ────────────────────────────────────────────────────────────── +// ─── Application ──────────────────────── export interface Application { /** Register path-scoped or global middleware */ @@ -195,7 +198,7 @@ export interface Application { /** Create a new http-native application */ export function createApp(): Application; -// ─── CORS Types ─────────────────────────────────────────────────────────────── +// ─── CORS Types ───────────────────────── export interface CorsOptions { /** Allowed origin(s). Default: "*" */ @@ -223,7 +226,7 @@ export interface CorsOptions { /** Create a CORS middleware */ export function cors(options?: CorsOptions): Middleware; -// ─── Validation Types ───────────────────────────────────────────────────────── +// ─── Validation Types ─────────────────── export interface ValidationSchema { parse(data: unknown): T; diff --git a/src/index.js b/src/index.js index 0d8b64e..d2317be 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,8 @@ 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 NOOP_NEXT = () => undefined; +const ROUTE_CACHE_PROMOTE_HITS = 16; const ERROR_REQUEST_PLAN = Object.freeze({ method: true, path: true, @@ -33,7 +35,7 @@ const ERROR_REQUEST_PLAN = Object.freeze({ jsonFastPath: "fallback", }); -// ─── Path Normalization ─────────────────────────────────────────────────────── +// ─── Path Normalization ───────────────── function normalizePathPrefix(path) { if (path === "/") { @@ -83,10 +85,12 @@ function normalizeContentType(type) { const RESPONSE_POOL_MAX = 512; -const responsePool = []; +const responseStatePool = []; +const responseObjectPool = []; +const DEFAULT_JSON_SERIALIZER = createJsonSerializer("fallback"); function acquireResponseState() { - const pooled = responsePool.pop(); + const pooled = responseStatePool.pop(); if (pooled) { pooled.status = 200; // Reset headers — use null-prototype object for security @@ -112,99 +116,108 @@ function acquireResponseState() { } function releaseResponseState(state) { - if (responsePool.length < RESPONSE_POOL_MAX) { - responsePool.push(state); + if (responseStatePool.length < RESPONSE_POOL_MAX) { + responseStatePool.push(state); } } -function createResponseEnvelope(jsonSerializer = createJsonSerializer("fallback")) { - const state = acquireResponseState(); +const RESPONSE_PROTO = { + get finished() { + return this._state.finished; + }, + + status(code) { + this._state.status = Number(code); + return this; + }, + + set(name, value) { + // Security: validate header name/value for CRLF injection + const headerName = String(name).toLowerCase(); + const headerValue = String(value); + if ( + headerName.includes("\r") || + headerName.includes("\n") || + headerValue.includes("\r") || + headerValue.includes("\n") + ) { + return this; // Silently reject — security + } + this._state.headers[headerName] = headerValue; + return this; + }, - const response = { - locals: state.locals, + header(name, value) { + return this.set(name, value); + }, - get finished() { - return state.finished; - }, + get(name) { + return this._state.headers[String(name).toLowerCase()]; + }, - status(code) { - state.status = Number(code); - return response; - }, + type(value) { + return this.set("content-type", normalizeContentType(String(value))); + }, - set(name, value) { - // Security: validate header name/value for CRLF injection - const headerName = String(name).toLowerCase(); - const headerValue = String(value); - if (headerName.includes("\r") || headerName.includes("\n") || - headerValue.includes("\r") || headerValue.includes("\n")) { - return response; // Silently reject — security - } - state.headers[headerName] = headerValue; - return response; - }, + json(data) { + const state = this._state; + if (state.finished) { + return this; + } - header(name, value) { - return response.set(name, value); - }, + if (!state.headers["content-type"]) { + state.headers["content-type"] = "application/json; charset=utf-8"; + } - get(name) { - return state.headers[String(name).toLowerCase()]; - }, + state.body = this._jsonSerializer(data); + state.finished = true; + return this; + }, - type(value) { - return response.set("content-type", normalizeContentType(String(value))); - }, + send(data) { + const state = this._state; + if (state.finished) { + return this; + } - json(data) { - if (state.finished) { - return response; + if (Buffer.isBuffer(data) || data instanceof Uint8Array) { + if (!state.headers["content-type"]) { + state.headers["content-type"] = "application/octet-stream"; } - + state.body = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } else if (typeof data === "string") { if (!state.headers["content-type"]) { - state.headers["content-type"] = "application/json; charset=utf-8"; + state.headers["content-type"] = "text/plain; charset=utf-8"; } + state.body = Buffer.from(data, "utf8"); + } else if (data === undefined || data === null) { + state.body = EMPTY_BUFFER; + } else { + return this.json(data); + } - state.body = jsonSerializer(data); - state.finished = true; - return response; - }, - - send(data) { - if (state.finished) { - return response; - } + state.finished = true; + return this; + }, - if (Buffer.isBuffer(data) || data instanceof Uint8Array) { - if (!state.headers["content-type"]) { - state.headers["content-type"] = "application/octet-stream"; - } - state.body = Buffer.isBuffer(data) - ? data - : Buffer.from(data.buffer, data.byteOffset, data.byteLength); - } else if (typeof data === "string") { - if (!state.headers["content-type"]) { - state.headers["content-type"] = "text/plain; charset=utf-8"; - } - state.body = Buffer.from(data, "utf8"); - } else if (data === undefined || data === null) { - state.body = EMPTY_BUFFER; - } else { - return response.json(data); - } - - state.finished = true; - return response; - }, + sendStatus(code) { + this.status(code); + const state = this._state; + if (!state.headers["content-type"]) { + state.headers["content-type"] = "text/plain; charset=utf-8"; + } + return this.send(String(code)); + }, +}; - sendStatus(code) { - response.status(code); - if (!state.headers["content-type"]) { - state.headers["content-type"] = "text/plain; charset=utf-8"; - } - return response.send(String(code)); - }, - }; +function createResponseEnvelope(jsonSerializer = DEFAULT_JSON_SERIALIZER) { + const state = acquireResponseState(); + const response = responseObjectPool.pop() ?? Object.create(RESPONSE_PROTO); + response._state = state; + response._jsonSerializer = jsonSerializer; + response.locals = state.locals; return { response, @@ -216,12 +229,18 @@ function createResponseEnvelope(jsonSerializer = createJsonSerializer("fallback" }; }, release() { + response.locals = null; + response._jsonSerializer = DEFAULT_JSON_SERIALIZER; + response._state = null; + if (responseObjectPool.length < RESPONSE_POOL_MAX) { + responseObjectPool.push(response); + } releaseResponseState(state); }, }; } -// ─── Compiled Middleware Runner ──────────────────────────────────────────────── +// ─── Compiled Middleware Runner ────────── // // Generates an optimized runner that avoids function.length checks at runtime // by pre-classifying middlewares during compilation. @@ -229,19 +248,19 @@ function createResponseEnvelope(jsonSerializer = createJsonSerializer("fallback" function createMiddlewareRunner(middlewares) { if (middlewares.length === 0) { // Fast path: no middlewares — return a no-op - return async function noopMiddleware(_req, _res) {}; + return function noopMiddleware(_req, _res) {}; } if (middlewares.length === 1) { // Fast path: single middleware — avoid dispatch overhead const mw = middlewares[0]; if (mw.handler.length >= 3) { - return async function runSingleMiddleware(req, res) { - await mw.handler(req, res, () => Promise.resolve()); + return function runSingleMiddleware(req, res) { + return mw.handler(req, res, NOOP_NEXT); }; } - return async function runSingleMiddleware(req, res) { - await mw.handler(req, res); + return function runSingleMiddleware(req, res) { + return mw.handler(req, res); }; } @@ -280,7 +299,7 @@ function createMiddlewareRunner(middlewares) { }; } -// ─── Error Handling (Security-Hardened) ─────────────────────────────────────── +// ─── Error Handling (Security-Hardened) ─ function normalizeErrorStatus(error, fallbackStatus = 500) { const status = Number(error?.status ?? error?.statusCode ?? fallbackStatus); @@ -301,14 +320,20 @@ function createHttpError(status, message, code) { function buildDefaultErrorSnapshot(error, fallbackStatus = 500) { // Security: NEVER leak internal error details to the client const status = normalizeErrorStatus(error, fallbackStatus); + if (status === 404) { + return { + status, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + body: Buffer.from("Not Found", "utf8"), + }; + } + const isProduction = process.env.NODE_ENV === "production"; let body; - if (status === 404) { - body = { - error: error?.message || "Route not found", - }; - } else if (status >= 500) { + if (status >= 500) { body = isProduction ? { error: "Internal Server Error" } : { @@ -337,7 +362,15 @@ function serializeErrorResponse(error, fallbackStatus = 500) { return encodeResponseEnvelope(buildDefaultErrorSnapshot(error, fallbackStatus)); } -// ─── Dispatcher ─────────────────────────────────────────────────────────────── +function isPromiseLike(value) { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + typeof value.then === "function" + ); +} + +// ─── Dispatcher ───────────────────────── function createDispatcher(compiledRoutes, runtimeOptimizer, errorHandlers = []) { const routesById = new Map(compiledRoutes.map((route) => [route.handlerId, route])); @@ -347,7 +380,10 @@ function createDispatcher(compiledRoutes, runtimeOptimizer, errorHandlers = []) try { if (!res.finished) { for (const errorHandler of errorHandlers) { - await errorHandler(error, req, res); + const result = errorHandler(error, req, res); + if (!res.finished && isPromiseLike(result)) { + await result; + } if (res.finished) { break; } @@ -396,13 +432,26 @@ function createDispatcher(compiledRoutes, runtimeOptimizer, errorHandlers = []) return serializeErrorResponse(new Error(`Unknown handler id ${decoded.handlerId}`)); } + const cachedResponse = route.runtimeResponseCache?.encoded; + if (cachedResponse) { + return cachedResponse; + } + const req = route.requestFactory(decoded); const { response: res, snapshot, release } = createResponseEnvelope(route.jsonSerializer); try { - await route.runMiddlewares(req, res); + const middlewareResult = route.runMiddlewares(req, res); + if (!res.finished && isPromiseLike(middlewareResult)) { + await middlewareResult; + } if (!res.finished) { - await route.compiledHandler(req, res); + const handlerResult = route.compiledHandler(req, res); + // Async handlers with no await often finish synchronously and only return + // an already-resolved Promise. Skip awaiting when response is already done. + if (!res.finished && isPromiseLike(handlerResult)) { + await handlerResult; + } } } catch (error) { return finalizeError(error, req, res, snapshot, release, 500); @@ -411,13 +460,14 @@ function createDispatcher(compiledRoutes, runtimeOptimizer, errorHandlers = []) const responseSnapshot = snapshot(); runtimeOptimizer?.recordDispatch(route, req, responseSnapshot); const encoded = encodeResponseEnvelope(responseSnapshot); + maybePromoteRouteResponseCache(route, responseSnapshot, encoded); releaseRequestObject(req); release(); return encoded; }; } -// ─── Route Registration & Compilation ───────────────────────────────────────── +// ─── Route Registration & Compilation ─── function normalizeRouteRegistration(method, path, handler) { if (typeof handler !== "function") { @@ -441,13 +491,124 @@ function compileMiddlewareRegistration(middleware) { }; } -function compileRouteDispatch(route, middlewares) { +function createRouteResponseCache(route, applicableMiddlewares, requestPlan, optConfig) { + if (optConfig?.cache !== true) { + return null; + } + + if (route.method !== "GET" || route.path.includes(":")) { + return null; + } + + if (applicableMiddlewares.length > 0) { + return null; + } + + if (!hasNoRequestAccess(requestPlan)) { + return null; + } + + const source = route.handlerSource ?? ""; + if (source.includes("await")) { + return null; + } + + if (/Date\.now|new Date|Math\.random|crypto\./.test(source)) { + return null; + } + + return { + encoded: null, + lastKey: "", + stableHits: 0, + }; +} + +function hasNoRequestAccess(plan) { + return ( + plan.method !== true && + plan.path !== true && + plan.url !== true && + plan.fullParams !== true && + plan.fullQuery !== true && + plan.fullHeaders !== true && + plan.paramKeys.size === 0 && + plan.queryKeys.size === 0 && + plan.headerKeys.size === 0 + ); +} + +function maybePromoteRouteResponseCache(route, snapshot, encoded) { + const cache = route.runtimeResponseCache; + if (!cache || cache.encoded) { + return; + } + + const key = buildSnapshotCacheKey(snapshot); + if (key === cache.lastKey) { + cache.stableHits += 1; + } else { + cache.lastKey = key; + cache.stableHits = 1; + } + + if (cache.stableHits >= ROUTE_CACHE_PROMOTE_HITS) { + cache.encoded = encoded; + } +} + +function buildSnapshotCacheKey(snapshot) { + let hash = 0x811c9dc5; + hash = fnv1aString(hash, String(snapshot.status ?? 200)); + + const headers = snapshot.headers ?? Object.create(null); + const headerNames = Object.keys(headers); + for (const name of headerNames) { + hash = fnv1aString(hash, name); + hash = fnv1aString(hash, String(headers[name])); + } + + const body = Buffer.isBuffer(snapshot.body) + ? snapshot.body + : snapshot.body instanceof Uint8Array + ? snapshot.body + : EMPTY_BUFFER; + hash = fnv1aBytes(hash, body); + + return `${hash}:${body.length}:${headerNames.length}`; +} + +function fnv1aString(seed, value) { + let hash = seed >>> 0; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +function fnv1aBytes(seed, bytes) { + let hash = seed >>> 0; + for (let index = 0; index < bytes.length; index += 1) { + hash ^= bytes[index]; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +function compileRouteDispatch( + route, + middlewares, + errorHandlerPlans = [], + optConfig = {}, +) { const applicableMiddlewares = middlewares.filter((middleware) => pathPrefixMatches(middleware.pathPrefix, route.path), ); const requestPlan = mergeRequestAccessPlans([ route.accessPlan, ...applicableMiddlewares.map((middleware) => middleware.accessPlan), + ...errorHandlerPlans, ]); const requestFactory = createRequestFactory( @@ -472,6 +633,7 @@ function compileRouteDispatch(route, middlewares) { dispatchKind: requestPlan.dispatchKind, jsonFastPath, jsonSerializer: createJsonSerializer(jsonFastPath), + runtimeResponseCache: createRouteResponseCache(route, applicableMiddlewares, requestPlan, optConfig), }; } @@ -506,7 +668,7 @@ function normalizeListenOptions(options = {}) { }; } -// ─── Application Factory ───────────────────────────────────────────────────── +// ─── Application Factory ─────────────── export function createApp() { const native = loadNativeModule(); @@ -557,6 +719,9 @@ export function createApp() { async listen(options = {}) { const normalizedOptions = normalizeListenOptions(options); const compiledMiddlewares = this._middlewares.map(compileMiddlewareRegistration); + const errorHandlerPlans = this._errorHandlers.map((handler) => + analyzeRequestAccess(Function.prototype.toString.call(handler)), + ); const routes = this._routes.map((route) => { const handlerSource = Function.prototype.toString.call(route.handler); @@ -570,7 +735,12 @@ export function createApp() { }; }); const compiledRoutes = routes.map((route) => - compileRouteDispatch(route, compiledMiddlewares), + compileRouteDispatch( + route, + compiledMiddlewares, + errorHandlerPlans, + normalizedOptions.opt, + ), ); const manifest = { @@ -590,6 +760,11 @@ export function createApp() { segmentCount: route.segmentCount, headerKeys: [...route.requestPlan.headerKeys], fullHeaders: route.requestPlan.fullHeaders, + needsPath: route.requestPlan.path, + needsUrl: route.requestPlan.url, + needsQuery: + route.requestPlan.fullQuery || + route.requestPlan.queryKeys.size > 0, })), }; diff --git a/src/native.js b/src/native.js index ac605b8..d205b40 100644 --- a/src/native.js +++ b/src/native.js @@ -7,7 +7,7 @@ const require = createRequire(import.meta.url); const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); export function loadNativeModule() { - const configuredPath = process.env.HTTP_NATIVE_NODE_PATH; + const configuredPath = process.env.HTTP_NATIVE_NATIVE_PATH ?? process.env.HTTP_NATIVE_NODE_PATH; const nativeModulePath = configuredPath ? resolve(rootDir, configuredPath) : resolve(rootDir, "http-native.node"); diff --git a/test/app.js b/test/app.js new file mode 100644 index 0000000..c2bfda6 --- /dev/null +++ b/test/app.js @@ -0,0 +1,22 @@ +import { createApp } from "../src/index.js"; + +const app = createApp(); + +app.error(async (error, req, res) => { + await Promise.resolve(); + console.error("Error observed in error handler:", error, req, res); +}); + +app.get("/", (req, res) => { + res.json({ + ok: true, + }); +}); + + + + +const server = await app.listen({ + port: 8190 +}); +console.log(`http-native listening on ${server.url}`); diff --git a/test/test.js b/test/test.js index f568c6d..38c07b4 100644 --- a/test/test.js +++ b/test/test.js @@ -164,6 +164,16 @@ async function main() { res.json(stablePayload); }); + app.get("/native/:id", (req, res) => { + res.json({ + id: req.params.id, + q: req.query.q, + tag: req.query.tag, + trace: req.header("x-trace"), + accept: req.headers.accept, + }); + }); + app.get("/users/:id", async (req, res) => { const user = await db.getUser(req.params.id); res.json(user); @@ -307,6 +317,24 @@ async function main() { accept: "application/json", }); + const nativeResponse = await fetch( + new URL("/native/9?q=ada&tag=math&tag=logic", server.url), + { + headers: { + "x-trace": "native-fast", + accept: "application/json", + }, + }, + ); + assert.equal(nativeResponse.status, 200); + assert.deepEqual(await nativeResponse.json(), { + id: "9", + q: "ada", + tag: ["math", "logic"], + trace: "native-fast", + accept: "application/json", + }); + const textResponse = await fetch(new URL("/text", server.url)); assert.equal(textResponse.status, 200); assert.equal(await textResponse.text(), "hello from binary bridge"); @@ -384,6 +412,9 @@ async function main() { const userRoute = snapshot.routes.find( (route) => route.method === "GET" && route.path === "/users/:id", ); + const nativeRoute = snapshot.routes.find( + (route) => route.method === "GET" && route.path === "/native/:id", + ); const chainRoute = snapshot.routes.find( (route) => route.method === "GET" && route.path === "/chain/:id", ); @@ -394,6 +425,7 @@ async function main() { assert.ok(rootRoute); assert.ok(stableRoute); assert.ok(userRoute); + assert.ok(nativeRoute); assert.ok(chainRoute); assert.ok(fallbackRoute); @@ -406,7 +438,7 @@ async function main() { assert.equal(stableRoute.bridgeObserved, true); assert.equal(stableRoute.cacheCandidate, true); assert.equal(stableRoute.hits, 32); - assert.equal(stableRoute.recommendation, "cache-candidate"); + assert.equal(stableRoute.recommendation, null); assert.equal(userRoute.staticFastPath, false); assert.equal(userRoute.binaryBridge, true); @@ -416,6 +448,12 @@ async function main() { assert.equal(userRoute.dispatchKind, "specialized"); assert.equal(userRoute.jsonFastPath, "generic"); + assert.equal(nativeRoute.staticFastPath, false); + assert.equal(nativeRoute.bridgeObserved, false); + assert.equal(nativeRoute.hits, 0); + assert.equal(nativeRoute.dispatchKind, "specialized"); + assert.equal(nativeRoute.jsonFastPath, "specialized"); + assert.equal(chainRoute.dispatchKind, "specialized"); assert.equal(chainRoute.jsonFastPath, "specialized"); assert.equal(fallbackRoute.dispatchKind, "generic_fallback");