Skip to content

Latest commit

 

History

History

README.md

@fluojs/http

English 한국어

Node.js support is >=24.0.0 <27. See Node.js support and migration before upgrading.

The HTTP execution layer that turns route metadata into a request pipeline with binding, validation, guards, interceptors, and response writing.

Table of Contents

Installation

npm install @fluojs/http

Static Asset Delivery

createStaticAssetsMiddleware(...) serves only GET and HEAD requests through an explicit application-owned StaticAssetSource. The portable HTTP package never assumes filesystem access, so Web and edge applications must provide their own source instead of receiving an implicit Node fallback.

The middleware decodes each URL segment once and rejects traversal, encoded separators, backslashes, and NUL before it asks the source to resolve anything. Dotfiles use an explicit policy: allow resolves them, ignore leaves the request to later middleware/routes, and deny commits 403, including a configured dotfile index. Directory indexes are disabled by default and are considered only for a trailing-slash URL.

import { createStaticAssetsMiddleware } from '@fluojs/http';
import { createNodeFileSystemAssetSource } from '@fluojs/platform-nodejs';

const assets = createStaticAssetsMiddleware({
  cacheControl: 'public, max-age=3600',
  index: ['index.html'],
  prefix: '/assets',
  source: createNodeFileSystemAssetSource({
    precompressed: true,
    root: './public',
  }),
});

Register assets in runtime bootstrap middleware. The selected representation owns MIME type, exact bytes and length, ETag, Last-Modified, and optional Content-Encoding; static writes bypass adapter dynamic compression so those values remain coherent for full GET, HEAD, conditional fields, Range, and If-Range. A source selects only request-acceptable br, gzip, or identity bytes, returns an explicit no-representation outcome for bodyless 406, and uses Vary: Accept-Encoding whenever selection can vary. Byte ranges address the selected encoded representation.

When to Use

Use this package when you need to:

  • define REST-style controllers with decorators such as @Controller, @Get, and @Post
  • bind request data into DTOs with @FromBody, @FromPath, @FromQuery, and related decorators
  • run guards, interceptors, and middleware in a predictable request lifecycle
  • access the active request through RequestContext without passing it through every function

Quick Start

import { Controller, FromBody, FromPath, Get, Post, RequestDto } from '@fluojs/http';
import { IsString, MinLength } from '@fluojs/validation';

class CreateUserDto {
  @FromBody()
  @IsString()
  @MinLength(3)
  name = '';
}

class FindUserParamsDto {
  @FromPath('id')
  id = '';
}

@Controller('/users')
export class UserController {
  @Post('/')
  @RequestDto(CreateUserDto)
  create(input: CreateUserDto) {
    return { id: '1', name: input.name };
  }

  @Get('/:id')
  @RequestDto(FindUserParamsDto)
  getById(input: FindUserParamsDto) {
    return { id: input.id, name: 'John Doe' };
  }
}

Initialize decorated DTO fields, as shown above, or declare them optional. A definite assignment assertion such as name!: string does not compile with the Babel decorator configuration Fluo ships, which rejects a definitely assigned field on a decorated class with Definitely assigned fields cannot be initialized here, but only in the constructor.

Optional route paths

Get, Post, Put, Patch, Delete, Options, Head, All, Sse, and HTTP Query accept omitted or undefined paths, meaning ''. Route(method) has the same path default, but its method remains required. With @Controller('cats'), @Get() dispatches GET /cats; with @Controller(), it dispatches GET /. ('/') resolves to the same route but keeps a different raw path; it never escapes the controller prefix. Duplicate detection and invalid path/method rejection are unchanged. These are factories, not bare @Get decorators.

Sse() preserves GET plus text/event-stream and stream lifecycle. All() remains a wildcard and HTTP Query() means RFC QUERY, not query-string binding or GraphQL Query. Root and @fluojs/http/portable share these defaults without expanding adapter method support. See the complete 165-API reconciliation for unchanged required arguments such as HttpCode(status) and Header(name, value).

Route path contract

HTTP route decorators such as @Controller(), @Get(), and @Post() accept only:

  • literal path segments like /users or /healthz
  • full-segment path params like /:id or /users/:userId/posts/:postId

Trailing slashes and duplicate slashes are normalized during route mapping, so //users///:id/ resolves to /users/:id.

Route decorators do not support wildcard, regex-like, or mixed-segment syntax such as *, ?, /(.*), user-:id, or :id.json. Wildcard matching remains middleware-only via forRoutes('/users/*').

Catch-all route grammar is intentionally deferred. The HTTP catch-all route grammar decision records the evaluated syntaxes, provisional precedence and params shape, OpenAPI limitations, adapter native fast-path constraints, and the evidence required before this HTTP contract can be revisited. No syntax described there is active route behavior.

Custom HTTP method contract

Use @Query(path) for RFC QUERY, or @Route(method, path) for another HTTP extension method such as PURGE or WebDAV PROPFIND:

import { Controller, Query, Route } from '@fluojs/http';

@Controller('/operations')
export class OperationsController {
  @Query('/search')
  search() {
    return { method: 'QUERY' };
  }

  @Route('purge', '/cache')
  purgeCache() {
    return { method: 'PURGE' };
  }
}

@Route(...) accepts a non-empty HTTP token, canonicalizes it to uppercase before metadata registration, and rejects whitespace, separators, control characters, and non-ASCII token characters with InvalidHttpMethodError. ALL is reserved for the framework-owned @All(...) wildcard and is rejected by @Route(...). Method-specific routes, including custom methods, take precedence over @All(...), participate in duplicate detection and route versioning, and use the ordinary DTO binding, validation, guard, interceptor, and response pipeline. Unless status metadata says otherwise, successful QUERY and extension-method handlers default to 200.

Adapter wire support is an explicit portability contract. Supported Node listeners, Fastify and Express wildcard fallbacks, and Bun, Deno, and Cloudflare Workers fetch dispatch execute QUERY and representative extension methods without converting them into ordinary methods. Custom methods stay off Bun native routes acceleration, while Fastify registers their method names only so its wildcard fallback can receive them; neither path creates a native fluo route handoff. CONNECT remains outside ordinary controller-route conformance.

Custom runtime methods do not become OpenAPI Path Item operations automatically. @fluojs/openapi continues to accept only its documented standard operation methods, so exclude custom-method descriptors from OpenAPI input or document those endpoints through an application-owned extension.

Portable header helpers

Use getRequestHeader(request, name) when middleware, versioning, DTO binding, or controller code needs a case-insensitive lookup without flattening adapter-provided string | string[] | undefined header values.

Use appendVaryHeader(response, ...fields) when response negotiation or caching logic needs to add Vary fields without duplicating case variants, re-parsing comma lists by hand, or accidentally expanding an existing wildcard Vary: * contract.

