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', + ]); + } + }); +});