diff --git a/.changeset/issue-2889-http-error-representations.md b/.changeset/issue-2889-http-error-representations.md
new file mode 100644
index 000000000..d53f1e003
--- /dev/null
+++ b/.changeset/issue-2889-http-error-representations.md
@@ -0,0 +1,13 @@
+---
+"@fluojs/http": minor
+"@fluojs/platform-express": patch
+"@fluojs/react": minor
+"@fluojs/runtime": minor
+"@fluojs/testing": minor
+---
+
+Add an HTTP-owned, content-negotiated error representation seam that preserves canonical JSON by default, optionally renders application-owned HTML for classified errors and route misses, and keeps status, headers, `HEAD`, abort, commit, and one-shot fallback behavior in the dispatcher.
+
+Expose runtime bootstrap wiring, a buffered React error-document provider adapter, and typed network/fetch-style portability assertions for the new representation contract.
+
+Preserve existing Express response `Vary` values when HTTP error representation negotiation adds `Accept`.
diff --git a/docs/CONTEXT.ko.md b/docs/CONTEXT.ko.md
index 11e1a3019..32d08df6d 100644
--- a/docs/CONTEXT.ko.md
+++ b/docs/CONTEXT.ko.md
@@ -162,6 +162,15 @@ Cron lifecycle contract는 committed scheduler handle token으로 callback을 ga
[`docs/architecture/http-catch-all-route-grammar.ko.md`](./architecture/http-catch-all-route-grammar.ko.md)는 catch-all 도입을 유예한다. `@fluojs/http`는 literal 및 full-segment `:param` route segment만 계속 허용하고, `@fluojs/react/client`의 실제 anchor는 client route grammar를 만들지 않으면서 명시적인 server route로 일반 full-document fallback을 제공한다. 재검토에는 HTTP-owned syntax, `static > param > catch-all` ordering, string params, OpenAPI policy, adapter parity, native fast path 결정, performance evidence가 필요하다.
+## HTTP Error Representation Decision
+
+채택된 [HTTP error representation decision](./architecture/http-error-representations.ko.md)은
+classification, `Accept` negotiation, status/header, request scope, `HEAD`, abort, commit ownership을
+`@fluojs/http`에 유지한다. Application은 `errorRepresentation.html`을 등록할 수 있고 canonical JSON은
+default, wildcard/tie winner, 406 fallback, one-shot provider-failure fallback으로 유지된다.
+`@fluojs/react`는 `createReactErrorRepresentationProvider(...)`를 통해 application error document를
+buffer할 수 있지만 route를 match하거나 page policy를 조회하거나 HTTP outcome을 override하지 않는다.
+
## React Render Policy Decision
채택된 [React render policy decorator decision](./architecture/react-render-policy-decorators.ko.md)은
@@ -175,10 +184,11 @@ post-shell recoverable error는 기존 owner와 phase를 유지합니다.
후속 [React page render policy decision](./architecture/react-page-render-policies.ko.md)은 synchronous
request-aware `@PageMetadata(...)` factory와 bounded title/meta/link resolution, ordinary React element
creation을 채택합니다. Factory는 active request, optional request id, request-scope container를 받지만
-response authority는 받지 않으며 matched application renderer만 이를 consume합니다. Generic error
-presentation과 page-local not-found presentation은 거부하므로 unmatched request, handler-thrown
-`NotFoundException`, SSR diagnostic phase, Vite asset discovery, inline serialization은 기존 owner와 boundary를
-유지합니다.
+response authority는 받지 않으며 matched application renderer만 이를 consume합니다. Generic page error
+presentation과 page-local not-found presentation은 거부한다. 후속 HTTP-owned application seam은 HTTP
+classification 이후에만 optional React-produced document를 선택할 수 있으므로 unmatched request,
+handler-thrown `NotFoundException`, SSR diagnostic phase, Vite asset discovery, inline serialization은 기존
+owner와 boundary를 유지한다.
## React RSC Graduation Gate
@@ -208,6 +218,7 @@ HTTP route 및 #2506 navigation ownership, dual-import test, bilingual docs, Cha
| 저장소 정체성과 위반 불가 규칙 확인 | `docs/CONTEXT.md` | `docs/contracts/behavioral-contract-policy.md` |
| 아키텍처 모델, 요청 흐름, 런타임 경계 확인 | `docs/architecture/architecture-overview.md` | `docs/reference/glossary-and-mental-model.md` |
| HTTP catch-all grammar 결정과 재검토 gate 확인 | `docs/architecture/http-catch-all-route-grammar.ko.md` | 활성 explicit-route contract는 `packages/http/README.ko.md` 및 `packages/react/README.ko.md` |
+| HTTP JSON/HTML error representation ownership와 negotiation 확인 | `docs/architecture/http-error-representations.ko.md` | `docs/architecture/error-responses.ko.md`, `packages/http/README.ko.md`, `packages/react/README.ko.md` |
| 패키지 계열 조회 또는 런타임 범위 확인 | `docs/reference/package-surface.md` | 선택 로직이 필요하면 `docs/reference/package-chooser.md` |
| i18n ecosystem bridge compatibility와 migration boundary 확인 | `docs/reference/i18n-ecosystem-bridges.ko.md` | third-party bridge 작성 시 `docs/contracts/third-party-extension-contract.ko.md` |
| behavioral guarantee, Changesets 릴리스 흐름, 버전 정책 확인 | `docs/contracts/behavioral-contract-policy.md` | `docs/contracts/release-governance.md` |
diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md
index 38a4f4ad9..2a526e854 100644
--- a/docs/CONTEXT.md
+++ b/docs/CONTEXT.md
@@ -162,6 +162,16 @@ The Cron lifecycle contract gates callbacks with the committed scheduler handle
[`docs/architecture/http-catch-all-route-grammar.md`](./architecture/http-catch-all-route-grammar.md) defers catch-all adoption. `@fluojs/http` continues to accept only literal and full-segment `:param` route segments, while `@fluojs/react/client` real anchors provide ordinary full-document fallback to explicit server routes without creating a client route grammar. Reconsideration requires an HTTP-owned syntax, `static > param > catch-all` ordering, string params, OpenAPI policy, adapter parity, native fast path decisions, and performance evidence.
+## HTTP Error Representation Decision
+
+The accepted [HTTP error representation decision](./architecture/http-error-representations.md)
+keeps classification, `Accept` negotiation, status/headers, request scope, `HEAD`, abort, and commit
+ownership in `@fluojs/http`. Applications may register `errorRepresentation.html`; canonical JSON
+remains the default, wildcard/tie winner, 406 fallback, and one-shot provider-failure fallback.
+`@fluojs/react` may buffer an application error document through
+`createReactErrorRepresentationProvider(...)`, but it does not match routes, consult page policies,
+or override the HTTP outcome.
+
## React Render Policy Decision
The accepted [React render policy decorator decision](./architecture/react-render-policy-decorators.md)
@@ -176,9 +186,10 @@ The follow-up [React page render policy decision](./architecture/react-page-rend
accepts synchronous request-aware `@PageMetadata(...)` factories plus bounded title/meta/link
resolution and ordinary React element creation. Factories receive the active request, optional
request id, and request-scope container but no response authority; only the matched application
-renderer consumes them. Generic error presentation and page-local not-found presentation are
-rejected, so unmatched requests, handler-thrown `NotFoundException`, SSR diagnostic phases, Vite
-asset discovery, and inline serialization keep their existing owners and boundaries.
+renderer consumes them. Generic page error presentation and page-local not-found presentation are
+rejected. The later HTTP-owned application seam may select an optional React-produced document only
+after HTTP classification, so unmatched requests, handler-thrown `NotFoundException`, SSR diagnostic
+phases, Vite asset discovery, and inline serialization keep their existing owners and boundaries.
## React RSC Graduation Gate
@@ -208,6 +219,7 @@ re-export for the documented deprecation window.
| Repository identity and non-negotiable rules | `docs/CONTEXT.md` | `docs/contracts/behavioral-contract-policy.md` |
| Architecture model, request flow, and runtime boundaries | `docs/architecture/architecture-overview.md` | `docs/reference/glossary-and-mental-model.md` |
| HTTP catch-all grammar decision and revisit gates | `docs/architecture/http-catch-all-route-grammar.md` | `packages/http/README.md` and `packages/react/README.md` for the active explicit-route contract |
+| HTTP JSON/HTML error representation ownership and negotiation | `docs/architecture/http-error-representations.md` | `docs/architecture/error-responses.md`, `packages/http/README.md`, and `packages/react/README.md` |
| Package family lookup or runtime coverage | `docs/reference/package-surface.md` | `docs/reference/package-chooser.md` when selection logic is needed |
| i18n ecosystem bridge compatibility and migration boundaries | `docs/reference/i18n-ecosystem-bridges.md` | `docs/contracts/third-party-extension-contract.md` when authoring a third-party bridge |
| Behavioral guarantees, Changesets release flow, and versioning policy | `docs/contracts/behavioral-contract-policy.md` | `docs/contracts/release-governance.md` |
diff --git a/docs/README.ko.md b/docs/README.ko.md
index e42bd0ec0..4fdc57b99 100644
--- a/docs/README.ko.md
+++ b/docs/README.ko.md
@@ -15,6 +15,7 @@
- 패키지 표면: [`reference/package-surface.ko.md`](./reference/package-surface.ko.md)
- 패키지 선택기: [`reference/package-chooser.ko.md`](./reference/package-chooser.ko.md)
- Behavioral contract: [`contracts/behavioral-contract-policy.ko.md`](./contracts/behavioral-contract-policy.ko.md)
+- HTTP error representation decision: [`architecture/http-error-representations.ko.md`](./architecture/http-error-representations.ko.md)
- React render policy decision: [`architecture/react-render-policy-decorators.ko.md`](./architecture/react-render-policy-decorators.ko.md)
- React page metadata/error/not-found policy decision: [`architecture/react-page-render-policies.ko.md`](./architecture/react-page-render-policies.ko.md)
- React RSC graduation policy: [`contracts/react-rsc-graduation.ko.md`](./contracts/react-rsc-graduation.ko.md)
diff --git a/docs/README.md b/docs/README.md
index b1bc759d8..25fdc0b88 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,6 +15,7 @@ This directory contains governed repository documentation for fluo. The official
- Package surface: [`reference/package-surface.md`](./reference/package-surface.md)
- Package chooser: [`reference/package-chooser.md`](./reference/package-chooser.md)
- Behavioral contracts: [`contracts/behavioral-contract-policy.md`](./contracts/behavioral-contract-policy.md)
+- HTTP error representation decision: [`architecture/http-error-representations.md`](./architecture/http-error-representations.md)
- React render policy decision: [`architecture/react-render-policy-decorators.md`](./architecture/react-render-policy-decorators.md)
- React page metadata/error/not-found policy decision: [`architecture/react-page-render-policies.md`](./architecture/react-page-render-policies.md)
- React RSC graduation policy: [`contracts/react-rsc-graduation.md`](./contracts/react-rsc-graduation.md)
diff --git a/docs/architecture/error-responses.ko.md b/docs/architecture/error-responses.ko.md
index 7ec5abac3..6e7d32cea 100644
--- a/docs/architecture/error-responses.ko.md
+++ b/docs/architecture/error-responses.ko.md
@@ -61,7 +61,7 @@ interface ErrorResponse {
| `429` | `TOO_MANY_REQUESTS` | `TooManyRequestsException` |
| `500` | `INTERNAL_SERVER_ERROR` | `InternalServerErrorException` |
-`packages/http/src/dispatch/dispatch-error-policy.ts`의 dispatcher 정규화 규칙은 다음 매핑을 추가합니다.
+`packages/http/src/dispatch/dispatch-error-representation.ts`의 dispatcher 정규화 규칙은 다음 매핑을 추가합니다.
| Input failure | Output code | Output status | Rule |
| --- | --- | --- | --- |
@@ -69,14 +69,35 @@ interface ErrorResponse {
| `HandlerNotFoundError` | `NOT_FOUND` | `404` | `NotFoundException`으로 변환됩니다. |
| 그 외 모든 throw 값 | `INTERNAL_SERVER_ERROR` | `500` | `InternalServerErrorException`으로 변환됩니다. |
+## Representation Selection
+
+Application이 `errorRepresentation.html`을 등록해도 위 envelope가 canonical contract로 유지된다. 기존
+`HttpException`과 route miss에 대해 HTTP는 먼저 canonical outcome을 만든 다음 `application/json`과 사용
+가능한 `text/html` provider를 negotiate한다. Provider는 `HttpErrorRepresentationContext`에서 같은
+`ErrorResponse`를 받으므로 HTML document가 status, code, details, metadata, request id를 다시 정의하지
+않고 outcome을 표현할 수 있다.
+
+`Accept`가 없거나 wildcard tie이면 JSON을 선택한다. Quality와 media-range specificity는 deterministic하며,
+specific `q=0` rejection이 해당 representation에 대해 broader wildcard보다 우선한다. 등록된 acceptable
+offer가 없으면 HTML을 재귀 호출하지 않고 canonical JSON `406 NOT_ACCEPTABLE`을 보낸다. `HEAD`는 선택된
+status/header를 보존하지만 render하거나 body를 보내지 않는다. Provider failure는 원래 canonical JSON
+outcome으로 한 번만 fallback한다.
+
+알 수 없는 throw 값은 canonical JSON 500으로 유지되고 HTML representation phase에 들어가지 않는다.
+Configured filter와 `onError`가 계속 우선하며 already-committed 또는 aborted request는 다시 쓰지 않는다.
+전체 ownership 및 React integration 계약은
+[HTTP error representation decision](./http-error-representations.ko.md)을 참고한다.
+
## Handling Rules
| Rule | Statement | Source anchor |
| --- | --- | --- |
| Serialization boundary | HTTP 클라이언트는 `createErrorResponse(...)`가 만든 `{ error: ... }` envelope를 받습니다. | `packages/http/src/exceptions.ts` |
-| Unknown failure masking | `HttpException`이 아닌 값은 `Internal server error.` 메시지와 `INTERNAL_SERVER_ERROR`로 정규화됩니다. | `packages/http/src/dispatch/dispatch-error-policy.ts` |
-| Route miss mapping | 누락된 handler는 가공되지 않은 런타임 에러가 아니라 `NOT_FOUND`로 노출됩니다. | `packages/http/src/dispatch/dispatch-error-policy.ts` |
-| Response commit guard | 응답이 이미 committed 상태이면 `writeErrorResponse(...)`는 아무 것도 쓰지 않고 반환합니다. | `packages/http/src/dispatch/dispatch-error-policy.ts` |
+| Unknown failure masking | `HttpException`이 아닌 값은 `Internal server error.` 메시지와 `INTERNAL_SERVER_ERROR`로 정규화됩니다. | `packages/http/src/dispatch/dispatch-error-representation.ts` |
+| Route miss mapping | 누락된 handler는 가공되지 않은 런타임 에러가 아니라 `NOT_FOUND`로 노출됩니다. | `packages/http/src/dispatch/dispatch-error-representation.ts` |
+| Response commit guard | 응답이 이미 committed 상태이면 `writeErrorResponse(...)`는 아무 것도 쓰지 않고 반환합니다. | `packages/http/src/dispatch/dispatch-error-representation.ts` |
+| Representation negotiation | HTML provider가 등록된 경우에만 eligible HTTP outcome이 deterministic JSON/HTML selection을 사용합니다. | `packages/http/src/dispatch/dispatch-error-negotiation.ts` |
+| Provider fallback | Pre-commit provider failure는 configured dispatcher logger로 기록되고 원래 JSON outcome으로 한 번만 fallback합니다. | `packages/http/src/dispatch/dispatch-error-representation.ts` |
| Request correlation | dispatcher는 `requestContext.requestId`를 에러 직렬화에 전달하고, correlation middleware는 가능할 때 inbound header에서 그 값을 채웁니다. | `packages/http/src/dispatch/dispatcher.ts`, `packages/http/src/middleware/correlation.ts` |
| Binding diagnostics | 누락된 요청 필드, 잘못된 body shape, 위험한 key, 미지원 body field는 구조화된 `details`와 함께 `BAD_REQUEST`를 생성합니다. | `packages/http/src/adapters/binding.ts` |
| Validation diagnostics | DTO 유효성 검사 실패는 매핑된 issue detail을 포함한 `BadRequestException`으로 변환됩니다. | `packages/http/src/adapters/dto-validation-adapter.ts` |
diff --git a/docs/architecture/error-responses.md b/docs/architecture/error-responses.md
index 1d956b0ee..2257080a3 100644
--- a/docs/architecture/error-responses.md
+++ b/docs/architecture/error-responses.md
@@ -61,7 +61,7 @@ Built-in exception classes in `packages/http/src/exceptions.ts` currently serial
| `429` | `TOO_MANY_REQUESTS` | `TooManyRequestsException` |
| `500` | `INTERNAL_SERVER_ERROR` | `InternalServerErrorException` |
-Normalization rules in `packages/http/src/dispatch/dispatch-error-policy.ts` add these dispatcher mappings:
+Normalization rules in `packages/http/src/dispatch/dispatch-error-representation.ts` add these dispatcher mappings:
| Input failure | Output code | Output status | Rule |
| --- | --- | --- | --- |
@@ -69,14 +69,36 @@ Normalization rules in `packages/http/src/dispatch/dispatch-error-policy.ts` add
| `HandlerNotFoundError` | `NOT_FOUND` | `404` | Converted to `NotFoundException`. |
| Any other thrown value | `INTERNAL_SERVER_ERROR` | `500` | Converted to `InternalServerErrorException`. |
+## Representation Selection
+
+The envelope above remains canonical even when an application registers
+`errorRepresentation.html`. For existing `HttpException` values and route misses, HTTP first creates
+the canonical outcome and then negotiates `application/json` versus an available `text/html`
+provider. The provider receives the same `ErrorResponse` in `HttpErrorRepresentationContext`, so an
+HTML document can present the outcome without redefining its status, code, details, metadata, or
+request id.
+
+Absent `Accept` and wildcard ties select JSON. Quality values and media-range specificity are
+deterministic; a specific `q=0` rejection overrides a broader wildcard for that representation.
+When no registered offer is acceptable, HTTP emits canonical JSON `406 NOT_ACCEPTABLE` without
+invoking HTML recursively. `HEAD` preserves the selected status and headers without rendering or
+emitting a body. Provider failures fall back once to the original canonical JSON outcome.
+
+Unknown thrown values remain canonical JSON 500 and do not enter the HTML representation phase.
+Configured filters and `onError` retain precedence, and already-committed or aborted requests are
+not rewritten. See the [HTTP error representation decision](./http-error-representations.md) for the
+complete ownership and React integration contract.
+
## Handling Rules
| Rule | Statement | Source anchor |
| --- | --- | --- |
| Serialization boundary | HTTP clients receive the `{ error: ... }` envelope created by `createErrorResponse(...)`. | `packages/http/src/exceptions.ts` |
-| Unknown failure masking | Non-`HttpException` values are normalized to `INTERNAL_SERVER_ERROR` with the message `Internal server error.` | `packages/http/src/dispatch/dispatch-error-policy.ts` |
-| Route miss mapping | Missing handlers are exposed as `NOT_FOUND` instead of raw runtime errors. | `packages/http/src/dispatch/dispatch-error-policy.ts` |
-| Response commit guard | `writeErrorResponse(...)` returns without writing when the response is already committed. | `packages/http/src/dispatch/dispatch-error-policy.ts` |
+| Unknown failure masking | Non-`HttpException` values are normalized to `INTERNAL_SERVER_ERROR` with the message `Internal server error.` | `packages/http/src/dispatch/dispatch-error-representation.ts` |
+| Route miss mapping | Missing handlers are exposed as `NOT_FOUND` instead of raw runtime errors. | `packages/http/src/dispatch/dispatch-error-representation.ts` |
+| Response commit guard | `writeErrorResponse(...)` returns without writing when the response is already committed. | `packages/http/src/dispatch/dispatch-error-representation.ts` |
+| Representation negotiation | Eligible HTTP outcomes use deterministic JSON/HTML selection only when an HTML provider is registered. | `packages/http/src/dispatch/dispatch-error-negotiation.ts` |
+| Provider fallback | A pre-commit provider failure logs through the configured dispatcher logger and falls back once to the original JSON outcome. | `packages/http/src/dispatch/dispatch-error-representation.ts` |
| Request correlation | The dispatcher passes `requestContext.requestId` into error serialization, and the correlation middleware populates that value from inbound headers when available. | `packages/http/src/dispatch/dispatcher.ts`, `packages/http/src/middleware/correlation.ts` |
| Binding diagnostics | Missing request fields, invalid body shapes, dangerous keys, and unknown body fields produce `BAD_REQUEST` with structured `details`. | `packages/http/src/adapters/binding.ts` |
| Validation diagnostics | DTO validation failures are converted to `BadRequestException` with mapped issue details. | `packages/http/src/adapters/dto-validation-adapter.ts` |
diff --git a/docs/architecture/http-error-representations.ko.md b/docs/architecture/http-error-representations.ko.md
new file mode 100644
index 000000000..c44f4b235
--- /dev/null
+++ b/docs/architecture/http-error-representations.ko.md
@@ -0,0 +1,135 @@
+# HTTP Error Representation Decision
+
+
English 한국어
+
+- Status: Accepted
+- Decision date: 2026-08-03
+- Issue: [#2889](https://github.com/fluojs/fluo/issues/2889)
+- Predecessor: [React Page Render Policy Decision](./react-page-render-policies.ko.md)
+
+## Decision Summary
+
+`@fluojs/http`가 error classification, `Accept` negotiation, status, header, request scope,
+abort/commit check, `HEAD` body suppression, 최종 response write를 소유한다. Application은
+`errorRepresentation.html`을 통해 optional HTML provider 하나를 등록할 수 있다. Canonical JSON은
+framework-owned default이자 compatibility representation으로 유지된다.
+
+이 seam은 HTTP가 기존 `HttpException` 또는 unmatched route의 `HandlerNotFoundError`를 분류한 뒤에만
+적용된다. React 및 다른 document renderer는 선택된 HTML representation의 byte를 만들 수 있지만 URL을
+match하거나 status를 선택하거나 header를 변경하거나 response를 commit하지 않는다.
+
+## Public Contract and Registration
+
+`CreateDispatcherOptions.errorRepresentation`과
+`BootstrapApplicationOptions.errorRepresentation`은 `HttpErrorRepresentationOptions`를 받는다.
+
+```ts
+interface HttpErrorRepresentationOptions {
+ readonly html: HtmlErrorRepresentationProvider;
+}
+
+interface HtmlErrorRepresentationProvider {
+ canRender?(context: HttpErrorRepresentationContext): boolean | Promise;
+ render(context: HttpErrorRepresentationContext): string | Uint8Array | Promise;
+}
+```
+
+`canRender(...)`는 optional application/route constraint다. 생략하면 HTML을 사용할 수 있다. `false`를
+반환하면 해당 outcome의 offer에서 HTML을 제거한다. 두 provider method 모두 async일 수 있으며 active
+dispatch lifetime 안에서 실행된다.
+
+`HttpErrorRepresentationContext`는 다음 값을 포함한다.
+
+- 분류된 `HttpException`
+- canonical JSON `ErrorResponse`
+- 정규화된 `FrameworkRequest`와 optional request id
+- active request-scope container
+- failure 전에 matching이 성공했다면 matched `HandlerDescriptor`
+
+Context는 의도적으로 `FrameworkResponse`를 제외한다. Provider는 request-scoped application dependency를
+resolve할 수 있지만 status, header, body suppression, commit authority는 HTTP가 유지한다. Unmatched route에는
+`handler`가 없다. React page descriptor, page catalog, layout, metadata, Suspense fallback, URL-prefix ancestry를
+조회하지 않는다.
+
+## Classification and Phase Boundaries
+
+| Failure | Representation behavior |
+| --- | --- |
+| Unmatched method/path | HTTP matcher가 `HandlerNotFoundError`를 throw하고 negotiation 전에 기존 `NotFoundException` outcome으로 변환한다. |
+| Middleware, DTO binding/validation, guard, interceptor, handler의 uncommitted `HttpException` | 원래 pipeline phase와 matched-handler identity를 보존한 뒤 같은 HTTP-owned negotiation을 사용한다. |
+| 알 수 없는 throw 값 | Masked canonical JSON `500 INTERNAL_SERVER_ERROR`로 유지되며 HTML representation eligible로 재분류하지 않는다. |
+| React pre-commit shell failure | React SSR pre-commit diagnostic과 canonical JSON 500 path를 유지하며 HTML error provider에 다시 진입하지 않는다. |
+| React post-shell recoverable error | Shell이 이미 commit되었을 수 있으므로 diagnostic-only로 유지한다. |
+| Request abort | Representation work를 시작하거나 재시작하지 않고 fallback commit 없이 중단한다. |
+| Client React error | Hydrated application과 browser runtime이 계속 소유한다. |
+
+설정된 runtime exception filter와 dispatcher `onError`는 default writer보다 먼저 실행된다. 이들이 처리하거나
+response를 commit하면 representation provider를 호출하지 않는다.
+
+## Deterministic `Accept` Negotiation
+
+Eligible outcome에 HTML provider가 등록되어 있으면 HTTP는 canonical `application/json`과 조건부
+`text/html`을 offer한다.
+
+| Request | Result |
+| --- | --- |
+| `Accept` 없음 | Canonical JSON. |
+| `application/json` | Canonical JSON. |
+| `text/html` | `canRender`가 없거나 `true`면 HTML. 그렇지 않으면 JSON도 허용될 때 canonical JSON, 허용되지 않으면 JSON `406`. |
+| Weighted range | 가장 높은 quality가 우선한다. 가장 specific한 matching range가 각 offer의 quality를 결정한다. |
+| 같은 quality와 specificity | Deterministic server tie에서 canonical JSON이 우선한다. |
+| `*/*` | 두 representation이 tie이므로 canonical JSON이 우선한다. |
+| Specific `q=0` range와 broader wildcard | 해당 media type에는 specific rejection이 우선한다. HTML이 reject되면 HTML provider를 조회하지 않는다. |
+| Acceptable offer 없음 | Canonical JSON `406 NOT_ACCEPTABLE`. 406을 위해 HTML provider를 재귀 호출하지 않는다. |
+
+Successful-route `@Produces(...)` metadata와 `ContentNegotiationOptions`는 error representation ownership을
+부여하지 않는다. Error availability는 provider의 `canRender(...)` constraint를 통해 application이 소유한다.
+
+## Response Commit and Fallback Rules
+
+1. HTTP는 classification, provider work, write 전에 abort와 `response.committed`를 검사한다.
+2. JSON write는 status, code, message, details, metadata, request id를 포함한 canonical `ErrorResponse`를
+ 보존한다.
+3. Negotiated response는 `Content-Type`을 설정하고 기존 `Vary` 값을 제거하지 않은 채 `Accept`를 추가한다.
+4. HTML provider는 trusted complete document text 또는 byte만 반환한다. Application은 request-derived 및
+ error-derived content를 escape하거나 sanitize해야 한다. HTTP는 provider output을 다시 쓰지 않고 classified
+ status와 `text/html; charset=utf-8`을 적용한다.
+5. `HEAD`는 선택된 status/header를 유지하고 `render(...)`를 호출하지 않으며 body를 보내지 않는다.
+6. 이미 committed된 response는 절대 다시 쓰지 않는다.
+7. `canRender(...)` 또는 `render(...)`가 commit 전에 throw하면 configured dispatcher logger가 provider
+ failure를 기록하고 HTTP는 원래 canonical JSON outcome으로 한 번만 fallback한다. Fallback은
+ representation selection을 우회하므로 재귀할 수 없다.
+8. Provider work 중 request가 abort되면 provider failure를 log하거나 fallback response를 commit하지 않는다.
+9. Response writer `send(...)` 또는 stream/write failure는 provider failure가 아니다. HTML body나 canonical JSON
+ fallback을 retry하지 않고 그대로 propagate한다.
+
+## React Integration
+
+`@fluojs/react`는 application `ReactErrorDocumentRenderer`를 HTTP provider seam에 연결하는 optional adapter
+`createReactErrorRepresentationProvider(...)`를 노출한다. 이미 분류된 context를 받아 하나의
+`ReactServerEntry`를 만들고 complete Web Stream을 buffer한 뒤 HTTP에 byte를 반환한다.
+
+Adapter는 `ReactServerEntry.status`와 `ReactServerEntry.headers`를 무시한다. 이 field는 successful page
+response용이며 HTTP error outcome을 override할 수 없다. Matching을 수행하지 않고 application page renderer나
+route-local render policy를 호출하지 않는다. Root는 runtime-neutral 상태를 유지한다. `react-dom/server`는
+lazy하게 resolve되며 Node.js, Vite, browser, matcher, RSC, file-routing dependency를 추가하지 않는다.
+
+## Preserved Contracts and Non-goals
+
+- Canonical JSON은 API client와 HTML provider 미등록 상태에서 변경되지 않는다.
+- Route grammar, matching precedence, DTO binding, middleware, guard, interceptor, request scope, successful
+ response negotiation, `onError` precedence는 변경되지 않는다.
+- React-owned matcher, catch-all, file router, route tree, page-local `notFound()`, URL-prefix layout ancestry,
+ SPA fallback, client cache, prefetch, RSC graduation을 추가하지 않는다.
+- HTTP failure, React shell failure, post-shell recoverable error, request abort, browser error를 하나의 generic
+ boundary로 합치지 않는다.
+- Provider는 이미 committed된 response를 다시 쓰거나 abort 후 rendering을 재시작할 수 없다.
+
+## Verification Evidence
+
+- HTTP dispatcher test가 classification, JSON compatibility, weighted range, wildcard, specific `q=0`,
+ constraint, 406, provider failure, `HEAD`, abort, commit guard, request scope를 검증한다.
+- React integration test가 buffered document, 무시되는 entry status/header, unmatched-route ownership,
+ route-policy isolation, provider failure fallback, shell-phase separation을 검증한다.
+- Shared network/Web portability harness가 Node.js, Express, Fastify, Bun, Deno, Cloudflare Workers에서 JSON,
+ HTML, `HEAD`, 406, already-committed response 동작을 검증한다.
diff --git a/docs/architecture/http-error-representations.md b/docs/architecture/http-error-representations.md
new file mode 100644
index 000000000..3e6b29e8b
--- /dev/null
+++ b/docs/architecture/http-error-representations.md
@@ -0,0 +1,141 @@
+# HTTP Error Representation Decision
+
+English 한국어
+
+- Status: Accepted
+- Decision date: 2026-08-03
+- Issue: [#2889](https://github.com/fluojs/fluo/issues/2889)
+- Predecessor: [React Page Render Policy Decision](./react-page-render-policies.md)
+
+## Decision Summary
+
+`@fluojs/http` owns error classification, `Accept` negotiation, status, headers, request scope,
+abort/commit checks, `HEAD` body suppression, and the final response write. Applications may
+register one optional HTML provider through `errorRepresentation.html`. Canonical JSON remains the
+framework-owned default and compatibility representation.
+
+The seam applies only after HTTP has classified either an existing `HttpException` or an unmatched
+route's `HandlerNotFoundError`. React and other document renderers may produce bytes for the selected
+HTML representation, but they do not match URLs, choose status, mutate headers, or commit the
+response.
+
+## Public Contract and Registration
+
+`CreateDispatcherOptions.errorRepresentation` and
+`BootstrapApplicationOptions.errorRepresentation` accept `HttpErrorRepresentationOptions`:
+
+```ts
+interface HttpErrorRepresentationOptions {
+ readonly html: HtmlErrorRepresentationProvider;
+}
+
+interface HtmlErrorRepresentationProvider {
+ canRender?(context: HttpErrorRepresentationContext): boolean | Promise;
+ render(context: HttpErrorRepresentationContext): string | Uint8Array | Promise;
+}
+```
+
+`canRender(...)` is an optional application or route constraint. Omitting it means HTML is
+available. Returning `false` removes HTML from the offers for that outcome. Both provider methods
+may be asynchronous and run inside the active dispatch lifetime.
+
+`HttpErrorRepresentationContext` contains:
+
+- the classified `HttpException`;
+- its canonical JSON `ErrorResponse`;
+- the normalized `FrameworkRequest` and optional request id;
+- the active request-scope container;
+- the matched `HandlerDescriptor` when matching succeeded before the failure.
+
+The context intentionally omits `FrameworkResponse`. A provider may resolve request-scoped
+application dependencies, but HTTP retains status, header, body-suppression, and commit authority.
+An unmatched route has no `handler`; no React page descriptor, page catalog, layout, metadata,
+Suspense fallback, or URL-prefix ancestry is consulted.
+
+## Classification and Phase Boundaries
+
+| Failure | Representation behavior |
+| --- | --- |
+| Unmatched method/path | The HTTP matcher throws `HandlerNotFoundError`, which becomes the existing `NotFoundException` outcome before negotiation. |
+| Uncommitted `HttpException` from middleware, DTO binding/validation, guards, interceptors, or a handler | Uses the same HTTP-owned negotiation after the original pipeline phase and matched-handler identity are preserved. |
+| Unknown thrown value | Remains the masked canonical JSON `500 INTERNAL_SERVER_ERROR`; it is not reclassified as HTML-representation eligible. |
+| React pre-commit shell failure | Remains the React SSR pre-commit diagnostic plus canonical JSON 500 path; the HTML error provider is not re-entered. |
+| React post-shell recoverable error | Remains diagnostic-only because the shell may already be committed. |
+| Request abort | Stops without starting or restarting representation work and without a fallback commit. |
+| Client React error | Remains owned by the hydrated application and browser runtime. |
+
+Configured runtime exception filters and dispatcher `onError` run before the default writer. If
+they handle or commit the response, the representation provider is not invoked.
+
+## Deterministic `Accept` Negotiation
+
+When an HTML provider is registered for an eligible outcome, HTTP offers canonical
+`application/json` and conditionally `text/html`:
+
+| Request | Result |
+| --- | --- |
+| No `Accept` | Canonical JSON. |
+| `application/json` | Canonical JSON. |
+| `text/html` | HTML when `canRender` is absent or returns `true`; otherwise canonical JSON if JSON is also acceptable, or JSON `406` if it is not. |
+| Weighted ranges | Highest quality wins; the most specific matching range determines an offer's quality. |
+| Equal quality and specificity | Canonical JSON wins the deterministic server tie. |
+| `*/*` | Both representations tie, so canonical JSON wins. |
+| A specific `q=0` range plus a broader wildcard | The specific rejection wins for that media type; the HTML provider is not consulted when HTML is rejected. |
+| No acceptable offer | Canonical JSON `406 NOT_ACCEPTABLE`; the HTML provider is not recursively invoked for the 406. |
+
+Successful-route `@Produces(...)` metadata and `ContentNegotiationOptions` do not grant error
+representation ownership. Error availability is application-owned through the provider's
+`canRender(...)` constraint.
+
+## Response Commit and Fallback Rules
+
+1. HTTP checks abort and `response.committed` before classification, provider work, and writing.
+2. JSON writes preserve the canonical `ErrorResponse`, including status, code, message, details,
+ metadata, and request id.
+3. Negotiated responses set `Content-Type` and add `Accept` to `Vary` without removing existing
+ `Vary` values.
+4. HTML providers return trusted complete document text or bytes. Applications must escape or
+ sanitize request-derived and error-derived content; HTTP applies the classified status and
+ `text/html; charset=utf-8` without rewriting provider output.
+5. `HEAD` keeps the selected status and headers, does not call `render(...)`, and sends no body.
+6. Already-committed responses are never rewritten.
+7. If `canRender(...)` or `render(...)` throws before commit, the configured dispatcher logger
+ records the provider failure and HTTP falls back once to the original canonical JSON outcome.
+ The fallback bypasses representation selection, so it cannot recurse.
+8. If the request aborts during provider work, HTTP does not log a provider failure or commit a
+ fallback response.
+9. Response writer `send(...)` or stream/write failures are not provider failures. They propagate
+ unchanged without retrying the HTML body or canonical JSON fallback.
+
+## React Integration
+
+`@fluojs/react` exposes `createReactErrorRepresentationProvider(...)` as an optional adapter from an
+application `ReactErrorDocumentRenderer` to the HTTP provider seam. It accepts an already-classified
+context, creates one `ReactServerEntry`, and buffers the complete Web Stream before returning bytes
+to HTTP.
+
+The adapter ignores `ReactServerEntry.status` and `ReactServerEntry.headers`; those fields belong to
+successful page responses and cannot override the HTTP error outcome. It performs no matching and
+does not invoke the application page renderer or route-local render policies. The root remains
+runtime-neutral: `react-dom/server` is resolved lazily, and no Node.js, Vite, browser, matcher, RSC,
+or file-routing dependency is added.
+
+## Preserved Contracts and Non-goals
+
+- Canonical JSON remains unchanged for API clients and when no HTML provider is registered.
+- Route grammar, matching precedence, DTO binding, middleware, guards, interceptors, request scopes,
+ successful response negotiation, and `onError` precedence are unchanged.
+- No React-owned matcher, catch-all, file router, route tree, page-local `notFound()`, URL-prefix
+ layout ancestry, SPA fallback, client cache, prefetch, or RSC graduation is introduced.
+- No generic boundary merges HTTP failures, React shell failures, post-shell recoverable errors,
+ request aborts, and browser errors.
+- No provider can rewrite an already-committed response or restart rendering after abort.
+
+## Verification Evidence
+
+- HTTP dispatcher tests cover classification, JSON compatibility, weighted ranges, wildcards,
+ specific `q=0`, constraints, 406, provider failure, `HEAD`, abort, commit guards, and request scope.
+- React integration tests cover buffered documents, ignored entry status/headers, unmatched-route
+ ownership, route-policy isolation, provider failure fallback, and shell-phase separation.
+- Shared network and Web portability harnesses cover Node.js, Express, Fastify, Bun, Deno, and
+ Cloudflare Workers behavior for JSON, HTML, `HEAD`, 406, and already-committed responses.
diff --git a/docs/architecture/http-runtime.ko.md b/docs/architecture/http-runtime.ko.md
index b0b30d2e8..3768952fc 100644
--- a/docs/architecture/http-runtime.ko.md
+++ b/docs/architecture/http-runtime.ko.md
@@ -18,9 +18,21 @@
10. `invokeControllerHandler(...)`는 request container에서 controller를 해석하고, binder로 선언된 DTO를 바인딩하며, route가 `request` metadata를 선언한 경우 `HttpDtoValidationAdapter`로 DTO 입력을 검증한다.
11. controller method는 `(input, requestContext)`를 받고 handler 결과를 반환한다.
12. 성공한 non-SSE 결과는 `writeSuccessResponse(...)`를 통해 기록되며, 여기서 redirect metadata, route header, formatter 선택, 기본 성공 status 규칙이 적용된다. Dispatcher는 handler 실행 전후에 `signal`과 `isAborted()`를 검사하고 어느 cancellation surface든 authoritative하게 처리하므로 `false` probe가 aborted signal을 가리지 않으며 abort된 요청은 뒤늦게 성공 응답을 commit하지 않는다.
-13. 어느 단계에서든 예외가 발생하면 dispatcher는 설정된 경우 `onError`를 실행하고, 그렇지 않으면 `writeErrorResponse(...)`가 기본 에러 응답을 기록한다.
+13. 어느 단계에서든 예외가 발생하면 dispatcher는 설정된 경우 `onError`를 실행한다. 그렇지 않으면 `writeErrorResponse(...)`가 failure를 분류하고 canonical JSON을 기록하거나, eligible `HttpException` 및 route-miss outcome에 대해 configured HTTP-owned error representation negotiation을 수행한다.
14. dispatcher는 항상 `onRequestFinish`를 호출한다. request scope가 생성되었거나 lazy promotion 되었다면 요청이 끝나기 전에 해당 isolated request-scoped container를 dispose하며, 끝까지 승격되지 않은 singleton-only fast-path 요청은 root container를 dispose하지 않는다.
+## Error Representation Boundary
+
+- Canonical JSON은 default error response이며 HTML provider가 등록되지 않으면 유일한 representation이다.
+- Application은 `createDispatcher(...)` 또는 runtime bootstrap의 `errorRepresentation.html`로 optional HTML을 등록한다. Provider는 classified exception, canonical JSON, request, optional matched handler, request id, active request-scope container를 받지만 response mutation authority는 받지 않는다.
+- `HandlerNotFoundError`는 `Accept` negotiation 전에 기존 HTTP 404 outcome으로 변환된다. Middleware, DTO binding/validation, guard, interceptor, handler의 uncommitted `HttpException` failure는 diagnostic phase를 합치지 않고 같은 selection을 사용한다.
+- Unknown failure, React shell failure, post-shell recoverable error, request abort, browser error는 별도 owner를 유지하며 HTML provider path에 들어가지 않는다.
+- HTTP가 deterministic JSON/HTML quality 및 specificity selection, JSON tie-break, JSON 406 response, `Vary: Accept`, status/content type, `HEAD` body suppression, abort check, already-committed response 보호를 소유한다.
+- Provider failure는 negotiation에 다시 진입하지 않고 원래 canonical JSON outcome으로 한 번만 fallback한다.
+
+전체 ownership, negotiation, React adapter, fallback 계약은
+[HTTP error representation decision](./http-error-representations.ko.md)에 기록되어 있다.
+
## Request Context Isolation
- Runtime-specific root entry는 `runWithRequestContext(...)`를 노출하기 전에 host async-context storage를 사용할 수 있게 한다. Node와 Bun은 `node:async_hooks` constructor를 등록하고 portable entry는 Node built-in import 없이 유지된다.
diff --git a/docs/architecture/http-runtime.md b/docs/architecture/http-runtime.md
index 0011e49dc..dc003819d 100644
--- a/docs/architecture/http-runtime.md
+++ b/docs/architecture/http-runtime.md
@@ -18,9 +18,21 @@ This document defines the current request execution contract implemented by `@fl
10. `invokeControllerHandler(...)` resolves the controller from the request container, binds the declared DTO through the binder, and validates DTO input through `HttpDtoValidationAdapter` when the route declares `request` metadata.
11. The controller method receives `(input, requestContext)` and returns the handler result.
12. Successful non-SSE results are written through `writeSuccessResponse(...)`, which applies redirect metadata, route headers, formatter selection, and default success status rules. The dispatcher checks `signal` and `isAborted()` before and after handler execution, treating either cancellation surface as authoritative so a `false` probe cannot mask an aborted signal and aborted requests do not commit late success responses.
-13. If any stage throws, the dispatcher runs `onError` when configured, otherwise `writeErrorResponse(...)` writes the default error response.
+13. If any stage throws, the dispatcher runs `onError` when configured. Otherwise `writeErrorResponse(...)` classifies the failure and either writes canonical JSON or, for eligible `HttpException` and route-miss outcomes, performs the configured HTTP-owned error representation negotiation.
14. The dispatcher always emits `onRequestFinish`. When a request scope was created or lazily promoted, it disposes that isolated request-scoped container before the request ends; singleton-only fast-path requests that never promote do not dispose the root container.
+## Error Representation Boundary
+
+- Canonical JSON remains the default error response and the only representation when no HTML provider is registered.
+- Applications register optional HTML through `errorRepresentation.html` on `createDispatcher(...)` or runtime bootstrap. The provider receives the classified exception, canonical JSON, request, optional matched handler, request id, and active request-scope container, but no response mutation authority.
+- `HandlerNotFoundError` is converted to the existing HTTP 404 outcome before `Accept` negotiation. Uncommitted `HttpException` failures from middleware, DTO binding/validation, guards, interceptors, and handlers use the same selection without merging those diagnostic phases.
+- Unknown failures, React shell failures, post-shell recoverable errors, request aborts, and browser errors retain their separate owners and do not enter the HTML provider path.
+- HTTP owns deterministic JSON/HTML quality and specificity selection, JSON tie-breaking, JSON 406 responses, `Vary: Accept`, status/content type, `HEAD` body suppression, abort checks, and already-committed response protection.
+- A provider failure falls back once to the original canonical JSON outcome without re-entering negotiation.
+
+The complete ownership, negotiation, React adapter, and fallback contract is recorded in the
+[HTTP error representation decision](./http-error-representations.md).
+
## Request Context Isolation
- Runtime-specific root entries make host async-context storage available before exposing `runWithRequestContext(...)`: Node and Bun register the `node:async_hooks` constructor, while portable entries remain free of Node built-in imports.
diff --git a/docs/architecture/react-page-render-policies.ko.md b/docs/architecture/react-page-render-policies.ko.md
index 043066fc2..70ac4f217 100644
--- a/docs/architecture/react-page-render-policies.ko.md
+++ b/docs/architecture/react-page-render-policies.ko.md
@@ -6,6 +6,7 @@
- Decision date: 2026-07-29
- Issue: [#2856](https://github.com/fluojs/fluo/issues/2856)
- Predecessor: [React Render Policy Decorator Decision](./react-render-policy-decorators.ko.md)
+- Successor: [HTTP Error Representation Decision](./http-error-representations.ko.md)
## Decision Summary
@@ -16,7 +17,7 @@ not-found response path를 추가하지 않는다.
| --- | --- | --- | --- | --- |
| Page metadata presentation | Typed resolution과 React element helper를 포함한 `@PageMetadata(factory)`로 **채택**한다. | Application `ReactPageRenderer`가 document-head placement를 소유한다. `@fluojs/react`는 declaration order, deterministic data composition, safe React element creation만 소유한다. | Authoritative HTTP matching 이후 renderer가 `ReactRenderPolicies`의 ordered metadata factory를 받고 active render context와 함께 `resolveReactPageMetadata(...)`를 호출한다. | Matched `@Path(...)` handler가 valid `ReactElement`를 반환한 뒤, renderer가 `ReactServerEntry`를 반환하기 전에만 실행한다. Resolution failure는 uncommitted `http-pipeline` failure로 남는다. |
| Generic error presentation | **거부**한다. Phase-specific presentation work는 **유예**한다. | `@fluojs/http`, React SSR diagnostic, application React tree, browser가 각자의 기존 phase를 유지한다. | 없음. `@ErrorPresentation(...)` export나 placeholder를 추가하지 않는다. | Handler/HTTP, pre-commit shell, post-shell recoverable, request-abort, client React failure를 계속 구분한다. |
-| Page-local not-found presentation | **거부**한다. HTTP-owned not-found outcome seam은 별도 작업에서 재검토할 수 있다. | HTTP matcher, `HandlerNotFoundError` conversion, application `onError`, HTTP error writer가 authoritative 상태를 유지한다. | 없음. Unmatched request는 React page metadata를 선택하거나 `ReactPageRenderer`를 호출하지 않는다. | Route miss와 handler가 throw한 `NotFoundException`은 React page response commit 전 HTTP error path에 남는다. |
+| Page-local not-found presentation | **거부**한다. 후속 HTTP-owned representation seam은 global/application-scoped이며 page-local이 아니다. | HTTP matcher, `HandlerNotFoundError` conversion, application `onError`, HTTP error writer가 authoritative 상태를 유지한다. | 없음. Unmatched request는 React page metadata를 선택하거나 `ReactPageRenderer`를 호출하지 않는다. Optional React HTML은 HTTP가 선택한 뒤에만 adapt된다. | Route miss와 handler가 throw한 `NotFoundException`은 React page response commit 전 HTTP error path에 남는다. |
## Accepted Metadata Policy
@@ -134,17 +135,18 @@ application `onError` 또는 일반 HTTP error writer가 처리하는 HTTP excep
result가 되지 않는다. Route miss와 handler-thrown not-found 양쪽 모두 metadata factory, layout, Suspense
fallback, page renderer를 실행하지 않는다.
-향후 HTML not-found presentation은 global error handling과 모든 adapter에 일관되게 제공되는 HTTP-owned
-typed not-found outcome에서 시작해야 한다. React를 optional response representation으로 사용하기 전에
-content negotiation, API/document selection, filter precedence, request scope, commit behavior를 정의해야
-한다. 이는 page render policy가 아니라 별도 HTTP contract다.
+후속 [HTTP error representation decision](./http-error-representations.ko.md)은 이 HTTP-owned typed
+outcome에서 시작하고 React를 optional representation으로 사용하기 전에 content negotiation,
+API/document selection, filter precedence, request scope, commit behavior를 정의한다. 이 rejection은
+바뀌지 않는다. 해당 seam은 page render policy가 아니라 별도 global/application HTTP contract다.
## Preserved Contracts
- `PageLayout` order와 `SuspenseFallback` nearest-wins behavior는 바뀌지 않는다.
- `ReactRenderContext`와 request-scope container identity는 바뀌지 않는다.
- HTTP route grammar, matcher precedence, conflict, param, versioning, DTO binding, middleware, guard,
- interceptor, filter, not-found conversion, error writing은 바뀌지 않는다.
+ interceptor, filter, not-found conversion은 HTTP-owned 상태를 유지한다. Successor decision은 해당 owner에
+ post-classification representation selection만 추가한다.
- Direct `ReactServerEntry`와 non-React value는 계속 application page renderer를 우회한다.
- Success metadata는 matched handler가 완료된 후, renderer가 entry를 반환하기 전, SSR shell creation과
response commit 전에만 resolve된다.
diff --git a/docs/architecture/react-page-render-policies.md b/docs/architecture/react-page-render-policies.md
index 9de494b2d..f6912bacf 100644
--- a/docs/architecture/react-page-render-policies.md
+++ b/docs/architecture/react-page-render-policies.md
@@ -6,6 +6,7 @@
- Decision date: 2026-07-29
- Issue: [#2856](https://github.com/fluojs/fluo/issues/2856)
- Predecessor: [React Render Policy Decorator Decision](./react-render-policy-decorators.md)
+- Successor: [HTTP Error Representation Decision](./http-error-representations.md)
## Decision Summary
@@ -16,7 +17,7 @@ or not-found response path.
| --- | --- | --- | --- | --- |
| Page metadata presentation | **Accepted** as `@PageMetadata(factory)` plus typed resolution and React element helpers. | The application `ReactPageRenderer` owns document-head placement. `@fluojs/react` owns only declaration ordering, deterministic data composition, and safe React element creation. | The renderer receives ordered metadata factories in `ReactRenderPolicies` after authoritative HTTP matching and calls `resolveReactPageMetadata(...)` with the active render context. | Runs only after a matched `@Path(...)` handler returns a valid `ReactElement` and before the renderer returns `ReactServerEntry`. Resolution failures remain uncommitted `http-pipeline` failures. |
| Generic error presentation | **Rejected**. Phase-specific presentation work is **deferred**. | `@fluojs/http`, React SSR diagnostics, the application React tree, and the browser each retain their existing phase. | None. No `@ErrorPresentation(...)` export or placeholder is added. | Handler/HTTP, pre-commit shell, post-shell recoverable, request-abort, and client React failures remain distinct. |
-| Page-local not-found presentation | **Rejected**. An HTTP-owned not-found outcome seam may be reconsidered separately. | The HTTP matcher, `HandlerNotFoundError` conversion, application `onError`, and the HTTP error writer remain authoritative. | None. An unmatched request never selects React page metadata or calls `ReactPageRenderer`. | Route misses and handler-thrown `NotFoundException` values stay on the HTTP error path before any React page response commit. |
+| Page-local not-found presentation | **Rejected**. The later HTTP-owned representation seam is global/application-scoped, not page-local. | The HTTP matcher, `HandlerNotFoundError` conversion, application `onError`, and the HTTP error writer remain authoritative. | None. An unmatched request never selects React page metadata or calls `ReactPageRenderer`; optional React HTML is adapted only after HTTP selects it. | Route misses and handler-thrown `NotFoundException` values stay on the HTTP error path before any React page response commit. |
## Accepted Metadata Policy
@@ -140,10 +141,10 @@ an HTTP exception handled by application `onError` or the normal HTTP error writ
a successful React page result. No metadata factory, layout, Suspense fallback, or page renderer runs
for either route-miss case.
-Any future HTML not-found presentation must start from an HTTP-owned, typed not-found outcome that is
-available consistently to global error handling and every adapter. It must define content
-negotiation, API versus document selection, filter precedence, request scope, and commit behavior
-before React can be one optional response representation. That is a separate HTTP contract, not a
+The later [HTTP error representation decision](./http-error-representations.md) starts from that
+HTTP-owned typed outcome and defines content negotiation, API versus document selection, filter
+precedence, request scope, and commit behavior before React can become one optional representation.
+It does not change this rejection: the seam is a separate global/application HTTP contract, not a
page render policy.
## Preserved Contracts
@@ -151,7 +152,8 @@ page render policy.
- `PageLayout` order and `SuspenseFallback` nearest-wins behavior are unchanged.
- `ReactRenderContext` and request-scope container identity are unchanged.
- HTTP route grammar, matcher precedence, conflicts, params, versioning, DTO binding, middleware,
- guards, interceptors, filters, not-found conversion, and error writing are unchanged.
+ guards, interceptors, filters, and not-found conversion remain HTTP-owned. The successor decision
+ adds only post-classification representation selection at that owner.
- Direct `ReactServerEntry` and non-React values still bypass the application page renderer.
- Success metadata is still resolved only after the matched handler completes, before the renderer
returns the entry, and before SSR shell creation or response commit.
diff --git a/packages/http/README.ko.md b/packages/http/README.ko.md
index 6a1bf611e..07907c583 100644
--- a/packages/http/README.ko.md
+++ b/packages/http/README.ko.md
@@ -10,6 +10,7 @@
- [사용 시점](#사용-시점)
- [빠른 시작](#빠른-시작)
- [주요 패턴](#주요-패턴)
+- [HTTP Error Representations](#http-error-representations)
- [요청 정리와 런타임 이식성](#요청-정리와-런타임-이식성)
- [공개 API](#공개-api)
- [관련 패키지](#관련-패키지)
@@ -110,6 +111,60 @@ function someDeepHelper() {
`runWithRequestContext(...)`는 호스트가 `globalThis.AsyncLocalStorage` 또는 `node:async_hooks` 모듈로 `AsyncLocalStorage`를 제공할 때 활성 컨텍스트를 `await` 이후까지 보존합니다. 루트 `@fluojs/http` export는 async-context storage를 probe하거나 instantiate하지 않고 runtime-specific entrypoint를 선택합니다. Node와 Bun은 module initialization 중 host constructor를 등록하고, Deno, worker, browser, default entry는 Node built-in import 없이 유지됩니다. Request-local store 자체는 첫 사용 시점에 계속 lazy하게 생성됩니다. Promise를 반환하는 non-async callback은 동기 호출, 반환, throw 동작을 유지하고, 반환한 promise가 settle될 때까지 continuation에서 바인딩된 context를 보존합니다. Helper는 `Promise.prototype.then`을 교체하지 않으므로 관련 없는 promise continuation이 request를 capture하지 않습니다. 비동기 컨텍스트 primitive가 없는 호스트는 awaited work가 재개되기 전에 context를 지우는 synchronous-only fallback을 사용합니다.
+## HTTP Error Representations
+
+Canonical JSON이 default error response로 유지된다. Browser request에 API client 동작을 바꾸지 않고 complete
+error/not-found document를 제공하려면 runtime bootstrap에 optional application-owned HTML provider를 등록한다.
+
+```ts
+import type { HttpErrorRepresentationOptions } from '@fluojs/http';
+import { bootstrapApplication } from '@fluojs/runtime';
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+const errorRepresentation = {
+ html: {
+ canRender({ request }) {
+ return request.method === 'GET' || request.method === 'HEAD';
+ },
+ render({ json }) {
+ return `${json.error.status}: ${escapeHtml(json.error.message)}`;
+ },
+ },
+} satisfies HttpErrorRepresentationOptions;
+
+const app = await bootstrapApplication({
+ errorRepresentation,
+ rootModule: AppModule,
+});
+```
+
+HTTP가 representation selection 전에 outcome을 분류한다. Route miss는 기존 404 outcome이 되고 middleware,
+DTO binding/validation, guard, interceptor, handler의 uncommitted `HttpException`은 같은 seam을 사용한다.
+Provider는 classified exception, canonical `ErrorResponse`, request, optional matched handler, request id, active
+request-scope container를 받는다. `FrameworkResponse`는 받지 않으므로 status, header, `HEAD`, abort, commit
+ownership은 dispatcher에 남는다.
+
+Provider return value는 application이 책임지는 trusted HTML이다. fluo는 이를 escape하거나 sanitize하지 않는다.
+예제의 `json.error.message`처럼 request-derived 또는 error-derived value를 interpolation 전에 모두 HTML escape하거나,
+text-node contract가 해당 escape를 수행하는 rendering framework를 사용해야 한다.
+
+`Accept` negotiation은 deterministic하다. `Accept`가 없거나 wildcard/tie이면 JSON을 선택하고 quality와
+specificity가 `application/json`과 available `text/html` 사이를 선택하며 unsupported range는 canonical JSON
+406을 만든다. `canRender(...)`로 application 또는 matched handler별 HTML availability를 제한할 수 있다.
+Provider failure는 원래 canonical JSON outcome으로 한 번만 fallback하며 committed 또는 aborted request는
+다시 쓰지 않는다. Response writer `send(...)` 또는 stream/write failure는 그대로 propagate하며 두 번째 canonical
+JSON write를 시작하지 않는다. HTTP가 `Accept`를 추가할 때 기존 native `Vary` 값도 보존한다. Successful-route
+`@Produces(...)` metadata는 error representation을 제어하지 않는다. 전체 phase/fallback 계약은
+[HTTP error representation decision](../../docs/architecture/http-error-representations.ko.md)을 참고한다.
+
### 프록시 뒤의 속도 제한
`createRateLimitMiddleware(...)`는 기본적으로 raw socket `remoteAddress`만으로 클라이언트 식별자를 해석합니다. `Forwarded`, `X-Forwarded-For`, `X-Real-IP`를 신뢰하려면 해당 헤더를 신뢰 가능한 프록시가 덮어쓰는 환경에서만 `trustProxyHeaders: true`를 명시적으로 켜세요. 어댑터가 신뢰 가능한 프록시 체인도 raw socket 식별자도 제공하지 않는다면 공유 fallback 버킷에 의존하지 말고 명시적인 `keyResolver`를 설정하세요.
@@ -206,7 +261,7 @@ Multipart upload를 parse하는 어댑터는 shared HTTP contract를 adapter-spe
- **바인딩 데코레이터**: `FromBody`, `FromQuery`, `FromPath`, `FromHeader`, `FromCookie`, `RequestDto`, `Optional`, `Convert`
- **실행 데코레이터**: `UseGuards`, `UseInterceptors`, `HttpCode`, `Version`, `Header`, `Redirect`, `Produces`
- **요청/응답 및 컨텍스트 타입**: `RequestContext`, `Principal`, `ContextKey`, `ControllerHandler`, `FrameworkRequest`, `FrameworkRequestFile`, `FrameworkResponse`, `FrameworkResponseStream`, `FrameworkResponseCompression`, `FrameworkResponseCompressionWriteOptions`, `SseResponse`, `SseMessage`
-- **디스패처, 라우팅, 협상 타입**: `Dispatcher`, `CreateDispatcherOptions`, `ErrorHandler`, `DispatcherLogger`, `HandlerMapping`, `HandlerMetadata`, `HandlerDescriptor`, `HandlerMatch`, `HandlerSource`, `RouteDefinition`, `HttpMethod`, `VersioningType`, `VersioningOptions`, `VersioningExtractor`, `VersioningExtractorResult`, `ContentNegotiationOptions`, `ResponseFormatter`, `FastPathEligibility`, `FastPathStats`
+- **디스패처, 라우팅, 협상 타입**: `Dispatcher`, `CreateDispatcherOptions`, `ErrorHandler`, `DispatcherLogger`, `HandlerMapping`, `HandlerMetadata`, `HandlerDescriptor`, `HandlerMatch`, `HandlerSource`, `RouteDefinition`, `HttpMethod`, `VersioningType`, `VersioningOptions`, `VersioningExtractor`, `VersioningExtractorResult`, `ContentNegotiationOptions`, `ResponseFormatter`, `HttpErrorRepresentationContext`, `HtmlErrorRepresentationProvider`, `HttpErrorRepresentationOptions`, `FastPathEligibility`, `FastPathStats`
- **파이프라인 계약 타입**: `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`, `UnsupportedHttpAdapterRealtimeCapability`, `createNoopHttpApplicationAdapter`, `createServerBackedHttpAdapterRealtimeCapability`, `createUnsupportedHttpAdapterRealtimeCapability`, `createFetchStyleHttpAdapterRealtimeCapability`
- **예외와 오류**: `HttpExceptionDetail`, `HttpExceptionOptions`, `ErrorResponse`, `HttpException`, `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `NotAcceptableException`, `TooManyRequestsException`, `InternalServerErrorException`, `PayloadTooLargeException`, `createErrorResponse`, `RouteConflictError`, `InvalidRoutePathError`, `HandlerNotFoundError`, `RequestAbortedError`
diff --git a/packages/http/README.md b/packages/http/README.md
index d5be3e314..63b09699d 100644
--- a/packages/http/README.md
+++ b/packages/http/README.md
@@ -10,6 +10,7 @@ The HTTP execution layer that turns route metadata into a request pipeline with
- [When to Use](#when-to-use)
- [Quick Start](#quick-start)
- [Common Patterns](#common-patterns)
+- [HTTP Error Representations](#http-error-representations)
- [Request Cleanup and Portability](#request-cleanup-and-portability)
- [Public API](#public-api)
- [Related Packages](#related-packages)
@@ -112,6 +113,64 @@ function someDeepHelper() {
`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.
+## 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:
+
+```ts
+import type { HttpErrorRepresentationOptions } from '@fluojs/http';
+import { bootstrapApplication } from '@fluojs/runtime';
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+const errorRepresentation = {
+ html: {
+ canRender({ request }) {
+ return request.method === 'GET' || request.method === 'HEAD';
+ },
+ render({ json }) {
+ return `${json.error.status}: ${escapeHtml(json.error.message)}`;
+ },
+ },
+} satisfies HttpErrorRepresentationOptions;
+
+const app = await bootstrapApplication({
+ errorRepresentation,
+ rootModule: AppModule,
+});
+```
+
+HTTP classifies the outcome before representation selection. 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](../../docs/architecture/http-error-representations.md) for the
+complete phase and fallback contract.
+
### Rate limiting behind proxies
`createRateLimitMiddleware(...)` resolves client identity from the raw socket `remoteAddress` by default. To trust `Forwarded`, `X-Forwarded-For`, or `X-Real-IP`, opt in with `trustProxyHeaders: true` only when your adapter sits behind a trusted proxy that overwrites those headers. If your adapter exposes neither a trusted proxy chain nor a raw socket identity, provide an explicit `keyResolver`.
@@ -208,7 +267,7 @@ Response content negotiation formatters must return `string` or `Uint8Array` fro
- **Binding decorators**: `FromBody`, `FromQuery`, `FromPath`, `FromHeader`, `FromCookie`, `RequestDto`, `Optional`, `Convert`
- **Execution decorators**: `UseGuards`, `UseInterceptors`, `HttpCode`, `Version`, `Header`, `Redirect`, `Produces`
- **Request/response and context types**: `RequestContext`, `Principal`, `ContextKey`, `ControllerHandler`, `FrameworkRequest`, `FrameworkRequestFile`, `FrameworkResponse`, `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`, `FastPathEligibility`, `FastPathStats`
+- **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`, `UnsupportedHttpAdapterRealtimeCapability`, `createNoopHttpApplicationAdapter`, `createServerBackedHttpAdapterRealtimeCapability`, `createUnsupportedHttpAdapterRealtimeCapability`, `createFetchStyleHttpAdapterRealtimeCapability`
- **Exceptions and errors**: `HttpExceptionDetail`, `HttpExceptionOptions`, `ErrorResponse`, `HttpException`, `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `NotAcceptableException`, `TooManyRequestsException`, `InternalServerErrorException`, `PayloadTooLargeException`, `createErrorResponse`, `RouteConflictError`, `InvalidRoutePathError`, `HandlerNotFoundError`, `RequestAbortedError`
diff --git a/packages/http/package.json b/packages/http/package.json
index 62c1d0303..042ba9971 100644
--- a/packages/http/package.json
+++ b/packages/http/package.json
@@ -48,7 +48,7 @@
],
"scripts": {
"prebuild": "node ../../tooling/scripts/clean-dist.mjs",
- "build": "pnpm exec babel src --extensions .ts --ignore 'src/**/*.test.ts' --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
+ "build": "pnpm exec babel src --extensions .ts --ignore 'src/**/*.test.ts','src/**/*.test-fixture.ts' --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
"typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
"test": "pnpm exec vitest run -c vitest.config.ts",
"test:watch": "pnpm exec vitest -c vitest.config.ts"
diff --git a/packages/http/src/dispatch/dispatch-error-negotiation.ts b/packages/http/src/dispatch/dispatch-error-negotiation.ts
new file mode 100644
index 000000000..6371c30df
--- /dev/null
+++ b/packages/http/src/dispatch/dispatch-error-negotiation.ts
@@ -0,0 +1,156 @@
+import type { RequestContext } from '../types.js';
+
+const HTML_MEDIA_TYPE = 'text/html';
+const JSON_MEDIA_TYPE = 'application/json';
+
+/** Error representation selected by HTTP content negotiation. */
+export type ErrorRepresentationKind = 'html' | 'json';
+
+type AcceptRange = {
+ readonly mediaRange: string;
+ readonly order: number;
+ readonly quality: number;
+ readonly specificity: number;
+};
+
+type RepresentationCandidate = {
+ readonly kind: ErrorRepresentationKind;
+ readonly priority: number;
+ readonly quality: number;
+ readonly specificity: number;
+};
+
+function parseQuality(value: string | undefined): number {
+ if (value === undefined) {
+ return 1;
+ }
+
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0;
+}
+
+function mediaRangeSpecificity(mediaRange: string): number {
+ if (mediaRange === '*/*') {
+ return 0;
+ }
+ return mediaRange.endsWith('/*') ? 1 : 2;
+}
+
+function parseAcceptHeader(value: string): readonly AcceptRange[] {
+ const ranges: AcceptRange[] = [];
+
+ for (const [order, token] of value.split(',').entries()) {
+ const [rawMediaRange, ...parameters] = token.trim().split(';');
+ const mediaRange = rawMediaRange?.trim().toLowerCase() ?? '';
+
+ if (!mediaRange.includes('/')) {
+ continue;
+ }
+
+ const qualityParameter = parameters
+ .map((parameter) => parameter.trim().split('='))
+ .find(([name]) => name?.toLowerCase() === 'q');
+
+ ranges.push({
+ mediaRange,
+ order,
+ quality: parseQuality(qualityParameter?.[1]?.trim()),
+ specificity: mediaRangeSpecificity(mediaRange),
+ });
+ }
+
+ return ranges;
+}
+
+function mediaRangeMatches(mediaRange: string, mediaType: string): boolean {
+ const [rangeType, rangeSubtype] = mediaRange.split('/');
+ const [type, subtype] = mediaType.split('/');
+ return rangeType !== undefined
+ && rangeSubtype !== undefined
+ && type !== undefined
+ && subtype !== undefined
+ && (rangeType === '*' || rangeType === type)
+ && (rangeSubtype === '*' || rangeSubtype === subtype);
+}
+
+function bestRangeForMediaType(ranges: readonly AcceptRange[], mediaType: string): AcceptRange | undefined {
+ return ranges
+ .filter((range) => mediaRangeMatches(range.mediaRange, mediaType))
+ .sort((left, right) => right.specificity - left.specificity || left.order - right.order)[0];
+}
+
+/**
+ * Reads the normalized Accept header from one request context.
+ *
+ * @param context Active HTTP request context.
+ * @returns The normalized header value when present.
+ */
+export function readAcceptHeader(context: RequestContext): string | undefined {
+ const raw = context.request.headers.accept ?? context.request.headers.Accept;
+ const value = Array.isArray(raw) ? raw.join(',') : raw;
+ const normalized = value?.trim();
+ return normalized ? normalized : undefined;
+}
+
+/**
+ * Determines whether an Accept header permits the HTML representation.
+ *
+ * @param acceptHeader Normalized Accept header value.
+ * @returns Whether HTML has a positive matching quality.
+ */
+export function canNegotiateHtml(acceptHeader: string | undefined): boolean {
+ if (acceptHeader === undefined) {
+ return false;
+ }
+
+ const range = bestRangeForMediaType(parseAcceptHeader(acceptHeader), HTML_MEDIA_TYPE);
+ return range !== undefined && range.quality > 0;
+}
+
+/**
+ * Selects the deterministic error representation for one Accept header.
+ *
+ * @param acceptHeader Normalized Accept header value.
+ * @param htmlAvailable Whether the application HTML provider is available.
+ * @returns The selected representation, or `undefined` when no offer is acceptable.
+ */
+export function selectErrorRepresentation(
+ acceptHeader: string | undefined,
+ htmlAvailable: boolean,
+): ErrorRepresentationKind | undefined {
+ if (acceptHeader === undefined) {
+ return 'json';
+ }
+
+ const ranges = parseAcceptHeader(acceptHeader);
+ const offers: readonly {
+ readonly kind: ErrorRepresentationKind;
+ readonly mediaType: string;
+ readonly priority: number;
+ }[] = htmlAvailable
+ ? [
+ { kind: 'json', mediaType: JSON_MEDIA_TYPE, priority: 0 },
+ { kind: 'html', mediaType: HTML_MEDIA_TYPE, priority: 1 },
+ ]
+ : [{ kind: 'json', mediaType: JSON_MEDIA_TYPE, priority: 0 }];
+ const candidates: RepresentationCandidate[] = [];
+
+ for (const offer of offers) {
+ const range = bestRangeForMediaType(ranges, offer.mediaType);
+ if (range !== undefined && range.quality > 0) {
+ candidates.push({
+ kind: offer.kind,
+ priority: offer.priority,
+ quality: range.quality,
+ specificity: range.specificity,
+ });
+ }
+ }
+
+ candidates.sort((left, right) => (
+ right.quality - left.quality
+ || right.specificity - left.specificity
+ || left.priority - right.priority
+ ));
+ return candidates[0]?.kind;
+}
diff --git a/packages/http/src/dispatch/dispatch-error-policy.ts b/packages/http/src/dispatch/dispatch-error-policy.ts
index 50a04ff2b..d4b288ad7 100644
--- a/packages/http/src/dispatch/dispatch-error-policy.ts
+++ b/packages/http/src/dispatch/dispatch-error-policy.ts
@@ -1,41 +1 @@
-import { HandlerNotFoundError } from '../errors.js';
-import {
- HttpException,
- InternalServerErrorException,
- NotFoundException,
- createErrorResponse,
-} from '../exceptions.js';
-import type { FrameworkResponse } from '../types.js';
-
-function toHttpException(error: unknown): HttpException {
- if (error instanceof HttpException) {
- return error;
- }
-
- if (error instanceof HandlerNotFoundError) {
- const message = error instanceof Error ? error.message : 'Resource not found.';
- return new NotFoundException(message, { cause: error });
- }
-
- return new InternalServerErrorException('Internal server error.', {
- cause: error,
- });
-}
-
-/**
- * Write error response.
- *
- * @param error The error.
- * @param response The response.
- * @param requestId The request id.
- * @returns The write error response result.
- */
-export async function writeErrorResponse(error: unknown, response: FrameworkResponse, requestId?: string): Promise {
- if (response.committed) {
- return;
- }
-
- const httpError = toHttpException(error);
- response.setStatus(httpError.status);
- await response.send(createErrorResponse(httpError, requestId));
-}
+export { writeErrorResponse } from './dispatch-error-representation.js';
diff --git a/packages/http/src/dispatch/dispatch-error-representation.ts b/packages/http/src/dispatch/dispatch-error-representation.ts
new file mode 100644
index 000000000..f514dd5b4
--- /dev/null
+++ b/packages/http/src/dispatch/dispatch-error-representation.ts
@@ -0,0 +1,183 @@
+import { HandlerNotFoundError } from '../errors.js';
+import {
+ HttpException,
+ InternalServerErrorException,
+ NotAcceptableException,
+ NotFoundException,
+ createErrorResponse,
+} from '../exceptions.js';
+import type {
+ DispatcherLogger,
+ FrameworkResponse,
+ HandlerDescriptor,
+ HtmlErrorRepresentationProvider,
+ HttpErrorRepresentationContext,
+ HttpErrorRepresentationOptions,
+ RequestContext,
+} from '../types.js';
+import {
+ canNegotiateHtml,
+ readAcceptHeader,
+ selectErrorRepresentation,
+} from './dispatch-error-negotiation.js';
+import { isRequestAborted } from './request-abort.js';
+
+const HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
+const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
+const NOT_ACCEPTABLE_MESSAGE = 'No acceptable response representation found.';
+const PROVIDER_FAILURE_MESSAGE = 'HTML error representation provider threw before response commit; falling back to canonical JSON.';
+
+type WriteErrorResponseOptions = {
+ readonly handler?: HandlerDescriptor;
+ readonly logger?: DispatcherLogger;
+ readonly representation?: HttpErrorRepresentationOptions;
+};
+
+function toHttpException(error: unknown): HttpException {
+ if (error instanceof HttpException) {
+ return error;
+ }
+
+ if (error instanceof HandlerNotFoundError) {
+ const message = error instanceof Error ? error.message : 'Resource not found.';
+ return new NotFoundException(message, { cause: error });
+ }
+
+ return new InternalServerErrorException('Internal server error.', { cause: error });
+}
+
+function isHttpRepresentationEligible(error: unknown): boolean {
+ return error instanceof HttpException || error instanceof HandlerNotFoundError;
+}
+
+function createRepresentationContext(
+ error: HttpException,
+ requestContext: RequestContext,
+ handler: HandlerDescriptor | undefined,
+): HttpErrorRepresentationContext {
+ return {
+ container: requestContext.container,
+ error,
+ ...(handler === undefined ? {} : { handler }),
+ json: createErrorResponse(error, requestContext.requestId),
+ request: requestContext.request,
+ ...(requestContext.requestId === undefined ? {} : { requestId: requestContext.requestId }),
+ };
+}
+
+async function isHtmlAvailable(
+ provider: HtmlErrorRepresentationProvider,
+ context: HttpErrorRepresentationContext,
+): Promise {
+ return provider.canRender === undefined || await provider.canRender(context);
+}
+
+function setNegotiatedHeaders(response: FrameworkResponse, contentType: string): void {
+ response.setHeader('Content-Type', contentType);
+ const varyEntry = Object.entries(response.headers).find(([name]) => name.toLowerCase() === 'vary');
+ const varyValues = (Array.isArray(varyEntry?.[1]) ? varyEntry[1] : varyEntry?.[1]?.split(','))
+ ?.map((value) => value.trim())
+ .filter(Boolean) ?? [];
+
+ if (!varyValues.some((value) => value.toLowerCase() === 'accept')) {
+ varyValues.push('Accept');
+ }
+ response.setHeader(varyEntry?.[0] ?? 'Vary', varyValues.join(', '));
+}
+
+async function writeBody(
+ requestContext: RequestContext,
+ status: number,
+ contentType: string,
+ body: unknown,
+): Promise {
+ if (requestContext.response.committed || isRequestAborted(requestContext.request)) {
+ return;
+ }
+
+ requestContext.response.setStatus(status);
+ setNegotiatedHeaders(requestContext.response, contentType);
+ await requestContext.response.send(requestContext.request.method.toUpperCase() === 'HEAD' ? undefined : body);
+}
+
+async function writeCanonicalJson(error: HttpException, requestContext: RequestContext): Promise {
+ await writeBody(
+ requestContext,
+ error.status,
+ JSON_CONTENT_TYPE,
+ createErrorResponse(error, requestContext.requestId),
+ );
+}
+
+/**
+ * Writes one HTTP-owned error representation when the response remains writable.
+ *
+ * @param error Failure classified by the HTTP dispatch pipeline.
+ * @param requestContext Active request and response context.
+ * @param options Matched-handler, logger, and optional HTML provider settings.
+ * @returns A promise that settles after the selected response is written or skipped.
+ */
+export async function writeErrorResponse(
+ error: unknown,
+ requestContext: RequestContext,
+ options: WriteErrorResponseOptions = {},
+): Promise {
+ if (requestContext.response.committed || isRequestAborted(requestContext.request)) {
+ return;
+ }
+
+ const httpError = toHttpException(error);
+ const provider = options.representation?.html;
+
+ if (provider === undefined || !isHttpRepresentationEligible(error)) {
+ requestContext.response.setStatus(httpError.status);
+ await requestContext.response.send(createErrorResponse(httpError, requestContext.requestId));
+ return;
+ }
+
+ const acceptHeader = readAcceptHeader(requestContext);
+ const representationContext = createRepresentationContext(httpError, requestContext, options.handler);
+ let htmlAvailable = false;
+
+ try {
+ htmlAvailable = canNegotiateHtml(acceptHeader) && await isHtmlAvailable(provider, representationContext);
+ } catch (providerError) {
+ if (isRequestAborted(requestContext.request)) {
+ return;
+ }
+ options.logger?.error(PROVIDER_FAILURE_MESSAGE, providerError, 'HttpDispatcher');
+ await writeCanonicalJson(httpError, requestContext);
+ return;
+ }
+
+ const selected = selectErrorRepresentation(acceptHeader, htmlAvailable);
+
+ if (selected === undefined) {
+ await writeCanonicalJson(new NotAcceptableException(NOT_ACCEPTABLE_MESSAGE), requestContext);
+ return;
+ }
+
+ if (selected === 'json') {
+ await writeCanonicalJson(httpError, requestContext);
+ return;
+ }
+
+ if (requestContext.request.method.toUpperCase() === 'HEAD') {
+ await writeBody(requestContext, httpError.status, HTML_CONTENT_TYPE, undefined);
+ return;
+ }
+
+ let body: string | Uint8Array;
+ try {
+ body = await provider.render(representationContext);
+ } catch (providerError) {
+ if (isRequestAborted(requestContext.request)) {
+ return;
+ }
+ options.logger?.error(PROVIDER_FAILURE_MESSAGE, providerError, 'HttpDispatcher');
+ await writeCanonicalJson(httpError, requestContext);
+ return;
+ }
+
+ await writeBody(requestContext, httpError.status, HTML_CONTENT_TYPE, body);
+}
diff --git a/packages/http/src/dispatch/dispatcher.ts b/packages/http/src/dispatch/dispatcher.ts
index e4387634f..f2b7619b7 100644
--- a/packages/http/src/dispatch/dispatcher.ts
+++ b/packages/http/src/dispatch/dispatcher.ts
@@ -21,6 +21,7 @@ import type {
HandlerDescriptor,
HandlerMapping,
HandlerMatch,
+ HttpErrorRepresentationOptions,
InterceptorLike,
MiddlewareContext,
MiddlewareLike,
@@ -72,6 +73,8 @@ export interface CreateDispatcherOptions {
fastPathDebugHeaders?: boolean;
/** Optional global error handler. */
onError?: ErrorHandler;
+ /** Optional application-owned HTML representation for HTTP-classified errors. */
+ errorRepresentation?: HttpErrorRepresentationOptions;
/** Request-scope optimization hints supplied by runtime bootstrap. */
requestScope?: {
/** Global DTO converters used by the default binder. */
@@ -1083,7 +1086,13 @@ async function handleDispatchError(context: DispatchPhaseContext, error: unknown
logDispatchFailure(context.options.logger, 'Managed SSE iterator cleanup threw an error.', dispatchError);
}
- await writeErrorResponse(dispatchError, context.response, context.requestContext.requestId);
+ await writeErrorResponse(dispatchError, context.requestContext, {
+ ...(context.matchedHandler === undefined ? {} : { handler: context.matchedHandler }),
+ ...(context.options.logger === undefined ? {} : { logger: context.options.logger }),
+ ...(context.options.errorRepresentation === undefined
+ ? {}
+ : { representation: context.options.errorRepresentation }),
+ });
}
/**
diff --git a/packages/http/src/dispatch/error-representation-lifecycle.test.ts b/packages/http/src/dispatch/error-representation-lifecycle.test.ts
new file mode 100644
index 000000000..4362e6bd1
--- /dev/null
+++ b/packages/http/src/dispatch/error-representation-lifecycle.test.ts
@@ -0,0 +1,149 @@
+import { Scope as ScopeDecorator } from '@fluojs/core';
+import { Container } from '@fluojs/di';
+import { describe, expect, it, vi } from 'vitest';
+
+import type {
+ FrameworkResponse,
+ HttpErrorRepresentationContext,
+} from '../index.js';
+import {
+ createRequest,
+ createResponse,
+ createTestDispatcher,
+} from './error-representation.test-fixture.js';
+
+class CountingContainer extends Container {
+ requestScopeDisposeCount = 0;
+
+ override createRequestScope(): Container {
+ const scope = super.createRequestScope();
+ const dispose = scope.dispose.bind(scope);
+ scope.dispose = async () => {
+ this.requestScopeDisposeCount += 1;
+ await dispose();
+ };
+ return scope;
+ }
+}
+
+describe('HTTP error representation lifecycle', () => {
+ it('falls back once to the original canonical JSON outcome when the HTML provider fails', async () => {
+ const representationFailure = new Error('representation failed');
+ const logger = { error: vi.fn() };
+ const { dispatcher } = createTestDispatcher({
+ render() {
+ throw representationFailure;
+ },
+ }, { logger });
+ const response = createResponse();
+
+ await dispatcher.dispatch(createRequest('/missing', 'text/html'), response);
+
+ expect(response.statusCode).toBe(404);
+ expect(response.body).toMatchObject({ error: { code: 'NOT_FOUND', status: 404 } });
+ expect(response.headers['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(logger.error).toHaveBeenCalledWith(
+ 'HTML error representation provider threw before response commit; falling back to canonical JSON.',
+ representationFailure,
+ 'HttpDispatcher',
+ );
+ });
+
+ it('propagates response writer failures without retrying through canonical JSON fallback', async () => {
+ const writerFailure = new Error('response writer failed');
+ const logger = { error: vi.fn() };
+ const { dispatcher } = createTestDispatcher({
+ render() {
+ return 'not written';
+ },
+ }, { logger });
+ const response = createResponse();
+ response.send = vi.fn(() => {
+ throw writerFailure;
+ });
+
+ await expect(dispatcher.dispatch(createRequest('/missing', 'text/html'), response)).rejects.toBe(writerFailure);
+
+ expect(response.send).toHaveBeenCalledTimes(1);
+ expect(response.send).toHaveBeenCalledWith('not written');
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+
+ it('does not merge unknown failures into the HttpException representation phase', async () => {
+ const render = vi.fn(() => 'unused');
+ const { dispatcher } = createTestDispatcher({ render });
+ const response = createResponse();
+
+ await dispatcher.dispatch(createRequest('/failures/unknown', 'text/html'), response);
+
+ expect(response.statusCode).toBe(500);
+ expect(response.body).toMatchObject({ error: { code: 'INTERNAL_SERVER_ERROR', status: 500 } });
+ expect(render).not.toHaveBeenCalled();
+ });
+
+ it('does not invoke a provider after onError handles or the response is already committed', async () => {
+ const render = vi.fn(() => 'unused');
+ const onError = vi.fn((_error, _request, response: FrameworkResponse) => {
+ response.setStatus(418);
+ void response.send({ handled: true });
+ return true;
+ });
+ const handled = createTestDispatcher({ render }, { onError });
+ const handledResponse = createResponse();
+
+ await handled.dispatcher.dispatch(createRequest('/missing', 'text/html'), handledResponse);
+
+ expect(handledResponse.statusCode).toBe(418);
+ expect(handledResponse.body).toEqual({ handled: true });
+ expect(render).not.toHaveBeenCalled();
+
+ const committed = createTestDispatcher({ render });
+ const committedResponse = createResponse();
+ await committed.dispatcher.dispatch(createRequest('/failures/committed', 'text/html'), committedResponse);
+
+ expect(committedResponse.statusCode).toBeUndefined();
+ expect(committedResponse.body).toBe('handler-owned');
+ expect(render).not.toHaveBeenCalled();
+ });
+
+ it('stops without fallback commit when the request aborts while the provider is rendering', async () => {
+ const abortController = new AbortController();
+ const { dispatcher } = createTestDispatcher({
+ render() {
+ abortController.abort();
+ return 'late';
+ },
+ });
+ const request = createRequest('/missing', 'text/html');
+ request.signal = abortController.signal;
+ const response = createResponse();
+
+ await dispatcher.dispatch(request, response);
+
+ expect(response.committed).toBe(false);
+ expect(response.statusCode).toBeUndefined();
+ expect(response.body).toBeUndefined();
+ });
+
+ it('lets unmatched HTML providers resolve request-scoped application dependencies', async () => {
+ @ScopeDecorator('request')
+ class ErrorDocumentState {
+ readonly requestId = 'scoped-2889';
+ }
+
+ const render = vi.fn(async ({ container, handler, json }: HttpErrorRepresentationContext) => {
+ const state = await container.resolve(ErrorDocumentState);
+ expect(handler).toBeUndefined();
+ return `${json.error.status}:${state.requestId}`;
+ });
+ const rootContainer = new CountingContainer();
+ const fixture = createTestDispatcher({ render }, {}, rootContainer);
+ fixture.rootContainer.register(ErrorDocumentState);
+ const response = createResponse();
+
+ await fixture.dispatcher.dispatch(createRequest('/unmatched', 'text/html'), response);
+
+ expect(response.body).toBe('404:scoped-2889');
+ expect(rootContainer.requestScopeDisposeCount).toBe(1);
+ });
+});
diff --git a/packages/http/src/dispatch/error-representation.test-fixture.ts b/packages/http/src/dispatch/error-representation.test-fixture.ts
new file mode 100644
index 000000000..9e1250c6a
--- /dev/null
+++ b/packages/http/src/dispatch/error-representation.test-fixture.ts
@@ -0,0 +1,125 @@
+import { Container } from '@fluojs/di';
+
+import {
+ BadRequestException,
+ type CreateDispatcherOptions,
+ Controller,
+ createDispatcher,
+ createHandlerMapping,
+ type FrameworkRequest,
+ type FrameworkResponse,
+ Get,
+ Head,
+ type HtmlErrorRepresentationProvider,
+ NotFoundException,
+ type RequestContext,
+} from '../index.js';
+
+/** Mutable framework response used by error-representation dispatch tests. */
+export type TestResponse = FrameworkResponse & { body?: unknown };
+
+@Controller('/failures')
+class FailureController {
+ @Get('/bad-request')
+ badRequest(): never {
+ throw new BadRequestException('Invalid request.', {
+ details: [{ code: 'INVALID_NAME', field: 'name', message: 'Name is invalid.', source: 'body' }],
+ meta: { retryable: false },
+ });
+ }
+
+ @Head('/head')
+ head(): never {
+ throw new NotFoundException('HEAD resource missing.');
+ }
+
+ @Get('/unknown')
+ unknown(): never {
+ throw new Error('Unknown pipeline failure.');
+ }
+
+ @Get('/committed')
+ committed(_input: undefined, context: RequestContext): never {
+ void context.response.send('handler-owned');
+ throw new NotFoundException('Too late to replace.');
+ }
+}
+
+/**
+ * Creates an error-representation test request.
+ *
+ * @param path Request path.
+ * @param accept Optional Accept header value.
+ * @param method HTTP method.
+ * @returns A framework request fixture.
+ */
+export function createRequest(
+ path: string,
+ accept?: string,
+ method: FrameworkRequest['method'] = 'GET',
+): FrameworkRequest {
+ return {
+ body: undefined,
+ cookies: {},
+ headers: accept === undefined ? {} : { accept },
+ method,
+ params: {},
+ path,
+ query: {},
+ raw: {},
+ requestId: 'request-2889',
+ url: path,
+ };
+}
+
+/**
+ * Creates a mutable error-representation response recorder.
+ *
+ * @returns A framework response fixture.
+ */
+export function createResponse(): TestResponse {
+ return {
+ committed: false,
+ headers: {},
+ redirect(status, location) {
+ this.setStatus(status);
+ this.setHeader('Location', location);
+ this.committed = true;
+ },
+ send(body) {
+ this.body = body;
+ this.committed = true;
+ },
+ setHeader(name, value) {
+ this.headers[name] = value;
+ },
+ setStatus(code) {
+ this.statusCode = code;
+ this.statusSet = true;
+ },
+ };
+}
+
+/**
+ * Creates a dispatcher configured with one HTML error provider.
+ *
+ * @param provider HTML provider under test.
+ * @param overrides Optional dispatcher settings.
+ * @param rootContainer Root DI container.
+ * @returns The dispatcher and root container fixture.
+ */
+export function createTestDispatcher(
+ provider: HtmlErrorRepresentationProvider,
+ overrides: Partial = {},
+ rootContainer: Container = new Container(),
+) {
+ rootContainer.register(FailureController);
+ const options: CreateDispatcherOptions = {
+ errorRepresentation: { html: provider },
+ handlerMapping: createHandlerMapping([{ controllerToken: FailureController }]),
+ rootContainer,
+ ...overrides,
+ };
+
+ return { dispatcher: createDispatcher(options), rootContainer };
+}
diff --git a/packages/http/src/dispatch/error-representation.test.ts b/packages/http/src/dispatch/error-representation.test.ts
new file mode 100644
index 000000000..d963ce821
--- /dev/null
+++ b/packages/http/src/dispatch/error-representation.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { HttpErrorRepresentationContext } from '../index.js';
+import {
+ createRequest,
+ createResponse,
+ createTestDispatcher,
+} from './error-representation.test-fixture.js';
+
+describe('HTTP-owned error representations', () => {
+ it.each([
+ { accept: 'application/json', contentType: 'application/json; charset=utf-8', htmlCalls: 0, kind: 'json' },
+ { accept: 'text/html', contentType: 'text/html; charset=utf-8', htmlCalls: 1, kind: 'html' },
+ { accept: 'application/json;q=0.2, text/html;q=0.9', contentType: 'text/html; charset=utf-8', htmlCalls: 1, kind: 'html' },
+ { accept: 'application/json;q=0.9, text/html;q=0.2', contentType: 'application/json; charset=utf-8', htmlCalls: 0, kind: 'json' },
+ { accept: 'text/*', contentType: 'text/html; charset=utf-8', htmlCalls: 1, kind: 'html' },
+ { accept: 'text/html;q=0, */*;q=1', contentType: 'application/json; charset=utf-8', htmlCalls: 0, kind: 'json' },
+ { accept: '*/*', contentType: 'application/json; charset=utf-8', htmlCalls: 0, kind: 'json' },
+ { accept: undefined, contentType: 'application/json; charset=utf-8', htmlCalls: 0, kind: 'json' },
+ ])('selects $kind deterministically for Accept=$accept', async ({ accept, contentType, htmlCalls, kind }) => {
+ const render = vi.fn(async ({ error }: HttpErrorRepresentationContext) => `${error.code}`);
+ const { dispatcher } = createTestDispatcher({ render });
+ const response = createResponse();
+
+ await dispatcher.dispatch(createRequest('/missing', accept), response);
+
+ expect(response.statusCode).toBe(404);
+ expect(response.headers['Content-Type']).toBe(contentType);
+ expect(response.headers.Vary).toBe('Accept');
+ expect(render).toHaveBeenCalledTimes(htmlCalls);
+ if (kind === 'html') {
+ expect(response.body).toBe('NOT_FOUND');
+ } else {
+ expect(response.body).toMatchObject({ error: { code: 'NOT_FOUND', status: 404 } });
+ }
+ });
+
+ it('returns a canonical JSON 406 without recursively invoking HTML for unsupported media types', async () => {
+ const render = vi.fn(() => 'unused');
+ const { dispatcher } = createTestDispatcher({ render });
+ const response = createResponse();
+
+ await dispatcher.dispatch(createRequest('/missing', 'image/avif'), response);
+
+ expect(response.statusCode).toBe(406);
+ expect(response.body).toMatchObject({ error: { code: 'NOT_ACCEPTABLE', status: 406 } });
+ expect(response.headers['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(render).not.toHaveBeenCalled();
+ });
+
+ it('does not consult the HTML provider when a specific q=0 range rejects HTML', async () => {
+ const canRender = vi.fn(() => true);
+ const render = vi.fn(() => 'unused');
+ const { dispatcher } = createTestDispatcher({ canRender, render });
+ const response = createResponse();
+
+ await dispatcher.dispatch(createRequest('/missing', 'text/html;q=0, */*;q=1'), response);
+
+ expect(response.statusCode).toBe(404);
+ expect(response.body).toMatchObject({ error: { code: 'NOT_FOUND', status: 404 } });
+ expect(canRender).not.toHaveBeenCalled();
+ expect(render).not.toHaveBeenCalled();
+ });
+
+ it('uses provider constraints without treating success @Produces metadata as error representation ownership', async () => {
+ const canRender = vi.fn(({ handler }: HttpErrorRepresentationContext) => handler?.methodName !== 'badRequest');
+ const render = vi.fn(() => 'unused');
+ const { dispatcher } = createTestDispatcher({ canRender, render });
+ const response = createResponse();
+
+ await dispatcher.dispatch(
+ createRequest('/failures/bad-request', 'text/html;q=1, application/json;q=0.5'),
+ response,
+ );
+
+ expect(canRender).toHaveBeenCalledWith(expect.objectContaining({
+ handler: expect.objectContaining({ methodName: 'badRequest' }),
+ }));
+ expect(render).not.toHaveBeenCalled();
+ expect(response.statusCode).toBe(400);
+ expect(response.body).toEqual({
+ error: {
+ code: 'BAD_REQUEST',
+ details: [{ code: 'INVALID_NAME', field: 'name', message: 'Name is invalid.', source: 'body' }],
+ message: 'Invalid request.',
+ meta: { retryable: false },
+ requestId: 'request-2889',
+ status: 400,
+ },
+ });
+ });
+
+ it('keeps HEAD status and negotiated headers without rendering or emitting a body', async () => {
+ const canRender = vi.fn(() => true);
+ const render = vi.fn(() => 'must not render');
+ const { dispatcher } = createTestDispatcher({ canRender, render });
+ const response = createResponse();
+
+ await dispatcher.dispatch(createRequest('/failures/head', 'text/html', 'HEAD'), response);
+
+ expect(response.statusCode).toBe(404);
+ expect(response.headers['Content-Type']).toBe('text/html; charset=utf-8');
+ expect(response.headers.Vary).toBe('Accept');
+ expect(response.body).toBeUndefined();
+ expect(response.committed).toBe(true);
+ expect(canRender).toHaveBeenCalledTimes(1);
+ expect(render).not.toHaveBeenCalled();
+ });
+
+});
diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts
index ee19ac752..7f77e137c 100644
--- a/packages/http/src/types.ts
+++ b/packages/http/src/types.ts
@@ -1,5 +1,6 @@
import type { Constructor, MaybePromise, MetadataPropertyKey, MetadataSource, Token } from '@fluojs/core';
import type { RequestScopeContainer } from '@fluojs/di';
+import type { ErrorResponse, HttpException } from './exceptions.js';
export type { ValidationIssue, Validator } from '@fluojs/validation';
/** HTTP methods understood by Fluo route metadata and dispatcher matching. */
@@ -111,6 +112,53 @@ export interface ContentNegotiationOptions {
formatters?: ResponseFormatter[];
}
+/**
+ * HTTP-classified failure data passed to an application HTML representation provider.
+ *
+ * @remarks
+ * The context intentionally omits `FrameworkResponse`. Providers may resolve
+ * request-scoped dependencies, but status, headers, body suppression, and
+ * response commit remain owned by the HTTP dispatcher.
+ */
+export interface HttpErrorRepresentationContext {
+ /** Request-scoped dependency container active for the failed dispatch. */
+ readonly container: RequestScopeContainer;
+ /** HTTP exception selected by dispatcher error classification. */
+ readonly error: HttpException;
+ /** Matched handler when the failure happened after route matching. */
+ readonly handler?: HandlerDescriptor;
+ /** Canonical JSON envelope for the selected HTTP outcome. */
+ readonly json: ErrorResponse;
+ /** Adapter-normalized request that produced the failure. */
+ readonly request: FrameworkRequest;
+ /** Optional request identifier preserved from the active request context. */
+ readonly requestId?: string;
+}
+
+/** Application-owned renderer for optional HTML error and not-found documents. */
+export interface HtmlErrorRepresentationProvider {
+ /**
+ * Decides whether HTML is available for one classified HTTP outcome.
+ *
+ * @param context HTTP-owned error outcome and request-scope access.
+ * @returns `true` when this provider can render the outcome; defaults to `true` when omitted.
+ */
+ canRender?(context: HttpErrorRepresentationContext): MaybePromise;
+ /**
+ * Renders a complete HTML document before the dispatcher commits the response.
+ *
+ * @param context HTTP-owned error outcome and request-scope access.
+ * @returns Runtime-neutral UTF-8 text or bytes for the HTML representation.
+ */
+ render(context: HttpErrorRepresentationContext): MaybePromise;
+}
+
+/** Optional error representation providers registered for one HTTP application. */
+export interface HttpErrorRepresentationOptions {
+ /** Application-owned HTML provider; canonical JSON remains framework-owned and always available. */
+ readonly html: HtmlErrorRepresentationProvider;
+}
+
/** Authenticated caller identity attached to the active request context. */
export interface Principal {
subject: string;
diff --git a/packages/http/tsconfig.build.json b/packages/http/tsconfig.build.json
index ebbe0638a..a4bdcfff9 100644
--- a/packages/http/tsconfig.build.json
+++ b/packages/http/tsconfig.build.json
@@ -6,5 +6,5 @@
"outDir": "dist",
"noEmit": false
},
- "exclude": ["src/**/*.test.ts"]
+ "exclude": ["src/**/*.test.ts", "src/**/*.test-fixture.ts"]
}
diff --git a/packages/platform-bun/src/adapter.test.ts b/packages/platform-bun/src/adapter.test.ts
index cc8c697f9..5af4ebce5 100644
--- a/packages/platform-bun/src/adapter.test.ts
+++ b/packages/platform-bun/src/adapter.test.ts
@@ -305,10 +305,19 @@ function registerBunWebRuntimePortabilitySuite(): void {
},
};
},
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'Bun',
});
describe('Bun web-runtime portability conformance', () => {
+ it('supports HTTP-owned error representations through the shared web-runtime harness', async () => {
+ await bunPortabilityHarness.assertSupportsHttpErrorRepresentations();
+ });
+
+ it('does not commit an error representation after request abort', async () => {
+ await bunPortabilityHarness.assertDoesNotCommitAbortedHttpErrorRepresentations();
+ });
+
it('preserves malformed cookie values through the shared web-runtime harness', async () => {
await bunPortabilityHarness.assertPreservesMalformedCookieValues();
});
diff --git a/packages/platform-bun/src/testing-web-runtime-adapter-portability.d.ts b/packages/platform-bun/src/testing-web-runtime-adapter-portability.d.ts
index 081308980..b4353061a 100644
--- a/packages/platform-bun/src/testing-web-runtime-adapter-portability.d.ts
+++ b/packages/platform-bun/src/testing-web-runtime-adapter-portability.d.ts
@@ -1,6 +1,13 @@
declare module '@fluojs/testing/web-runtime-adapter-portability' {
+ import type { HttpErrorRepresentationOptions, Middleware } from '@fluojs/http';
import type { ModuleType } from '@fluojs/runtime';
+ export type WebHttpErrorRepresentationBootstrapOptions = {
+ readonly cors: false;
+ readonly errorRepresentation: HttpErrorRepresentationOptions;
+ readonly middleware: Middleware[];
+ };
+
type WebRuntimePortabilityAppLike = {
close(): Promise;
dispatch(request: Request): Promise;
@@ -11,6 +18,9 @@ declare module '@fluojs/testing/web-runtime-adapter-portability' {
TApp extends WebRuntimePortabilityAppLike = WebRuntimePortabilityAppLike,
> {
bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ createErrorRepresentationBootstrapOptions?: (
+ options: WebHttpErrorRepresentationBootstrapOptions,
+ ) => TBootstrapOptions;
name: string;
}
@@ -18,11 +28,13 @@ declare module '@fluojs/testing/web-runtime-adapter-portability' {
TBootstrapOptions extends object,
TApp extends WebRuntimePortabilityAppLike = WebRuntimePortabilityAppLike,
> {
+ assertDoesNotCommitAbortedHttpErrorRepresentations(): Promise;
assertExcludesRawBodyForMultipart(): Promise;
assertPreservesExactRawBodyBytesForByteSensitivePayloads(): Promise;
assertPreservesMalformedCookieValues(): Promise;
assertPreservesQueryArraysAndDecoding(): Promise;
assertPreservesRawBodyForJsonAndText(): Promise;
+ assertSupportsHttpErrorRepresentations(): Promise;
assertSupportsSseStreaming(): Promise;
}
diff --git a/packages/platform-deno/src/fetch-handler.test.ts b/packages/platform-deno/src/fetch-handler.test.ts
index 2261c1450..71581ed90 100644
--- a/packages/platform-deno/src/fetch-handler.test.ts
+++ b/packages/platform-deno/src/fetch-handler.test.ts
@@ -36,10 +36,19 @@ function registerHostOwnedDenoPortabilitySuite(): void {
},
};
},
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'host-owned Deno fetch handler',
});
describe('host-owned Deno fetch handler portability', () => {
+ it('supports HTTP-owned error representations', async () => {
+ await harness.assertSupportsHttpErrorRepresentations();
+ });
+
+ it('does not commit an error representation after request abort', async () => {
+ await harness.assertDoesNotCommitAbortedHttpErrorRepresentations();
+ });
+
it('preserves malformed cookie values', async () => {
await harness.assertPreservesMalformedCookieValues();
});
diff --git a/packages/platform-express/src/adapter.test.ts b/packages/platform-express/src/adapter.test.ts
index 4129052be..bf210a62c 100644
--- a/packages/platform-express/src/adapter.test.ts
+++ b/packages/platform-express/src/adapter.test.ts
@@ -236,6 +236,7 @@ JNCDpGwh8us=
const expressPortabilityHarness = createHttpAdapterPortabilityHarness({
bootstrap: bootstrapExpressApplication,
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'express',
run: runExpressApplication,
});
@@ -263,6 +264,14 @@ describe('@fluojs/platform-express', () => {
});
describe('adapter portability', () => {
+ it('supports HTTP-owned JSON and HTML error representations', async () => {
+ await expressPortabilityHarness.assertSupportsHttpErrorRepresentations();
+ });
+
+ it('does not commit an error representation after client disconnect', async () => {
+ await expressPortabilityHarness.assertDoesNotCommitAbortedHttpErrorRepresentations();
+ });
+
it('preserves malformed cookie values', async () => {
await expressPortabilityHarness.assertPreservesMalformedCookieValues();
});
@@ -376,6 +385,48 @@ describe('@fluojs/platform-express', () => {
}
});
+ it('preserves native Vary values when HTTP adds error representation negotiation', async () => {
+ const nativeMiddleware: RequestHandler = (_request, response, next) => {
+ response.setHeader('Vary', 'Origin');
+ next();
+ };
+
+ class AppModule {}
+ defineModule(AppModule, {});
+
+ const port = await findAvailablePort();
+ const app = await fluoFactory.create(AppModule, {
+ adapter: createExpressAdapter({
+ nativeMiddleware: [nativeMiddleware],
+ port,
+ }),
+ errorRepresentation: {
+ html: {
+ render({ json }) {
+ return `${String(json.error.status)}:${json.error.code}`;
+ },
+ },
+ },
+ });
+
+ await app.listen();
+
+ try {
+ const response = await requestHttp({
+ headers: { accept: 'text/html' },
+ method: 'GET',
+ path: '/native-vary-missing',
+ port,
+ });
+
+ expect(response.statusCode).toBe(404);
+ expect(response.headers.get('vary')).toBe('Origin, Accept');
+ expect(response.body).toBe('404:NOT_FOUND');
+ } finally {
+ await app.close();
+ }
+ });
+
it('lets native Express middleware terminate a response without entering fluo dispatch', async () => {
const dispatch = vi.fn();
const nativeMiddleware: RequestHandler = (_request, response) => {
diff --git a/packages/platform-express/src/adapter.ts b/packages/platform-express/src/adapter.ts
index 3335241ad..4746bcc29 100644
--- a/packages/platform-express/src/adapter.ts
+++ b/packages/platform-express/src/adapter.ts
@@ -645,9 +645,15 @@ export async function runExpressApplication(
}
function createFrameworkResponse(response: ExpressResponse): ExpressFrameworkResponse {
+ const headers = Object.fromEntries(
+ Object.entries(response.getHeaders())
+ .filter((entry): entry is [string, string | number | string[]] => entry[1] !== undefined)
+ .map(([name, value]) => [name, typeof value === 'number' ? String(value) : value]),
+ );
+
return {
committed: response.headersSent || response.writableEnded,
- headers: {},
+ headers,
raw: response,
stream: createFrameworkResponseStream(response),
redirect(status: number, location: string) {
diff --git a/packages/platform-express/src/testing-http-adapter-portability.d.ts b/packages/platform-express/src/testing-http-adapter-portability.d.ts
index 352164925..f832e8503 100644
--- a/packages/platform-express/src/testing-http-adapter-portability.d.ts
+++ b/packages/platform-express/src/testing-http-adapter-portability.d.ts
@@ -1,4 +1,5 @@
declare module '@fluojs/testing/http-adapter-portability' {
+ import type { HttpErrorRepresentationOptions, Middleware, RequestObserver } from '@fluojs/http';
import type { ModuleType } from '@fluojs/runtime';
type AppLike = {
@@ -12,6 +13,13 @@ declare module '@fluojs/testing/http-adapter-portability' {
TApp extends AppLike = AppLike,
> {
bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ createErrorRepresentationBootstrapOptions?: (options: {
+ readonly cors: false;
+ readonly errorRepresentation: HttpErrorRepresentationOptions;
+ readonly middleware: Middleware[];
+ readonly observers: RequestObserver[];
+ readonly port: 0;
+ }) => TBootstrapOptions;
exactRawBodyByteContentType?: string;
name: string;
prepareExactRawBodyByteTest?: (app: TApp) => void | Promise;
@@ -23,6 +31,7 @@ declare module '@fluojs/testing/http-adapter-portability' {
TRunOptions extends object,
TApp extends AppLike = AppLike,
> {
+ assertDoesNotCommitAbortedHttpErrorRepresentations(): Promise;
assertDefaultsMultipartTotalLimitToMaxBodySize(): Promise;
assertExcludesRawBodyForMultipart(): Promise;
assertPreservesExactRawBodyBytesForByteSensitivePayloads(): Promise;
@@ -32,6 +41,7 @@ declare module '@fluojs/testing/http-adapter-portability' {
assertReportsConfiguredHostInStartupLogs(): Promise;
assertReportsHttpsStartupUrl(https: { cert: string; key: string }): Promise;
assertSettlesStreamDrainWaitOnClose(): Promise;
+ assertSupportsHttpErrorRepresentations(): Promise;
assertSupportsSseStreaming(): Promise;
}
diff --git a/packages/platform-fastify/src/adapter.test.ts b/packages/platform-fastify/src/adapter.test.ts
index f03393e9a..cf94d4569 100644
--- a/packages/platform-fastify/src/adapter.test.ts
+++ b/packages/platform-fastify/src/adapter.test.ts
@@ -204,6 +204,7 @@ const fastifyPortabilityHarness = createHttpAdapterPortabilityHarness<
Application
>({
bootstrap: bootstrapFastifyApplication,
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'fastify',
run: runFastifyApplication,
});
@@ -214,6 +215,14 @@ interface FastifyReplySerializerHost {
describe('@fluojs/platform-fastify', () => {
describe('adapter portability', () => {
+ it('supports HTTP-owned JSON and HTML error representations', async () => {
+ await fastifyPortabilityHarness.assertSupportsHttpErrorRepresentations();
+ });
+
+ it('does not commit an error representation after client disconnect', async () => {
+ await fastifyPortabilityHarness.assertDoesNotCommitAbortedHttpErrorRepresentations();
+ });
+
it('preserves malformed cookie values', async () => {
await fastifyPortabilityHarness.assertPreservesMalformedCookieValues();
});
diff --git a/packages/platform-nodejs/src/index.test.ts b/packages/platform-nodejs/src/index.test.ts
index 45adddb43..6f307e98c 100644
--- a/packages/platform-nodejs/src/index.test.ts
+++ b/packages/platform-nodejs/src/index.test.ts
@@ -111,12 +111,21 @@ const nodejsPortabilityHarness = createHttpAdapterPortabilityHarness<
RunNodejsApplicationOptions
>({
bootstrap: bootstrapNodejsApplication,
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'nodejs',
run: runNodejsApplication,
});
describe('@fluojs/platform-nodejs', () => {
describe('adapter portability', () => {
+ it('supports HTTP-owned JSON and HTML error representations', async () => {
+ await nodejsPortabilityHarness.assertSupportsHttpErrorRepresentations();
+ });
+
+ it('does not commit an error representation after client disconnect', async () => {
+ await nodejsPortabilityHarness.assertDoesNotCommitAbortedHttpErrorRepresentations();
+ });
+
it('preserves malformed cookie values', async () => {
await nodejsPortabilityHarness.assertPreservesMalformedCookieValues();
});
diff --git a/packages/platform-nodejs/test-types/testing-http-adapter-portability.d.ts b/packages/platform-nodejs/test-types/testing-http-adapter-portability.d.ts
index 352164925..f832e8503 100644
--- a/packages/platform-nodejs/test-types/testing-http-adapter-portability.d.ts
+++ b/packages/platform-nodejs/test-types/testing-http-adapter-portability.d.ts
@@ -1,4 +1,5 @@
declare module '@fluojs/testing/http-adapter-portability' {
+ import type { HttpErrorRepresentationOptions, Middleware, RequestObserver } from '@fluojs/http';
import type { ModuleType } from '@fluojs/runtime';
type AppLike = {
@@ -12,6 +13,13 @@ declare module '@fluojs/testing/http-adapter-portability' {
TApp extends AppLike = AppLike,
> {
bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ createErrorRepresentationBootstrapOptions?: (options: {
+ readonly cors: false;
+ readonly errorRepresentation: HttpErrorRepresentationOptions;
+ readonly middleware: Middleware[];
+ readonly observers: RequestObserver[];
+ readonly port: 0;
+ }) => TBootstrapOptions;
exactRawBodyByteContentType?: string;
name: string;
prepareExactRawBodyByteTest?: (app: TApp) => void | Promise;
@@ -23,6 +31,7 @@ declare module '@fluojs/testing/http-adapter-portability' {
TRunOptions extends object,
TApp extends AppLike = AppLike,
> {
+ assertDoesNotCommitAbortedHttpErrorRepresentations(): Promise;
assertDefaultsMultipartTotalLimitToMaxBodySize(): Promise;
assertExcludesRawBodyForMultipart(): Promise;
assertPreservesExactRawBodyBytesForByteSensitivePayloads(): Promise;
@@ -32,6 +41,7 @@ declare module '@fluojs/testing/http-adapter-portability' {
assertReportsConfiguredHostInStartupLogs(): Promise;
assertReportsHttpsStartupUrl(https: { cert: string; key: string }): Promise;
assertSettlesStreamDrainWaitOnClose(): Promise;
+ assertSupportsHttpErrorRepresentations(): Promise;
assertSupportsSseStreaming(): Promise;
}
diff --git a/packages/react/README.ko.md b/packages/react/README.ko.md
index 823ee161b..a6f49781e 100644
--- a/packages/react/README.ko.md
+++ b/packages/react/README.ko.md
@@ -17,6 +17,7 @@ fluo 애플리케이션을 위한 런타임 중립 React 통합입니다.
- [Application Page Renderer](#application-page-renderer)
- [Render Policy Decorators](#render-policy-decorators)
- [SSR Diagnostic Phases](#ssr-diagnostic-phases)
+- [HTTP Error Documents](#http-error-documents)
- [Router 및 Path Decorators](#router-및-path-decorators)
- [Bootstrap-Resolved Page Catalog](#bootstrap-resolved-page-catalog)
- [Path-Only Page Type Generation](#path-only-page-type-generation)
@@ -392,6 +393,49 @@ ReactModule.forRoot({
노출합니다. 기존 entry `onRecoverableError` hook도 `ReactRecoverableErrorContext`의 `code`와 `phase`를
받습니다.
+## HTTP Error Documents
+
+Application이 HTTP error representation contract가 선택한 optional HTML byte를 React로 만들고 싶다면
+`createReactErrorRepresentationProvider(...)`를 사용한다.
+
+```tsx
+import { bootstrapApplication } from '@fluojs/runtime';
+import {
+ createReactErrorRepresentationProvider,
+ createReactServerEntry,
+} from '@fluojs/react';
+
+const html = createReactErrorRepresentationProvider({
+ renderDocument({ json }) {
+ return createReactServerEntry(
+
+ {json.error.status}: {json.error.message}
+ ,
+ );
+ },
+});
+
+const app = await bootstrapApplication({
+ errorRepresentation: { html },
+ rootModule: AppModule,
+});
+```
+
+`@fluojs/http`가 adapter 실행 전에 route-miss conversion과 `Accept` negotiation을 수행한다. Callback은
+canonical JSON과 active request-scope container를 포함한 `HttpErrorRepresentationContext`를 받지만 response
+mutation authority는 받지 않는다. React entry는 HTTP가 status, `Content-Type`, `Vary`, `HEAD` suppression,
+commit을 적용하기 전에 완전히 buffer된다.
+
+예제의 `json.error.message`는 JSX text child이므로 React가 escape한다. 그래도 complete trusted-HTML document
+contract는 application이 소유한다. Request-derived 또는 error-derived content를 application이 승인한 sanitizer나
+동등한 trusted-content boundary 없이 `dangerouslySetInnerHTML`로 옮기면 안 된다.
+
+이 path에서는 `ReactServerEntry.status`와 `ReactServerEntry.headers`를 무시한다. Helper는
+`ReactPageRenderer`, `PageLayout`, `PageMetadata`, `SuspenseFallback`, page catalog를 호출하지 않고 URL도
+match하지 않는다. Render failure는 HTTP의 one-shot canonical JSON fallback으로 전달된다. Matched-page
+pre-commit shell failure는 별도 React SSR diagnostic phase로 남으며 provider를 호출하지 않는다. 자세한 계약은
+[HTTP error representation decision](../../docs/architecture/http-error-representations.ko.md)을 참고한다.
+
## Router 및 Path Decorators
`@Router(basePath)`는 class를 React router로 표시하고 `@Controller(basePath)`와 동등한 HTTP
@@ -1083,8 +1127,8 @@ stable subpath를 추가하지 않고 deprecation window도 시작하지 않습
- `renderToPipeableStream(...)` 같은 Node 전용 `react-dom/server` pipeable stream root API
- Next.js-style segment `loading`, `error`, `notFound`, template 또는 layout ancestry semantic.
`@SuspenseFallback(...)`은 SSR-descendant Suspense metadata만 제공합니다.
-- generic page error-presentation policy 또는 page-local not-found renderer. Phase-specific error
- presentation과 HTTP-owned HTML not-found seam은 별도 ownership decision이 필요합니다.
+- generic page error-presentation policy 또는 page-local not-found renderer. Optional HTML은
+ global/application HTTP representation이며 page ancestry나 matching semantic을 추가하지 않습니다.
## Public API
@@ -1127,6 +1171,10 @@ stable subpath를 추가하지 않고 deprecation window도 시작하지 않습
- `ReactSsrDiagnostic`, `ReactSsrDiagnosticCode`, `ReactSsrDiagnosticErrorOptions`,
`ReactSsrDiagnosticHandler`, `ReactSsrDiagnosticPhase` — application diagnostics tooling을 위한
type-only contract입니다.
+- `createReactErrorRepresentationProvider` — application React error document renderer를 HTTP-owned HTML
+ provider seam에 adapt하고 commit 전에 buffer합니다.
+- `ReactErrorDocumentRenderer`, `ReactErrorRepresentationProviderOptions` — application callback, optional
+ availability constraint, renderer override를 위한 type-only contract입니다.
- `createReactServerEntry` — page handler가 Web Streams SSR을 위해 반환하는 runtime-neutral React server
entry를 생성합니다.
- `renderReactResponse` — lazy `react-dom/server` loading으로 React server entry 하나를 fluo HTML
@@ -1178,7 +1226,8 @@ stable subpath를 추가하지 않고 deprecation window도 시작하지 않습
- `@fluojs/core`: 스캐폴드가 사용하는 standard `@Module` decorator를 제공합니다.
- `@fluojs/http`: `@Router(...)`와 `@Path(...)`가 재사용하는 controller, route, DTO, guard,
interceptor, header, version metadata pipeline을 제공합니다.
-- `@fluojs/runtime`: 향후 React 통합 작업은 root import boundary를 넓히지 않고 runtime bootstrap contract와 합성될 예정입니다.
+- `@fluojs/runtime`: React root import boundary를 넓히지 않고 기존 application bootstrap contract와 함께
+ optional HTTP error representation을 등록합니다.
- `@fluojs/vite`: Vite TC39 decorator transform boundary를 소유합니다. React hydration manifest를 파싱하지
않으므로 React server/client asset mapping에는 `@fluojs/react/vite`를 사용하세요.
- Application-selected Flight renderer: RSC payload를 encode하고 renderer-specific build manifest를
@@ -1206,6 +1255,8 @@ stable subpath를 추가하지 않고 deprecation window도 시작하지 않습
- `packages/react/src/decorators.ts`
- `packages/react/src/server-entry.ts`
- `packages/react/src/render.ts`
+- `packages/react/src/error-representation.ts`
+- `packages/react/src/error-representation.test.ts`
- `packages/react/src/module.ts`
- `packages/react/src/page-renderer.ts`
- `packages/react/src/render-policy.ts`
diff --git a/packages/react/README.md b/packages/react/README.md
index 0c5cb769d..2d753d0d9 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -17,6 +17,7 @@ Runtime-neutral React integration for fluo applications.
- [Application Page Renderer](#application-page-renderer)
- [Render Policy Decorators](#render-policy-decorators)
- [SSR Diagnostic Phases](#ssr-diagnostic-phases)
+- [HTTP Error Documents](#http-error-documents)
- [Router and Path Decorators](#router-and-path-decorators)
- [Bootstrap-Resolved Page Catalog](#bootstrap-resolved-page-catalog)
- [Path-Only Page Type Generation](#path-only-page-type-generation)
@@ -397,6 +398,51 @@ request outcome. Stable phases and codes are:
The existing `onRecoverableError` entry hook also receives `code` and `phase` in
`ReactRecoverableErrorContext`.
+## HTTP Error Documents
+
+Use `createReactErrorRepresentationProvider(...)` when the application wants React to produce the
+optional HTML bytes selected by the HTTP error representation contract:
+
+```tsx
+import { bootstrapApplication } from '@fluojs/runtime';
+import {
+ createReactErrorRepresentationProvider,
+ createReactServerEntry,
+} from '@fluojs/react';
+
+const html = createReactErrorRepresentationProvider({
+ renderDocument({ json }) {
+ return createReactServerEntry(
+
+ {json.error.status}: {json.error.message}
+ ,
+ );
+ },
+});
+
+const app = await bootstrapApplication({
+ errorRepresentation: { html },
+ rootModule: AppModule,
+});
+```
+
+`@fluojs/http` performs route-miss conversion and `Accept` negotiation before this adapter runs.
+The callback receives `HttpErrorRepresentationContext`, including canonical JSON and the active
+request-scope container. It does not receive response mutation authority. The React entry is fully
+buffered before HTTP applies status, `Content-Type`, `Vary`, `HEAD` suppression, and commit.
+
+React escapes `json.error.message` in the example because it is a JSX text child. The application
+still owns the complete trusted-HTML document contract: do not move request-derived or error-derived
+content into `dangerouslySetInnerHTML` unless it has passed an application-approved sanitizer or
+equivalent trusted-content boundary.
+
+`ReactServerEntry.status` and `ReactServerEntry.headers` are ignored on this path. The helper never
+calls `ReactPageRenderer`, `PageLayout`, `PageMetadata`, `SuspenseFallback`, or the page catalog, and
+it never matches a URL. A render failure propagates to HTTP's one-shot canonical JSON fallback;
+matched-page pre-commit shell failures remain a separate React SSR diagnostic phase and do not invoke
+the provider. See the
+[HTTP error representation decision](../../docs/architecture/http-error-representations.md).
+
## Router and Path Decorators
`@Router(basePath)` marks a class as a React router and writes HTTP controller metadata equivalent
@@ -1098,8 +1144,8 @@ This package currently does **not** provide:
- Node-only `react-dom/server` pipeable stream root APIs such as `renderToPipeableStream(...)`
- Next.js-style segment `loading`, `error`, `notFound`, template, or layout ancestry semantics;
`@SuspenseFallback(...)` is SSR-descendant Suspense metadata only
-- a generic page error-presentation policy or page-local not-found renderer; phase-specific error
- presentation and any HTTP-owned HTML not-found seam require separate ownership decisions
+- a generic page error-presentation policy or page-local not-found renderer; optional HTML is a
+ global/application HTTP representation and does not add page ancestry or matching semantics
## Public API
@@ -1143,6 +1189,10 @@ This package currently does **not** provide:
- `ReactSsrDiagnostic`, `ReactSsrDiagnosticCode`, `ReactSsrDiagnosticErrorOptions`,
`ReactSsrDiagnosticHandler`, and `ReactSsrDiagnosticPhase` — type-only contracts for application
diagnostics tooling.
+- `createReactErrorRepresentationProvider` — adapts an application React error document renderer to
+ the HTTP-owned HTML provider seam while buffering before commit.
+- `ReactErrorDocumentRenderer` and `ReactErrorRepresentationProviderOptions` — type-only contracts
+ for the application callback, optional availability constraint, and renderer override.
- `createReactServerEntry` — creates a runtime-neutral React server entry returned by page handlers
for Web Streams SSR.
- `renderReactResponse` — renders one React server entry to a fluo HTML response with lazy
@@ -1194,8 +1244,8 @@ This package currently does **not** provide:
- `@fluojs/core`: Provides the standard `@Module` decorator used by the scaffold.
- `@fluojs/http`: Provides the controller, route, DTO, guard, interceptor, header, and version
metadata pipeline reused by `@Router(...)` and `@Path(...)`.
-- `@fluojs/runtime`: Future React integration work is expected to compose with runtime bootstrap
- contracts without widening the root import boundary.
+- `@fluojs/runtime`: Registers the optional HTTP error representation together with the existing
+ application bootstrap contracts without widening the React root import boundary.
- `@fluojs/vite`: Owns Vite's TC39 decorator transform boundary. It does not parse React hydration
manifests; use `@fluojs/react/vite` for React server/client asset mapping.
- Application-selected Flight renderer: Encodes RSC payloads and consumes renderer-specific build
@@ -1223,6 +1273,8 @@ This package currently does **not** provide:
- `packages/react/src/decorators.ts`
- `packages/react/src/server-entry.ts`
- `packages/react/src/render.ts`
+- `packages/react/src/error-representation.ts`
+- `packages/react/src/error-representation.test.ts`
- `packages/react/src/module.ts`
- `packages/react/src/page-renderer.ts`
- `packages/react/src/render-policy.ts`
diff --git a/packages/react/src/error-representation.test.ts b/packages/react/src/error-representation.test.ts
new file mode 100644
index 000000000..92b7a208f
--- /dev/null
+++ b/packages/react/src/error-representation.test.ts
@@ -0,0 +1,249 @@
+import { Module } from '@fluojs/core';
+import {
+ type FrameworkRequest,
+ type FrameworkResponse,
+ type HtmlErrorRepresentationProvider,
+ type HttpErrorRepresentationContext,
+ NotFoundException,
+} from '@fluojs/http';
+import { bootstrapApplication } from '@fluojs/runtime';
+import { createElement, type ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import * as reactApi from './index.js';
+import { ReactModule } from './module.js';
+import * as pageCatalogApi from './page-catalog.js';
+import { PageMetadata } from './page-metadata.js';
+import type { ReactPageRenderer } from './page-renderer.js';
+import { PageLayout, SuspenseFallback } from './render-policy.js';
+import {
+ type ReactReadableStreamRenderer,
+ type ReactRenderContext,
+ renderReactResponse,
+} from './render.js';
+import { createReactServerEntry, type ReactServerEntry } from './server-entry.js';
+import * as typegenApi from './typegen.js';
+import { Path, Router } from './decorators.js';
+
+type ErrorDocumentRenderer = (
+ context: HttpErrorRepresentationContext,
+) => ReactServerEntry | Promise;
+
+type ErrorProviderFactory = (options: {
+ readonly canRender?: HtmlErrorRepresentationProvider['canRender'];
+ readonly renderDocument: ErrorDocumentRenderer;
+ readonly renderToReadableStream?: ReactReadableStreamRenderer;
+}) => HtmlErrorRepresentationProvider;
+
+type TestResponse = FrameworkResponse & { body?: unknown };
+type ReactResponseWriterContext = {
+ readonly applySuccessResponseMetadata: () => void;
+ readonly requestContext: ReactRenderContext;
+};
+
+function isErrorProviderFactory(value: unknown): value is ErrorProviderFactory {
+ return typeof value === 'function';
+}
+
+function resolveErrorProviderFactory(): ErrorProviderFactory {
+ const candidate: unknown = Reflect.get(reactApi, 'createReactErrorRepresentationProvider');
+ if (!isErrorProviderFactory(candidate)) {
+ throw new Error('Expected createReactErrorRepresentationProvider to be exported.');
+ }
+ return candidate;
+}
+
+function createRequest(path: string): FrameworkRequest {
+ return {
+ cookies: {},
+ headers: { accept: 'text/html' },
+ method: 'GET',
+ params: {},
+ path,
+ query: {},
+ raw: {},
+ requestId: 'react-error-2889',
+ url: path,
+ };
+}
+
+function createResponse(): TestResponse {
+ return {
+ committed: false,
+ headers: {},
+ redirect(status, location) {
+ this.setStatus(status);
+ this.setHeader('Location', location);
+ this.committed = true;
+ },
+ send(body) {
+ this.body = body;
+ this.committed = true;
+ },
+ setHeader(name, value) {
+ this.headers[name] = value;
+ },
+ setStatus(code) {
+ this.statusCode = code;
+ this.statusSet = true;
+ },
+ };
+}
+
+function decodeBody(body: unknown): string {
+ if (body instanceof Uint8Array) {
+ return new TextDecoder().decode(body);
+ }
+ return typeof body === 'string' ? body : '';
+}
+
+describe('React HTTP error representation integration', () => {
+ it('renders an unmatched document without consulting page policies, the page catalog, or typegen', async () => {
+ const layout = vi.fn(({ children }: { readonly children: ReactNode }) => children);
+ const fallback = vi.fn(() => createElement('p', null, 'Loading'));
+ const metadata = vi.fn(() => ({ title: 'Matched page' }));
+ const renderPage = vi.fn((page) => createReactServerEntry(page));
+ const createPageCatalog = vi.spyOn(pageCatalogApi, 'createReactPageCatalog');
+ const generatePageTypes = vi.spyOn(typegenApi, 'generateReactPageTypes');
+
+ @PageLayout(layout)
+ @Router('/owned')
+ class OwnedRouter {
+ @PageMetadata(metadata)
+ @SuspenseFallback(fallback)
+ @Path('/missing')
+ missing(): never {
+ throw new NotFoundException('Owned resource missing.');
+ }
+ }
+
+ const renderDocument = vi.fn((context: HttpErrorRepresentationContext) => createReactServerEntry(
+ createElement('html', { lang: 'en' },
+ createElement('body', null, `${context.json.error.status}:${context.json.error.code}`)),
+ { headers: { 'x-react-entry': 'ignored' }, status: 299 },
+ ));
+ const provider = resolveErrorProviderFactory()({ renderDocument });
+
+ @Module({
+ imports: [ReactModule.forRoot({ controllers: [OwnedRouter], renderPage })],
+ })
+ class AppModule {}
+
+ const app = await bootstrapApplication({
+ errorRepresentation: { html: provider },
+ logger: { debug() {}, error() {}, log() {}, warn() {} },
+ rootModule: AppModule,
+ });
+
+ try {
+ const unmatchedResponse = createResponse();
+ const handlerResponse = createResponse();
+
+ await app.dispatch(createRequest('/not-registered'), unmatchedResponse);
+ await app.dispatch(createRequest('/owned/missing'), handlerResponse);
+
+ expect(unmatchedResponse.statusCode).toBe(404);
+ expect(unmatchedResponse.headers['Content-Type']).toBe('text/html; charset=utf-8');
+ expect(unmatchedResponse.headers['x-react-entry']).toBeUndefined();
+ expect(decodeBody(unmatchedResponse.body)).toContain('404:NOT_FOUND');
+ expect(handlerResponse.statusCode).toBe(404);
+ expect(decodeBody(handlerResponse.body)).toContain('404:NOT_FOUND');
+ expect(renderDocument.mock.calls[0]?.[0]).not.toHaveProperty('handler');
+ expect(renderDocument).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ handler: expect.objectContaining({ methodName: 'missing' }),
+ }));
+ expect(layout).not.toHaveBeenCalled();
+ expect(fallback).not.toHaveBeenCalled();
+ expect(metadata).not.toHaveBeenCalled();
+ expect(renderPage).not.toHaveBeenCalled();
+ expect(createPageCatalog).not.toHaveBeenCalled();
+ expect(generatePageTypes).not.toHaveBeenCalled();
+ } finally {
+ await app.close();
+ }
+ });
+
+ it('propagates React representation rendering failures to the HTTP non-recursive JSON fallback', async () => {
+ const representationError = new Error('React error document failed.');
+ const renderToReadableStream = vi.fn(async () => {
+ throw representationError;
+ });
+ const provider = resolveErrorProviderFactory()({
+ renderDocument: () => createReactServerEntry(createElement('html')),
+ renderToReadableStream,
+ });
+
+ @Module({})
+ class AppModule {}
+
+ const app = await bootstrapApplication({
+ errorRepresentation: { html: provider },
+ logger: { debug() {}, error() {}, log() {}, warn() {} },
+ rootModule: AppModule,
+ });
+
+ try {
+ const response = createResponse();
+ await app.dispatch(createRequest('/broken-document'), response);
+
+ expect(response.statusCode).toBe(404);
+ expect(response.headers['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toMatchObject({ error: { code: 'NOT_FOUND', status: 404 } });
+ expect(renderToReadableStream).toHaveBeenCalledTimes(1);
+ } finally {
+ await app.close();
+ }
+ });
+
+ it('keeps matched React pre-commit shell failures outside the HttpException representation phase', async () => {
+ const shellError = new Error('Matched page shell failed.');
+ const entry: ReactServerEntry = {
+ assetMap: {},
+ bootstrapModules: [],
+ bootstrapScripts: [],
+ headers: {},
+ node: createElement('html'),
+ };
+ Object.defineProperty(entry, Symbol.for('fluo.http.responseWriter'), {
+ value: async (context: ReactResponseWriterContext) => {
+ await renderReactResponse(entry, context.requestContext, {
+ applySuccessResponseMetadata: context.applySuccessResponseMetadata,
+ renderToReadableStream: async () => {
+ throw shellError;
+ },
+ });
+ },
+ });
+
+ @Router('/shell-failure')
+ class ShellFailureRouter {
+ @Path('/')
+ show() {
+ return entry;
+ }
+ }
+
+ const renderDocument = vi.fn(() => createReactServerEntry(createElement('html')));
+ const provider = resolveErrorProviderFactory()({ renderDocument });
+
+ @Module({ imports: [ReactModule.forRoot({ controllers: [ShellFailureRouter] })] })
+ class AppModule {}
+
+ const app = await bootstrapApplication({
+ errorRepresentation: { html: provider },
+ logger: { debug() {}, error() {}, log() {}, warn() {} },
+ rootModule: AppModule,
+ });
+
+ try {
+ const response = createResponse();
+ await app.dispatch(createRequest('/shell-failure'), response);
+
+ expect(response.statusCode).toBe(500);
+ expect(response.body).toMatchObject({ error: { code: 'INTERNAL_SERVER_ERROR', status: 500 } });
+ expect(renderDocument).not.toHaveBeenCalled();
+ } finally {
+ await app.close();
+ }
+ });
+});
diff --git a/packages/react/src/error-representation.ts b/packages/react/src/error-representation.ts
new file mode 100644
index 000000000..5ccd6f365
--- /dev/null
+++ b/packages/react/src/error-representation.ts
@@ -0,0 +1,51 @@
+import type { MaybePromise } from '@fluojs/core';
+import type {
+ HtmlErrorRepresentationProvider,
+ HttpErrorRepresentationContext,
+} from '@fluojs/http';
+
+import {
+ type ReactReadableStreamRenderer,
+ renderReactServerEntryToBytes,
+} from './render.js';
+import type { ReactServerEntry } from './server-entry.js';
+
+/** Application callback that creates one React error document after HTTP classifies the outcome. */
+export type ReactErrorDocumentRenderer = (
+ context: HttpErrorRepresentationContext,
+) => MaybePromise;
+
+/** Options for adapting an application React document renderer to the HTTP HTML provider seam. */
+export type ReactErrorRepresentationProviderOptions = {
+ /** Optional route/application constraint evaluated by the HTTP dispatcher before rendering. */
+ readonly canRender?: HtmlErrorRepresentationProvider['canRender'];
+ /** Creates the complete React document for the already-classified HTTP outcome. */
+ readonly renderDocument: ReactErrorDocumentRenderer;
+ /** Optional Web Streams renderer override used by custom integrations and tests. */
+ readonly renderToReadableStream?: ReactReadableStreamRenderer;
+};
+
+/**
+ * Adapts an application-owned React document renderer to HTTP error representation negotiation.
+ *
+ * @remarks
+ * Rendering is fully buffered before the HTTP dispatcher applies status, headers,
+ * or commit. `ReactServerEntry.status` and `ReactServerEntry.headers` are ignored,
+ * and this helper never performs matching or consults page render policies.
+ *
+ * @param options Application document renderer and optional availability/rendering hooks.
+ * @returns An HTTP HTML provider registered through `errorRepresentation.html`.
+ */
+export function createReactErrorRepresentationProvider(
+ options: ReactErrorRepresentationProviderOptions,
+): HtmlErrorRepresentationProvider {
+ return {
+ ...(options.canRender === undefined ? {} : { canRender: options.canRender }),
+ async render(context) {
+ const entry = await options.renderDocument(context);
+ return options.renderToReadableStream === undefined
+ ? renderReactServerEntryToBytes(entry, context)
+ : renderReactServerEntryToBytes(entry, context, options.renderToReadableStream);
+ },
+ };
+}
diff --git a/packages/react/src/index.test.ts b/packages/react/src/index.test.ts
index a3a8d3891..7f318aecb 100644
--- a/packages/react/src/index.test.ts
+++ b/packages/react/src/index.test.ts
@@ -27,6 +27,7 @@ describe('@fluojs/react root package scaffold', () => {
'ReactSsrDiagnosticError',
'Router',
'SuspenseFallback',
+ 'createReactErrorRepresentationProvider',
'createReactPageCatalog',
'createReactPageMetadataElements',
'createReactServerEntry',
@@ -57,6 +58,7 @@ describe('@fluojs/react root package scaffold', () => {
expect(react).toHaveProperty('REACT_PAGE_RENDERER');
expect(react).toHaveProperty('Router');
expect(react).toHaveProperty('SuspenseFallback');
+ expect(react).toHaveProperty('createReactErrorRepresentationProvider');
expect(react).toHaveProperty('createReactPageCatalog');
expect(react).toHaveProperty('createReactPageMetadataElements');
expect(react).toHaveProperty('createReactServerEntry');
@@ -80,6 +82,7 @@ describe('@fluojs/react root package scaffold', () => {
expect(rootEntrypoint).toContain("from './render-policy.js'");
expect(rootEntrypoint).toContain("from './decorators.js'");
expect(rootEntrypoint).toContain("from './diagnostics.js'");
+ expect(rootEntrypoint).toContain("from './error-representation.js'");
expect(rootEntrypoint).toContain("from './server-entry.js'");
expect(rootEntrypoint).toContain("from './render.js'");
expect(rootEntrypoint).not.toContain('react-dom/server');
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 568735e73..296978284 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -17,6 +17,11 @@ export {
REACT_SSR_DIAGNOSTIC_PHASES,
ReactSsrDiagnosticError,
} from './diagnostics.js';
+export type {
+ ReactErrorDocumentRenderer,
+ ReactErrorRepresentationProviderOptions,
+} from './error-representation.js';
+export { createReactErrorRepresentationProvider } from './error-representation.js';
export type { ReactModuleOptions } from './module.js';
export { ReactModule } from './module.js';
export type { ReactPageCatalogEntry } from './page-catalog.js';
diff --git a/packages/react/src/render.ts b/packages/react/src/render.ts
index 0d7083594..d5fc79e19 100644
--- a/packages/react/src/render.ts
+++ b/packages/react/src/render.ts
@@ -117,7 +117,7 @@ function hasAssetMap(assetMap: ReactAssetMap): boolean {
function createReactReadableStreamRenderOptions(
entry: ReactServerEntry,
- requestContext: ReactRenderContext,
+ requestContext: Pick,
onError: (error: unknown, errorInfo?: unknown) => void,
): ReactReadableStreamRenderOptions {
return {
@@ -153,6 +153,41 @@ async function defaultRenderToReadableStream(
return renderToReadableStream(node, createReactDomRenderOptions(options));
}
+/**
+ * Buffers a React server entry without mutating or committing a framework response.
+ *
+ * @param entry React server entry whose node and hydration assets are rendered.
+ * @param requestContext HTTP-owned request and abort surfaces.
+ * @param renderToReadableStream Optional Web Streams renderer override.
+ * @returns Complete HTML bytes after the render finishes without errors.
+ */
+export async function renderReactServerEntryToBytes(
+ entry: ReactServerEntry,
+ requestContext: Pick,
+ renderToReadableStream: ReactReadableStreamRenderer = defaultRenderToReadableStream,
+): Promise {
+ throwIfReactRequestAborted(requestContext.request);
+ let hasRenderError = false;
+ let renderError: unknown;
+ const stream = await renderToReadableStream(
+ entry.node,
+ createReactReadableStreamRenderOptions(entry, requestContext, (error) => {
+ if (!hasRenderError) {
+ hasRenderError = true;
+ renderError = error;
+ }
+ }),
+ );
+ const body = await collectReadableStream(stream, requestContext.request);
+ throwIfReactRequestAborted(requestContext.request);
+
+ if (hasRenderError) {
+ throw renderError;
+ }
+
+ return body;
+}
+
/**
* Renders a React server entry to one fluo HTML response using Web Streams SSR.
*
diff --git a/packages/runtime/README.ko.md b/packages/runtime/README.ko.md
index 5f50ce279..508410e9c 100644
--- a/packages/runtime/README.ko.md
+++ b/packages/runtime/README.ko.md
@@ -108,6 +108,41 @@ const app = await fluoFactory.create(AppModule, {
});
```
+### Optional HTML Error Representations
+
+`FluoFactory.create(...)`와 `bootstrapApplication(...)`은 `errorRepresentation`을 받아 HTTP dispatcher에
+변경 없이 전달합니다. JSON을 canonical representation으로 유지하면서 negotiated browser request에 complete
+HTML error/not-found document를 제공하려면 application-owned provider를 등록하세요.
+
+```typescript
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+const app = await fluoFactory.create(AppModule, {
+ adapter: createNodejsAdapter({ port: 3000 }),
+ errorRepresentation: {
+ html: {
+ render({ json }) {
+ return `${json.error.status}: ${escapeHtml(json.error.message)}`;
+ },
+ },
+ },
+});
+```
+
+Runtime은 이 option을 wiring만 합니다. Error classification, `Accept` negotiation, request scope, response status와
+header, `HEAD`, abort, commit, canonical JSON fallback은 `@fluojs/http`가 소유합니다. 반환 string/byte는
+application이 책임지는 trusted HTML입니다. Runtime은 request-derived 또는 error-derived value를 escape하거나
+sanitize하지 않으므로 provider가 interpolation 전에 처리해야 합니다. Standalone application
+context는 HTTP dispatcher를 생성하지 않으므로 이 option을 사용하지 않습니다. 자세한 내용은
+[HTTP package contract](../http/README.ko.md#http-error-representations)를 참고하세요.
+
### Framework-managed response와 handler-owned response
일반 request path는 framework-managed 방식입니다. Handler가 값을 반환하면 interceptor가 그 값을 변환할 수 있고, runtime response writer가 최종 결과를 commit합니다. `@fluojs/serialization`의 `SerializerInterceptor`가 반환 DTO에 적용되는 경로도 이 경로입니다.
@@ -155,6 +190,7 @@ class UsersModule {}
- `@fluojs/runtime/web` 멀티파트 파싱은 Node.js `Buffer` global 없이 Web 표준 `TextEncoder`와 `Uint8Array` primitive만 사용합니다. 업로드 파일의 `buffer` 값은 `Uint8Array`이며, Node 전용 consumer는 애플리케이션 경계에서 `Buffer.from(file.buffer)`로 명시적으로 변환할 수 있습니다.
- `createNodeHttpAdapter(...)`, `bootstrapNodeApplication(...)`, `runNodeApplication(...)`는 `maxBodySize`를 0 이상의 정수 바이트 수로만 받으며, 값이 잘못되면 어댑터 생성/부트스트랩 단계에서 즉시 실패합니다.
- 응답 스트림 백프레셔 헬퍼는 `drain`, `close`, `error` 중 어느 경우에도 `waitForDrain()`을 완료시켜 끊어진 연결에서 스트리밍 작성기가 멈추지 않도록 합니다.
+- HTTP application bootstrap은 optional application-owned `errorRepresentation.html` provider를 representation ownership 없이 dispatcher에 전달합니다. Canonical JSON은 default로 유지되며 classification, negotiation, status/header, `HEAD`, abort, commit, fallback 의미는 HTTP가 소유합니다.
- HTTP response writing은 단일 owner를 가집니다. Framework-managed handler 결과는 runtime이 commit하기 전에 interceptor가 변환할 수 있습니다. Handler나 response helper가 `RequestContext.response`를 commit한 뒤에는 dispatcher가 두 번째 success-response write를 건너뜁니다. `SerializerInterceptor`는 serialization을 우회하고 `next.handle()`에서 받은 값을 그대로 반환하지만, 다른 interceptor는 chain 결과를 계속 변환할 수 있습니다.
- 런타임 health 모듈은 bootstrap이 ready로 표시하기 전까지 `/ready`를 HTTP 503과 `starting`으로 보고하며, 애플리케이션/컨텍스트 종료가 시작되는 즉시, 종료 시도가 실패하더라도 다시 `starting`으로 내려갑니다.
- 런타임 health module readiness check는 현재 `RequestContext`를 받으므로, public integration이 internal runtime token을 import하지 않고도 runtime-exposed status provider를 해석할 수 있습니다.
@@ -181,7 +217,7 @@ class UsersModule {}
- `RuntimeHealthModule`: `HealthModule.forRoot(...)`가 반환하는 module class contract이며 `addReadinessCheck(...)`, `markReady()`, `markStarting()`을 포함합니다.
- `ReadinessCheck`: runtime health module이 사용하는 function type입니다. Check는 `/ready` request context를 받고 boolean 또는 promise를 반환합니다.
- `defineModule(cls, metadata)`: 프로그래밍 방식의 모듈 정의 헬퍼입니다.
-- `bootstrapApplication(options)`: 저수준 비동기 부트스트랩 함수입니다.
+- `bootstrapApplication(options)`: 저수준 비동기 부트스트랩 함수입니다. `BootstrapApplicationOptions.errorRepresentation`은 optional HTTP-owned HTML representation provider를 등록하며 `CreateApplicationOptions`는 `FluoFactory.create(...)`에서 같은 field를 노출합니다.
- `bootstrapModule(...)`: 저수준 module graph bootstrap helper입니다. `BootstrapModuleOptions`에는 opt-in compile-result cache를 위한 `moduleGraphCache`와 authored module identity를 안정적으로 유지하는 testing-only module replacement compilation을 위한 `moduleReplacements` / `ModuleReplacementMap`이 포함됩니다.
- `createBootstrapTimingDiagnostics(...)`, `createRuntimeDiagnosticsGraph(...)`: CLI/support tooling을 위한 runtime 소유 diagnostics snapshot helper입니다. 이 helper들은 기계 읽기 가능한 데이터를 생산하며, Studio가 viewer parsing, graph presentation, Mermaid rendering을 소유합니다.
- `createRuntimeRouteInspection(...)`, `createRuntimeRouteCatalog(...)`, `createRuntimeInspectionSnapshot(...)`: HTTP route behavior를 변경하지 않고 platform snapshot에 effective compiled route diagnostics를 추가하는 runtime-owned immutable projection입니다.
diff --git a/packages/runtime/README.md b/packages/runtime/README.md
index 9637af0e0..60abff860 100644
--- a/packages/runtime/README.md
+++ b/packages/runtime/README.md
@@ -108,6 +108,41 @@ const app = await fluoFactory.create(AppModule, {
});
```
+### Optional HTML Error Representations
+
+`FluoFactory.create(...)` and `bootstrapApplication(...)` accept `errorRepresentation` and pass it
+unchanged to the HTTP dispatcher. Register an application-owned provider when negotiated browser
+requests should receive complete HTML error or not-found documents while JSON remains canonical:
+
+```typescript
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+const app = await fluoFactory.create(AppModule, {
+ adapter: createNodejsAdapter({ port: 3000 }),
+ errorRepresentation: {
+ html: {
+ render({ json }) {
+ return `${json.error.status}: ${escapeHtml(json.error.message)}`;
+ },
+ },
+ },
+});
+```
+
+Runtime only wires this option. `@fluojs/http` owns error classification, `Accept` negotiation,
+request scope, response status and headers, `HEAD`, abort, commit, and canonical JSON fallback.
+The returned string or bytes are trusted application HTML: runtime does not escape or sanitize
+request-derived or error-derived values, so the provider must do so before interpolation.
+Standalone application contexts do not use the option because they do not create an HTTP dispatcher.
+See the [HTTP package contract](../http#http-error-representations).
+
### Framework-Managed and Handler-Owned Responses
The normal request path is framework-managed: a handler returns a value, interceptors may transform it, and the runtime response writer commits it. This is the path where `@fluojs/serialization` can apply `SerializerInterceptor` to a returned DTO.
@@ -155,6 +190,7 @@ class UsersModule {}
- `@fluojs/runtime/web` multipart parsing uses Web-standard `TextEncoder` and `Uint8Array` primitives without requiring the Node.js `Buffer` global. Uploaded file `buffer` values are `Uint8Array`; Node-only consumers can convert them explicitly with `Buffer.from(file.buffer)` at their application boundary.
- `createNodeHttpAdapter(...)`, `bootstrapNodeApplication(...)`, and `runNodeApplication(...)` accept `maxBodySize` only as a non-negative integer byte count and fail fast during adapter creation/bootstrap when the value is invalid.
- Response stream backpressure helpers settle `waitForDrain()` on `drain`, `close`, or `error` so streaming writers do not hang on dead connections.
+- HTTP application bootstrap passes an optional application-owned `errorRepresentation.html` provider to the dispatcher without taking representation ownership. Canonical JSON remains the default; HTTP keeps classification, negotiation, status/header, `HEAD`, abort, commit, and fallback semantics.
- HTTP response writing is single-owner: framework-managed handler results may be transformed by interceptors before the runtime commits them. Once a handler or response helper commits `RequestContext.response`, the dispatcher skips a second success-response write. `SerializerInterceptor` bypasses serialization and returns the value it received from `next.handle()` unchanged, while other interceptors may still transform the chain result.
- Runtime health modules report `/ready` as `starting` with HTTP 503 until bootstrap marks them ready, and they return to `starting` as soon as application/context shutdown begins, including failed shutdown attempts.
- Runtime health module readiness checks receive the current `RequestContext`, allowing public integrations to resolve runtime-exposed status providers without importing internal runtime tokens.
@@ -181,7 +217,7 @@ class UsersModule {}
- `RuntimeHealthModule`: Module class contract returned by `HealthModule.forRoot(...)`, including `addReadinessCheck(...)`, `markReady()`, and `markStarting()`.
- `ReadinessCheck`: Function type used by runtime health modules. Checks receive the `/ready` request context and return a boolean or promise.
- `defineModule(cls, metadata)`: Programmatic module definition helper.
-- `bootstrapApplication(options)`: Lower-level async bootstrap function.
+- `bootstrapApplication(options)`: Lower-level async bootstrap function. `BootstrapApplicationOptions.errorRepresentation` registers the optional HTTP-owned HTML representation provider; `CreateApplicationOptions` exposes the same field through `FluoFactory.create(...)`.
- `bootstrapModule(...)`: Lower-level module graph bootstrap helper. Its `BootstrapModuleOptions` include `moduleGraphCache` for opt-in compile-result caching and `moduleReplacements` / `ModuleReplacementMap` for testing-only module replacement compilation that keeps authored module identities stable.
- `createBootstrapTimingDiagnostics(...)`, `createRuntimeDiagnosticsGraph(...)`: Runtime-owned diagnostics snapshot helpers for CLI/support tooling. They produce machine-readable data; Studio owns viewer parsing, graph presentation, and Mermaid rendering.
- `createRuntimeRouteInspection(...)`, `createRuntimeRouteCatalog(...)`, and `createRuntimeInspectionSnapshot(...)`: Runtime-owned immutable projections that add effective compiled route diagnostics to platform snapshots without changing HTTP route behavior.
diff --git a/packages/runtime/src/bootstrap.ts b/packages/runtime/src/bootstrap.ts
index 6601f2afd..89121ba41 100644
--- a/packages/runtime/src/bootstrap.ts
+++ b/packages/runtime/src/bootstrap.ts
@@ -1391,6 +1391,9 @@ function createRuntimeDispatcherOptions(
const converters = options.converters ?? [];
const dispatcherOptions: ErrorAwareDispatcherOptions = {
appMiddleware: options.middleware ?? [],
+ ...(options.errorRepresentation === undefined
+ ? {}
+ : { errorRepresentation: options.errorRepresentation }),
handlerMapping,
interceptors: options.interceptors ?? [],
logger,
diff --git a/packages/runtime/src/error-representation.test.ts b/packages/runtime/src/error-representation.test.ts
new file mode 100644
index 000000000..7f174e063
--- /dev/null
+++ b/packages/runtime/src/error-representation.test.ts
@@ -0,0 +1,81 @@
+import { Module } from '@fluojs/core';
+import {
+ type FrameworkRequest,
+ type FrameworkResponse,
+ type HttpErrorRepresentationOptions,
+} from '@fluojs/http';
+import { describe, expect, it, vi } from 'vitest';
+
+import { bootstrapApplication } from './bootstrap.js';
+import type { BootstrapApplicationOptions } from './types.js';
+
+type TestResponse = FrameworkResponse & { body?: unknown };
+type BootstrapOptionsWithErrorRepresentation = BootstrapApplicationOptions & {
+ readonly errorRepresentation: HttpErrorRepresentationOptions;
+};
+
+function createRequest(accept: string): FrameworkRequest {
+ return {
+ cookies: {},
+ headers: { accept },
+ method: 'GET',
+ params: {},
+ path: '/runtime-missing',
+ query: {},
+ raw: {},
+ url: '/runtime-missing',
+ };
+}
+
+function createResponse(): TestResponse {
+ return {
+ committed: false,
+ headers: {},
+ redirect(status, location) {
+ this.setStatus(status);
+ this.setHeader('Location', location);
+ this.committed = true;
+ },
+ send(body) {
+ this.body = body;
+ this.committed = true;
+ },
+ setHeader(name, value) {
+ this.headers[name] = value;
+ },
+ setStatus(code) {
+ this.statusCode = code;
+ this.statusSet = true;
+ },
+ };
+}
+
+describe('runtime HTTP error representation registration', () => {
+ it('forwards the application HTML provider into the dispatcher without bypassing JSON clients', async () => {
+ @Module({})
+ class AppModule {}
+
+ const render = vi.fn(({ json }) => `${json.error.code}`);
+ const options: BootstrapOptionsWithErrorRepresentation = {
+ errorRepresentation: { html: { render } },
+ rootModule: AppModule,
+ };
+ const app = await bootstrapApplication(options);
+
+ try {
+ const htmlResponse = createResponse();
+ const jsonResponse = createResponse();
+
+ await app.dispatch(createRequest('text/html'), htmlResponse);
+ await app.dispatch(createRequest('application/json'), jsonResponse);
+
+ expect(htmlResponse.statusCode).toBe(404);
+ expect(htmlResponse.body).toBe('NOT_FOUND');
+ expect(jsonResponse.statusCode).toBe(404);
+ expect(jsonResponse.body).toMatchObject({ error: { code: 'NOT_FOUND', status: 404 } });
+ expect(render).toHaveBeenCalledTimes(1);
+ } finally {
+ await app.close();
+ }
+ });
+});
diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts
index b1f27946a..c6d944e58 100644
--- a/packages/runtime/src/types.ts
+++ b/packages/runtime/src/types.ts
@@ -5,6 +5,7 @@ import type {
Dispatcher,
FrameworkRequest,
FrameworkResponse,
+ HttpErrorRepresentationOptions,
HttpApplicationAdapter,
InterceptorLike,
MiddlewareLike,
@@ -139,6 +140,8 @@ export interface ExceptionFilterHandler {
/** High-level bootstrap options for creating an HTTP application shell. */
export interface BootstrapApplicationOptions {
adapter?: HttpApplicationAdapter;
+ /** Application-owned HTML provider for HTTP-classified error and not-found outcomes. */
+ errorRepresentation?: HttpErrorRepresentationOptions;
/**
* Enables the opt-in process-local module graph compile result cache for this
* bootstrap. The default is `false`, so each bootstrap compiles a fresh graph
diff --git a/packages/testing/README.ko.md b/packages/testing/README.ko.md
index a9bf7b56f..85e2b35b9 100644
--- a/packages/testing/README.ko.md
+++ b/packages/testing/README.ko.md
@@ -120,7 +120,7 @@ try {
`app.request(...).send()`는 수동 `FrameworkRequest`/`FrameworkResponse` stub 없이 HTTP 의미에 가까운 테스트를 작성하게 해 주고 runtime dispatch와 같은 isolated request-scoped DI boundary를 생성하므로 애플리케이션 개발자의 기본 경로입니다. Assertion 실패가 runtime resource 누수로 이어지지 않도록 반환된 app은 `finally` 블록에서 닫으세요. `app.dispatch(...)`, `makeRequest(...)`, raw `FluoFactory.create(...)` 테스트는 adapter/runtime contract, framework internal, 또는 low-level dispatch boundary 자체를 증명해야 하는 compatibility case에 남겨 둡니다.
-`createTestApp(...)`은 runtime HTTP bootstrap과 같은 application bootstrap option을 받습니다. 여기에는 `providers`, `filters`, `converters`, `interceptors`, `middleware`, `observers`, `versioning`, diagnostics option이 포함됩니다. 테스트 헬퍼는 request-context middleware를 앞에 추가하되, 호출자가 넘긴 middleware를 같은 app middleware chain 안에 보존합니다.
+`createTestApp(...)`은 runtime HTTP bootstrap과 같은 application bootstrap option을 받습니다. 여기에는 `providers`, `filters`, `converters`, `interceptors`, `middleware`, `observers`, `versioning`, `errorRepresentation`, diagnostics option이 포함됩니다. 따라서 application test는 같은 virtual request pipeline으로 canonical JSON, negotiated HTML, `HEAD`, 406, provider fallback을 검증할 수 있습니다. 테스트 헬퍼는 request-context middleware를 앞에 추가하되, 호출자가 넘긴 middleware를 같은 app middleware chain 안에 보존합니다.
### 명시적 서브패스의 mock 헬퍼
@@ -146,6 +146,16 @@ const mailer = createDeepMock(MailService);
HTTP 어댑터가 런타임 전반에서 `rawBody`의 byte-sensitive payload byte를 그대로 보존하는지 증명해야 할 때는 `assertPreservesExactRawBodyBytesForByteSensitivePayloads()`를 사용하세요.
+JSON, HTML, `HEAD`, unsupported `Accept` 406, already-committed response 동작을 증명하려면
+`assertSupportsHttpErrorRepresentations()`를 사용하세요. Network harness는 shared
+`NetworkHttpErrorRepresentationBootstrapOptions`를 adapt하고 fetch-style harness는
+`WebHttpErrorRepresentationBootstrapOptions`를 adapt합니다. Adapter bootstrap type에 추가 required field가
+있다면 `createErrorRepresentationBootstrapOptions`를 제공하세요. Typed builder는 common fixture field만 받아 cast
+없이 해당 adapter의 complete bootstrap option을 반환합니다.
+`assertDoesNotCommitAbortedHttpErrorRepresentations()`는 HTML provider를 시작한 뒤 adapter의 native request
+surface를 통해 abort하고, cancellation 이후 provider result와 canonical JSON fallback 어느 쪽도 write되지 않음을
+증명합니다.
+
## canonical TDD ladder
애플리케이션 기능 테스트는 가장 작은 명시적 dependency boundary에서 시작해 바깥쪽으로 확장합니다.
@@ -171,7 +181,7 @@ fluo는 테스트가 명시적인 `rootModule`을 이름으로 지정해야 한
- **루트 패키지**: `createTestingModule(...)`, `Test.createTestingModule(...)`, `createTestApp(...)`, 모듈 introspection 헬퍼, `DeepMocked`를 포함한 공용 app/module 테스트 타입
- **서브패스**: `@fluojs/testing/app`, `@fluojs/testing/module`, `@fluojs/testing/http`, `@fluojs/testing/mock` (`DeepMocked` 포함), `@fluojs/testing/types` (`DeepMocked` 포함), `@fluojs/testing/vitest`, `@fluojs/testing/vitest/tooling`
-- **하니스 서브패스**: `platform-conformance`, `http-adapter-portability`, `web-runtime-adapter-portability`, `fetch-style-websocket-conformance`
+- **하니스 서브패스**: `platform-conformance`, `http-adapter-portability`, `web-runtime-adapter-portability`, `fetch-style-websocket-conformance`. HTTP portability harness는 adapter-owned bootstrap typing을 위해 `assertSupportsHttpErrorRepresentations()`, `assertDoesNotCommitAbortedHttpErrorRepresentations()`, `createErrorRepresentationBootstrapOptions`, `NetworkHttpErrorRepresentationBootstrapOptions`, `WebHttpErrorRepresentationBootstrapOptions`를 노출합니다.
- **도구 지원**: `@fluojs/testing/vitest`의 `fluoBabelDecoratorsPlugin()` 및 `@fluojs/testing/vitest/tooling`의 Vitest workspace config helper (`vitest`와 `@babel/core`를 함께 요구)
Package manifest는 `engines.node >=20.0.0`을 선언합니다. 문서화된 경우 non-Node runtime 애플리케이션 테스트에서 runtime-native 도구를 사용할 수 있지만, 배포된 `@fluojs/testing` 패키지 자체는 이 Node.js engine floor를 따릅니다.
@@ -187,5 +197,6 @@ Package manifest는 `engines.node >=20.0.0`을 선언합니다. 문서화된 경
## 예제 소스
- `packages/testing/src/module.test.ts`
+- `packages/testing/src/portability/error-representation-portability.ts`
- `examples/minimal/src/app.test.ts`
- `examples/auth-jwt-passport/src/app.test.ts`
diff --git a/packages/testing/README.md b/packages/testing/README.md
index eafe7646b..2601ada47 100644
--- a/packages/testing/README.md
+++ b/packages/testing/README.md
@@ -118,7 +118,7 @@ try {
`app.request(...).send()` is the preferred app-developer path because it keeps tests close to HTTP semantics without manual `FrameworkRequest`/`FrameworkResponse` stubs and creates the same isolated request-scoped DI boundary as runtime dispatch. Close the returned app from a `finally` block so assertion failures do not leak runtime resources. Keep `app.dispatch(...)`, `makeRequest(...)`, and raw `FluoFactory.create(...)` tests for adapter/runtime contracts, framework internals, or compatibility cases where the low-level dispatch boundary itself is what the test must prove.
-`createTestApp(...)` accepts the same application bootstrap options as the runtime HTTP bootstrap, including `providers`, `filters`, `converters`, `interceptors`, `middleware`, `observers`, `versioning`, and diagnostics options. The testing helper prepends its request-context middleware while preserving caller-provided middleware in the same app middleware chain.
+`createTestApp(...)` accepts the same application bootstrap options as the runtime HTTP bootstrap, including `providers`, `filters`, `converters`, `interceptors`, `middleware`, `observers`, `versioning`, `errorRepresentation`, and diagnostics options. This lets application tests assert canonical JSON, negotiated HTML, `HEAD`, 406, and provider fallback behavior through the same virtual request pipeline. The testing helper prepends its request-context middleware while preserving caller-provided middleware in the same app middleware chain.
### Mock helpers from explicit subpaths
@@ -144,6 +144,16 @@ Portability harness cleanup is part of the contract: if setup, `listen()`, a run
Use `assertPreservesExactRawBodyBytesForByteSensitivePayloads()` when an HTTP adapter must prove `rawBody` keeps byte-sensitive payload bytes intact across runtimes.
+Use `assertSupportsHttpErrorRepresentations()` to prove JSON, HTML, `HEAD`, unsupported `Accept`
+406, and already-committed response behavior. Network harnesses adapt the shared
+`NetworkHttpErrorRepresentationBootstrapOptions`; fetch-style harnesses adapt
+`WebHttpErrorRepresentationBootstrapOptions`. Supply `createErrorRepresentationBootstrapOptions`
+when an adapter's bootstrap type contains additional required fields—the typed builder receives only
+the common fixture fields and returns that adapter's complete bootstrap options without casts.
+Use `assertDoesNotCommitAbortedHttpErrorRepresentations()` to start an HTML provider, abort through
+the adapter's native request surface, and prove that neither the provider result nor canonical JSON
+fallback is written after cancellation.
+
## Canonical TDD Ladder
For application features, build tests from the smallest explicit dependency boundary outward:
@@ -169,7 +179,7 @@ fluo differs from NestJS by requiring tests to name an explicit `rootModule`. Th
- **Root package**: `createTestingModule(...)`, `Test.createTestingModule(...)`, `createTestApp(...)`, module introspection helpers, and shared app/module testing types including `DeepMocked`
- **Subpaths**: `@fluojs/testing/app`, `@fluojs/testing/module`, `@fluojs/testing/http`, `@fluojs/testing/mock` (including `DeepMocked`), `@fluojs/testing/types` (including `DeepMocked`), `@fluojs/testing/vitest`, `@fluojs/testing/vitest/tooling`
-- **Harness subpaths**: `platform-conformance`, `http-adapter-portability`, `web-runtime-adapter-portability`, `fetch-style-websocket-conformance`
+- **Harness subpaths**: `platform-conformance`, `http-adapter-portability`, `web-runtime-adapter-portability`, `fetch-style-websocket-conformance`. The HTTP portability harnesses expose `assertSupportsHttpErrorRepresentations()`, `assertDoesNotCommitAbortedHttpErrorRepresentations()`, `createErrorRepresentationBootstrapOptions`, `NetworkHttpErrorRepresentationBootstrapOptions`, and `WebHttpErrorRepresentationBootstrapOptions` for adapter-owned bootstrap typing.
- **Tooling**: `@fluojs/testing/vitest` with `fluoBabelDecoratorsPlugin()` and `@fluojs/testing/vitest/tooling` with Vitest workspace config helpers (requires `vitest` and `@babel/core` in the consuming workspace)
The package manifest declares `engines.node >=20.0.0`. Non-Node runtime application tests can still use runtime-native tools where documented, but the published `@fluojs/testing` package itself is governed by that Node.js engine floor.
@@ -185,5 +195,6 @@ The package manifest declares `engines.node >=20.0.0`. Non-Node runtime applicat
## Example Sources
- `packages/testing/src/module.test.ts`
+- `packages/testing/src/portability/error-representation-portability.ts`
- `examples/minimal/src/app.test.ts`
- `examples/auth-jwt-passport/src/app.test.ts`
diff --git a/packages/testing/src/portability/error-representation-abort-portability.test.ts b/packages/testing/src/portability/error-representation-abort-portability.test.ts
new file mode 100644
index 000000000..13630b48c
--- /dev/null
+++ b/packages/testing/src/portability/error-representation-abort-portability.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ createHttpAdapterPortabilityHarness,
+ type NetworkHttpErrorRepresentationBootstrapOptions,
+} from './http-adapter-portability.js';
+
+describe('HTTP error-representation abort portability', () => {
+ it('passes a request-finish observer to the network abort bootstrap', async () => {
+ const bootstrapInspected = new Error('network abort bootstrap inspected');
+ const harness = createHttpAdapterPortabilityHarness({
+ async bootstrap(_rootModule, options: NetworkHttpErrorRepresentationBootstrapOptions) {
+ expect(options.observers).toHaveLength(1);
+ expect(options.observers[0]?.onRequestFinish).toEqual(expect.any(Function));
+ throw bootstrapInspected;
+ },
+ createErrorRepresentationBootstrapOptions: (options) => options,
+ name: 'abort-lifecycle-completion',
+ async run() {
+ throw new Error('run should not be used');
+ },
+ });
+
+ await expect(harness.assertDoesNotCommitAbortedHttpErrorRepresentations()).rejects.toBe(bootstrapInspected);
+ });
+});
diff --git a/packages/testing/src/portability/error-representation-abort-portability.ts b/packages/testing/src/portability/error-representation-abort-portability.ts
new file mode 100644
index 000000000..2b25cf8e2
--- /dev/null
+++ b/packages/testing/src/portability/error-representation-abort-portability.ts
@@ -0,0 +1,271 @@
+import type {
+ HtmlErrorRepresentationProvider,
+ Middleware,
+ RequestObserver,
+} from '@fluojs/http';
+import { defineModule, type ModuleType } from '@fluojs/runtime';
+
+import type {
+ NetworkHttpErrorRepresentationBootstrapOptions,
+ WebHttpErrorRepresentationBootstrapOptions,
+} from './error-representation-portability.js';
+
+type NetworkApp = {
+ close(): Promise;
+ listen(): Promise;
+};
+
+type WebApp = {
+ close(): Promise;
+ dispatch(request: Request): Promise;
+};
+
+type NetworkHarnessOptions = {
+ readonly bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ readonly createBootstrapOptions: (
+ options: NetworkHttpErrorRepresentationBootstrapOptions,
+ ) => TBootstrapOptions;
+ readonly name: string;
+};
+
+type WebHarnessOptions = {
+ readonly bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ readonly createBootstrapOptions: (
+ options: WebHttpErrorRepresentationBootstrapOptions,
+ ) => TBootstrapOptions;
+ readonly name: string;
+};
+
+type Deferred = {
+ readonly promise: Promise;
+ readonly resolve: () => void;
+};
+
+type AbortProbe = {
+ readonly bootstrapOptions: WebHttpErrorRepresentationBootstrapOptions;
+ readonly providerAborted: Promise;
+ readonly providerStarted: Promise;
+ readonly requestFinished: Promise;
+ readonly requestObserver: RequestObserver;
+ assertNoCommit(name: string): void;
+};
+
+type ListenTarget = { readonly url: string };
+type AdapterWithListenTarget = { getListenTarget(): ListenTarget };
+
+function createDeferred(): Deferred {
+ let resolve = (): void => {};
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+function createAbortProbe(): AbortProbe {
+ const providerAborted = createDeferred();
+ const providerStarted = createDeferred();
+ const requestFinished = createDeferred();
+ let providerCalls = 0;
+ const representationWrites: unknown[] = [];
+ const middleware: Middleware = {
+ async handle(context, next) {
+ const send = context.response.send.bind(context.response);
+ context.response.send = (body: unknown) => {
+ if (body !== undefined) {
+ representationWrites.push(body);
+ }
+ return send(body);
+ };
+
+ await next();
+ },
+ };
+ const requestObserver: RequestObserver = {
+ onRequestFinish() {
+ requestFinished.resolve();
+ },
+ };
+ const html: HtmlErrorRepresentationProvider = {
+ render({ request }) {
+ providerCalls += 1;
+ providerStarted.resolve();
+
+ return new Promise((resolve, reject) => {
+ const signal = request.signal;
+ if (signal === undefined) {
+ reject(new Error('The adapter did not expose an AbortSignal to the HTML error provider.'));
+ return;
+ }
+
+ const completeAfterAbort = (): void => {
+ providerAborted.resolve();
+ resolve('late error document');
+ };
+
+ if (signal.aborted) {
+ completeAfterAbort();
+ return;
+ }
+
+ signal.addEventListener('abort', completeAfterAbort, { once: true });
+ });
+ },
+ };
+
+ return {
+ assertNoCommit(name) {
+ if (providerCalls !== 1) {
+ throw new Error(`${name} did not execute exactly one in-flight HTML error provider before abort.`);
+ }
+ if (representationWrites.length !== 0) {
+ throw new Error(
+ `${name} committed an HTML or canonical JSON fallback response after abort: ${JSON.stringify(representationWrites)}.`,
+ );
+ }
+ },
+ bootstrapOptions: {
+ cors: false,
+ errorRepresentation: { html },
+ middleware: [middleware],
+ },
+ providerAborted: providerAborted.promise,
+ providerStarted: providerStarted.promise,
+ requestFinished: requestFinished.promise,
+ requestObserver,
+ };
+}
+
+function hasListenTarget(value: unknown): value is AdapterWithListenTarget {
+ return typeof value === 'object'
+ && value !== null
+ && 'getListenTarget' in value
+ && typeof value.getListenTarget === 'function';
+}
+
+function resolveListeningUrl(app: NetworkApp, name: string): string {
+ const adapter: unknown = Reflect.get(app, 'adapter');
+ if (!hasListenTarget(adapter)) {
+ throw new Error(`${name} abort portability check could not resolve its listener URL.`);
+ }
+ return adapter.getListenTarget().url;
+}
+
+async function withTimeout(promise: Promise, name: string, phase: string): Promise {
+ let timeout: ReturnType | undefined;
+ const timeoutPromise = new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => {
+ reject(new Error(`${name} timed out while waiting for ${phase}.`));
+ }, 2_000);
+ });
+
+ try {
+ await Promise.race([promise, timeoutPromise]);
+ } finally {
+ if (timeout !== undefined) {
+ clearTimeout(timeout);
+ }
+ }
+}
+
+async function closeAfterAbortAssertion(
+ app: NetworkApp | WebApp,
+ name: string,
+ assertion: () => Promise,
+): Promise {
+ let assertionError: unknown;
+ try {
+ await assertion();
+ } catch (error) {
+ assertionError = error;
+ }
+
+ try {
+ await app.close();
+ } catch (cleanupError) {
+ throw assertionError === undefined
+ ? cleanupError
+ : new AggregateError([assertionError, cleanupError], `${name} abort assertion and cleanup both failed.`);
+ }
+
+ if (assertionError !== undefined) {
+ throw assertionError;
+ }
+}
+
+function createEmptyModule(): ModuleType {
+ class AppModule {}
+ defineModule(AppModule, {});
+ return AppModule;
+}
+
+/**
+ * Verifies that a disconnected network request commits neither HTML nor canonical JSON fallback.
+ *
+ * @param options Adapter bootstrap and identity callbacks.
+ * @returns A promise that resolves after native disconnect and no-representation-write checks pass.
+ */
+export async function assertNetworkHttpErrorRepresentationAbortPortability<
+ TBootstrapOptions extends object,
+ TApp extends NetworkApp,
+>(options: NetworkHarnessOptions): Promise {
+ const probe = createAbortProbe();
+ const app = await options.bootstrap(
+ createEmptyModule(),
+ options.createBootstrapOptions({
+ ...probe.bootstrapOptions,
+ observers: [probe.requestObserver],
+ port: 0,
+ }),
+ );
+
+ await closeAfterAbortAssertion(app, options.name, async () => {
+ await app.listen();
+ const { request } = await import('node:http');
+ const clientRequest = request(`${resolveListeningUrl(app, options.name)}/abort-error-representation`, {
+ headers: { accept: 'text/html' },
+ });
+ clientRequest.on('error', () => {});
+
+ try {
+ clientRequest.end();
+ await withTimeout(probe.providerStarted, options.name, 'the HTML error provider to start');
+ clientRequest.destroy();
+ await withTimeout(probe.providerAborted, options.name, 'the provider request signal to abort');
+ await withTimeout(probe.requestFinished, options.name, 'the aborted request lifecycle to finish');
+ probe.assertNoCommit(options.name);
+ } finally {
+ clientRequest.destroy();
+ }
+ });
+}
+
+/**
+ * Verifies that an aborted Web request commits neither HTML nor canonical JSON fallback.
+ *
+ * @param options Adapter bootstrap and identity callbacks.
+ * @returns A promise that resolves after Web abort and no-representation-write checks pass.
+ */
+export async function assertWebHttpErrorRepresentationAbortPortability<
+ TBootstrapOptions extends object,
+ TApp extends WebApp,
+>(options: WebHarnessOptions): Promise {
+ const probe = createAbortProbe();
+ const app = await options.bootstrap(
+ createEmptyModule(),
+ options.createBootstrapOptions(probe.bootstrapOptions),
+ );
+
+ await closeAfterAbortAssertion(app, options.name, async () => {
+ const abortController = new AbortController();
+ const dispatch = app.dispatch(new Request('https://runtime.test/abort-error-representation', {
+ headers: { accept: 'text/html' },
+ signal: abortController.signal,
+ }));
+
+ await withTimeout(probe.providerStarted, options.name, 'the HTML error provider to start');
+ abortController.abort();
+ await withTimeout(probe.providerAborted, options.name, 'the provider request signal to abort');
+ await dispatch;
+ probe.assertNoCommit(options.name);
+ });
+}
diff --git a/packages/testing/src/portability/error-representation-portability.ts b/packages/testing/src/portability/error-representation-portability.ts
new file mode 100644
index 000000000..2d447a88a
--- /dev/null
+++ b/packages/testing/src/portability/error-representation-portability.ts
@@ -0,0 +1,249 @@
+import {
+ Controller,
+ Get,
+ Head,
+ type HttpErrorRepresentationOptions,
+ type Middleware,
+ NotFoundException,
+ type RequestContext,
+ type RequestObserver,
+} from '@fluojs/http';
+import { defineModule, type ModuleType } from '@fluojs/runtime';
+
+type NetworkApp = {
+ close(): Promise;
+ listen(): Promise;
+};
+
+type NetworkHarnessOptions = {
+ readonly bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ readonly createBootstrapOptions: (
+ options: NetworkHttpErrorRepresentationBootstrapOptions,
+ ) => TBootstrapOptions;
+ readonly name: string;
+};
+
+type WebApp = {
+ close(): Promise;
+ dispatch(request: Request): Promise;
+};
+
+type WebHarnessOptions = {
+ readonly bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ readonly createBootstrapOptions: (
+ options: WebHttpErrorRepresentationBootstrapOptions,
+ ) => TBootstrapOptions;
+ readonly name: string;
+};
+
+/** Adapter bootstrap fields required by the network error-representation portability scenario. */
+export type NetworkHttpErrorRepresentationBootstrapOptions = {
+ readonly cors: false;
+ readonly errorRepresentation: HttpErrorRepresentationOptions;
+ readonly middleware: Middleware[];
+ readonly observers: RequestObserver[];
+ readonly port: 0;
+};
+
+/** Adapter bootstrap fields required by the Web error-representation portability scenario. */
+export type WebHttpErrorRepresentationBootstrapOptions = {
+ readonly cors: false;
+ readonly errorRepresentation: HttpErrorRepresentationOptions;
+ readonly middleware: Middleware[];
+};
+
+type ListenTarget = { readonly url: string };
+type AdapterWithListenTarget = { getListenTarget(): ListenTarget };
+
+function hasListenTarget(value: unknown): value is AdapterWithListenTarget {
+ return typeof value === 'object'
+ && value !== null
+ && 'getListenTarget' in value
+ && typeof value.getListenTarget === 'function';
+}
+
+function resolveListeningUrl(app: NetworkApp, name: string): string {
+ const adapter: unknown = Reflect.get(app, 'adapter');
+ if (!hasListenTarget(adapter)) {
+ throw new Error(`${name} error representation portability check could not resolve its listener URL.`);
+ }
+ return adapter.getListenTarget().url;
+}
+
+function hasErrorCode(value: unknown, code: string): boolean {
+ if (typeof value !== 'object' || value === null) {
+ return false;
+ }
+ const error: unknown = Reflect.get(value, 'error');
+ return typeof error === 'object' && error !== null && Reflect.get(error, 'code') === code;
+}
+
+function assertStatus(response: Response, expected: number, name: string, scenario: string): void {
+ if (response.status !== expected) {
+ throw new Error(`${name} changed ${scenario} status: expected ${String(expected)}, received ${String(response.status)}.`);
+ }
+}
+
+async function assertRepresentationResponses(
+ name: string,
+ responses: {
+ readonly committed: Response;
+ readonly head: Response;
+ readonly html: Response;
+ readonly json: Response;
+ readonly unsupported: Response;
+ },
+): Promise {
+ assertStatus(responses.html, 404, name, 'HTML error representation');
+ assertStatus(responses.json, 404, name, 'JSON error representation');
+ assertStatus(responses.head, 404, name, 'HEAD error representation');
+ assertStatus(responses.unsupported, 406, name, 'unsupported error representation');
+ assertStatus(responses.committed, 202, name, 'already-committed response');
+
+ const [html, json, head, unsupported, committed] = await Promise.all([
+ responses.html.text(),
+ responses.json.json(),
+ responses.head.text(),
+ responses.unsupported.json(),
+ responses.committed.text(),
+ ]);
+
+ if (!responses.html.headers.get('content-type')?.includes('text/html') || !html.includes('404:NOT_FOUND')) {
+ throw new Error(`${name} changed negotiated HTML error representation semantics.`);
+ }
+ if (!hasErrorCode(json, 'NOT_FOUND')) {
+ throw new Error(`${name} changed canonical JSON error representation semantics.`);
+ }
+ if (!responses.head.headers.get('content-type')?.includes('text/html') || head !== '') {
+ throw new Error(`${name} changed HEAD error representation body suppression.`);
+ }
+ if (!hasErrorCode(unsupported, 'NOT_ACCEPTABLE')) {
+ throw new Error(`${name} changed unsupported error representation fallback semantics.`);
+ }
+ if (committed !== 'handler-owned') {
+ throw new Error(`${name} rewrote an already-committed response through the error provider.`);
+ }
+}
+
+function createRepresentationFixture(): ModuleType {
+ @Controller('/error-representations')
+ class ErrorRepresentationController {
+ @Get('/json')
+ json(): never {
+ throw new NotFoundException('Matched resource missing.');
+ }
+
+ @Head('/head')
+ head(): never {
+ throw new NotFoundException('HEAD resource missing.');
+ }
+
+ @Get('/committed')
+ async committed(_input: undefined, context: RequestContext): Promise {
+ context.response.setStatus(202);
+ await context.response.send('handler-owned');
+ throw new NotFoundException('Committed response must not be replaced.');
+ }
+ }
+
+ class AppModule {}
+ defineModule(AppModule, { controllers: [ErrorRepresentationController] });
+ return AppModule;
+}
+
+function createErrorRepresentationOptions(): WebHttpErrorRepresentationBootstrapOptions {
+ return {
+ cors: false,
+ errorRepresentation: {
+ html: {
+ render({ json }: { readonly json: { readonly error: { readonly code: string; readonly status: number } } }) {
+ return `${String(json.error.status)}:${json.error.code}`;
+ },
+ },
+ },
+ middleware: [],
+ };
+}
+
+async function closeAfterAssertion(app: NetworkApp | WebApp, name: string, assertion: () => Promise): Promise {
+ let assertionError: unknown;
+ try {
+ await assertion();
+ } catch (error) {
+ assertionError = error;
+ }
+
+ try {
+ await app.close();
+ } catch (cleanupError) {
+ throw assertionError === undefined
+ ? cleanupError
+ : new AggregateError([assertionError, cleanupError], `${name} representation assertion and cleanup both failed.`);
+ }
+
+ if (assertionError !== undefined) {
+ throw assertionError;
+ }
+}
+
+/**
+ * Verifies negotiated HTTP error representations through a listening adapter.
+ *
+ * @param options Adapter bootstrap and identity callbacks.
+ * @returns A promise that resolves after JSON, HTML, HEAD, 406, and commit-guard checks pass.
+ */
+export async function assertNetworkHttpErrorRepresentationPortability<
+ TBootstrapOptions extends object,
+ TApp extends NetworkApp,
+>(options: NetworkHarnessOptions): Promise {
+ const app = await options.bootstrap(
+ createRepresentationFixture(),
+ options.createBootstrapOptions({
+ ...createErrorRepresentationOptions(),
+ observers: [],
+ port: 0,
+ }),
+ );
+
+ await closeAfterAssertion(app, options.name, async () => {
+ await app.listen();
+ const baseUrl = resolveListeningUrl(app, options.name);
+ await assertRepresentationResponses(options.name, {
+ committed: await fetch(`${baseUrl}/error-representations/committed`, { headers: { accept: 'text/html' } }),
+ head: await fetch(`${baseUrl}/error-representations/head`, { headers: { accept: 'text/html' }, method: 'HEAD' }),
+ html: await fetch(`${baseUrl}/not-registered`, { headers: { accept: 'text/html' } }),
+ json: await fetch(`${baseUrl}/error-representations/json`, { headers: { accept: 'application/json' } }),
+ unsupported: await fetch(`${baseUrl}/not-registered`, { headers: { accept: 'image/avif' } }),
+ });
+ });
+}
+
+/**
+ * Verifies negotiated HTTP error representations through a fetch-style adapter.
+ *
+ * @param options Adapter bootstrap and identity callbacks.
+ * @returns A promise that resolves after JSON, HTML, HEAD, 406, and commit-guard checks pass.
+ */
+export async function assertWebHttpErrorRepresentationPortability<
+ TBootstrapOptions extends object,
+ TApp extends WebApp,
+>(options: WebHarnessOptions): Promise {
+ const app = await options.bootstrap(
+ createRepresentationFixture(),
+ options.createBootstrapOptions(createErrorRepresentationOptions()),
+ );
+
+ await closeAfterAssertion(app, options.name, async () => {
+ const request = (path: string, accept: string, method = 'GET') => new Request(`https://runtime.test${path}`, {
+ headers: { accept },
+ method,
+ });
+ await assertRepresentationResponses(options.name, {
+ committed: await app.dispatch(request('/error-representations/committed', 'text/html')),
+ head: await app.dispatch(request('/error-representations/head', 'text/html', 'HEAD')),
+ html: await app.dispatch(request('/not-registered', 'text/html')),
+ json: await app.dispatch(request('/error-representations/json', 'application/json')),
+ unsupported: await app.dispatch(request('/not-registered', 'image/avif')),
+ });
+ });
+}
diff --git a/packages/testing/src/portability/http-adapter-portability.test.ts b/packages/testing/src/portability/http-adapter-portability.test.ts
index 959f35ea3..7b85b6aa5 100644
--- a/packages/testing/src/portability/http-adapter-portability.test.ts
+++ b/packages/testing/src/portability/http-adapter-portability.test.ts
@@ -58,6 +58,7 @@ JNCDpGwh8us=
-----END CERTIFICATE-----`;
interface PortabilityAssertions {
+ assertDoesNotCommitAbortedHttpErrorRepresentations(): Promise;
assertDefaultsMultipartTotalLimitToMaxBodySize(): Promise;
assertExcludesRawBodyForMultipart(): Promise;
assertPreservesExactRawBodyBytesForByteSensitivePayloads(): Promise;
@@ -67,6 +68,7 @@ interface PortabilityAssertions {
assertReportsConfiguredHostInStartupLogs(): Promise;
assertReportsHttpsStartupUrl(https: { cert: string; key: string }): Promise;
assertSettlesStreamDrainWaitOnClose(): Promise;
+ assertSupportsHttpErrorRepresentations(): Promise;
assertSupportsSseStreaming(): Promise;
}
@@ -81,6 +83,14 @@ function registerPortabilitySuite(
options: { exactByteCoverage?: boolean; streamDrainCloseEdge?: boolean } = {},
): void {
describe(`${name} adapter portability`, () => {
+ it('supports HTTP-owned JSON and HTML error representations', async () => {
+ await harness.assertSupportsHttpErrorRepresentations();
+ });
+
+ it('does not commit an error representation after client disconnect', async () => {
+ await harness.assertDoesNotCommitAbortedHttpErrorRepresentations();
+ });
+
it('preserves malformed cookie values', async () => {
await harness.assertPreservesMalformedCookieValues();
});
@@ -249,6 +259,63 @@ describe('http adapter portability cleanup reporting', () => {
}
});
+ it('closes the error-representation app when network listen fails', async () => {
+ const listenError = new Error('representation listen exploded');
+ const close = vi.fn(async () => {});
+ const harness = createHttpAdapterPortabilityHarness({
+ async bootstrap() {
+ return {
+ close,
+ async listen() {
+ throw listenError;
+ },
+ };
+ },
+ createErrorRepresentationBootstrapOptions: (options) => options,
+ name: 'representation-listen-cleanup',
+ async run() {
+ throw new Error('run should not be used');
+ },
+ });
+
+ await expect(harness.assertSupportsHttpErrorRepresentations()).rejects.toBe(listenError);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves error-representation listen and cleanup failures', async () => {
+ const listenError = new Error('representation listen exploded');
+ const closeError = new Error('representation close exploded');
+ const harness = createHttpAdapterPortabilityHarness({
+ async bootstrap() {
+ return {
+ async close() {
+ throw closeError;
+ },
+ async listen() {
+ throw listenError;
+ },
+ };
+ },
+ createErrorRepresentationBootstrapOptions: (options) => options,
+ name: 'representation-listen-cleanup-fails',
+ async run() {
+ throw new Error('run should not be used');
+ },
+ });
+
+ try {
+ await harness.assertSupportsHttpErrorRepresentations();
+ throw new Error('Expected representation listen cleanup failure to be reported.');
+ } catch (error) {
+ expect(error).toBeInstanceOf(AggregateError);
+ if (!(error instanceof AggregateError)) {
+ throw error;
+ }
+ expect(error.message).toContain('representation assertion and cleanup both failed');
+ expect(error.errors).toEqual([listenError, closeError]);
+ }
+ });
+
it('reports close failures when the assertion path succeeds', async () => {
const closeError = new Error('close exploded');
const signal = 'SIGTERM' as const;
@@ -325,6 +392,7 @@ registerPortabilitySuite(
'node',
createHttpAdapterPortabilityHarness({
bootstrap: bootstrapNodeApplication,
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'node',
run: runNodeApplication,
}),
@@ -337,6 +405,7 @@ registerPortabilitySuite(
'nodejs-platform',
createHttpAdapterPortabilityHarness({
bootstrap: bootstrapNodejsApplication,
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'nodejs-platform',
run: runNodejsApplication,
}),
@@ -349,6 +418,7 @@ registerPortabilitySuite(
'express',
createHttpAdapterPortabilityHarness({
bootstrap: bootstrapExpressApplication,
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'express',
run: runExpressApplication,
}),
@@ -359,6 +429,7 @@ registerPortabilitySuite(
const fastifyPortabilityHarness = createHttpAdapterPortabilityHarness({
bootstrap: bootstrapFastifyApplication,
+ createErrorRepresentationBootstrapOptions: (options) => options,
exactRawBodyByteContentType: 'application/octet-stream',
name: 'fastify',
prepareExactRawBodyByteTest(app) {
diff --git a/packages/testing/src/portability/http-adapter-portability.ts b/packages/testing/src/portability/http-adapter-portability.ts
index 806168a59..4e2d1132d 100644
--- a/packages/testing/src/portability/http-adapter-portability.ts
+++ b/packages/testing/src/portability/http-adapter-portability.ts
@@ -1,5 +1,12 @@
import { Controller, Get, Post, type RequestContext, SseResponse } from '@fluojs/http';
import { type ApplicationLogger, defineModule, type ModuleType } from '@fluojs/runtime';
+import { assertNetworkHttpErrorRepresentationAbortPortability } from './error-representation-abort-portability.js';
+import {
+ assertNetworkHttpErrorRepresentationPortability,
+ type NetworkHttpErrorRepresentationBootstrapOptions,
+} from './error-representation-portability.js';
+
+export type { NetworkHttpErrorRepresentationBootstrapOptions } from './error-representation-portability.js';
type AppLike = {
close(): Promise;
@@ -39,6 +46,11 @@ export interface HttpAdapterPortabilityHarnessOptions<
*/
bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ /** Adapts the shared error-representation fixture fields to this adapter's bootstrap options. */
+ createErrorRepresentationBootstrapOptions?: (
+ options: NetworkHttpErrorRepresentationBootstrapOptions,
+ ) => TBootstrapOptions;
+
/**
* Optional adapter-specific content type used by the exact-byte raw-body portability assertion.
*/
@@ -252,6 +264,34 @@ export class HttpAdapterPortabilityHarness<
*/
constructor(private readonly options: HttpAdapterPortabilityHarnessOptions) {}
+ /** Verifies JSON, HTML, HEAD, 406, and committed error-response portability. */
+ async assertSupportsHttpErrorRepresentations(): Promise {
+ const createBootstrapOptions = this.options.createErrorRepresentationBootstrapOptions;
+ if (createBootstrapOptions === undefined) {
+ throw new Error(`${this.options.name} adapter portability harness requires createErrorRepresentationBootstrapOptions.`);
+ }
+
+ await assertNetworkHttpErrorRepresentationPortability({
+ bootstrap: this.options.bootstrap,
+ createBootstrapOptions,
+ name: this.options.name,
+ });
+ }
+
+ /** Verifies client-disconnect abort propagation without an HTML or JSON fallback commit. */
+ async assertDoesNotCommitAbortedHttpErrorRepresentations(): Promise {
+ const createBootstrapOptions = this.options.createErrorRepresentationBootstrapOptions;
+ if (createBootstrapOptions === undefined) {
+ throw new Error(`${this.options.name} adapter portability harness requires createErrorRepresentationBootstrapOptions.`);
+ }
+
+ await assertNetworkHttpErrorRepresentationAbortPortability({
+ bootstrap: this.options.bootstrap,
+ createBootstrapOptions,
+ name: this.options.name,
+ });
+ }
+
/**
* Asserts that the adapter preserves malformed cookie values without crashing
* or incorrectly normalizing them.
diff --git a/packages/testing/src/portability/web-runtime-adapter-portability.test.ts b/packages/testing/src/portability/web-runtime-adapter-portability.test.ts
index ac251d478..ba21d470a 100644
--- a/packages/testing/src/portability/web-runtime-adapter-portability.test.ts
+++ b/packages/testing/src/portability/web-runtime-adapter-portability.test.ts
@@ -264,7 +264,9 @@ async function createBunPortabilityApp(
function registerWebRuntimePortabilitySuite(
name: string,
harness: {
+ assertDoesNotCommitAbortedHttpErrorRepresentations(): Promise;
assertExcludesRawBodyForMultipart(): Promise;
+ assertSupportsHttpErrorRepresentations(): Promise;
assertPreservesExactRawBodyBytesForByteSensitivePayloads(): Promise;
assertPreservesQueryArraysAndDecoding(): Promise;
assertPreservesMalformedCookieValues(): Promise;
@@ -273,6 +275,14 @@ function registerWebRuntimePortabilitySuite(
},
): void {
describe(`${name} web runtime adapter portability`, () => {
+ it('supports HTTP-owned JSON and HTML error representations', async () => {
+ await harness.assertSupportsHttpErrorRepresentations();
+ });
+
+ it('does not commit an error representation after request abort', async () => {
+ await harness.assertDoesNotCommitAbortedHttpErrorRepresentations();
+ });
+
it('preserves query arrays and decoding semantics', async () => {
await harness.assertPreservesQueryArraysAndDecoding();
});
@@ -305,6 +315,7 @@ registerWebRuntimePortabilitySuite(
async bootstrap(rootModule, options) {
return await createBunPortabilityApp(rootModule, options);
},
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'bun',
}),
);
@@ -375,6 +386,7 @@ registerWebRuntimePortabilitySuite(
},
};
},
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'deno',
}),
);
@@ -394,6 +406,7 @@ registerWebRuntimePortabilitySuite(
},
};
},
+ createErrorRepresentationBootstrapOptions: (options) => options,
name: 'cloudflare-workers',
}),
);
diff --git a/packages/testing/src/portability/web-runtime-adapter-portability.ts b/packages/testing/src/portability/web-runtime-adapter-portability.ts
index 6f096bdc3..978eada73 100644
--- a/packages/testing/src/portability/web-runtime-adapter-portability.ts
+++ b/packages/testing/src/portability/web-runtime-adapter-portability.ts
@@ -1,5 +1,12 @@
import { Controller, Get, Post, SseResponse, type RequestContext } from '@fluojs/http';
import { defineModule, type ModuleType } from '@fluojs/runtime';
+import { assertWebHttpErrorRepresentationAbortPortability } from './error-representation-abort-portability.js';
+import {
+ assertWebHttpErrorRepresentationPortability,
+ type WebHttpErrorRepresentationBootstrapOptions,
+} from './error-representation-portability.js';
+
+export type { WebHttpErrorRepresentationBootstrapOptions } from './error-representation-portability.js';
type WebRuntimePortabilityAppLike = {
close(): Promise;
@@ -14,6 +21,10 @@ export interface WebRuntimeHttpAdapterPortabilityHarnessOptions<
TApp extends WebRuntimePortabilityAppLike = WebRuntimePortabilityAppLike,
> {
bootstrap: (rootModule: ModuleType, options: TBootstrapOptions) => Promise;
+ /** Adapts the shared error-representation fixture fields to this runtime's bootstrap options. */
+ createErrorRepresentationBootstrapOptions?: (
+ options: WebHttpErrorRepresentationBootstrapOptions,
+ ) => TBootstrapOptions;
name: string;
}
@@ -66,6 +77,34 @@ export class WebRuntimeHttpAdapterPortabilityHarness<
> {
constructor(private readonly options: WebRuntimeHttpAdapterPortabilityHarnessOptions) {}
+ /** Verifies JSON, HTML, HEAD, 406, and committed error-response portability. */
+ async assertSupportsHttpErrorRepresentations(): Promise {
+ const createBootstrapOptions = this.options.createErrorRepresentationBootstrapOptions;
+ if (createBootstrapOptions === undefined) {
+ throw new Error(`${this.options.name} adapter portability harness requires createErrorRepresentationBootstrapOptions.`);
+ }
+
+ await assertWebHttpErrorRepresentationPortability({
+ bootstrap: this.options.bootstrap,
+ createBootstrapOptions,
+ name: this.options.name,
+ });
+ }
+
+ /** Verifies request abort propagation without an HTML or JSON fallback commit. */
+ async assertDoesNotCommitAbortedHttpErrorRepresentations(): Promise {
+ const createBootstrapOptions = this.options.createErrorRepresentationBootstrapOptions;
+ if (createBootstrapOptions === undefined) {
+ throw new Error(`${this.options.name} adapter portability harness requires createErrorRepresentationBootstrapOptions.`);
+ }
+
+ await assertWebHttpErrorRepresentationAbortPortability({
+ bootstrap: this.options.bootstrap,
+ createBootstrapOptions,
+ name: this.options.name,
+ });
+ }
+
async assertPreservesQueryArraysAndDecoding(): Promise {
@Controller('/query')
class QueryController {
diff --git a/packages/testing/src/surface.test.ts b/packages/testing/src/surface.test.ts
index 695317386..4084a6200 100644
--- a/packages/testing/src/surface.test.ts
+++ b/packages/testing/src/surface.test.ts
@@ -14,7 +14,9 @@ import type { DeepMocked as RootDeepMocked } from './index.js';
import * as testing from './index.js';
import type { DeepMocked as MockDeepMocked } from './mock.js';
import * as mock from './mock.js';
+import type { NetworkHttpErrorRepresentationBootstrapOptions } from './portability/http-adapter-portability.js';
import * as portability from './portability/http-adapter-portability.js';
+import type { WebHttpErrorRepresentationBootstrapOptions } from './portability/web-runtime-adapter-portability.js';
import * as webPortability from './portability/web-runtime-adapter-portability.js';
import type { DeepMocked } from './types.js';
import * as vitestTooling from './vitest/tooling.js';
@@ -55,6 +57,23 @@ type _RootDeepMockedPreservesVitestMockCompatibility = Assert<
type _MockDeepMockedPreservesVitestMockCompatibility = Assert<
IsAssignable['findById'], Mock<(id: string) => Promise<{ id: string }>>>
>;
+type _NetworkErrorRepresentationOptionsArePublic = Assert<
+ IsAssignable<
+ NetworkHttpErrorRepresentationBootstrapOptions,
+ {
+ readonly cors: false;
+ readonly middleware: readonly unknown[];
+ readonly observers: readonly unknown[];
+ readonly port: 0;
+ }
+ >
+>;
+type _WebErrorRepresentationOptionsArePublic = Assert<
+ IsAssignable<
+ WebHttpErrorRepresentationBootstrapOptions,
+ { readonly cors: false; readonly middleware: readonly unknown[] }
+ >
+>;
const packageRoot = new URL('..', import.meta.url);
const packageRootPath = fileURLToPath(packageRoot);
@@ -468,6 +487,10 @@ describe('@fluojs/testing surface', () => {
expect(readFileSync(resolve(packageRootPath, 'dist/types.d.ts'), 'utf8')).toContain('type DeepMocked');
expect(readFileSync(resolve(packageRootPath, 'dist/mock.d.ts'), 'utf8')).toContain('./mock-types.js');
expect(readFileSync(resolve(packageRootPath, 'dist/index.d.ts'), 'utf8')).not.toContain('TestingMockFunction');
+ expect(readFileSync(resolve(packageRootPath, 'dist/portability/http-adapter-portability.d.ts'), 'utf8'))
+ .toContain('NetworkHttpErrorRepresentationBootstrapOptions');
+ expect(readFileSync(resolve(packageRootPath, 'dist/portability/web-runtime-adapter-portability.d.ts'), 'utf8'))
+ .toContain('WebHttpErrorRepresentationBootstrapOptions');
}, 300_000);
it('imports every public package subpath through the published export map', async () => {
diff --git a/tooling/governance/http-error-representation-contract.test.ts b/tooling/governance/http-error-representation-contract.test.ts
new file mode 100644
index 000000000..05f9b8aff
--- /dev/null
+++ b/tooling/governance/http-error-representation-contract.test.ts
@@ -0,0 +1,89 @@
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { describe, expect, it } from 'vitest';
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+function read(relativePath: string): string {
+ return readFileSync(resolve(repoRoot, relativePath), 'utf8');
+}
+
+function expectIdentifiers(document: string, identifiers: readonly string[]): void {
+ for (const identifier of identifiers) {
+ expect(document).toContain(identifier);
+ }
+}
+
+describe('HTTP error representation documentation contract', () => {
+ it('keeps the bilingual architecture decision discoverable from both documentation hubs', () => {
+ expect(read('docs/README.md')).toContain('./architecture/http-error-representations.md');
+ expect(read('docs/README.ko.md')).toContain('./architecture/http-error-representations.ko.md');
+ expect(read('docs/CONTEXT.md')).toContain('docs/architecture/http-error-representations.md');
+ expect(read('docs/CONTEXT.ko.md')).toContain('docs/architecture/http-error-representations.ko.md');
+ });
+
+ it('keeps ownership and negotiation identifiers aligned across the bilingual decision pair', () => {
+ for (const path of [
+ 'docs/architecture/http-error-representations.md',
+ 'docs/architecture/http-error-representations.ko.md',
+ ]) {
+ expectIdentifiers(read(path), [
+ 'HttpErrorRepresentationOptions',
+ 'HtmlErrorRepresentationProvider',
+ 'HandlerNotFoundError',
+ 'HttpException',
+ 'application/json',
+ 'text/html',
+ 'HEAD',
+ '406',
+ 'Vary',
+ 'send(...)',
+ 'trusted',
+ ]);
+ }
+ });
+
+ it('keeps package-level bootstrap, React, and portability entrypoints documented in both locales', () => {
+ for (const path of ['packages/http/README.md', 'packages/http/README.ko.md']) {
+ expectIdentifiers(read(path), [
+ 'HttpErrorRepresentationOptions',
+ 'application/json',
+ 'text/html',
+ 'HEAD',
+ '406',
+ 'escapeHtml',
+ 'trusted',
+ ]);
+ }
+
+ for (const path of ['packages/runtime/README.md', 'packages/runtime/README.ko.md']) {
+ expectIdentifiers(read(path), [
+ 'BootstrapApplicationOptions.errorRepresentation',
+ 'FluoFactory.create(...)',
+ 'escapeHtml',
+ 'trusted',
+ ]);
+ }
+
+ for (const path of ['packages/react/README.md', 'packages/react/README.ko.md']) {
+ expectIdentifiers(read(path), [
+ 'createReactErrorRepresentationProvider',
+ 'ReactServerEntry.status',
+ 'ReactServerEntry.headers',
+ 'dangerouslySetInnerHTML',
+ ]);
+ }
+
+ for (const path of ['packages/testing/README.md', 'packages/testing/README.ko.md']) {
+ expectIdentifiers(read(path), [
+ 'assertSupportsHttpErrorRepresentations()',
+ 'assertDoesNotCommitAbortedHttpErrorRepresentations()',
+ 'createErrorRepresentationBootstrapOptions',
+ 'NetworkHttpErrorRepresentationBootstrapOptions',
+ 'WebHttpErrorRepresentationBootstrapOptions',
+ ]);
+ }
+ });
+});