Use getResponseHeader(response, name) and hasResponseHeader(response, name) for the same case-insensitive lookup over adapter-provided response headers. They preserve the original string | string[] shape and do not write headers, body, status, or commit state.

Use buildContentDisposition(disposition, filename) to create an attachment or inline Content-Disposition field value. It emits an escaped printable-ASCII filename fallback and a deterministic RFC 8187 UTF-8 filename* value. Carriage return and line feed filenames reject before a header value is returned.

import {
  appendVaryHeader,
  buildContentDisposition,
  getRequestHeader,
  getResponseHeader,
  hasResponseHeader,
  type RequestContext,
} from '@fluojs/http';

export function readLanguage(context: RequestContext): string | undefined {
  const acceptLanguage = getRequestHeader(context.request, 'accept-language');
  return Array.isArray(acceptLanguage) ? acceptLanguage[0] : acceptLanguage;
}

export function markLanguageVariance(context: RequestContext): void {
  appendVaryHeader(context.response, 'Accept-Language', 'Origin');
  context.response.setHeader(
    'Content-Disposition',
    buildContentDisposition('attachment', 'résumé.pdf'),
  );
}

export function readResponseEtag(
  context: RequestContext,
): string | string[] | undefined {
  return getResponseHeader(context.response, 'etag');
}

export function shouldSetResponseEtag(context: RequestContext): boolean {
  return !hasResponseHeader(context.response, 'etag');
}

Content negotiation

Configure ContentNegotiationOptions with response formatters, then use @Produces(...) to limit each route to the representations it can return. The dispatcher owns formatter selection, response commit, Content-Type, canonical 406 responses, and Vary: Accept; handlers only return their values.

import { Controller, Get, Produces } from '@fluojs/http';

@Controller('/reports')
export class ReportController {
  @Produces('application/json', 'text/plain')
  @Get('/')
  getReport() {
    return { ok: true };
  }
}

