Skip to content

feat(ffi): pass StackConfig knobs through FFI — per-fetch timeout/decompress, per-serve decompression, per-call max_response_body_bytes #189

Description

@momics

Summary

iroh_http_core::StackConfig (Slice B of #182) defines the typed knobs that drive the tower stack on both serve and fetch. Several of those knobs are silently dropped at the FFI boundary: the JS adapters either pass hard-coded values or have no field for them at all. The knobs exist in core, the FFI signature accepts them in some cases, and the wiring stops before reaching JS.

The FFI argument lists were frozen before StackConfig existed and never caught up after Slice B / Slice D landed.

Evidence

Gap 1 — fetch ignores timeout and decompress from JS

iroh_http_core::fetch accepts both knobs and threads them into StackConfig:

crates/iroh-http-core/src/ffi/fetch.rs:26-36

pub async fn fetch(
    ...
    timeout: Option<Duration>,
    decompress: bool,
)

Both adapters call it with hard-coded values:

There is no timeout or decompress field on JsFetchArgs / the deno fetch args struct.

Gap 2 — serve hard-codes decompression: true

crates/iroh-http-core/src/http/server/accept.rs:220-229 builds StackConfig per-bistream with decompression: true literal. Not driven by ServeOptions.

There is no decompress field on JsServeOptions (packages/iroh-http-node/index.d.ts:228-245) and no corresponding field on core's ServeOptions (crates/iroh-http-core/src/http/server/options.rs:14-34).

Gap 3 — max_response_body_bytes is endpoint-wide, not per-call

crates/iroh-http-core/src/ffi/fetch.rs:193 reads it off endpoint.max_response_body_bytes() rather than off the per-fetch StackConfig. Server side similarly does not surface it per-serve. So a caller cannot allow 1 GiB on one specific fetch while keeping the 16 MiB default elsewhere — it is endpoint-construction-time only.

This one is structurally different from gaps 1 and 2 (config scoping rather than pass-through) but has the same shape of root cause.

What is correctly wired (for balance)

  • requestTimeout / maxRequestBodyBytes / loadShed / maxConcurrency / maxConnectionsPerPeer / maxTotalConnections / maxServeErrors / drainTimeout flow JS → core on serve.
  • compressionLevel / compressionMinBodyBytes flow at endpoint construction time (slightly awkward — they are on JsNodeOptions rather than JsServeOptions, but they do reach StackConfig.compression).
  • maxHeaderBytes flows correctly.

Impact

  • Per-fetch timeout (gap 1). A stuck server hangs the request for the connect/idle timeout instead of the caller's intended deadline. JS callers cannot bound a fetch.
  • Per-fetch decompress (gap 1). A caller streaming a known-compressed payload to forward as-is cannot opt out; bytes get re-decompressed transparently and the original encoding is lost.
  • Per-serve decompression (gap 2). A relay/proxy that wants to receive raw content-encoding: zstd bodies and forward them downstream cannot — server unconditionally decompresses.
  • Per-call response body limit (gap 3). Mixed-workload nodes (small JSON APIs alongside one large-blob endpoint) must size the limit for the worst case at endpoint bind time.

Workarounds exist for all three (set conservative endpoint-wide defaults; hold fetch tokens and cancel manually for timeout) but they are workarounds for asymmetric API surface, not for missing capability — the capability exists in core today.

Remediation

Single PR per gap recommended (each is small and independent).

Gap 1 — fetch knobs:

  1. Add requestTimeout?: number (ms) and decompress?: boolean to JsFetchArgs (Node) and the Deno fetch dispatch args struct.
  2. Plumb them into iroh_http_core::fetch(..., timeout, decompress) instead of (..., None, true).
  3. Update index.d.ts and the shared TS makeFetch wrapper in iroh-http-shared if it owns the type.

Gap 2 — serve decompression toggle:

  1. Add decompression: Option<bool> to iroh_http_core::ServeOptions (default Some(true) to preserve current behaviour).
  2. Plumb through serve_service_with_events to the StackConfig constructed in accept.rs.
  3. Add decompress?: boolean to JsServeOptions (Node) and the Deno serve options struct.

Gap 3 — per-call response body limit:

  1. Add max_response_body_bytes to StackConfig (or a sibling per-call config; needs a small design call).
  2. Have ffi::fetch prefer the per-call value, falling back to the endpoint-wide default.
  3. Optional: surface as maxResponseBodyBytes?: number on the JS fetch args struct.

Per ADR-013 and the epic-#182 closing comment, the structural rule is that StackConfig is the single source of truth for tower middleware. These gaps are exactly the "FFI argument lists drifted from the typed config" pattern the epic was meant to prevent recurring.

Acceptance criteria

  1. JS callers can pass a per-fetch timeout via Node fetch() and Deno fetch(); an obviously-stuck server returns a typed timeout error within the supplied deadline (regression test: spin up a serve that holds the connection without responding, fetch with 200 ms timeout, assert TIMEOUT within < 500 ms).
  2. JS callers can opt out of response decompression via Node and Deno fetch(); the response body bytes returned to JS are exactly what the server sent (regression test: server returns content-encoding: zstd with raw zstd payload, fetch with decompress: false, assert byte-equality).
  3. serve(handler, { decompress: false }) accepts content-encoding: zstd request bodies and forwards them to the handler unchanged (regression test parallel to Incoming request URL used http:// scheme instead of httpi:// [FIXED] #2).
  4. Per-call maxResponseBodyBytes overrides the endpoint default (regression test: endpoint default 1 MiB, single fetch with 10 MiB limit succeeds against a 5 MiB response).
  5. Optional: clippy.toml disallowed-methods or a new architecture test catches future call sites that pass literal None / true / false to FFI functions where a typed JS option exists. Prevents recurrence.
  6. npm run ci green; 92 interop pairs pass.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium priorityapiAPI design / ergonomicsenhancementNew feature or requestrustPull requests that update rust code

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions