You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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:
Add requestTimeout?: number (ms) and decompress?: boolean to JsFetchArgs (Node) and the Deno fetch dispatch args struct.
Plumb them into iroh_http_core::fetch(..., timeout, decompress) instead of (..., None, true).
Update index.d.ts and the shared TS makeFetch wrapper in iroh-http-shared if it owns the type.
Gap 2 — serve decompression toggle:
Add decompression: Option<bool> to iroh_http_core::ServeOptions (default Some(true) to preserve current behaviour).
Plumb through serve_service_with_events to the StackConfig constructed in accept.rs.
Add decompress?: boolean to JsServeOptions (Node) and the Deno serve options struct.
Gap 3 — per-call response body limit:
Add max_response_body_bytes to StackConfig (or a sibling per-call config; needs a small design call).
Have ffi::fetch prefer the per-call value, falling back to the endpoint-wide default.
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
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).
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).
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).
Optional: clippy.tomldisallowed-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.
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
StackConfigexisted and never caught up after Slice B / Slice D landed.Evidence
Gap 1 —
fetchignorestimeoutanddecompressfrom JSiroh_http_core::fetchaccepts both knobs and threads them intoStackConfig:crates/iroh-http-core/src/ffi/fetch.rs:26-36
Both adapters call it with hard-coded values:
..., None, true)..., None, true)There is no
timeoutordecompressfield onJsFetchArgs/ the deno fetch args struct.Gap 2 —
servehard-codesdecompression: truecrates/iroh-http-core/src/http/server/accept.rs:220-229 builds
StackConfigper-bistream withdecompression: trueliteral. Not driven byServeOptions.There is no
decompressfield onJsServeOptions(packages/iroh-http-node/index.d.ts:228-245) and no corresponding field on core'sServeOptions(crates/iroh-http-core/src/http/server/options.rs:14-34).Gap 3 —
max_response_body_bytesis endpoint-wide, not per-callcrates/iroh-http-core/src/ffi/fetch.rs:193 reads it off
endpoint.max_response_body_bytes()rather than off the per-fetchStackConfig. 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/drainTimeoutflow JS → core on serve.compressionLevel/compressionMinBodyBytesflow at endpoint construction time (slightly awkward — they are onJsNodeOptionsrather thanJsServeOptions, but they do reachStackConfig.compression).maxHeaderBytesflows correctly.Impact
content-encoding: zstdbodies and forward them downstream cannot — server unconditionally decompresses.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:
requestTimeout?: number(ms) anddecompress?: booleantoJsFetchArgs(Node) and the Deno fetch dispatch args struct.iroh_http_core::fetch(..., timeout, decompress)instead of(..., None, true).index.d.tsand the shared TSmakeFetchwrapper iniroh-http-sharedif it owns the type.Gap 2 — serve decompression toggle:
decompression: Option<bool>toiroh_http_core::ServeOptions(defaultSome(true)to preserve current behaviour).serve_service_with_eventsto theStackConfigconstructed inaccept.rs.decompress?: booleantoJsServeOptions(Node) and the Deno serve options struct.Gap 3 — per-call response body limit:
max_response_body_bytestoStackConfig(or a sibling per-call config; needs a small design call).ffi::fetchprefer the per-call value, falling back to the endpoint-wide default.maxResponseBodyBytes?: numberon the JS fetch args struct.Per ADR-013 and the epic-#182 closing comment, the structural rule is that
StackConfigis 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
fetch()and Denofetch(); an obviously-stuck server returns a typed timeout error within the supplied deadline (regression test: spin up aservethat holds the connection without responding, fetch with 200 ms timeout, assertTIMEOUTwithin < 500 ms).fetch(); the response body bytes returned to JS are exactly what the server sent (regression test: server returnscontent-encoding: zstdwith raw zstd payload, fetch withdecompress: false, assert byte-equality).serve(handler, { decompress: false })acceptscontent-encoding: zstdrequest bodies and forwards them to the handler unchanged (regression test parallel to Incoming request URL used http:// scheme instead of httpi:// [FIXED] #2).maxResponseBodyBytesoverrides the endpoint default (regression test: endpoint default 1 MiB, single fetch with 10 MiB limit succeeds against a 5 MiB response).clippy.tomldisallowed-methodsor a new architecture test catches future call sites that pass literalNone/true/falseto FFI functions where a typed JS option exists. Prevents recurrence.npm run cigreen; 92 interop pairs pass.References
StackConfigandffi → httpone-way rule.StackConfigis the single source of truth for tower middleware.