The configured default formatter is used when Accept is absent, blank, or */*; if that formatter is not allowed by @Produces(...), the first declared allowed formatter is used instead. For a supplied header, exact ranges, type/*, */*, and structured suffix ranges such as application/*+json are matched case-insensitively. Higher q wins, then the more specific range, then the configured default (or formatter declaration order). A more-specific q=0 range excludes that representation even when a broader wildcard is positive.

Each q value must be between 0 and 1 with at most three fractional digits. Malformed media ranges or qualities are ignored; if no valid acceptable representation remains, or no formatter is allowed or matched, the dispatcher returns its canonical 406 Not Acceptable response. Every successful formatter selection emits one canonical, deduplicated Vary: Accept while preserving any existing Vary fields.

Common Patterns

Explicit input policies

Scope and imports: InputPolicy and InputPolicyOptions from @fluojs/http or @fluojs/http/portable configure top-level body binding for a class DTO or a route method. Ordinary class DTOs need no schema binder. They still require RequestDto and explicit source decorators such as FromBody.

Option Default Opt-in behavior
unknownFields 'reject' 'strip' projects only declared body source keys, including aliases.
nonObjects 'reject' 'empty' supplies an empty body to binding for arrays, primitives, and other non-plain values.

Omitted fields retain strict defaults. A route overrides only the policy fields it explicitly declares; other fields come from the DTO, including inherited DTO policy. null and absent bodies retain existing missing-field behavior. nonObjects: 'empty' does not make required class fields optional: FromBody still reports MISSING_FIELD unless Optional permits omission. Field initializers do not satisfy required transport fields.

import {
  Controller, FromBody, InputPolicy, Post, RequestDto,
} from '@fluojs/http';
import { IsDefined, IsString } from '@fluojs/validation';

@InputPolicy({ unknownFields: 'strip' })
class DraftInput {
  @FromBody('post_title')
  @IsDefined()
  @IsString()
  title = '';
}

@Controller('/drafts')
class DraftController {
  @Post()
  @RequestDto(DraftInput)
  create(input: DraftInput) {
    return { title: input.title };
  }

  @Post('/strict')
  @RequestDto(DraftInput)
  @InputPolicy({ unknownFields: 'reject' })
  strict(input: DraftInput) {
    return { title: input.title };
  }
}

Here { post_title: 'Draft', authorId: 'untrusted' } binds to { title: 'Draft' } on /drafts, but fails on /drafts/strict. The allowlist uses the transport alias post_title, not the logical field title. Unknown keys fail with HTTP 400 / UNKNOWN_FIELD; invalid body shapes fail with HTTP 400 / INVALID_BODY. Dangerous own enumerable body keys __proto__, constructor, and prototype remain blocked, including with strip or empty; projection never turns them into accepted input. Unsupported JavaScript policy values throw TypeError at declaration.

Order and ownership: after transport parsing and middleware, guards and interceptor-before code see the original parsed input. If they continue, binding checks/projects input, converters and ordinary class validation run, the handler/service executes, and interceptor-after code receives the result. Configured conditional requests may finish after guards and before interceptors. Projection uses a binding-local request view; it never replaces RequestContext.request.body. That original body remains visible to later context readers too. Projection is shallow, not a deep clone or recursive sanitizer. Keep server-owned identity and authorization decisions outside client input; a guard does not receive a future validated handler argument. Parser failures still precede guards. Neither parser nor HEAD policy changes.

Decorator composition: standard class/method declarations and the existing legacy declaration path are supported. A standard composed method decorator forwards the same value and context to each declaration; it must retain RequestDto and the route declaration:

function StrictDraft(value: Function, context: ClassMethodDecoratorContext) {
  InputPolicy({ unknownFields: 'reject' })(value, context);
  RequestDto(DraftInput)(value, context);
  Post('/composed')(value, context);
}

Use @StrictDraft on a controller method. For legacy integrations, the equivalent policy calls are InputPolicy(options)(DtoClass) and InputPolicy(options)(ControllerClass.prototype, methodName, descriptor). These declarations do not replace the DTO token or alter sibling route policies. Application source should retain the standard decorator configuration rather than enable emitDecoratorMetadata.

Standard Schema output binding

Scope and prerequisites: createSchemaDto, StandardSchemaBinder, SchemaDtoOptions, and SchemaBindingField are public exports from @fluojs/http and @fluojs/http/portable. Supply a Standard Schema v1 implementation and install the binder at application bootstrap. Schema libraries are application dependencies; no particular vendor is required.

createSchemaDto(schema, { fields, policy? }) returns an opaque token for RequestDto. InstanceType<typeof Token> is the schema's output type, including transformations and defaults, not its input type. The binder returns the successful schema output directly rather than constructing a class instance.

Mapping input Contract
fields record property Logical schema input name.
source Explicit 'body', 'path', or 'query'; no source inference.
key Optional transport alias; defaults to the logical input name.
Missing mapped value Omitted from schema input so the schema owns required values and defaults. Explicit null is passed through.
Unmapped query/path keys Ignored; this does not relax the independent body unknown-field policy.
policy The same body options as InputPolicy; explicit route fields override them.

Query mappings also accept repeatedQuery:

Value Behavior
Omitted or 'preserve' Scalars remain scalars; arrays are copied without changing their values or order.
'first' / 'last' Select the first/last array element; a scalar remains a scalar.
'reject' More than one array element fails with HTTP 400 / REPEATED_QUERY; a one-element array is still an array.

The schema receives one projected object containing the mapped logical names. Its own unknown-key option cannot strip transport body keys that HTTP rejects before parsing that object. Configure policy: { unknownFields: 'strip' } explicitly when that is the intended body boundary.

The following standalone application fragment uses Zod 4 as one Standard Schema vendor. Install @fluojs/core, @fluojs/http, @fluojs/runtime, @fluojs/platform-nodejs, and zod in an application with Fluo's supported decorator build configuration:

import { Module } from '@fluojs/core';
import {
  Controller, createSchemaDto, Post, RequestDto, StandardSchemaBinder,
} from '@fluojs/http';
import { NodeHttpApplicationAdapter } from '@fluojs/platform-nodejs';
import { FluoFactory } from '@fluojs/runtime';
import { z } from 'zod';

const DraftRequest = createSchemaDto(z.object({
  title: z.string().trim().min(1),
  count: z.coerce.number().int().min(1).default(1),
  id: z.coerce.number().int().positive(),
  tag: z.string().optional(),
}), {
  fields: {
    title: { source: 'body', key: 'post_title' },
    count: { source: 'body' },
    id: { source: 'path', key: 'postId' },
    tag: { source: 'query', repeatedQuery: 'first' },
  },
  policy: { unknownFields: 'strip' },
});

@Controller('/drafts')
class DraftController {
  @Post('/:postId')
  @RequestDto(DraftRequest)
  create(input: InstanceType<typeof DraftRequest>) {
    return input;
  }
}

@Module({ controllers: [DraftController] })
class AppModule { }

const app = await FluoFactory.create(AppModule, {
  adapter: NodeHttpApplicationAdapter.create({ host: '127.0.0.1', port: 3000 }),
  binder: (defaultBinder) => new StandardSchemaBinder(defaultBinder),
});
await app.listen();

For POST /drafts/7?tag=a&tag=b with { "post_title": " Draft ", "authorId": "untrusted" }, the expected handler input and JSON result are { title: 'Draft', count: 1, id: 7, tag: 'a' }. An array body still fails unless nonObjects: 'empty' is explicitly selected. The application owns shutdown and must call app.close() at its host boundary; this fragment does not install process-signal handlers.

Compatibility and failures: StandardSchemaBinder delegates ordinary DTOs to its fallback, preserving the ordinary converter/class-validation path. Pass the bootstrap-supplied default binder so configured global converters are retained; constructing new StandardSchemaBinder() directly uses a default binder without those application settings. Schema tokens use schema conversion, not class-field converters. Async schema validators are awaited before the handler. DtoValidationError, including a failure with issues: [], becomes HTTP 400. Malformed results throw TypeError, and schema implementation exceptions propagate through the normal server-error path rather than being disguised as validation failures. See the output parser contract.

Do not construct, subclass, or pass a schema token to mapped-class DTO helpers such as PickType, OmitType, PartialType, or IntersectionType. Construction throws InvariantError; the token is not runtime reflected class-field metadata and does not provide automatic OpenAPI schema conversion. Keep ordinary class DTOs when those class contracts are needed, or document schema-token routes with explicit application-owned OpenAPI schemas. Invalid mapping sources, repeated-query options, or dangerous logical/source keys throw TypeError during token creation.

Evidence: implementations are src/input-policy.ts, src/adapters/binding.ts, src/schema-binding.ts, and src/dispatch/dispatch-handler-policy.ts; exports are in src/index.portable.ts. Regression locations are src/adapters/binding.test.ts, src/input-materialization.test.ts, and ../testing/src/input-materialization.e2e.test.ts. Bootstrap composition belongs to the runtime contract.

Guards and interceptors

import { Controller, Get, UseGuards, UseInterceptors } from '@fluojs/http';

@Controller('/admin')
@UseGuards(AdminGuard)
@UseInterceptors(LoggingInterceptor)
class AdminController {
  @Get('/')
  dashboard() {
    return { data: 'secret' };
  }
}

Request observers

onRequestSuccess runs only after the matched handler and all module-level and application-level middleware have settled, including work after await next(). If middleware throws after next() returns, observers receive onRequestError without a preceding success notification. onRequestFinish still runs after either outcome.

Access logging

createAccessLogObserver(...) turns the request-observer lifecycle into application-owned structured records. It emits a start record, an error record for each dispatch error, and exactly one terminal finish record with a monotonic duration, optional request ID, method, path, matched route, status, and outcome (success, handled_error, unhandled_error, not_found, or aborted). Native route dispatch falls back to this complete lifecycle when observers are configured.

The sink is deliberately consumer-owned: route AccessLogEvent values to the structured logger, telemetry pipeline, or retention policy that owns your operational data. No headers are emitted unless they are allowlisted. authorization, cookie, set-cookie, proxy-authorization, and x-api-key remain redacted even when allowlisted; add organization-specific names with redact.

import { createAccessLogObserver } from '@fluojs/http';

const accessLogObserver = createAccessLogObserver({
  clientIdentity: {},
  headers: {
    allow: ['user-agent', 'set-cookie'],
    redact: ['x-tenant-token'],
  },
  sink: {
    emit(event) {
      structuredLog.write(event);
    },
  },
});

Omit clientIdentity when no client address is needed. clientIdentity: {} explicitly opts into the adapter's direct transport peer and ignores forwarding fields. Use clientIdentity: { trustProxy: ['10.0.0.0/8'] } only when a trusted proxy boundary should supply forwarded identity.

Request IDs are optional. An observer alone does not create one, so observer-only records can omit requestId. When createCorrelationMiddleware() is installed, the dispatcher adopts an incoming x-request-id or legacy x-correlation-id, or generates an ID before the access-log start record.

Async request context

import { getCurrentRequestContext } from '@fluojs/http';

function someDeepHelper() {
  const ctx = getCurrentRequestContext();
  console.log(ctx?.requestId);
}

runWithRequestContext(...) preserves the active context across awaited work when the host provides AsyncLocalStorage through globalThis.AsyncLocalStorage or the node:async_hooks module. The root @fluojs/http export selects a runtime-specific entrypoint without probing or instantiating async-context storage: Node and Bun register the host constructor during module initialization, while Deno, worker, browser, and default entries remain free of Node built-in imports. The request-local store itself is still created lazily on first use. Promise-returning non-async callbacks keep synchronous invocation, return, and throw behavior, and their continuations retain the bound context until the returned promise settles. The helpers never replace Promise.prototype.then, so unrelated promise continuations cannot capture a request. Hosts without an async-context primitive use a synchronous-only fallback that clears the context before awaited work resumes.

Response Cookies

Use the portable setCookie() and clearCookie() helpers instead of adapter-native response APIs. Every call writes one independent Set-Cookie field, so repeated calls preserve their order and are never comma-folded.

import { clearCookie, setCookie } from '@fluojs/http';

setCookie(context.response, 'session', sessionToken, {
  httpOnly: true,
  maxAgeSeconds: 60 * 60,
  path: '/',
  sameSite: 'lax',
  secure: true,
});

clearCookie(context.response, 'session', {
  path: '/',
});

maxAgeSeconds is a non-negative whole-second lifetime on every adapter. Values are percent-encoded, names and attributes are validated before the response changes, and sameSite: 'none' requires secure: true. To delete the same browser cookie, repeat its original path and domain; httpOnly, secure, and sameSite are policy attributes rather than browser matching keys.

Early Hints

FrameworkResponse.earlyHints is an optional, request-scoped capability for HTTP 103 informational responses. Check for property presence before use; property absence means the active adapter cannot emit Early Hints. There is no required FrameworkResponse.writeEarlyHints() method and unsupported adapters never silently ignore a write.

import type { RequestContext } from '@fluojs/http';

async function render(_input: undefined, context: RequestContext) {
  const earlyHints = context.response.earlyHints;

  if (earlyHints) {
    await earlyHints.write({
      link: [
        '</styles.css>; rel=preload; as=style',
        '</app.js>; rel=modulepreload',
      ],
      'x-trace-id': 'render-1',
    });
  }

  context.response.setHeader('link', '</final.css>; rel=stylesheet');
  return { ok: true };
}

Each write(...) emits one 103, so callers may await multiple writes before the final response. Every write requires at least one non-empty link value and may include additional informational fields with valid HTTP names and values. Header names are case-insensitive and cannot be repeated with different casing. Status-forbidden framing fields (content-length and transfer-encoding) are rejected before the native write. Early fields do not populate response.headers, change status, set committed, or become final-response headers.

Node.js, Express, and Fastify expose this capability. Fetch-style Web, Bun, Deno, and Cloudflare Workers responses omit it because their Response APIs cannot represent an informational response before the final response. A write after final commitment or a native validation/write failure rejects with EarlyHintsWriteError (EARLY_HINTS_WRITE_FAILED); a disconnect before settlement rejects with RequestAbortedError (REQUEST_ABORTED).

Realtime Adapter Capabilities

HttpApplicationAdapter.getRealtimeCapability() reports whether a platform is server-backed, fetch-style, or unsupported for realtime protocol integration. The fetch-style capability remains version 1. Hosts may additionally expose its optional, independently versioned bindingInstallation extension so first-party realtime packages can install their binding before adapter listen() starts without changing the stable capability discriminator.

createFetchStyleHttpAdapterRealtimeCapability(reason, options) always returns the source-compatible version 1 capability. When an installer is supplied, the returned value also includes bindingInstallation; that installer accepts a protocol-owned binding or undefined for pre-listen cleanup. The platform adapter remains responsible for parsing that boundary into its host-specific binding type. Once a managed adapter is live, its close() boundary owns final binding cleanup. Application code should normally register @fluojs/websockets or @fluojs/socket.io modules rather than call this low-level adapter capability directly.

HTTP Error Representations

Canonical JSON remains the default error response. Register an optional application-owned HTML provider at runtime bootstrap when browser requests should receive complete error or not-found documents without changing API clients:

import type { HttpErrorRepresentationOptions } from '@fluojs/http';
import { FluoFactory } from '@fluojs/runtime';

function escapeHtml(value: string): string {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

const errorRepresentation = {
  html: {
    canRender({ request }) {
      return request.method === 'GET' || request.method === 'HEAD';
    },
    render({ json }) {
      return `<!doctype html><main>${json.error.status}: ${escapeHtml(json.error.message)}</main>`;
    },
  },
} satisfies HttpErrorRepresentationOptions;

const app = await FluoFactory.create(AppModule, {
  errorRepresentation,
});

HTTP classifies the outcome before representation selection. isHttpException(...) validates the versioned HTTP owner contract plus status, details, and common error fields, so compatible same-realm duplicate package copies retain their status and JSON/HTML representation. Plain lookalikes and incompatible contract versions remain unhandled server errors. Route misses become the existing 404 outcome, and uncommitted HttpException values from middleware, DTO binding/validation, guards, interceptors, and handlers use the same seam. The provider receives the classified exception, canonical ErrorResponse, request, optional matched handler, request id, and active request-scope container. It receives no FrameworkResponse, so status, headers, HEAD, abort, and commit ownership remain in the dispatcher.

The provider return value is trusted application HTML. fluo does not escape or sanitize it. Escape every request-derived or error-derived value before interpolation, as the example does for json.error.message, or render through a framework whose text-node contract performs that escaping.

Accept negotiation is deterministic: absent Accept and wildcard/tie cases select JSON; quality and specificity select between application/json and available text/html; unsupported ranges produce canonical JSON 406. canRender(...) may constrain HTML per application or matched handler. A provider failure falls back once to the original canonical JSON outcome, and committed or aborted requests are never rewritten. Response writer send(...) or stream/write failures propagate unchanged and do not trigger a second canonical JSON write. Existing native Vary values are preserved when HTTP adds Accept. Successful-route @Produces(...) metadata does not control error representations. See the HTTP error representation decision for the complete phase and fallback contract.

Rate limiting behind proxies

createRateLimitMiddleware(...) resolves client identity from the adapter-snapshotted direct transport address by default. To trust Forwarded, X-Forwarded-For, or X-Real-IP, configure trustProxy with an explicit hop count, address/CIDR list, or predicate. Forwarded data is ignored unless the direct peer satisfies that policy; malformed Forwarded data fails closed to the direct transport identity.

import { resolveHttpConnection } from '@fluojs/http';

const connection = resolveHttpConnection(context.request, {
  trustProxy: ['10.0.0.0/8', '2001:db8:feed::/48'],
});

connection is immutable and exposes the selected clientAddress, direct remoteAddress, trusted proxyChain, protocol, secure, host, hostname, and port. Fetch-only adapters may leave the direct address undefined because the Web Request contract does not expose it. A fetch-style HTTPS Request without an adapter-provided connection snapshot or explicit headers has no peer, host, or port, and resolveHttpConnection(...) does not infer HTTPS, secure, host, or port from its URL. The legacy trustProxyHeaders: true setting is broad compatibility only and is not recommended for new deployments; use trustProxy to describe the deployment boundary precisely. Only use either setting when you control the proxy that rewrites those headers. If an adapter provides neither a trusted proxy chain nor a raw socket identity, provide an explicit keyResolver.

Server-sent events

Manual SseResponse values from a compatible same-realm @fluojs/http copy are recognized only through their complete non-enumerable versioned owner capability. Full and fast dispatch both wait for that response's original lifecycle completion before observers and request-scoped resources dispose, exactly once.

import { Controller, Sse, type SseMessage } from '@fluojs/http';

@Controller('/orders')
export class OrdersEventsController {
  @Sse('/events')
  async *stream(): AsyncIterable<SseMessage<{ status: string }> | { heartbeat: true }> {
    yield { data: { status: 'connected' }, event: 'ready', id: 'orders-ready' };

    while (true) {
      await new Promise((resolve) => setTimeout(resolve, 15_000));
      yield { heartbeat: true };
    }
  }
}

@Sse(path) registers a GET route and declares text/event-stream produced media type metadata. Handlers may either return SseResponse for manual stream control or return AsyncIterable<SseMessage<T> | T> for managed streaming. A manual SseResponse keeps its dispatch, request observers, and request-scoped resources active until explicit close, request abort, or raw stream close; those lifecycle stages then release exactly once. Managed async iterables are converted with the same encodeSseMessage(...) behavior as SseResponse: plain yielded values become data: frames, while yielded objects with a data field may also provide event, id, and retry. The dispatcher stops consuming the source when RequestContext.request.signal aborts or the response stream closes, calls FrameworkResponseStream.waitForDrain() when a write reports backpressure, and closes the stream on completion or source errors. The same cancellation boundary bounds an in-flight waitForDrain(): request abort or stream close wins over an unsettled drain promise, after which the dispatcher closes the source iterator exactly once and continues request-scope disposal. Stream write failures and rejected drain promises still propagate their original errors. On cancellation, the dispatcher closes the response stream promptly and awaits the source iterator's return() cleanup before disposing request-scoped resources. Cleanup failures are reported through the request observer and dispatcher logger seams without replacing the already-committed SSE response. Thrown source errors follow the same committed-response error/observer boundary. Observable values remain out of scope and no RxJS dependency is required.

Managed SSE requires an adapter that exposes FrameworkResponse.stream. When the active adapter does not provide a response stream, the dispatcher rejects the managed async iterable before marking the response handled and surfaces the failure through the standard dispatch error path (request error observers and the configured error response writer) instead of silently reporting the stream as handled.

On the browser side, create the EventSource inside the React effect that owns it and always close it from the cleanup function so route changes, Strict Mode remounts, and component unmounts do not leave duplicate streams open:

import { useEffect, useState } from 'react';

export function OrderEvents({ orderId }: { orderId: string }) {
  const [events, setEvents] = useState<string[]>([]);

  useEffect(() => {
    const source = new EventSource(`/orders/events?orderId=${encodeURIComponent(orderId)}`, {
      withCredentials: true,
    });

    source.addEventListener('ready', (event) => {
      setEvents((current) => [...current, event.data]);
    });

    source.onerror = () => {
      // Browsers reconnect automatically unless the server closes with a terminal status.
      console.warn('Order event stream disconnected; waiting for browser retry.');
    };

    return () => {
      source.close();
    };
  }, [orderId]);

  return <output>{events.join('\n')}</output>;
}

Browser EventSource does not let callers attach arbitrary Authorization headers. Authenticate SSE endpoints with same-origin cookies, withCredentials plus explicit CORS credentials policy, or a short-lived signed URL/query token that your guard validates. Do not document a bearer-header browser example unless you are using a custom fetch-based SSE client instead of the built-in EventSource API.

Operationally, keep SSE connections unbuffered and long-lived: allow credentials in CORS only for trusted origins, disable proxy buffering and response transforms (SseResponse sets Cache-Control: no-cache, no-transform and X-Accel-Buffering: no), avoid compression middleware that buffers text/event-stream, set load balancer or platform idle timeouts above your heartbeat interval, send comment heartbeats such as sse.comment('heartbeat'), and persist enough event history to honor Last-Event-ID when clients reconnect and need replay.

Versioning

createHandlerMapping(...) supports URI, header, media-type, and custom versioning strategies through VersioningType and the versioning option. Route registration keeps exact/static matches ahead of fallbacks while preserving registration order for equivalent normalized routes.

Request context helpers

Use runWithRequestContext(...), assertRequestContext(), createRequestContext(...), createContextKey(...), getContextValue(...), and setContextValue(...) when framework integrations need explicit request context boundaries or typed per-request storage.

Fast-path observability

The dispatcher exposes fast-path observability for adapters and diagnostics through FAST_PATH_ELIGIBILITY_SYMBOL, FAST_PATH_STATS_SYMBOL, formatFastPathStats(...), and getDispatcherFastPathStats(...). Eligibility decisions belong to the dispatcher instance rather than the shared HandlerMapping: dispatchers may reuse one mapping with different middleware, observer, interceptor, binder, or adapter options without overwriting one another. describeRoutes() exposes frozen eligibility snapshots on its cloned descriptors, and dispatcher statistics plus their route entries are frozen observability values.

When the sole application middleware is an unmodified framework-created security headers instance, the dispatcher can apply its headers directly and use an otherwise eligible fast route without creating a request scope for those headers. All header defaults, overrides and ordering remain unchanged. Other middleware, including copied or modified instances, retains the normal chain. Request-scoped dependencies, guards, interceptors and observers still require their usual path.

Bun decorator bundling compatibility

Fluo's HTTP decorators are standard TC39 decorators and continue to record metadata through context.metadata when the runtime or compiler provides the standard decorator context. When Bun bundles an application through its legacy TypeScript decorator transform, the same controller, route, DTO binding, guard/interceptor, header, redirect, versioning, status, request DTO, and @Produces(...) metadata is recorded through Fluo's internal metadata stores so generated Bun bundles preserve route mapping behavior.

This compatibility path is an execution fallback for Bun bundle output; application source should still use Fluo's standard decorators and should not enable emitDecoratorMetadata or rely on reflect-metadata.

Request Cleanup and Portability

The dispatcher binds RequestContext with host async-context storage for the active dispatch only. On hosts with AsyncLocalStorage, including supported Node 20+ runtimes, the context remains available across awaited work. On non-Node hosts without an async-context primitive, the fallback context is synchronous-only and intentionally unavailable after await so overlapping requests cannot observe one another's context. When a request may use request-scoped DI through its controller graph, middleware, guards, interceptors, observers, DTO converters, a custom binder, or manual getCurrentRequestContext() / assertRequestContext() container access, the dispatcher creates and disposes an isolated request-scoped DI container from its finally path after request observers finish. Routes whose graphs do not require request scope skip that container lifecycle until RequestContext.container is accessed, so the baseline path avoids unnecessary per-request allocation while preserving request-scoped provider isolation whenever the graph is ambiguous or request-scoped. The fast path caches handler metadata only and resolves the controller through the active container for every dispatch: singleton providers remain shared by the container, while transient controllers and dependencies retain fresh-per-resolution identity. Public RequestContext.container reads are therefore always safe for resolving request-scoped providers; the request-scope-free fast path is an internal dispatcher optimization, not a promise that the public context exposes the root container.

Adapters should pass an AbortSignal on FrameworkRequest.signal when the platform exposes one, or an isAborted() probe when allocating a signal is not practical. The dispatcher preserves both abort surfaces on its per-dispatch request clone and treats the request as aborted when either surface reports cancellation, so a false probe never masks an aborted signal. It checks both surfaces before and after handler work so adapters without AbortSignal can still stop abandoned requests. For SSE, adapters should also expose FrameworkResponse.stream.onClose(...) when possible; SseResponse listens to both request abort and raw stream close, closes idempotently, and removes registered listeners when either side terminates first.

Compatible duplicate copies

Compatible same-realm @fluojs/http copies share only request-owner-bound state: the active AsyncLocalStorage request context, native route handoff consumed from the exact raw request, known-absent request IDs, and authoritative abort probes. A native handoff is still consumed once, and an aborted signal still wins over a false probe. Separate requests, applications, DI containers, and transaction context are never shared.

Adapters that parse multipart uploads should attach runtime-neutral FrameworkRequestFile values to FrameworkRequest.files rather than augmenting the shared HTTP contract with adapter-specific file types. The seam intentionally models the portable fields every HTTP adapter can provide (fieldname, originalname, mimetype, buffer, and size); platform packages may keep richer native file objects on their raw request surfaces, but guards, binders, middleware, interceptors, and controllers should read files through RequestContext.request.files when they need cross-runtime behavior.

Multipart DTO fields

Use @FromFiles(fieldname?) with @RequestDto(...) when multipart files are part of a handler's input contract:

import {
  Controller,
  FromFiles,
  Optional,
  Post,
  RequestDto,
  type FrameworkRequestFile,
} from '@fluojs/http';

class UploadAssetsDto {
  @FromFiles('attachments')
  attachments: readonly FrameworkRequestFile[] = [];

  @FromFiles('cover')
  @Optional()
  cover?: readonly FrameworkRequestFile[];
}

@Controller('/uploads')
export class UploadController {
  @Post('/')
  @RequestDto(UploadAssetsDto)
  upload(input: UploadAssetsDto) {
    return input.attachments.map((file) => file.originalname);
  }
}

@FromFiles(...) is array-only: when FrameworkRequest.files exists, it returns a readonly array filtered by fieldname in adapter arrival order; a present collection without matches becomes []. When the collection is absent, required fields produce the standard missing-field error and @Optional() leaves the field undefined. Converters and validation receive that same portable array. The DTO binder projects only the five FrameworkRequestFile fields, so adapter-native file properties cannot leak through the DTO boundary. Direct RequestContext.request.files access remains supported for controllers and pipeline stages that need the entire request collection.

Response content negotiation formatters must return string or Uint8Array from ResponseFormatter.format(...). Node.js Buffer values remain assignable because Buffer implements Uint8Array, but formatter contracts should rely only on runtime-neutral byte behavior.

Public API

FAST_PATH_ELIGIBILITY_SYMBOL and FAST_PATH_STATS_SYMBOL have stable same-realm identities across compatible duplicate package copies, so adapters and diagnostics can read metadata written by another copy without sharing dispatcher-owned mutable state.

  • Routing decorators: Controller, Get, Sse, Query, Route, Post, Put, Patch, Delete, All, Options, Head
  • Binding decorators: FromBody, FromQuery, FromPath, FromHeader, FromCookie, FromFiles, RequestDto, Optional, Convert, InputPolicy
  • Explicit input materialization: InputPolicyOptions, createSchemaDto, StandardSchemaBinder, SchemaDtoOptions, SchemaBindingField
  • Execution decorators: UseGuards, UseInterceptors, HttpCode, Version, Header, Redirect, Produces
  • Header helpers: getRequestHeader, getResponseHeader, hasResponseHeader, appendVaryHeader, buildContentDisposition
  • Response cookie helpers: setCookie, clearCookie, CookieOptions, ClearCookieOptions, CookieSameSite
  • Trusted connection API: resolveHttpConnection, HttpConnection, ResolveHttpConnectionOptions, TrustProxyPolicy, TrustProxyPredicate, FrameworkRequestConnection
  • Structured access logging: createAccessLogObserver, CreateAccessLogObserverOptions, AccessLogSink, AccessLogEvent, AccessLogStartEvent, AccessLogErrorEvent, AccessLogFinishEvent, AccessLogOutcome, AccessLogHeaderOptions, AccessLogRequestFields
  • Conditional request types: EntityTagStrength, EntityTag, ResponseValidators, ConditionalRequestContext, ConditionalRequestResolution, ConditionalRequestResolver, ConditionalRequestOptions
  • Byte-range responses: createByteRangeResponse, ByteRangeResponseSource, ByteRangeResponseOptions
  • Static assets: createStaticAssetsMiddleware, StaticAssetSource, StaticAsset, StaticAssetAcceptedEncoding, StaticAssetContentEncoding, StaticAssetNotAcceptable, StaticAssetResolveContext, StaticAssetResolution, StaticAssetsMiddleware, StaticAssetsMiddlewareOptions. This package owns middleware and source-selection contracts; @fluojs/platform-nodejs owns the optional Node filesystem source.
  • Response transport controls: FrameworkResponseSendOptions, FrameworkResponseStream (onError reports every transport failure occurrence, including undefined, and returns an optional remover that callers invoke after their stream settles)
  • Request/response and context types: RequestContext, Principal, ContextKey, ControllerHandler, FrameworkRequest, FrameworkRequestFile, FrameworkResponse, EarlyHintsHeaders, FrameworkResponseEarlyHints, FrameworkResponseStream, FrameworkResponseCompression, FrameworkResponseCompressionWriteOptions, SseResponse, SseMessage
  • Dispatcher, routing, and negotiation types: Dispatcher, CreateDispatcherOptions, ErrorHandler, DispatcherLogger, HandlerMapping, HandlerMetadata, HandlerDescriptor, HandlerMatch, HandlerSource, RouteDefinition, HttpMethod, VersioningType, VersioningOptions, VersioningExtractor, VersioningExtractorResult, ContentNegotiationOptions, ResponseFormatter, HttpErrorRepresentationContext, HtmlErrorRepresentationProvider, HttpErrorRepresentationOptions, FastPathEligibility, FastPathStats
  • Pipeline contract types: Middleware, MiddlewareLike, MiddlewareContext, MiddlewareRouteConfig, Next, Guard, GuardLike, GuardContext, Interceptor, InterceptorLike, InterceptorContext, CallHandler, RequestObserver, RequestObserverLike, RequestObservationContext, ArgumentResolverContext, Binder, Converter, ConverterLike, ConverterTarget, ValidationIssue, Validator
  • Adapter API: HttpApplicationAdapter, HttpAdapterRealtimeCapability, ServerBackedHttpAdapterRealtimeCapability, FetchStyleHttpAdapterRealtimeCapability, HttpAdapterRealtimeBindingInstallation, UnsupportedHttpAdapterRealtimeCapability, createNoopHttpApplicationAdapter, createServerBackedHttpAdapterRealtimeCapability, createUnsupportedHttpAdapterRealtimeCapability, createFetchStyleHttpAdapterRealtimeCapability
  • Exceptions and errors: HttpExceptionDetail, HttpExceptionOptions, ErrorResponse, HttpException, BadRequestException, UnauthorizedException, ForbiddenException, NotFoundException, ConflictException, NotAcceptableException, TooManyRequestsException, InternalServerErrorException, PayloadTooLargeException, createErrorResponse, RouteConflictError, InvalidRoutePathError, InvalidHttpMethodError, HandlerNotFoundError, RequestAbortedError, EarlyHintsWriteError
  • Helpers: createHandlerMapping, createDispatcher, forRoutes, normalizeRoutePattern, matchRoutePattern, isMiddlewareRouteConfig, createCorrelationMiddleware, createCorsMiddleware, createRateLimitMiddleware, createMemoryRateLimitStore, createSecurityHeadersMiddleware, getRequestHeader, getResponseHeader, hasResponseHeader, appendVaryHeader, buildContentDisposition, runWithRequestContext, getCurrentRequestContext, assertRequestContext, createRequestContext, createContextKey, getContextValue, setContextValue, encodeSseComment, encodeSseMessage, isSseMessage, formatFastPathStats, getDispatcherFastPathStats, FAST_PATH_ELIGIBILITY_SYMBOL, FAST_PATH_STATS_SYMBOL
  • Option and store types: CorsOptions, RateLimitOptions, RateLimitStore, RateLimitStoreEntry, SecurityHeadersOptions, SseSendOptions

Portable Subpath (@fluojs/http/portable)

Use @fluojs/http/portable from runtime-neutral integrations that need HTTP authoring contracts without eagerly initializing the Node AsyncLocalStorage bootstrap. It exports the supported HTTP decorators, exceptions, request/response contracts, and authoring helpers; Node applications should continue to import the root package when they need its Node request-context behavior.

Internal Subpath (@fluojs/http/internal)

The ./internal subpath exports only the low-level utilities used by platform adapters, the core runtime, and first-party response integrations. These are subject to change and should not be used in typical application code.

  • DefaultBinder: Default DTO/request binder used by the runtime bootstrap path.
  • bindRawRequestNativeRouteHandoff(...) / attachFrameworkRequestNativeRouteHandoff(...): Internal adapter/runtime helpers for reusing semantically safe native route matches without widening the public dispatcher API.
  • consumeRawRequestNativeRouteHandoff(...) / consumeFrameworkRequestNativeRouteHandoff(...): Internal helpers for consuming native route handoffs.
  • Native route handoffs snapshot the framework request method and path when attached; if app middleware rewrites either value before handler matching, the dispatcher ignores the stale handoff and falls back to normal route matching.
  • isRoutePathNormalizationSensitive(path): Internal guard for keeping duplicate-slash and trailing-slash requests on the generic dispatcher path.
  • getCompiledRouteIdentity(descriptor): Reads the deterministic source/method position assigned by createHandlerMapping(...) for first-party package integrations. Manually authored descriptors return undefined.
  • getHandlerFastPathEligibility(descriptor): Reads a dispatcher route snapshot's execution decision so adapters can avoid a known-full native fast attempt.
  • markAbsentRequestId(request): Records an adapter snapshot with neither supported inbound ID header, avoiding header materialization during initial context creation.
  • registerAuthoritativeAbortProbe(request, probe): Declares that this request's probe observes the same cancellation source as its lazy signal. Adapters must preserve that equivalence; generic requests and copies retain independent signal checks.
  • resolveClientIdentity(request): Conservative client identity resolver used by rate limiting and other runtime integrations.
  • createFetchStyleHttpAdapterRealtimeCapability(...), Dispatcher, and HttpApplicationAdapter: internal adapter seams for edge/fetch-style platform packages that must avoid instantiating the full HTTP root barrel.
  • FRAMEWORK_RESPONSE_WRITER / registerFrameworkResponseWriter(...): Typed response-entry branding seam for first-party response integrations.
  • FRAMEWORK_RESPONSE_VALUE_FINALIZER / registerFrameworkResponseValueFinalizer(...): Typed request-local response finalization seam. Finalizers compose in registration order, each receives the prior resolved value, and the dispatcher awaits them so throws and rejections follow its normal error policy.

Opt-in HEAD Selection

FrameworkRequest.headRouting?: 'explicit-or-get' is an adapter-owned input to the shared createHandlerMapping(...) matcher. Omission retains ordinary method matching. For HEAD only, opt-in selects an eligible explicit HEAD route, then the ALL method wildcard, then GET, before dispatching a single pipeline. Each method group retains its existing static/parameter and version rules. Version extraction runs once against the original HEAD request, and neither the native request nor the method seen by middleware, guards, and handlers is rewritten. Handler results, including 404, never re-enter route selection.

This field does not add path wildcards or change other adapters' defaults. Custom HandlerMapping implementations own their matching policy; native route handoffs remain preselected routes and do not invoke this shared matcher. The field selects a route, not a transport response writer: existing custom writers still own HEAD body emission. The Next adapter option adds transport body suppression and stream cleanup for Next consumers. Regression evidence is in head-routing.test.ts and the Next pipeline tests.

Conditional Requests

Configure conditionalRequest during runtime bootstrap to resolve representation existence separately from optional validators:

import { FluoFactory } from '@fluojs/runtime';
import { createConsoleApplicationLogger, NodeHttpApplicationAdapter } from '@fluojs/platform-nodejs';
const app = await FluoFactory.create(AppModule, {
  adapter: NodeHttpApplicationAdapter.create({}),
  conditionalRequest: {
    resolve({ handler, request }) {
      return {
        exists: true,
        validators: {
          etag: { opaqueValue: `${handler.route.method}:${request.path}:v1`, strength: 'strong' },
          lastModified: new Date('2026-01-01T00:00:00Z'),
        },
      };
    },
  },
  logger: createConsoleApplicationLogger(),
});

Return { exists: false } when no representation exists. Return { exists: true } when it exists but intentionally has no validators. The dispatcher evaluates this resolver after application/module middleware and guards, so conditional 304 and 412 responses never bypass authorization or audit logic. It accepts only valid entity-tag lists and HTTP-date forms; malformed conditional fields are ignored.

The dispatcher owns RFC 9110 precedence and comparison: a successful If-Match skips only If-Unmodified-Since, then If-None-Match still takes precedence over If-Modified-Since; If-Match uses strong comparison and If-None-Match weak comparison. 304 and 412 are bodyless and retain ETag/Last-Modified, including redirect and supported custom response-writer paths. For the same selected representation, HEAD and GET use the same conditional result and framework-generated HEAD bodies are suppressed. An explicit @Head route remains an independent route; custom response writers own their body emission and must preserve the HEAD bodyless contract themselves. See the HTTP Runtime Contract.

Byte Range Responses

Returning a Uint8Array or ArrayBuffer enables RFC single-range bytes responses automatically for GET and the HEAD metadata mirror. A valid range produces 206, Accept-Ranges: bytes, Content-Range, and the exact identity-byte Content-Length; malformed or multi-range fields fall back to the complete representation, while unsatisfiable ranges return bodyless 416 with Accept-Ranges: bytes, Content-Range: bytes */<size>, and Content-Length: 0. POST, unsafe, and custom methods ignore Range and retain their ordinary full status, body, and metadata.

Use createByteRangeResponse(...) for a portable ReadableStream and provide its exact full size. Pass a factory when HEAD must not construct the stream:

import { createByteRangeResponse } from '@fluojs/http';

return createByteRangeResponse(
  () => file.stream(),
  { contentType: 'image/png', size: file.size },
);

The dispatcher evaluates normal conditional requests first. If-Range then permits a partial response only for an exact strong ETag or a current Last-Modified date; otherwise it sends the complete representation. Partial responses preserve identity bytes and bypass Node compression so range offsets and lengths remain meaningful. HEAD preserves GET status and metadata without opening the stream. The application owns its bytes, exact size, and any filesystem resource: createByteRangeResponse(...) never opens, stats, seeks, sizes, or closes files, and it intentionally does not construct multi-range responses.

Related Packages

  • @fluojs/core: stores controller, route, and DTO metadata
  • @fluojs/validation: validates DTOs after HTTP binding
  • @fluojs/runtime: assembles the dispatcher during application bootstrap
  • @fluojs/passport: plugs auth guards into the same HTTP guard chain

Example Sources

  • examples/realworld-api/src/users/create-user.dto.ts
  • examples/auth-jwt-passport/src/auth/auth.controller.ts
  • packages/http/src/dispatch/dispatcher.test.ts

Bounded Body Parser Policy

BodyParser and BodyParserContext are exported from @fluojs/http and @fluojs/http/portable. HTTP owns this policy; runtime implements Web reads; platform adapters expose the supported configuration. Omission or 'default' keeps existing MIME-based parsing, including malformed JSON as HTTP 400, valid JSON null as null, and non-JSON text. Multipart uses its existing parser.

Opt in with 'text' to receive UTF-8 decoded text without changing Content-Type, headers, or the native Request. An empty stream produces ''; an absent body remains undefined. A custom (text, context) => value | Promise<value> receives creation-time contentType, headers, method, path, and the framework signal. context.parseDefault() applies the existing MIME parser to the bounded bytes, without consuming the request again, so a per-path policy can delegate other paths without duplicating JSON/MIME logic. Custom callbacks are not invoked for absent or multipart bodies. Invalid JavaScript option values fail setup with TypeError; TypeScript rejects unsupported modes and callback signatures.

All modes retain byte limits before decoding or callbacks. With text/custom parsing, rawBody: true retains exact bytes independently, including zero bytes for an empty stream; multipart raw-body exclusion is unchanged. Materialization is deferred until dispatch and memoized across concurrent calls and failures. Opt-in stream reads observe cancellation and release the reader; async callbacks must cooperate with context.signal, and an aborted callback result is never assigned. A custom parser cannot re-read the consumed body.

Ordering: materialization and parser failures precede HTTP middleware, route matching, guards, DTO binding, and exception filters. Text mode alone never guarantees authentication order. To give authentication precedence over malformed JSON, explicitly authenticate in middleware/guards, then interpret text in the handler or a later application-owned stage and throw BadRequestException for invalid JSON. Byte-limit 413 and transport failures still precede authentication. A custom parser runs before authentication too; throw an HttpException to select its early HTTP error, while unknown exceptions remain 500. No global malformed-JSON-to-null fallback is installed.

Adapter Capability Matrix

Surface Parser policy Ownership and evidence
@fluojs/runtime/web Supported Web factory, dispatch, and standalone request helper; packages/runtime/src/web-body-parser.test.ts
Next App Router and Pages Router Supported NextAdapterOptions.bodyParser; Pages still requires Next bodyParser: false; packages/platform-nextjs/src/body-parser.test.ts and real Next E2E
Cloudflare Workers Supported Inherited Web factory option on adapter/bootstrap; packages/platform-cloudflare-workers/src/body-parser.test.ts exercises the actual fetch adapter, not a deployed isolate
Bun and Deno adapter/fetch helpers Not exposed Keep existing adapter options/default parsing; direct runtime Web helpers are a separate integration surface
Node.js, Express, Fastify Not exposed Their existing host/parser ownership remains unchanged; Fastify's already parsed host body is not re-consumed by the Web policy

The runtime, Next, and Workers tests consume the same wire-case fixture at tooling/testing/body-parser-cases.json. Existing Web portability and Fastify native-body tests retain default/raw-body/multipart behavior. Public declaration tests build a cold isolated dependency closure and resolve package export maps. See the Next usage and migration.

Create HTTP applications through FluoFactory.create(AppModule, { adapter }) from @fluojs/runtime, then use instance listen()/close(). Optional HttpApplicationAdapter.getListenTarget?() supplies { bindTarget, url } after listen for startup logging; socketless hosts may omit it. Factory owns common middleware and failure cleanup while HTTP retains its request/input/response policies. See the migration guide.