Compare commits

..
70 changed files with 3079 additions and 550 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ module.exports = {
{ {
name: "presentation-does-not-know-adapters", name: "presentation-does-not-know-adapters",
severity: "error", severity: "error",
from: { path: "^src/presentation" }, from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" }, to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
}, },
{ {
+11 -4
View File
@@ -78,7 +78,9 @@
{ "script": "check:types:fixture:ts-port", "expect": "fail" }, { "script": "check:types:fixture:ts-port", "expect": "fail" },
{ "script": "check:types:fixture:ts-result", "expect": "fail" }, { "script": "check:types:fixture:ts-result", "expect": "fail" },
{ "script": "check:types:fixture:application-output", "expect": "fail" }, { "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" } { "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "expect": "fail" }
], ],
"logPath": "artifacts/quality/check-types.txt", "logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"], "evidence": ["artifacts/quality/check-types.txt"],
@@ -143,13 +145,15 @@
"steps": [ "steps": [
{ "script": "check:architecture", "expect": "pass" }, { "script": "check:architecture", "expect": "pass" },
{ "script": "check:registries", "expect": "pass" }, { "script": "check:registries", "expect": "pass" },
{ "script": "check:registries:fixture", "expect": "fail" } { "script": "check:registries:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" }
], ],
"logPath": "artifacts/quality/gates/FE-GATE-010.txt", "logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": [ "evidence": [
"artifacts/quality/dependency-report.json", "artifacts/quality/dependency-report.json",
"artifacts/quality/registries.json", "artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json" "artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json"
], ],
"retentionClass": "merge-cycle" "retentionClass": "merge-cycle"
}, },
@@ -157,7 +161,10 @@
"name": "build", "name": "build",
"steps": [{ "script": "build", "expect": "pass" }], "steps": [{ "script": "build", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-011.txt", "logPath": "artifacts/quality/gates/FE-GATE-011.txt",
"evidence": ["artifacts/release/build-manifest.json"], "evidence": [
"artifacts/release/build-manifest.json",
"artifacts/release/runtime-config.schema.json"
],
"retentionClass": "release-coherence" "retentionClass": "release-coherence"
}, },
"FE-GATE-012": { "FE-GATE-012": {
+66 -1
View File
@@ -6,6 +6,37 @@
"path": "src/contracts/routes.js", "path": "src/contracts/routes.js",
"exportName": "ROUTE_REGISTRY", "exportName": "ROUTE_REGISTRY",
"owner": "feature-routing-navigation-guard-contract", "owner": "feature-routing-navigation-guard-contract",
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
"paramsSchema": [null, "NotFoundSplat"],
"searchSchema": [null, "SampleResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"sample-resource-list",
"none"
],
"errorSurface": [
"route-boundary",
"feature-boundary",
"not-found"
],
"chunkId": [
"route-home",
"route-examples-ui",
"route-examples-states",
"route-examples-auth",
"route-sample-resources",
"route-not-found"
]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE-RUNTIME",
"targetField": "routeId"
}
],
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
"path", "path",
@@ -17,6 +48,38 @@
"chunkId" "chunkId"
] ]
}, },
{
"registryId": "FE-REG-ROUTE-RUNTIME",
"path": "src/contracts/route-runtime-contract.js",
"exportName": "ROUTE_RUNTIME_CONTRACT",
"owner": "feature-frontend-routing-release-recovery-runtime",
"requiredFields": [
"routeId",
"moduleId",
"paramsCodec",
"searchCodec"
],
"uniqueFields": ["routeId", "moduleId"],
"allowedValues": {
"moduleId": [
"home-page",
"ui-gallery-page",
"state-gallery-page",
"auth-example-page",
"sample-contract-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat"],
"searchCodec": ["none", "SampleResourceListQuery"]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE",
"targetField": "routeId"
}
]
},
{ {
"registryId": "FE-REG-API", "registryId": "FE-REG-API",
"path": "src/contracts/api-operations.js", "path": "src/contracts/api-operations.js",
@@ -29,6 +92,8 @@
"auth", "auth",
"timeoutMs", "timeoutMs",
"idempotency", "idempotency",
"retry",
"requestSource",
"requestSchema", "requestSchema",
"responseSchema", "responseSchema",
"owner" "owner"
@@ -111,6 +176,6 @@
], ],
"compatibilityImpact": { "compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"], "allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "additive" "current": "behavior-change"
} }
} }
@@ -0,0 +1,58 @@
# VD-03: React Router Data Mode와 서버 상태 소유권
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-routing-release-recovery-runtime`
## 배경
기존 라우터는 `BrowserRouter`와 수동 JSX route 목록을 사용했다. 직렬화 가능한
route registry에 params/search schema, loading/error surface, access, title,
navigation과 chunk ID가 있었지만 실행 route tree와 독립적이어서 선언과 행동이
어긋날 수 있었다.
이 저장소는 client-only SPA이며 서버 상태는 application input과 TanStack Query가
소유한다. Framework Mode의 loader/action 중심 데이터 소유권이나 SSR을 도입하지
않으면서 route object, 오류 경계와 navigation lifecycle은 중앙에서 조립할
필요가 있다.
## 결정
1. 고정된 React Router `7.18.1``createBrowserRouter``RouterProvider`
사용하는 Data Mode를 기본값으로 채택한다.
2. 직렬화 가능한 route contract와 React component/codec runtime map을 분리한다.
3. 모든 executable route object와 navigation은 registry에서 생성한다. JSX에서
route 목록을 다시 열거하지 않는다.
4. params/search는 route 경계의 Zod codec으로 parse하고 같은 codec으로 canonical
URL을 생성한다.
5. loader/action은 같은 서버 데이터를 직접 다시 요청하지 않는다. 필요하면
application input 또는 query adapter 한 경로를 호출한다.
6. 서버 상태, retry, cache와 mutation lifecycle은 application input과 TanStack
Query가 계속 소유한다.
7. lazy chunk rejection만 release recovery input으로 보내며 일반 render error는
route/feature boundary가 소유한다.
8. Framework Mode, SSR, static generation과 router version upgrade는 별도
dependency/architecture 브랜치에서 결정한다.
## 검증
- route contract/runtime map의 누락과 orphan은 TypeScript negative fixture와
registry gate가 모두 거절한다.
- duplicate ID/path, unknown codec/surface/chunk와 참조 불일치를 negative registry
fixture로 검증한다.
- params/search parse/build round-trip, canonical redirect, 최대 redirect hop,
access rejection, title/focus와 boundary reset을 unit/component test로 검증한다.
- Vite dynamic entry와 release route chunk map, runtime config JSON Schema를
build/release 검증기가 확인한다.
- chunk failure는 no-store manifest refetch 후 build/release 쌍마다 한 번만
reload하며 offline, malformed manifest와 storage 실패는 fail-closed한다.
## 결과와 rollback
Data Router는 navigation lifecycle의 조립 경계이며 서버 데이터 계층이 아니다.
이 구분을 지키면 React Router를 교체해도 application input과 output port는
유지된다.
rollback은 RP-04 merge를 되돌려 이전 수동 router와 generic route failure
surface로 복구한다. URL shape와 application API는 유지하고, 이미 배포된 asset
cache의 purge는 저장소 rollback 범위에 포함하지 않는다.
@@ -12,9 +12,9 @@
- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다. - 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다.
- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다. - 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다.
검토 기준 브랜치는 `develop`, 기준 커밋은 `cb195f8`다. 이후 구현으로 경로나 최초 검토 기준 `develop` `cb195f8`며, RP-01~RP-04 구현 결과를 이 문서에
세부 내용이 달라질 수 있으므로, 각 항목은 문서의 경로뿐 아니라 해당 테스트와 누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은
아키텍처 게이트로 계속 검증해야 한다. 문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다.
## 2. 결론 ## 2. 결론
@@ -33,13 +33,14 @@
특히 다음은 선행 해결이 필요하다. 특히 다음은 선행 해결이 필요하다.
1. React 화면이 호출할 application input API와 런타임 주입 경계 RP-01~RP-04에서 TypeScript 도구 안전망, application runtime 주입,
2. TanStack Query를 사용하는 표준 query/mutation inbound adapter query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
3. TypeScript 전환 전에 TS 파일까지 검사하도록 만드는 도구 안전망 recovery 계약은 구현됐다. 현재 선행 해결
4. path/query/body projection과 runtime timeout/retry가 정확히 연결된 HTTP 계층 대상은 다음과 같다.
5. 선언과 실행이 일치하는 typed route 계약
6. 전체를 제거할 수 있는 실제 reference feature 1. 전체를 제거할 수 있는 실제 reference feature
7. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 2. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
3. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다. 더 정확한 표현은 다음과 같다.
@@ -63,16 +64,16 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 | | 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 |
| 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | | 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 |
| application facade | 부분 준비 | `create-application.js`는 있으나 `main.jsx`에서 우회 | UI에는 input API만 주입 | | application facade | 준비 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 |
| HTTP client | 부분 준비 | timeout, abort, retry, auth, envelope, Zod가 존재 | path/query, parsed body, runtime 설정, 정리 로직 보완 | | HTTP client | 준비 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 부분 준비 | safe/keyed 요청 정책 존재 | retry 소유자 단일화, 설정 연결, telemetry | | retry | 준비 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | | 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 부분 준비 | runtime/API Zod 존재 | route/form/domain 경계를 분리하고 실제 parse 결과 사용 | | 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 미제공에 가까운 부분 준비 | QueryClientProvider와 cache port는 존재 | query/mutation hook과 화면 reference flow | | 서버 상태 | 부분 준비 | 제한된 query/mutation bridge와 lifecycle test | RP-05 reference route에서 실제 feature 연결 |
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 부분 준비 | lazy route, access hint, registry 존재 | typed runtime map, codec, recovery, metadata 집행 | | 라우팅 | 준비 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 | | 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 |
| 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 | | 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 | | 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 |
@@ -82,7 +83,7 @@
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | | 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | | telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 |
| 비동기 상태 불변식 | 부분 준비 | 공통 model/surface는 있으나 일부 모순 상태를 허용 | query/mutation 상태 조합과 action latch를 닫음 | | 비동기 상태 불변식 | 준비 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
| 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 | | 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 |
| UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 | | UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 |
| 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 | | 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 |
@@ -94,13 +95,12 @@
### 5.1 P0: 기능 개발을 막는 항목 ### 5.1 P0: 기능 개발을 막는 항목
#### application 계층이 런타임에서 우회된다 #### RP-02에서 application 런타임 우회 해결
`src/bootstrap/composition-root.js` application을 생성하지만 `src/bootstrap/composition-root.js`가 만든 typed application input API는
`src/bootstrap/main.jsx`는 이를 라우터에 주입하지 않는다. UI에는 auth, storage, production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와
telemetry 같은 raw outbound dependency와 concrete QueryClient가 전달된다. release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime
`src/application/create-application.js`도 use case 중심 input API보다 outbound query만 사용한다.
capability를 다시 노출하는 형태다.
목표 상태: 목표 상태:
@@ -110,12 +110,13 @@ capability를 다시 노출하는 형태다.
- bootstrap만 concrete outbound adapter를 알고 조합한다. - bootstrap만 concrete outbound adapter를 알고 조합한다.
- 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다. - 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다.
#### 서버 상태 라이브러리는 마운트됐지만 사용할 수 없다 #### RP-03에서 표준 서버 상태 bridge 구현
`QueryClientProvider`는 존재하지만 저장소의 제품 코드에서 `useQuery` `src/presentation/adapters/query` 한 경계만 `@tanstack/**`를 import한다.
`useMutation`을 사용하지 않는다. 동시에 presentation의 `@tanstack/**` import는 `useApplicationQuery``useApplicationMutation`은 application result를 React
금지돼 있다. 현재 `QueryCachePort`는 명령형 read/write/invalidate만 제공하여 lifecycle에 연결하며 cancellation, stale failure, duplicate submit, optimistic
React 구독, 요청 상태, cancellation, optimistic update를 대신할 수 없다. rollback, conflict resolution과 invalidation을 검증한다. 다른 presentation
경로의 직접 TanStack import는 negative fixture가 거절한다.
목표 상태: 목표 상태:
@@ -128,7 +129,7 @@ React 구독, 요청 상태, cancellation, optimistic update를 대신할 수
- loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을 - loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을
reference feature에서 보여 준다. reference feature에서 보여 준다.
#### TypeScript 전환 전에 검사 도구가 TS를 인식해야 한다 #### RP-01에서 TypeScript 검사 도구 안전망 구현
현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은 현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은
중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다. 중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다.
@@ -143,31 +144,29 @@ TypeScript 전환은
[TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html) [TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)
처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다. 처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다.
#### HTTP 계약에 선언과 실행의 차이가 있다 #### RP-03에서 HTTP 선언과 실행의 차이 해결
현재 HTTP 계층은 공통화 수준이 높지만 다음 정확성 문제가 남아 있다. HTTP request builder는 path segment escaping, canonical optional/array search,
Zod default/trim 결과의 실제 query/body 전송을 담당한다. runtime timeout과
- runtime config의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`가 client 생성에 0/1/N max retry가 client factory에 주입되고 caller abort와 timeout을 다른 typed
전달되지 않는다. failure로 투영한다. validation 조기 반환은 fetch/timer 0회이며 success, schema
- operation path에 path parameter와 search parameter를 투영하는 표준 builder가 failure, abort, timeout과 exhausted retry는 scheduler/listener cleanup을
다. 검증한다. HTTP 사건의 semantic telemetry 연결은 RP-09 범위다.
- sample filter는 cache key에만 반영되고 실제 요청 URL에는 반영되지 않는다.
- Zod의 parsed/transformed request body 대신 원본 body를 전송한다.
- request validation의 조기 반환 경로에서 timeout/listener 정리가 늦어진다.
- HTTP failure, retry, recovery가 telemetry 사건과 이어지지 않는다.
client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth, client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth,
timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP
메서드보다 feature가 요구하는 gateway interface를 노출한다. 메서드보다 feature가 요구하는 gateway interface를 노출한다.
#### route registry 실행 계약이 아니다 #### RP-04에서 route registry 실행 계약으로 전환
route registry에는 `paramsSchema`, `searchSchema`, `loadingSurface`, `src/contracts/routes.js`와 직렬화 가능한
`errorSurface`, `chunkId`가 있지만 실제 router tree, lazy module, navigation `src/contracts/route-runtime-contract.js`를 기준으로
목록은 별도로 작성된다. 여러 필드는 선언만 되고 런타임에 사용되지 않는다. `src/presentation/routes/app-router.tsx`가 Data Router route object와
chunk recovery use case와 redirect loop guard도 실제 route flow에 연결되지 않는다. navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만
소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry
gate가 모두 거절한다.
목표 상태: 현재 보장:
- serializable contract와 executable runtime map을 분리한다. - serializable contract와 executable runtime map을 분리한다.
- `satisfies Record<RouteId, RouteRuntime>`로 양방향 완전성을 검사한다. - `satisfies Record<RouteId, RouteRuntime>`로 양방향 완전성을 검사한다.
@@ -175,8 +174,10 @@ chunk recovery use case와 redirect loop guard도 실제 route flow에 연결되
사용한다. 사용한다.
- loading/error/chunk/access/title/navigation metadata를 실제 route object에 - loading/error/chunk/access/title/navigation metadata를 실제 route object에
연결한다. 연결한다.
- route change 시 boundary reset, focus, scroll, navigation cancellation을 - route change 시 boundary reset, title, focus scroll을 검증한다.
검증한다. - Vite manifest의 실제 dynamic entry와 route chunk ID를 release manifest에
연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를
production application input까지 연결한다.
#### reference feature가 완전히 제거되지 않는다 #### reference feature가 완전히 제거되지 않는다
@@ -208,9 +209,11 @@ mutation-pending
mutation-conflict mutation-conflict
``` ```
chunk recovery와 release coherence도 policy 함수가 존재하는 것으로 완료되지 RP-04에서 lazy import failure는 `ChunkRecoveryBoundary` → application recovery
않는다. 실제 lazy import failure가 manifest 재확인, build 비교, 단 한 번의 guarded input → `ReleaseInfoPort.refresh()`의 no-store manifest 조회 → build/release 쌍
reload, 반복 실패 지원 표면까지 이어지고 E2E로 검증되어야 한다. guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 render
failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage
실패는 지원 표면으로 fail-closed된다.
#### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다 #### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다
@@ -382,7 +385,8 @@ tree-shakable SVG icon source로 적절하지만 select, dialog, menu, focus man
- TS source와 test 전체 typecheck - TS source와 test 전체 typecheck
- 실제 composition root부터 page까지의 통합 - 실제 composition root부터 page까지의 통합
- query/mutation controller와 optimistic rollback - query/mutation controller와 optimistic rollback
- route registry/runtime map 정합성 - route registry/runtime map 정합성은 RP-04에서 unit, component, negative
registry/type fixture와 built artifact 검증으로 구현됨
- runtime timeout/retry와 path/query/parsed body - runtime timeout/retry와 path/query/parsed body
- shared MSW scenario catalog - shared MSW scenario catalog
- isolated component stories와 interaction test - isolated component stories와 interaction test
@@ -141,17 +141,28 @@ RP-02 구현으로 다음 경계는 실행 경로에 연결됐다.
- presentation의 direct fetch/browser storage/concrete adapter/TanStack import와 - presentation의 direct fetch/browser storage/concrete adapter/TanStack import와
application의 React/concrete adapter import는 negative fixture가 거절한다. application의 React/concrete adapter import는 negative fixture가 거절한다.
RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다.
- `src/presentation/adapters/query`만 TanStack Query import를 허용하며
application query/mutation을 cancellation, invalidation, deduplication,
optimistic rollback과 conflict 해제에 연결한다.
- HTTP request builder는 path escaping과 canonical search를 소유하고 Zod가
변환한 search/body를 실제 request에 사용한다.
- runtime timeout과 max retry attempts가 transport factory에 주입되며
validation, success, abort, timeout과 exhausted retry의 timer/listener
정리를 테스트한다.
- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는
비활성화한다.
RP-04에서 첫 번째 실행 불일치는 닫혔다. route registry와 runtime map은
Data Router tree, codec, surface, title, navigation, chunk/release recovery의
단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다.
후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다. 후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다.
1. `QueryClientProvider`는 존재하지만 실제 product route에서 1. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
`useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다.
2. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`,
`errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다.
3. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
operation, Zod schema, mapper, domain model과 query key는 다른 경로에 operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다. 남는다.
4. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만
concrete HTTP client 구성에 전달되지 않는다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다. 불일치를 제거하는 것이다.
@@ -1203,9 +1214,9 @@ contract와 실패 분기를 우선한다.
### 26.4 Routing과 상태 ### 26.4 Routing과 상태
- [ ] route registry와 실행 route tree가 동일 source에서 생성된다. - [x] route registry와 실행 route tree가 동일 source에서 생성된다.
- [ ] params/search schema가 실제 navigation에서 실행된다. - [x] params/search schema가 실제 navigation에서 실행된다.
- [ ] loading/error/chunk/access metadata가 실행 behavior와 연결된다. - [x] loading/error/chunk/access metadata가 실행 behavior와 연결된다.
- [ ] local, URL, server, session, persisted state가 분류 규칙을 따른다. - [ ] local, URL, server, session, persisted state가 분류 규칙을 따른다.
- [ ] server state를 별도 global store에 중복 보관하지 않는다. - [ ] server state를 별도 global store에 중복 보관하지 않는다.
+18 -20
View File
@@ -14,24 +14,22 @@
## 2. 현재 상태와 문제 ## 2. 현재 상태와 문제
현재 구현에는 다음 장점이 있다. RP-04 이후 현재 구현에는 다음 장점이 있다.
- route registry가 path와 access policy를 소유한다. - route registry가 path와 access policy를 소유한다.
- route component를 lazy import한다. - contract에서 Data Router route object와 navigation을 생성한다.
- runtime map이 route component를 lazy import하고 codec을 연결한다.
- 앱 셸과 보호 route, not-found surface가 있다. - 앱 셸과 보호 route, not-found surface가 있다.
- route heading focus와 비동기/render error boundary가 있다. - route heading focus와 비동기/render error boundary가 있다.
- redirect loop와 chunk recovery에 대한 policy 함수가 일부 존재한다. - redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
하지만 `src/contracts/routes.js`의 metadata와 `src/contracts/routes.js`, `src/contracts/route-runtime-contract.js`,
`src/presentation/routes/app-router.jsx`의 executable route tree가 별도 수동 목록이다. `src/presentation/routes/route-runtime.tsx`의 완전성은 TypeScript와 registry
그 결과 다음 필드는 선언돼도 실제 행동을 보장하지 않는다. negative fixture가 함께 검사한다. params/search codec, loading/error surface,
access, title, navigation, chunk ID는
- params/search schema `src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite
- loading/error surface manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
- chunk ID `ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
- route title/navigation label
- redirect loop guard
- chunk recovery policy
페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록, 페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록,
상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시 상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시
@@ -50,15 +48,14 @@ selector를 `7.18.1`로 맞춰 확인한다.
| mode | 선택 조건 | 이 저장소에서의 판단 | | mode | 선택 조건 | 이 저장소에서의 판단 |
| --- | --- | --- | | --- | --- | --- |
| Declarative | React composition과 외부 data layer가 route data를 소유 | 현재 구현이 사용 중인 기준선 | | Declarative | React composition과 외부 data layer가 route data를 소유 | RP-04 이전 기준선 |
| Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | 목표 skeleton의 navigation lifecycle에 적합 | | Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | VD-03으로 채택하고 RP-04에서 구현 |
| Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 | | Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 |
목표 결정: 채택한 결정:
- client-only SPA와 TanStack Query/application use case를 유지한다. - client-only SPA와 TanStack Query/application use case를 유지한다.
- 현재 `BrowserRouter` 기반 Declarative Mode에서 `createBrowserRouter` - `createBrowserRouter``RouterProvider` 기반 Data Mode를 사용한다.
`RouterProvider` 기반 Data Mode로 이동한다.
- Data Mode를 선택하는 이유는 route object, navigation blocker, scroll - Data Mode를 선택하는 이유는 route object, navigation blocker, scroll
restoration, route error 경계를 일관되게 소유하기 위해서다. loader/action으로 restoration, route error 경계를 일관되게 소유하기 위해서다. loader/action으로
서버 상태를 다시 소유하기 위해서가 아니다. 서버 상태를 다시 소유하기 위해서가 아니다.
@@ -68,8 +65,9 @@ selector를 `7.18.1`로 맞춰 확인한다.
- SSR/static generation을 선택하기 전에는 Framework Mode를 기본값으로 만들지 - SSR/static generation을 선택하기 전에는 Framework Mode를 기본값으로 만들지
않는다. 않는다.
전환 브랜치 전까지 현재 Declarative router에 새 custom scroll/blocker 결정 근거와 rollback 경계는
implementation을 추가하지 않는다. 전환할 수 없는 프로젝트만 별도 ADR과 `docs/architecture/decisions/VD-03-react-router-data-mode.md`에 고정한다.
Data Mode를 사용할 수 없는 프로젝트만 별도 ADR과
`NavigationLifecycleAdapter`를 구현한다. `NavigationLifecycleAdapter`를 구현한다.
## 4. route 계약과 runtime map ## 4. route 계약과 runtime map
@@ -387,6 +387,20 @@ retry 조건:
timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든 timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든
경로에서 정리되어야 한다. 경로에서 정리되어야 한다.
RP-03의 현재 구현은 다음 계약을 자동 검증한다.
- `request-builder.ts`가 path 값을 escape하고 search key를 정렬하며 array 순서를
보존한다.
- operation의 `requestSource`가 search/body schema를 선택하고 Zod의
default/trim 결과만 URL 또는 JSON payload에 전달한다.
- runtime `REQUEST_TIMEOUT_MS``MAX_RETRY_ATTEMPTS`가 transport factory에
주입된다.
- query adapter의 자동 retry는 끄고 HTTP만 bounded network retry를 소유한다.
- `AsyncOverlay`는 refreshing/stale-degraded/mutation-pending/
mutation-conflict를 TypeScript union으로 배타화한다.
- `useApplicationQuery``useApplicationMutation`은 cancellation, stale latch,
duplicate submit, optimistic rollback, conflict resolution을 제공한다.
## 6. 인증과 token 소유권 ## 6. 인증과 token 소유권
기본 skeleton은 token manager를 제공하지 않는다. 기본 skeleton은 token manager를 제공하지 않는다.
@@ -91,12 +91,14 @@ Vitest의 변환 성공을 TypeScript typecheck의 대체물로 취급하지 않
- boot error shell의 safe metadata - boot error shell의 safe metadata
- StrictMode와 unmount cleanup - StrictMode와 unmount cleanup
#### TanStack Query의 React integration test가 없다 #### TanStack Query의 React integration test 기반
Query cache adapter의 명령형 `read/write/invalidate` unit test는 있지만 `tests/component/application-query.test.jsx`는 production query inbound
`useQuery`, `useMutation`, cancellation, stale/background refresh, optimistic adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal
rollback을 사용하는 production presentation adapter가 아직 없다. 따라서 query failure, background stale-failure latch와 retry 복구, duplicate submit,
provider가 마운트되어도 React 사용자의 실제 상태 전환은 검증되지 않는다. optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제
QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의
query/mutation vendor retry는 꺼져 있다.
#### Form 테스트가 단일 TextField 흐름에 머문다 #### Form 테스트가 단일 TextField 흐름에 머문다
@@ -368,10 +370,9 @@ QueryClientProvider
-> AppShell -> AppShell
``` ```
04 routing branch에서 Data Mode로 전환할 때 `BrowserRouter` RP-04에서 `RouterProvider` 기반 Data Mode로 전환했다. router component test와
`RouterProvider`로 바꾸고 테스트 fixture도 같은 composition factory에서 runtime composition test는 production `AppRouter` composition 함수를 사용하며,
생성한다. 문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 말고 문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 않는다.
production composition 함수를 호출한다.
### 6.3 Boot E2E ### 6.3 Boot E2E
@@ -1271,7 +1272,8 @@ CI registry에 추가한다.
3. MSW handlers/scenario/factory를 중앙 catalog로 이동한다. 3. MSW handlers/scenario/factory를 중앙 catalog로 이동한다.
4. query/mutation presentation adapter와 integration harness를 만든다. 4. query/mutation presentation adapter와 integration harness를 만든다.
5. form foundation과 form/controller test matrix를 만든다. 5. form foundation과 form/controller test matrix를 만든다.
6. route registry/runtime map contract와 built-dist E2E를 추가한다. 6. route registry/runtime map contract와 built-dist artifact 검증을 유지하고,
release server를 사용하는 built-dist E2E까지 확장한다.
7. Storybook build, interaction, a11y gate를 추가한다. 7. Storybook build, interaction, a11y gate를 추가한다.
8. pinned Chromium visual baseline을 추가한다. 8. pinned Chromium visual baseline을 추가한다.
9. critical flow의 3-engine release profile을 분리한다. 9. critical flow의 3-engine release profile을 분리한다.
+14
View File
@@ -176,6 +176,20 @@ export default [
], ],
}, },
}, },
{
files: [
`src/presentation/adapters/query/**/*.${sourceExtensions}`,
],
rules: {
"no-restricted-imports": restrictedImports([
"**/adapters/http/**",
"**/adapters/storage/**",
"**/adapters/auth/**",
"**/bootstrap/**",
"**/application/ports/out/**",
]),
},
},
{ {
files: [`src/adapters/**/*.${sourceExtensions}`], files: [`src/adapters/**/*.${sourceExtensions}`],
rules: { rules: {
+3
View File
@@ -24,6 +24,8 @@
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts", "check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
"check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", "check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", "check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
@@ -39,6 +41,7 @@
"check:browser-security": "node scripts/check-browser-security.mjs", "check:browser-security": "node scripts/check-browser-security.mjs",
"check:registries": "node scripts/check-registries.mjs", "check:registries": "node scripts/check-registries.mjs",
"check:registries:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/forbidden/governance.json --artifact artifacts/quality/registry-fixture.json", "check:registries:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/forbidden/governance.json --artifact artifacts/quality/registry-fixture.json",
"check:routes:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/routes/governance.json --artifact artifacts/quality/route-registry-fixture.json",
"verify:compatibility": "node scripts/check-compatibility.mjs", "verify:compatibility": "node scripts/check-compatibility.mjs",
"verify:release": "node scripts/verify-release.mjs", "verify:release": "node scripts/verify-release.mjs",
"verify:hosting-headers": "node scripts/verify-hosting-headers.mjs", "verify:hosting-headers": "node scripts/verify-hosting-headers.mjs",
+9 -1
View File
@@ -7,5 +7,13 @@
"apiContractVersion": "1", "apiContractVersion": "1",
"assetManifestHash": "generated-during-build", "assetManifestHash": "generated-during-build",
"releaseId": "local-release", "releaseId": "local-release",
"builtAt": "1970-01-01T00:00:00.000Z" "builtAt": "1970-01-01T00:00:00.000Z",
"routeChunks": {
"route-home": "src/presentation/pages/home-page.jsx",
"route-examples-ui": "src/presentation/examples/ui-gallery-page.jsx",
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
"route-examples-auth": "src/presentation/examples/auth-example-page.jsx",
"route-sample-resources": "src/presentation/pages/sample-contract-page.jsx",
"route-not-found": "src/presentation/pages/not-found-page.jsx"
}
} }
+18
View File
@@ -103,6 +103,24 @@ for (const specification of governance.registries) {
} }
} }
for (const [field, allowed] of Object.entries(
specification.allowedValues ?? {},
)) {
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
if (
!allowed.some(
/** @param {unknown} value */
(value) => Object.is(value, row[field]),
)
) {
failures.push(
`${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`,
);
}
}
}
snapshots.push({ snapshots.push({
registryId: specification.registryId, registryId: specification.registryId,
owner: specification.owner, owner: specification.owner,
+2
View File
@@ -123,6 +123,8 @@ async function drillChunkMismatch() {
failureKind: "DEPLOY_MISMATCH", failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true, manifestLoaded: true,
currentBuildId: "build-a", currentBuildId: "build-a",
currentReleaseId: "release-a",
activeBuildId: "build-b",
activeReleaseId: "release-b", activeReleaseId: "release-b",
storage, storage,
}; };
+37 -1
View File
@@ -1,6 +1,11 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { z } from "zod";
import { ROUTE_REGISTRY } from "../src/contracts/routes.js";
import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js";
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js";
const packageJson = JSON.parse(await readFile("package.json", "utf8")); const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageManagerVersion = packageJson.packageManager.split("@").at(-1); const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
@@ -9,11 +14,31 @@ const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
const releaseId = process.env.RELEASE_ID ?? "local-release"; const releaseId = process.env.RELEASE_ID ?? "local-release";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`; const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const builtAt = new Date().toISOString(); const builtAt = new Date().toISOString();
const viteManifest = await readFile("dist/.vite/manifest.json"); const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifestObject =
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
JSON.parse(viteManifest)
);
const assetManifestHash = createHash("sha256") const assetManifestHash = createHash("sha256")
.update(viteManifest) .update(viteManifest)
.digest("hex"); .digest("hex");
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
/** @type {Record<string, string>} */
const routeChunks = {};
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime =
/** @type {Record<string, {moduleId: string}>} */ (
ROUTE_RUNTIME_CONTRACT
)[definition.routeId];
const asset = Object.values(viteManifestObject).find(
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
);
if (!runtime || !asset?.file) {
throw new Error(`Missing built route chunk: ${definition.routeId}`);
}
routeChunks[definition.chunkId] = asset.file;
}
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema);
runtimeConfig.BUILD_ID = buildId; runtimeConfig.BUILD_ID = buildId;
runtimeConfig.RELEASE_ID = releaseId; runtimeConfig.RELEASE_ID = releaseId;
@@ -31,6 +56,8 @@ const manifest = {
outputs: { outputs: {
directory: "dist", directory: "dist",
viteManifest: "dist/.vite/manifest.json", viteManifest: "dist/.vite/manifest.json",
routeChunks,
runtimeConfigSchema: "dist/runtime-config.schema.json",
}, },
}; };
@@ -44,6 +71,7 @@ const releaseManifest = {
assetManifestHash, assetManifestHash,
releaseId, releaseId,
builtAt, builtAt,
routeChunks,
}; };
await mkdir("artifacts/release", { recursive: true }); await mkdir("artifacts/release", { recursive: true });
@@ -52,6 +80,14 @@ await writeFile(
"dist/release-manifest.json", "dist/release-manifest.json",
`${JSON.stringify(releaseManifest, null, 2)}\n`, `${JSON.stringify(releaseManifest, null, 2)}\n`,
); );
await writeFile(
"dist/runtime-config.schema.json",
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
);
await writeFile(
"artifacts/release/runtime-config.schema.json",
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
);
await writeFile( await writeFile(
"artifacts/release/build-manifest.json", "artifacts/release/build-manifest.json",
`${JSON.stringify(manifest, null, 2)}\n`, `${JSON.stringify(manifest, null, 2)}\n`,
+65 -1
View File
@@ -6,6 +6,8 @@ import {
compareReleaseToRuntime, compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY, RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.js"; } from "../src/contracts/release-tokens.js";
import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js";
import { ROUTE_REGISTRY } from "../src/contracts/routes.js";
const fixturesDocument = const fixturesDocument =
/** @type {{ /** @type {{
@@ -34,7 +36,17 @@ const fixturesDocument =
); );
const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8")); const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8"));
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
const viteManifest = await readFile("dist/.vite/manifest.json"); const buildManifest = JSON.parse(
await readFile("artifacts/release/build-manifest.json", "utf8"),
);
const runtimeConfigJsonSchema = JSON.parse(
await readFile("dist/runtime-config.schema.json", "utf8"),
);
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifestObject =
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
JSON.parse(viteManifest)
);
const actualAssetManifestHash = createHash("sha256") const actualAssetManifestHash = createHash("sha256")
.update(viteManifest) .update(viteManifest)
.digest("hex"); .digest("hex");
@@ -52,6 +64,58 @@ if (!Number.isFinite(Date.parse(release.builtAt))) {
if (release.assetManifestHash !== actualAssetManifestHash) { if (release.assetManifestHash !== actualAssetManifestHash) {
artifactMismatches.push("assetManifestContent"); artifactMismatches.push("assetManifestContent");
} }
if (
runtimeConfigJsonSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" ||
runtimeConfigJsonSchema.type !== "object" ||
!runtimeConfigJsonSchema.properties
) {
artifactMismatches.push("runtimeConfigSchema");
}
if (
buildManifest.outputs?.runtimeConfigSchema !==
"dist/runtime-config.schema.json"
) {
artifactMismatches.push("buildManifest:runtimeConfigSchema");
}
const expectedChunkIds = new Set(
Object.values(ROUTE_REGISTRY).map((definition) => definition.chunkId),
);
const actualChunkIds = new Set(Object.keys(release.routeChunks ?? {}));
for (const chunkId of expectedChunkIds) {
if (!actualChunkIds.has(chunkId)) {
artifactMismatches.push(`routeChunk:missing:${chunkId}`);
}
}
for (const chunkId of actualChunkIds) {
if (!expectedChunkIds.has(chunkId)) {
artifactMismatches.push(`routeChunk:orphan:${chunkId}`);
}
}
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime =
/** @type {Record<string, {moduleId: string}>} */ (
ROUTE_RUNTIME_CONTRACT
)[definition.routeId];
const viteEntry = Object.values(viteManifestObject).find(
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
);
const routeAsset = release.routeChunks?.[definition.chunkId];
if (!runtime || !viteEntry || routeAsset !== viteEntry.file) {
artifactMismatches.push(`routeChunk:mismatch:${definition.chunkId}`);
continue;
}
if (
buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset
) {
artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`);
}
try {
await readFile(`dist/${routeAsset}`);
} catch {
artifactMismatches.push(`routeChunk:file:${definition.chunkId}`);
}
}
const fixtures = fixturesDocument.fixtures.map((fixture) => { const fixtures = fixturesDocument.fixtures.map((fixture) => {
const result = verifyCompatibilityTuple({ const result = verifyCompatibilityTuple({
+109 -32
View File
@@ -12,6 +12,7 @@ import {
validateOperationPayload, validateOperationPayload,
validateOperationRequest, validateOperationRequest,
} from "./schema-registry.js"; } from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js";
const noAuthSession = const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
@@ -22,6 +23,14 @@ const noAuthSession =
}); });
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */ /** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */
/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */
/**
* @typedef {{
* setTimeout(callback: () => void, milliseconds: number): unknown,
* clearTimeout(handle: unknown): void
* }} Scheduler
*/
/** /**
* @typedef {{ ok: true, value: unknown, meta: Record<string, string> } | * @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
@@ -38,7 +47,11 @@ const noAuthSession =
* validatePayload?: (schemaId: string, value: unknown) => * validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }, * { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown, * mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string * idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
* maxRetryAttempts?: number,
* scheduler?: Scheduler,
* getOperation?: typeof getApiOperation
* }} dependencies * }} dependencies
*/ */
export function createHttpClient(dependencies) { export function createHttpClient(dependencies) {
@@ -51,19 +64,46 @@ export function createHttpClient(dependencies) {
const mapPayload = dependencies.mapPayload ?? mapOperationPayload; const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory = const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const selectOperation = dependencies.getOperation ?? getApiOperation;
const scheduler =
dependencies.scheduler ??
/** @type {Scheduler} */ ({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
/** @type {ReturnType<typeof setTimeout>} */ (handle),
),
});
/** /**
* @param {string} operationId * @param {string | OperationRequestInput} request
* @param {{ * @param {{
* body?: unknown, * body?: unknown,
* routeId?: string, * routeId?: string,
* signal?: AbortSignal, * pathParams?: Record<string, string | number>,
* idempotencyKey?: string * searchParams?: unknown,
* }} [input] * signal?: AbortSignal,
* @returns {Promise<HttpResult>} * idempotencyKey?: string
*/ * }} [legacyInput]
async function execute(operationId, input = {}) { * @returns {Promise<HttpResult>}
const operation = getApiOperation(operationId); */
async function execute(request, legacyInput = {}) {
const input =
typeof request === "string"
? {
operationId: request,
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
pathParams: legacyInput.pathParams,
searchParams: legacyInput.searchParams,
body: legacyInput.body,
signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey,
}
: request;
const operation = selectOperation(input.operationId);
const logicalIdempotencyKey = const logicalIdempotencyKey =
operation.idempotency === "keyed" operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory() ? input.idempotencyKey ?? idempotencyKeyFactory()
@@ -105,7 +145,14 @@ export function createHttpClient(dependencies) {
return outcome; return outcome;
} }
if (!shouldRetry(operation, outcome.error, retryCount)) { if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return outcome; return outcome;
} }
@@ -117,7 +164,7 @@ export function createHttpClient(dependencies) {
} catch { } catch {
return { return {
ok: false, ok: false,
error: failure("REQUEST_ABORTED", operationId, retryCount, { error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED", code: "REQUEST_ABORTED",
}), }),
}; };
@@ -128,7 +175,7 @@ export function createHttpClient(dependencies) {
/** /**
* @param {{ * @param {{
* operation: ReturnType<typeof getApiOperation>, * operation: ReturnType<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal }, * input: OperationRequestInput,
* attempt: number, * attempt: number,
* idempotencyKey?: string * idempotencyKey?: string
* }} context * }} context
@@ -136,23 +183,19 @@ export function createHttpClient(dependencies) {
*/ */
async function performAttempt(context) { async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context; const { operation, input, attempt, idempotencyKey } = context;
const controller = new AbortController(); /** @type {unknown} */
let timedOut = false; let parsedSearch = {};
const timeout = setTimeout(() => { let parsedBody;
timedOut = true; const requestValue =
controller.abort("timeout"); operation.requestSource === "search"
}, operation.timeoutMs); ? input.searchParams ?? {}
const onExternalAbort = () => controller.abort(input.signal?.reason); : operation.requestSource === "body"
input.signal?.addEventListener("abort", onExternalAbort, { once: true }); ? input.body
: {};
const headers = new Headers({ Accept: "application/json" }); if (operation.requestSource !== "none") {
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (input.body !== undefined) {
const requestValidation = validateOperationRequest( const requestValidation = validateOperationRequest(
operation.requestSchema, operation.requestSchema,
input.body, requestValue,
); );
if (!requestValidation.success) { if (!requestValidation.success) {
return { return {
@@ -162,12 +205,46 @@ export function createHttpClient(dependencies) {
}), }),
}; };
} }
if (operation.requestSource === "search") {
parsedSearch = requestValidation.data;
} else {
parsedBody = requestValidation.data;
}
} }
let request = new Request(new URL(operation.path, dependencies.baseUrl), { const target = buildRequestTarget(
dependencies.baseUrl,
operation,
input.pathParams,
parsedSearch,
);
if (!target.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: target.code,
}),
};
}
const controller = new AbortController();
let timedOut = false;
const timeout = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs ?? defaultTimeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
if (input.signal?.aborted) onExternalAbort();
const headers = new Headers({ Accept: "application/json" });
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
let request = new Request(target.url, {
method: operation.method, method: operation.method,
headers, headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body), body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal, signal: controller.signal,
}); });
@@ -237,7 +314,7 @@ export function createHttpClient(dependencies) {
}), }),
}; };
} finally { } finally {
clearTimeout(timeout); scheduler.clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort); input.signal?.removeEventListener("abort", onExternalAbort);
} }
} }
+73
View File
@@ -0,0 +1,73 @@
import type { ApiOperation } from "../../contracts/api-operations.js";
export type OperationRequestInput = Readonly<{
operationId: string;
routeId: string;
pathParams?: Readonly<Record<string, string | number>>;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
}>;
export type RequestTargetResult =
| Readonly<{ success: true; url: URL }>
| Readonly<{
success: false;
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
}>;
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
export function buildRequestTarget(
baseUrl: string,
operation: ApiOperation,
pathParams: Readonly<Record<string, string | number>> = {},
parsedSearch: unknown = {},
): RequestTargetResult {
let missingPathParameter = false;
const pathname = operation.path.replace(
pathParameterPattern,
(_token, colonName: string | undefined, braceName: string | undefined) => {
const name = colonName ?? braceName ?? "";
const value = pathParams[name];
if (value === undefined) {
missingPathParameter = true;
return "";
}
return encodeURIComponent(String(value));
},
);
if (missingPathParameter) {
return { success: false, code: "PATH_PARAMETER_MISSING" };
}
if (
parsedSearch === null ||
typeof parsedSearch !== "object" ||
Array.isArray(parsedSearch)
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
const url = new URL(pathname, baseUrl);
const search = parsedSearch as Readonly<Record<string, unknown>>;
for (const key of Object.keys(search).sort((left, right) =>
left.localeCompare(right),
)) {
const value = search[key];
if (value === undefined || value === null) continue;
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
if (
typeof item !== "string" &&
typeof item !== "number" &&
typeof item !== "boolean"
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
url.searchParams.append(key, String(item));
}
}
return { success: true, url };
}
+2 -1
View File
@@ -35,12 +35,13 @@ export function parseRetryAfter(value, now = Date.now()) {
} }
/** /**
* @param {{ idempotency: "safe" | "keyed" | "none" }} operation * @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure * @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount * @param {number} retryCount
* @param {number} [maxRetries] * @param {number} [maxRetries]
*/ */
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) { export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
if (operation.retry === "never") return false;
if (retryCount >= maxRetries) return false; if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false; if (!retryKinds.has(failure.kind)) return false;
if ( if (
+1
View File
@@ -57,6 +57,7 @@ const requestSchemas =
.object({ .object({
cursor: z.string().optional(), cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20), limit: z.int().min(1).max(100).default(20),
tags: z.array(z.string().trim().min(1)).optional(),
}) })
.strict(), .strict(),
CreateSampleResourceCommand: z CreateSampleResourceCommand: z
+5 -1
View File
@@ -8,9 +8,13 @@ export const systemClock = Object.freeze({
return; return;
} }
const timer = setTimeout(resolve, milliseconds); const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
const onAbort = () => { const onAbort = () => {
clearTimeout(timer); clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(signal?.reason); reject(signal?.reason);
}; };
signal?.addEventListener("abort", onAbort, { once: true }); signal?.addEventListener("abort", onAbort, { once: true });
+45
View File
@@ -5,6 +5,7 @@ import type {
RenderFailureReport, RenderFailureReport,
} from "./ports/in/application-api.js"; } from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js"; import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js";
export type { ApplicationApi, ApplicationOutputPorts }; export type { ApplicationApi, ApplicationOutputPorts };
@@ -64,10 +65,54 @@ export function createApplication(
}, },
}); });
const recovery = Object.freeze({
async recoverChunk(input: {
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH";
}) {
try {
const current = await outputPorts.releaseInfo.getCurrent();
const active = await outputPorts.releaseInfo.refresh();
if (!active.routeChunks[input.chunkId]) {
return {
action: "support" as const,
reason: "active-chunk-unknown",
};
}
const decision = decideChunkRecovery({
failureKind: input.failureKind,
manifestLoaded: true,
currentBuildId: current.buildId,
currentReleaseId: current.releaseId,
activeBuildId: active.buildId,
activeReleaseId: active.releaseId,
storage: outputPorts.preferences,
});
if (decision.action === "reload-once") {
try {
outputPorts.navigation.reload();
} catch {
return {
action: "support" as const,
reason: "reload-failed",
};
}
}
return decision;
} catch {
return {
action: "support" as const,
reason: "manifest-unavailable",
};
}
},
});
return Object.freeze({ return Object.freeze({
session, session,
preferences, preferences,
diagnostics, diagnostics,
runtime, runtime,
recovery,
}); });
} }
@@ -38,4 +38,13 @@ export type ApplicationApi = Readonly<{
runtime: Readonly<{ runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>; getReleaseSummary(): Promise<ReleaseSummary>;
}>; }>;
recovery: Readonly<{
recoverChunk(input: Readonly<{
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH";
}>): Promise<
| Readonly<{ action: "reload-once"; releasePair: string }>
| Readonly<{ action: "support"; reason: string }>
>;
}>;
}>; }>;
@@ -15,4 +15,5 @@ export type ApplicationOutputPorts = Readonly<{
preferences: StoragePort; preferences: StoragePort;
diagnostics: TelemetryPort; diagnostics: TelemetryPort;
releaseInfo: ReleaseInfoPort; releaseInfo: ReleaseInfoPort;
navigation: Readonly<{ reload(): void }>;
}>; }>;
+10 -1
View File
@@ -9,7 +9,16 @@
* apiContractVersion: string, * apiContractVersion: string,
* assetManifestHash: string, * assetManifestHash: string,
* releaseId: string, * releaseId: string,
* builtAt?: string * builtAt?: string,
* routeChunks: Record<string, string>
* }>,
* refresh(): Promise<{
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string,
* routeChunks: Record<string, string>
* }> * }>
* }} ReleaseInfoPort * }} ReleaseInfoPort
*/ */
@@ -1,13 +1,21 @@
const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]); const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]);
/**
* @typedef {{action: "reload-once", releasePair: string} |
* {action: "support", reason: string}} ChunkRecoveryDecision
*/
/** /**
* @param {{ * @param {{
* failureKind: string, * failureKind: string,
* manifestLoaded: boolean, * manifestLoaded: boolean,
* currentBuildId: string, * currentBuildId: string,
* currentReleaseId: string,
* activeBuildId: string,
* activeReleaseId: string, * activeReleaseId: string,
* storage: import("../ports/storage-port.js").StoragePort * storage: import("../ports/storage-port.js").StoragePort
* }} input * }} input
* @returns {ChunkRecoveryDecision}
*/ */
export function decideChunkRecovery(input) { export function decideChunkRecovery(input) {
if (!RECOVERABLE_KINDS.has(input.failureKind)) { if (!RECOVERABLE_KINDS.has(input.failureKind)) {
@@ -16,13 +24,21 @@ export function decideChunkRecovery(input) {
if (!input.manifestLoaded) { if (!input.manifestLoaded) {
return { action: "support", reason: "manifest-unavailable" }; return { action: "support", reason: "manifest-unavailable" };
} }
if (input.activeReleaseId === input.currentBuildId) { if (
input.activeBuildId === input.currentBuildId &&
input.activeReleaseId === input.currentReleaseId
) {
return { action: "support", reason: "same-release" }; return { action: "support", reason: "same-release" };
} }
const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`; const releasePair =
`${input.currentBuildId}/${input.currentReleaseId}` +
`->${input.activeBuildId}/${input.activeReleaseId}`;
const guard = input.storage.read("CHUNK_RELOAD_GUARD"); const guard = input.storage.read("CHUNK_RELOAD_GUARD");
if (!guard.ok || guard.value === releasePair) { if (!guard.ok) {
return { action: "support", reason: "guard-read-failed" };
}
if (guard.value === releasePair) {
return { action: "support", reason: "reload-already-attempted" }; return { action: "support", reason: "reload-already-attempted" };
} }
@@ -1,81 +0,0 @@
export const ASYNC_BASE_STATES = Object.freeze([
"initial-loading",
"success",
"empty",
"terminal-error",
]);
export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-conflict",
]);
/**
* @typedef {{
* data?: unknown,
* isInitialLoading?: boolean,
* failure?: import("../../contracts/errors.js").ApiFailure,
* isFetching?: boolean,
* isStale?: boolean,
* isDegraded?: boolean,
* isMutationPending?: boolean,
* hasMutationConflict?: boolean
* }} AsyncSignals
*/
/** @param {AsyncSignals} signals */
export function deriveAsyncState(signals) {
const hasData = signals.data !== undefined && signals.data !== null;
const empty =
hasData &&
((Array.isArray(signals.data) && signals.data.length === 0) ||
signals.data === "");
let base;
if (signals.isInitialLoading && !hasData) {
base = "initial-loading";
} else if (signals.failure && !hasData) {
base = "terminal-error";
} else if (empty) {
base = "empty";
} else if (hasData) {
base = "success";
} else {
base = "initial-loading";
}
const overlay = Object.freeze({
refreshing: Boolean(signals.isFetching && hasData),
staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData),
mutationPending: Boolean(signals.isMutationPending && hasData),
mutationConflict: Boolean(signals.hasMutationConflict && hasData),
});
const state = {
base,
data: base === "success" || base === "empty" ? signals.data : undefined,
failure: base === "terminal-error" ? signals.failure : undefined,
overlay,
indicator: selectOverlayIndicator(overlay),
};
return Object.freeze(state);
}
/**
* @param {{
* refreshing: boolean,
* staleDegraded: boolean,
* mutationPending: boolean,
* mutationConflict: boolean
* }} overlay
*/
export function selectOverlayIndicator(overlay) {
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
if (overlay.refreshing) return "refreshing";
return null;
}
+156
View File
@@ -0,0 +1,156 @@
import type { ApiFailure } from "../../contracts/errors.js";
export const ASYNC_BASE_STATES = Object.freeze([
"initial-loading",
"success",
"empty",
"terminal-error",
] as const);
export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-conflict",
] as const);
export type AsyncOverlay =
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: true;
staleDegraded: false;
mutationPending: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: true;
mutationPending: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: true;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: false;
mutationConflict: true;
}>;
export type AsyncSignals = Readonly<{
data?: unknown;
isInitialLoading?: boolean;
failure?: ApiFailure;
isFetching?: boolean;
isStale?: boolean;
isDegraded?: boolean;
isMutationPending?: boolean;
hasMutationConflict?: boolean;
}>;
export type AsyncState = Readonly<{
base: (typeof ASYNC_BASE_STATES)[number];
data?: unknown;
failure?: ApiFailure;
overlay: AsyncOverlay;
indicator: (typeof ASYNC_OVERLAYS)[number] | null;
}>;
export function deriveAsyncState(signals: AsyncSignals): AsyncState {
const hasData = signals.data !== undefined && signals.data !== null;
const empty =
hasData &&
((Array.isArray(signals.data) && signals.data.length === 0) ||
signals.data === "");
const base =
signals.isInitialLoading && !hasData
? "initial-loading"
: signals.failure && !hasData
? "terminal-error"
: empty
? "empty"
: hasData
? "success"
: "initial-loading";
const indicator =
signals.hasMutationConflict && hasData
? "mutation-conflict"
: signals.isMutationPending && hasData
? "mutation-pending"
: signals.isStale && signals.isDegraded && hasData
? "stale-degraded"
: signals.isFetching && hasData
? "refreshing"
: null;
const overlay = overlayFor(indicator);
return Object.freeze({
base,
data: base === "success" || base === "empty" ? signals.data : undefined,
failure: base === "terminal-error" ? signals.failure : undefined,
overlay,
indicator,
});
}
export function selectOverlayIndicator(
overlay: AsyncOverlay,
): AsyncState["indicator"] {
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
if (overlay.refreshing) return "refreshing";
return null;
}
function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
if (indicator === "refreshing") {
return Object.freeze({
refreshing: true,
staleDegraded: false,
mutationPending: false,
mutationConflict: false,
});
}
if (indicator === "stale-degraded") {
return Object.freeze({
refreshing: false,
staleDegraded: true,
mutationPending: false,
mutationConflict: false,
});
}
if (indicator === "mutation-pending") {
return Object.freeze({
refreshing: false,
staleDegraded: false,
mutationPending: true,
mutationConflict: false,
});
}
if (indicator === "mutation-conflict") {
return Object.freeze({
refreshing: false,
staleDegraded: false,
mutationPending: false,
mutationConflict: true,
});
}
return Object.freeze({
refreshing: false,
staleDegraded: false,
mutationPending: false,
mutationConflict: false,
});
}
+65 -34
View File
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/); const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
const releaseManifestSchema = z export const releaseManifestSchema = z
.object({ .object({
schemaVersion: z.literal(1), schemaVersion: z.literal(1),
appVersion: z.string().min(1), appVersion: z.string().min(1),
@@ -12,6 +12,7 @@ const releaseManifestSchema = z
assetManifestHash: z.string().min(1), assetManifestHash: z.string().min(1),
releaseId: z.string().min(1), releaseId: z.string().min(1),
builtAt: z.string().min(1), builtAt: z.string().min(1),
routeChunks: z.record(z.string().min(1), z.string().min(1)),
}) })
.strict(); .strict();
@@ -20,7 +21,14 @@ export class ReleaseManifestError extends Error {
constructor(code, safe) { constructor(code, safe) {
super("Release manifest could not be loaded"); super("Release manifest could not be loaded");
this.name = "ReleaseManifestError"; this.name = "ReleaseManifestError";
this.kind = "RELEASE_MANIFEST_FAILURE"; this.kind =
{
MANIFEST_BUILD_MISMATCH: "BUILD_MISMATCH",
MANIFEST_CONFIG_SCHEMA_MISMATCH: "CONFIG_MISMATCH",
MANIFEST_API_CONTRACT_MISMATCH: "API_CONTRACT_MISMATCH",
MANIFEST_RELEASE_MISMATCH: "RELEASE_MISMATCH",
MANIFEST_ASSET_MISMATCH: "ASSET_MISMATCH",
}[code] ?? "RELEASE_MANIFEST_FAILURE";
this.code = code; this.code = code;
this.safe = Object.freeze({ this.safe = Object.freeze({
kind: this.kind, kind: this.kind,
@@ -33,75 +41,98 @@ export class ReleaseManifestError extends Error {
} }
/** /**
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime * Fetches and validates the active manifest without imposing the current
* @param {{fetcher?: typeof fetch}} [options] * build tuple. Chunk recovery uses this no-store view to detect a new release.
*
* @param {string} url
* @param {{
* fetcher?: typeof fetch,
* buildId: string,
* releaseId?: string
* }} options
*/ */
export async function loadReleaseManifest(runtime, options = {}) { export async function fetchReleaseManifest(url, options) {
const fetcher = options.fetcher ?? fetch; const fetcher = options.fetcher ?? fetch;
let response; let response;
try { try {
response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, { response = await fetcher(url, {
cache: "no-store", cache: "no-store",
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}); });
} catch { } catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", { throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", options);
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
} }
if (!response.ok) { if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", { throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options);
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
} }
let raw; let raw;
try { try {
raw = await response.json(); raw = await response.json();
} catch { } catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", { throw new ReleaseManifestError("MANIFEST_JSON_INVALID", options);
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
} }
const parsed = releaseManifestSchema.safeParse(raw); const parsed = releaseManifestSchema.safeParse(raw);
if (!parsed.success) { if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", { throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options);
}
return Object.freeze(structuredClone(parsed.data));
}
/**
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
* @param {{fetcher?: typeof fetch, expectedAssetManifestHash?: string}} [options]
*/
export async function loadReleaseManifest(runtime, options = {}) {
const manifest = await fetchReleaseManifest(
runtime.config.RELEASE_MANIFEST_URL,
{
fetcher: options.fetcher,
buildId: runtime.build.buildId, buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID, releaseId: runtime.config.RELEASE_ID,
}); },
);
let mismatchCode = null;
if (manifest.buildId !== runtime.build.buildId) {
mismatchCode = "MANIFEST_BUILD_MISMATCH";
} }
const manifest = parsed.data;
const mismatches = [];
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId");
if ( if (
!mismatchCode &&
runtime.config.BUILD_ID && runtime.config.BUILD_ID &&
manifest.buildId !== runtime.config.BUILD_ID manifest.buildId !== runtime.config.BUILD_ID
) { ) {
mismatches.push("runtimeBuildId"); mismatchCode = "MANIFEST_BUILD_MISMATCH";
} }
if ( if (
!mismatchCode &&
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
) { ) {
mismatches.push("configSchemaVersion"); mismatchCode = "MANIFEST_CONFIG_SCHEMA_MISMATCH";
}
if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) {
mismatches.push("apiContractVersion");
} }
if ( if (
!mismatchCode &&
manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION
) {
mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH";
}
if (
!mismatchCode &&
runtime.config.RELEASE_ID && runtime.config.RELEASE_ID &&
manifest.releaseId !== runtime.config.RELEASE_ID manifest.releaseId !== runtime.config.RELEASE_ID
) { ) {
mismatches.push("releaseId"); mismatchCode = "MANIFEST_RELEASE_MISMATCH";
} }
if (mismatches.length > 0) { if (
throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", { !mismatchCode &&
options.expectedAssetManifestHash &&
manifest.assetManifestHash !== options.expectedAssetManifestHash
) {
mismatchCode = "MANIFEST_ASSET_MISMATCH";
}
if (mismatchCode) {
throw new ReleaseManifestError(mismatchCode, {
buildId: runtime.build.buildId, buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID, releaseId: runtime.config.RELEASE_ID,
}); });
} }
return Object.freeze(structuredClone(manifest)); return manifest;
} }
+44
View File
@@ -3,11 +3,13 @@ import {
createExternalAuthSessionAdapter, createExternalAuthSessionAdapter,
createUnavailableSessionAdapter, createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js"; } from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import { import {
createQueryClient, createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js"; } from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js"; import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js"; import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
import { fetchReleaseManifest } from "./load-release-manifest.js";
/** /**
* @param {Record<string, unknown>} host * @param {Record<string, unknown>} host
@@ -40,6 +42,30 @@ function storageOrUndefined(value) {
: undefined; : undefined;
} }
/**
* Runtime-aware transport factory. Feature gateway composition calls this
* factory when a registered API capability is installed.
*
* @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
* authSession: import("../application/ports/auth-session-port.js").AuthSessionPort,
* fetcher?: typeof fetch,
* clock?: import("../application/ports/clock-port.js").ClockPort,
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
* }} context
*/
export function createRuntimeHttpClient(context) {
return createHttpClient({
baseUrl: context.runtime.config.API_BASE_URL,
timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS,
maxRetryAttempts: context.runtime.config.MAX_RETRY_ATTEMPTS,
authSession: context.authSession,
fetcher: context.fetcher,
clock: context.clock,
scheduler: context.scheduler,
});
}
/** /**
* @param {{ * @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>, * runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
@@ -72,6 +98,23 @@ export async function createRuntimeAdapters(context) {
async getCurrent() { async getCurrent() {
return structuredClone(context.release); return structuredClone(context.release);
}, },
async refresh() {
return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
fetcher: context.fetcher,
buildId: context.release.buildId,
releaseId: context.release.releaseId,
});
},
});
const navigation = Object.freeze({
reload() {
const location =
/** @type {{reload?: () => void} | undefined} */ (host.location);
if (typeof location?.reload !== "function") {
throw new Error("Browser reload is unavailable");
}
location.reload();
},
}); });
return Object.freeze({ return Object.freeze({
@@ -80,6 +123,7 @@ export async function createRuntimeAdapters(context) {
preferences: storage, preferences: storage,
diagnostics: telemetry, diagnostics: telemetry,
releaseInfo, releaseInfo,
navigation,
}), }),
infrastructure: Object.freeze({ infrastructure: Object.freeze({
queryClient, queryClient,
+9 -3
View File
@@ -4,8 +4,10 @@
* path: string, * path: string,
* operationId: string, * operationId: string,
* auth: "none" | "external-session", * auth: "none" | "external-session",
* timeoutMs: number, * timeoutMs: number | null,
* idempotency: "safe" | "keyed" | "none", * idempotency: "safe" | "keyed" | "none",
* retry: "runtime" | "never",
* requestSource: "search" | "body" | "none",
* requestSchema: string, * requestSchema: string,
* responseSchema: string, * responseSchema: string,
* owner: string * owner: string
@@ -21,8 +23,10 @@ export const API_OPERATIONS = Object.freeze({
path: "/api/sample/resources", path: "/api/sample/resources",
operationId: "LIST_SAMPLE_RESOURCES", operationId: "LIST_SAMPLE_RESOURCES",
auth: "external-session", auth: "external-session",
timeoutMs: 10_000, timeoutMs: null,
idempotency: "safe", idempotency: "safe",
retry: "runtime",
requestSource: "search",
requestSchema: "SampleResourceListQuery", requestSchema: "SampleResourceListQuery",
responseSchema: "SampleResourceListPayload", responseSchema: "SampleResourceListPayload",
owner: "feature-sample-feature-slice-contract-fixture", owner: "feature-sample-feature-slice-contract-fixture",
@@ -32,8 +36,10 @@ export const API_OPERATIONS = Object.freeze({
path: "/api/sample/resources", path: "/api/sample/resources",
operationId: "CREATE_SAMPLE_RESOURCE", operationId: "CREATE_SAMPLE_RESOURCE",
auth: "external-session", auth: "external-session",
timeoutMs: 10_000, timeoutMs: null,
idempotency: "keyed", idempotency: "keyed",
retry: "runtime",
requestSource: "body",
requestSchema: "CreateSampleResourceCommand", requestSchema: "CreateSampleResourceCommand",
responseSchema: "SampleResourcePayload", responseSchema: "SampleResourcePayload",
owner: "feature-sample-feature-slice-contract-fixture", owner: "feature-sample-feature-slice-contract-fixture",
+35
View File
@@ -111,6 +111,41 @@ export const ERROR_REGISTRY = Object.freeze({
"reload-once", "reload-once",
"release.mismatch.detected", "release.mismatch.detected",
), ),
BUILD_MISMATCH: row(
"BUILD_MISMATCH",
false,
"error",
"reload-once",
"release.mismatch.detected",
),
CONFIG_MISMATCH: row(
"CONFIG_MISMATCH",
false,
"error",
"contact-support",
"app.boot.failed",
),
API_CONTRACT_MISMATCH: row(
"API_CONTRACT_MISMATCH",
false,
"error",
"contact-support",
"app.boot.failed",
),
RELEASE_MISMATCH: row(
"RELEASE_MISMATCH",
false,
"error",
"reload-once",
"release.mismatch.detected",
),
ASSET_MISMATCH: row(
"ASSET_MISMATCH",
false,
"error",
"reload-once",
"release.mismatch.detected",
),
STORAGE_UNAVAILABLE: row( STORAGE_UNAVAILABLE: row(
"STORAGE_UNAVAILABLE", "STORAGE_UNAVAILABLE",
false, false,
+45
View File
@@ -0,0 +1,45 @@
/**
* @typedef {"none" | "NotFoundSplat" | "SampleResourceListQuery"} RouteCodecId
*/
/** @param {Readonly<{routeId: string, moduleId: string, paramsCodec: RouteCodecId, searchCodec: RouteCodecId}>} value */
const runtime = (value) => Object.freeze(value);
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
APP_HOME: runtime({
routeId: "APP_HOME",
moduleId: "home-page",
paramsCodec: "none",
searchCodec: "none",
}),
EXAMPLES_UI: runtime({
routeId: "EXAMPLES_UI",
moduleId: "ui-gallery-page",
paramsCodec: "none",
searchCodec: "none",
}),
EXAMPLES_STATES: runtime({
routeId: "EXAMPLES_STATES",
moduleId: "state-gallery-page",
paramsCodec: "none",
searchCodec: "none",
}),
EXAMPLES_AUTH: runtime({
routeId: "EXAMPLES_AUTH",
moduleId: "auth-example-page",
paramsCodec: "none",
searchCodec: "none",
}),
SAMPLE_RESOURCE_LIST: runtime({
routeId: "SAMPLE_RESOURCE_LIST",
moduleId: "sample-contract-page",
paramsCodec: "none",
searchCodec: "SampleResourceListQuery",
}),
NOT_FOUND: runtime({
routeId: "NOT_FOUND",
moduleId: "not-found-page",
paramsCodec: "NotFoundSplat",
searchCodec: "none",
}),
});
+1 -1
View File
@@ -86,7 +86,7 @@ export const ROUTE_REGISTRY = Object.freeze({
NOT_FOUND: route({ NOT_FOUND: route({
routeId: "NOT_FOUND", routeId: "NOT_FOUND",
path: "*", path: "*",
paramsSchema: null, paramsSchema: "NotFoundSplat",
searchSchema: null, searchSchema: null,
access: "public", access: "public",
loadingSurface: "none", loadingSurface: "none",
@@ -0,0 +1,194 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deriveAsyncState,
type AsyncState,
} from "../../../application/view-models/async-state.js";
import type { ApiFailure } from "../../../contracts/errors.js";
export type ApplicationResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
class ApplicationQueryError extends Error {
readonly failure: ApiFailure;
constructor(failure: ApiFailure) {
super(failure.kind);
this.name = "ApplicationQueryError";
this.failure = failure;
}
}
export function useApplicationQuery<Value>(
options: Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
): Readonly<{
data: Value | undefined;
state: AsyncState;
retry(): Promise<void>;
}> {
const { queryKey, execute, enabled = true } = options;
const [staleFailure, setStaleFailure] = useState(false);
const query = useQuery<Value, ApplicationQueryError>({
queryKey,
enabled,
retry: false,
queryFn: async ({ signal }) => {
const result = await execute({ signal });
if (result.ok) return result.value;
if (signal.aborted || result.error.kind === "REQUEST_ABORTED") {
throw new DOMException("Query cancelled", "AbortError");
}
throw new ApplicationQueryError(result.error);
},
});
const hasData = query.data !== undefined && query.data !== null;
useEffect(() => {
if (query.isError && hasData) {
setStaleFailure(true);
} else if (query.isSuccess && !query.isFetching) {
setStaleFailure(false);
}
}, [hasData, query.isError, query.isFetching, query.isSuccess]);
const retry = useCallback(async () => {
setStaleFailure(false);
await query.refetch();
}, [query]);
return Object.freeze({
data: query.data,
state: deriveAsyncState({
data: query.data,
isInitialLoading: query.isPending,
failure:
!hasData && query.error instanceof ApplicationQueryError
? query.error.failure
: undefined,
isFetching: query.isFetching && !query.isPending,
isStale: staleFailure,
isDegraded: staleFailure,
}),
retry,
});
}
export function useApplicationMutation<Input, Value>(
options: Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly (readonly unknown[])[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>,
): Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
}> {
const queryClient = useQueryClient();
const { execute, invalidate = [], optimistic, currentData } = options;
const [conflict, setConflict] = useState<ApiFailure | null>(null);
const inFlight = useRef<Promise<ApplicationResult<Value>> | null>(null);
const mutation = useMutation<Value, ApplicationQueryError, Input>({
retry: false,
mutationFn: async (input) => {
const result = await execute(input);
if (result.ok) return result.value;
throw new ApplicationQueryError(result.error);
},
});
const submit = useCallback(
(input: Input): Promise<ApplicationResult<Value>> => {
if (inFlight.current) return inFlight.current;
setConflict(null);
mutation.reset();
const previous = optimistic
? queryClient.getQueryData(optimistic.queryKey)
: undefined;
if (optimistic) {
queryClient.setQueryData(
optimistic.queryKey,
optimistic.update(previous, input),
);
}
const pending = mutation
.mutateAsync(input)
.then(async (value) => {
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
}
return { ok: true as const, value };
})
.catch((error: unknown) => {
if (optimistic) {
queryClient.setQueryData(optimistic.queryKey, previous);
}
const failure =
error instanceof ApplicationQueryError
? error.failure
: unexpectedMutationFailure();
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false as const, error: failure };
})
.finally(() => {
inFlight.current = null;
});
inFlight.current = pending;
return pending;
},
[invalidate, mutation, optimistic, queryClient],
);
const resolveConflict = useCallback(async () => {
setConflict(null);
mutation.reset();
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
}
}, [invalidate, mutation, queryClient]);
return Object.freeze({
state: deriveAsyncState({
data: currentData ?? true,
isMutationPending: mutation.isPending,
hasMutationConflict: conflict !== null,
}),
submit,
resolveConflict,
});
}
function unexpectedMutationFailure(): ApiFailure {
return {
kind: "UNKNOWN_FAILURE",
code: "UNKNOWN_FAILURE",
retryable: false,
operationId: "APPLICATION_MUTATION",
attemptCount: 1,
userMessageKey: "error.unknown_failure",
action: "contact-support",
};
}
+5
View File
@@ -0,0 +1,5 @@
export {
useApplicationMutation,
useApplicationQuery,
type ApplicationResult,
} from "./application-query.js";
@@ -0,0 +1,97 @@
import {
Component,
type ErrorInfo,
type ReactNode,
} from "react";
type RecoveryResult =
| Readonly<{ action: "reload-once"; releasePair: string }>
| Readonly<{ action: "support"; reason: string }>;
type Props = Readonly<{
children: ReactNode;
chunkId: string;
recover(input: Readonly<{
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE";
}>): Promise<RecoveryResult>;
}>;
type State = Readonly<{
error: unknown | null;
recovery: "idle" | "checking" | "reload-requested" | "support";
reason?: string;
}>;
export function isChunkLoadFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const value = `${error.name} ${error.message}`.toLowerCase();
return (
value.includes("chunkloaderror") ||
value.includes("loading chunk") ||
value.includes("dynamically imported module") ||
value.includes("failed to fetch module script")
);
}
export class ChunkRecoveryBoundary extends Component<Props, State> {
state: State = { error: null, recovery: "idle" };
static getDerivedStateFromError(error: unknown): State {
return { error, recovery: "checking" };
}
componentDidCatch(error: unknown, _info: ErrorInfo) {
if (!isChunkLoadFailure(error)) return;
void this.props
.recover({
chunkId: this.props.chunkId,
failureKind: "CHUNK_LOAD_FAILURE",
})
.then((result) => {
this.setState({
error,
recovery:
result.action === "reload-once"
? "reload-requested"
: "support",
...(result.action === "support" ? { reason: result.reason } : {}),
});
})
.catch(() => {
this.setState({
error,
recovery: "support",
reason: "recovery-controller-failed",
});
});
}
render() {
const { error, recovery, reason } = this.state;
if (error && !isChunkLoadFailure(error)) throw error;
if (error && recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
.
</section>
);
}
if (error && recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
.
</section>
);
}
if (error && recovery === "support") {
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1> .</h1>
<p> .</p>
</section>
);
}
return this.props.children;
}
}
@@ -6,6 +6,7 @@ import { Component } from "react";
* boundaryName: string, * boundaryName: string,
* routeId: string, * routeId: string,
* buildId: string, * buildId: string,
* resetKey?: string,
* onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void, * onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
* fallback?: React.ReactNode * fallback?: React.ReactNode
* }} RenderBoundaryProps * }} RenderBoundaryProps
@@ -37,6 +38,16 @@ export class RenderErrorBoundary extends Component {
} }
} }
/** @param {RenderBoundaryProps} previous */
componentDidUpdate(previous) {
if (
this.state.hasError &&
previous.resetKey !== this.props.resetKey
) {
this.setState({ hasError: false });
}
}
reset = () => { reset = () => {
this.setState({ hasError: false }); this.setState({ hasError: false });
}; };
+23 -9
View File
@@ -65,7 +65,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
data-message-key={userMessageKey} data-message-key={userMessageKey}
> >
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2> <h2 id={messageId}>{errorMessage(userMessageKey)}</h2>
{action !== "none" && ( {action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button> <Button onClick={onAction}>{actionLabels[action]}</Button>
)} )}
</section> </section>
@@ -76,10 +76,18 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
* @param {{ * @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>, * state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode, * children?: React.ReactNode,
* onAction?: () => void * onAction?: () => void,
* onRetry?: () => void,
* onResolveConflict?: () => void
* }} props * }} props
*/ */
export function AsyncSurface({ state, children, onAction }) { export function AsyncSurface({
state,
children,
onAction,
onRetry,
onResolveConflict,
}) {
if (state.base === "initial-loading") return <LoadingSurface />; if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />; if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) { if (state.base === "terminal-error" && state.failure) {
@@ -87,18 +95,24 @@ export function AsyncSurface({ state, children, onAction }) {
<TerminalErrorSurface <TerminalErrorSurface
userMessageKey={state.failure.userMessageKey} userMessageKey={state.failure.userMessageKey}
action={state.failure.action} action={state.failure.action}
onAction={onAction} onAction={onRetry ?? onAction}
/> />
); );
} }
return ( return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}> <section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator && ( {state.indicator ? (
<p role="status" aria-live="polite"> <div role="status" aria-live="polite">
{state.indicator} <span>{state.indicator}</span>
</p> {state.indicator === "stale-degraded" && onRetry ? (
)} <Button onClick={onRetry}>다시 시도</Button>
) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>충돌 해결</Button>
) : null}
</div>
) : null}
{children} {children}
</section> </section>
); );
@@ -8,6 +8,11 @@ const ERROR_MESSAGES = Object.freeze({
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.", "error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.", "error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.", "error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.",
"error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.",
"error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.",
"error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.",
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.", "error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.", "error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
}); });
@@ -11,7 +11,6 @@ export function PageHeader({ title, description, eyebrow }) {
const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null)); const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null));
useEffect(() => { useEffect(() => {
document.title = `${title} · Frontend Skeleton`;
headingRef.current?.focus(); headingRef.current?.focus();
}, [title]); }, [title]);
-260
View File
@@ -1,260 +0,0 @@
import { lazy, Suspense, useState } from "react";
import {
BrowserRouter,
Route,
Routes,
useLocation,
} from "react-router-dom";
import { getRoute, routePath } from "../../contracts/routes.js";
import { RouteBoundary } from "../boundaries/render-error-boundary.jsx";
import { AppShell } from "../layouts/app-shell.jsx";
import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx";
import { decideRouteAccess } from "./navigation-policy.js";
const HomePage = lazy(() => import("../pages/home-page.jsx"));
const UiGalleryPage = lazy(() => import("../examples/ui-gallery-page.jsx"));
const StateGalleryPage = lazy(
() => import("../examples/state-gallery-page.jsx"),
);
const AuthExamplePage = lazy(
() => import("../examples/auth-example-page.jsx"),
);
const SampleContractPage = lazy(
() => import("../pages/sample-contract-page.jsx"),
);
const NotFoundPage = lazy(() => import("../pages/not-found-page.jsx"));
/** @param {{ routeId: string }} props */
function RouteLoadingSurface({ routeId }) {
const definition = getRoute(routeId);
return (
<section className="ui-page route-loading" aria-live="polite" aria-busy="true">
<div className="ui-skeleton" aria-hidden="true" />
<p>화면을 준비하고 있습니다.</p>
<span className="visually-hidden">{definition.title} 로딩 </span>
</section>
);
}
function RouteFailureSurface() {
return (
<section className="ui-page">
<PageHeader
title="화면을 표시하지 못했습니다."
description="잠시 후 페이지를 새로고침해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요."
/>
</section>
);
}
/**
* @param {{
* routeId: string,
* buildId: string,
* children: React.ReactNode
* }} props
*/
function RouteSurface({ routeId, buildId, children }) {
const { diagnostics } = useApplication();
return (
<RouteBoundary
routeId={routeId}
buildId={buildId}
onRenderFailure={diagnostics.reportRenderFailure}
fallback={<RouteFailureSurface />}
>
<Suspense fallback={<RouteLoadingSurface routeId={routeId} />}>
{children}
</Suspense>
</RouteBoundary>
);
}
/**
* @param {{
* routeId: string,
* children: React.ReactNode
* }} props
*/
function ProtectedRoute({ routeId, children }) {
const location = useLocation();
const { sessionState, beginSignIn, recover } = useSession();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const decision = decideRouteAccess(routeId, sessionState);
async function continueSession() {
setPending(true);
setFailed(false);
try {
if (decision.action === "wait-for-session") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
);
}
} catch {
setFailed(true);
} finally {
setPending(false);
}
}
if (decision.allowed) return children;
if (sessionState === "integration-failed") {
return (
<section className="ui-page">
<PageHeader
title="로그인 연동이 필요합니다."
description="외부 인증 소유자가 런타임에 연결되면 이 보호 라우트를 사용할 수 있습니다."
/>
</section>
);
}
const recovering = decision.action === "wait-for-session";
return (
<section className="ui-page">
<PageHeader
title={recovering ? "세션을 복구하고 있습니다." : "세션이 필요합니다."}
description={
recovering
? "기존 세션 확인을 계속하려면 복구를 실행하세요."
: "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다."
}
/>
<div>
<button
className="ui-button"
type="button"
disabled={pending}
onClick={() => void continueSession()}
>
{pending
? "처리 중…"
: recovering
? "세션 복구"
: "로그인 시작"}
</button>
</div>
{failed ? (
<p className="ui-terminal-error" role="alert">
세션 작업을 완료하지 못했습니다.
</p>
) : null}
</section>
);
}
/**
* @param {{
* routeId: string,
* buildId: string,
* children: React.ReactNode
* }} props
*/
function PublicRoute({ routeId, buildId, children }) {
return (
<RouteSurface routeId={routeId} buildId={buildId}>
{children}
</RouteSurface>
);
}
/**
* @param {{
* basename?: string,
* buildId?: string
* }} props
*/
export function AppRouter({
basename = "/",
buildId = "local-build",
}) {
return (
<BrowserRouter basename={basename}>
<ThemeProvider>
<SessionProvider>
<Routes>
<Route element={<AppShell />}>
<Route
index
element={
<PublicRoute
routeId="APP_HOME"
buildId={buildId}
>
<HomePage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_UI")}
element={
<PublicRoute
routeId="EXAMPLES_UI"
buildId={buildId}
>
<UiGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_STATES")}
element={
<PublicRoute
routeId="EXAMPLES_STATES"
buildId={buildId}
>
<StateGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_AUTH")}
element={
<PublicRoute
routeId="EXAMPLES_AUTH"
buildId={buildId}
>
<AuthExamplePage />
</PublicRoute>
}
/>
<Route
path={routePath("SAMPLE_RESOURCE_LIST")}
element={
<RouteSurface
routeId="SAMPLE_RESOURCE_LIST"
buildId={buildId}
>
<ProtectedRoute routeId="SAMPLE_RESOURCE_LIST">
<SampleContractPage />
</ProtectedRoute>
</RouteSurface>
}
/>
<Route
path={routePath("NOT_FOUND")}
element={
<PublicRoute
routeId="NOT_FOUND"
buildId={buildId}
>
<NotFoundPage />
</PublicRoute>
}
/>
</Route>
</Routes>
</SessionProvider>
</ThemeProvider>
</BrowserRouter>
);
}
+309
View File
@@ -0,0 +1,309 @@
import {
createContext,
type ReactNode,
Suspense,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
createBrowserRouter,
RouterProvider,
type RouteObject,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import {
getRoute,
ROUTE_REGISTRY,
type RouteDefinition,
} from "../../contracts/routes.js";
import {
FeatureBoundary,
RouteBoundary,
} from "../boundaries/render-error-boundary.jsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js";
import { PageHeader } from "../components/page-header.jsx";
import { AppShell } from "../layouts/app-shell.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx";
import {
createRedirectLoopGuard,
decideRouteAccess,
} from "./navigation-policy.js";
import {
buildRouteUrl,
parseRouteInput,
type ParsedRouteInput,
type RouteId,
} from "./route-codecs.js";
import { ROUTE_RUNTIME } from "./route-runtime.js";
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
export function useRouteInput(): ParsedRouteInput {
const input = useContext(RouteInputContext);
if (!input) throw new Error("Registered route input is required");
return input;
}
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
return (
<section
className="ui-page route-loading"
aria-live="polite"
aria-busy="true"
data-surface={definition.loadingSurface}
>
<div className="ui-skeleton" aria-hidden="true" />
<p> .</p>
<span className="visually-hidden">{definition.title} </span>
</section>
);
}
function RouteFailureSurface({
definition,
}: {
definition?: RouteDefinition;
}) {
return (
<section
className="ui-page"
data-surface={definition?.errorSurface ?? "route-boundary"}
>
<PageHeader
title="화면을 표시하지 못했습니다."
description="잠시 후 다시 시도해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요."
/>
</section>
);
}
function InvalidRouteSurface({ code }: { code: string }) {
return (
<section className="ui-page" data-surface="invalid-route">
<PageHeader
title="올바르지 않은 주소입니다."
description="주소의 경로 또는 검색 조건을 확인해 주세요."
/>
<p data-route-error={code}> .</p>
</section>
);
}
function RouteLifecycle({ definition }: { definition: RouteDefinition }) {
const location = useLocation();
useEffect(() => {
document.title = `${definition.title} · Frontend Skeleton`;
const main = document.getElementById("main-content");
main?.focus({ preventScroll: true });
try {
if (!navigator.userAgent.toLowerCase().includes("jsdom")) {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
} catch {
// Non-browser test hosts may not implement scrolling.
}
}, [definition, location.key, location.pathname]);
return null;
}
function CanonicalRouteRedirect({
input,
}: {
input: ParsedRouteInput;
}) {
const location = useLocation();
const navigate = useNavigate();
const guard = useRef(createRedirectLoopGuard(3));
useEffect(() => {
if (input.routeId === "NOT_FOUND") return;
const source = `${location.pathname}${location.search}`;
const target = buildRouteUrl(input.routeId, {
params: input.params,
search: input.search,
});
if (source !== target && guard.current.allow(source, target)) {
void navigate(target, { replace: true });
}
}, [input, location.pathname, location.search, navigate]);
return null;
}
function ProtectedRoute({
routeId,
children,
}: {
routeId: RouteId;
children: ReactNode;
}) {
const location = useLocation();
const { sessionState, beginSignIn, recover } = useSession();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const decision = decideRouteAccess(routeId, sessionState);
async function continueSession() {
setPending(true);
setFailed(false);
try {
if (decision.action === "wait-for-session") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
);
}
} catch {
setFailed(true);
} finally {
setPending(false);
}
}
if (decision.allowed) return children;
if (sessionState === "integration-failed") {
return (
<section className="ui-page" data-surface="auth-integration-required">
<PageHeader
title="로그인 연동이 필요합니다."
description="외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다."
/>
</section>
);
}
const recovering = decision.action === "wait-for-session";
return (
<section className="ui-page" data-surface="authentication-required">
<PageHeader
title={recovering ? "세션을 복구하고 있습니다." : "세션이 필요합니다."}
description={
recovering
? "기존 세션 확인을 계속하려면 복구를 실행하세요."
: "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다."
}
/>
<button
className="ui-button"
type="button"
disabled={pending}
onClick={() => void continueSession()}
>
{pending ? "처리 중…" : recovering ? "세션 복구" : "로그인 시작"}
</button>
{failed ? (
<p className="ui-terminal-error" role="alert">
.
</p>
) : null}
</section>
);
}
function RegisteredRoute({
routeId,
buildId,
}: {
routeId: RouteId;
buildId: string;
}) {
const definition = getRoute(routeId);
const runtime = ROUTE_RUNTIME[routeId];
const params = useParams();
const [search] = useSearchParams();
const location = useLocation();
const { diagnostics, recovery } = useApplication();
const parsed = parseRouteInput(routeId, params, search);
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
const content = (
<RouteInputContext.Provider value={parsed.data}>
<CanonicalRouteRedirect input={parsed.data} />
<RouteLifecycle definition={definition} />
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
<ChunkRecoveryBoundary
chunkId={definition.chunkId}
recover={recovery.recoverChunk}
>
<runtime.Component />
</ChunkRecoveryBoundary>
</Suspense>
</RouteInputContext.Provider>
);
const protectedContent =
definition.access === "public" ? (
content
) : (
<ProtectedRoute routeId={routeId}>{content}</ProtectedRoute>
);
const boundaryProps = {
routeId,
buildId,
resetKey: `${location.pathname}${location.search}`,
onRenderFailure: diagnostics.reportRenderFailure,
fallback: <RouteFailureSurface definition={definition} />,
children: protectedContent,
};
return definition.errorSurface === "feature-boundary" ? (
<FeatureBoundary {...boundaryProps} />
) : (
<RouteBoundary {...boundaryProps} />
);
}
function createRegisteredRoutes(buildId: string): RouteObject[] {
const children = Object.values(ROUTE_REGISTRY).map((definition) => {
const routeId = definition.routeId as RouteId;
if (definition.path === "/") {
return {
id: routeId,
index: true,
element: <RegisteredRoute routeId={routeId} buildId={buildId} />,
};
}
return {
id: routeId,
path:
definition.path === "*"
? "*"
: definition.path.replace(/^\//, ""),
element: <RegisteredRoute routeId={routeId} buildId={buildId} />,
};
});
return [
{
id: "APP_SHELL",
path: "/",
element: <AppShell />,
errorElement: <RouteFailureSurface />,
children,
},
];
}
export function AppRouter({
basename = "/",
buildId = "local-build",
}: Readonly<{ basename?: string; buildId?: string }>) {
const router = useMemo(
() =>
createBrowserRouter(createRegisteredRoutes(buildId), {
basename,
}),
[basename, buildId],
);
return (
<ThemeProvider>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</ThemeProvider>
);
}
+15 -2
View File
@@ -16,8 +16,10 @@ export function decideRouteAccess(routeId, sessionState) {
return { allowed: false, action: "show-sign-in" }; return { allowed: false, action: "show-sign-in" };
} }
export function createRedirectLoopGuard() { /** @param {number} [maxHops] */
export function createRedirectLoopGuard(maxHops = 5) {
const visitedPairs = new Set(); const visitedPairs = new Set();
let hops = 0;
return Object.freeze({ return Object.freeze({
/** /**
@@ -26,12 +28,23 @@ export function createRedirectLoopGuard() {
*/ */
allow(source, target) { allow(source, target) {
const pair = `${source}->${target}`; const pair = `${source}->${target}`;
if (source === target || visitedPairs.has(pair)) return false; if (
source === target ||
visitedPairs.has(pair) ||
hops >= maxHops
) {
return false;
}
visitedPairs.add(pair); visitedPairs.add(pair);
hops += 1;
return true; return true;
}, },
reset() { reset() {
visitedPairs.clear(); visitedPairs.clear();
hops = 0;
},
get hopCount() {
return hops;
}, },
}); });
} }
+128
View File
@@ -0,0 +1,128 @@
import { z } from "zod";
import { getRoute } from "../../contracts/routes.js";
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
const emptyCodec = z.object({}).strict();
const notFoundSplatCodec = z.object({ "*": z.string().optional() }).strict();
const sampleResourceListQuery = z
.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tags: z
.preprocess(
(value) =>
value === undefined
? undefined
: Array.isArray(value)
? value
: [value],
z.array(z.string().trim().min(1)),
)
.optional(),
})
.strict();
const codecs = {
none: emptyCodec,
NotFoundSplat: notFoundSplatCodec,
SampleResourceListQuery: sampleResourceListQuery,
} as const;
export type ParsedRouteInput = Readonly<{
routeId: RouteId;
params: Readonly<Record<string, unknown>>;
search: Readonly<Record<string, unknown>>;
}>;
export type RouteInputResult =
| Readonly<{ success: true; data: ParsedRouteInput }>
| Readonly<{
success: false;
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
}>;
export function parseRouteInput(
routeId: RouteId,
rawParams: Readonly<Record<string, string | undefined>>,
rawSearch: URLSearchParams,
): RouteInputResult {
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecs[runtime.paramsCodec].safeParse(rawParams);
if (!params.success) {
return { success: false, code: "ROUTE_PARAMS_INVALID" };
}
const search = codecs[runtime.searchCodec].safeParse(
searchRecord(rawSearch),
);
if (!search.success) {
return { success: false, code: "ROUTE_SEARCH_INVALID" };
}
const parsedParams: Record<string, unknown> = { ...params.data };
const parsedSearch: Record<string, unknown> = { ...search.data };
return {
success: true,
data: Object.freeze({
routeId,
params: Object.freeze(parsedParams),
search: Object.freeze(parsedSearch),
}),
};
}
export function buildRouteUrl(
routeId: RouteId,
input: Readonly<{
params?: Readonly<Record<string, unknown>>;
search?: Readonly<Record<string, unknown>>;
}> = {},
): string {
const definition = getRoute(routeId);
if (definition.path === "*") {
throw new TypeError("The not-found route cannot build a canonical URL");
}
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecs[runtime.paramsCodec].parse(input.params ?? {});
const search = codecs[runtime.searchCodec].parse(input.search ?? {});
const parsedParams: Record<string, unknown> = { ...params };
const parsedSearch: Record<string, unknown> = { ...search };
let path = definition.path;
path = path.replace(
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
(_token, colonName: string | undefined, braceName: string | undefined) => {
const name = colonName ?? braceName ?? "";
const value = parsedParams[name];
if (typeof value !== "string" && typeof value !== "number") {
throw new TypeError(`Missing route path parameter: ${name}`);
}
return encodeURIComponent(String(value));
},
);
const query = new URLSearchParams();
for (const key of Object.keys(parsedSearch).sort((left, right) =>
left.localeCompare(right),
)) {
const value = parsedSearch[key];
if (value === undefined || value === null) continue;
for (const item of Array.isArray(value) ? value : [value]) {
query.append(key, String(item));
}
}
const serialized = query.toString();
return serialized ? `${path}?${serialized}` : path;
}
function searchRecord(
search: URLSearchParams,
): Readonly<Record<string, string | readonly string[]>> {
const result: Record<string, string | readonly string[]> = {};
for (const key of [...new Set(search.keys())].sort((left, right) =>
left.localeCompare(right),
)) {
const values = search.getAll(key);
result[key] = values.length === 1 ? values[0] : values;
}
return result;
}
+48
View File
@@ -0,0 +1,48 @@
import {
lazy,
type ComponentType,
type LazyExoticComponent,
} from "react";
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
import type { RouteId } from "./route-codecs.js";
type RouteModule = Readonly<{ default: ComponentType }>;
type RouteRuntime = Readonly<{
moduleId: string;
Component: LazyExoticComponent<ComponentType>;
}>;
function runtime(
routeId: RouteId,
load: () => Promise<RouteModule>,
): RouteRuntime {
return Object.freeze({
moduleId: ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
Component: lazy(load),
});
}
export const ROUTE_RUNTIME = {
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")),
EXAMPLES_UI: runtime(
"EXAMPLES_UI",
() => import("../examples/ui-gallery-page.jsx"),
),
EXAMPLES_STATES: runtime(
"EXAMPLES_STATES",
() => import("../examples/state-gallery-page.jsx"),
),
EXAMPLES_AUTH: runtime(
"EXAMPLES_AUTH",
() => import("../examples/auth-example-page.jsx"),
),
SAMPLE_RESOURCE_LIST: runtime(
"SAMPLE_RESOURCE_LIST",
() => import("../pages/sample-contract-page.jsx"),
),
NOT_FOUND: runtime(
"NOT_FOUND",
() => import("../pages/not-found-page.jsx"),
),
} satisfies Record<RouteId, RouteRuntime>;
+218
View File
@@ -0,0 +1,218 @@
// @vitest-environment jsdom
import {
act,
renderHook,
waitFor,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../src/presentation/adapters/query/application-query.js";
import { createFailure } from "../../src/contracts/errors.js";
function queryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, gcTime: Infinity },
mutations: { retry: false },
},
});
}
/** @param {QueryClient} client */
function wrapper(client) {
/** @param {{children: React.ReactNode}} props */
return function QueryWrapper({ children }) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};
}
describe("application query inbound bridge", () => {
it("latches a background failure over stale data and clears it on retry success", async () => {
const client = queryClient();
const responses = [
{ ok: /** @type {const} */ (true), value: ["first"] },
{
ok: /** @type {const} */ (false),
error: createFailure("SERVER_FAILURE", "LIST", 0),
},
{ ok: /** @type {const} */ (true), value: ["recovered"] },
];
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["resource", "list"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
);
expect(hook.result.current.state.base).toBe("success");
expect(hook.result.current.data).toEqual(["first"]);
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.data).toEqual(["recovered"]),
);
expect(hook.result.current.state.indicator).toBeNull();
expect(execute).toHaveBeenCalledTimes(3);
});
it("projects an initial application failure into terminal state", async () => {
const client = queryClient();
const failure = createFailure("FORBIDDEN", "LIST", 0);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["forbidden"],
execute: async () => ({ ok: false, error: failure }),
}),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.base).toBe("terminal-error"),
);
expect(hook.result.current.state.failure).toBe(failure);
});
it("passes cancellation to the application and does not retain an unmounted error", async () => {
const client = queryClient();
let aborted = false;
const execute = vi.fn(
({ signal }) =>
new Promise((resolve) => {
signal.addEventListener(
"abort",
() => {
aborted = true;
resolve({
ok: false,
error: createFailure("REQUEST_ABORTED", "LIST", 0),
});
},
{ once: true },
);
}),
);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["cancelled"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
hook.unmount();
await waitFor(() => expect(aborted).toBe(true));
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
});
});
describe("application mutation inbound bridge", () => {
it("deduplicates submit and commits one optimistic mutation", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
/** @type {(value: {ok: true, value: string}) => void} */
let complete = () => {};
const execute = vi.fn(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation({
execute,
invalidate: [["resource"]],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
.../** @type {string[]} */ (previous),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
let first = null;
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
let duplicate = null;
act(() => {
first = hook.result.current.submit("created");
duplicate = hook.result.current.submit("created");
});
expect(first).toBe(duplicate);
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
);
complete({ ok: true, value: "created" });
if (!first) throw new Error("expected pending mutation");
await act(() => first);
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
await waitFor(() =>
expect(hook.result.current.state.indicator).toBeNull(),
);
});
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
const conflict = createFailure("CONFLICT", "CREATE", 0);
const hook = renderHook(
() =>
useApplicationMutation({
execute: async () => ({ ok: false, error: conflict }),
invalidate: [["resource"]],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
.../** @type {string[]} */ (previous),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("conflicting");
});
expect(outcome).toEqual({ ok: false, error: conflict });
expect(client.getQueryData(key)).toEqual(["existing"]);
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
expect(hook.result.current.state.overlay).toMatchObject({
mutationPending: false,
mutationConflict: true,
});
await act(() => hook.result.current.resolveConflict());
expect(hook.result.current.state.indicator).toBeNull();
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
});
});
+39 -5
View File
@@ -1,7 +1,8 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js"; import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx"; import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
@@ -29,7 +30,7 @@ describe("async UI state matrix", () => {
expect(deriveAsyncState(signals).indicator).toBe(indicator); expect(deriveAsyncState(signals).indicator).toBe(indicator);
}); });
it("uses deterministic overlay priority for crossed states", () => { it("makes crossed overlay inputs mutually exclusive by priority", () => {
const state = deriveAsyncState({ const state = deriveAsyncState({
data: ["value"], data: ["value"],
isFetching: true, isFetching: true,
@@ -38,8 +39,8 @@ describe("async UI state matrix", () => {
}); });
expect(state.indicator).toBe("mutation-conflict"); expect(state.indicator).toBe("mutation-conflict");
expect(state.overlay).toMatchObject({ expect(state.overlay).toMatchObject({
refreshing: true, refreshing: false,
mutationPending: true, mutationPending: false,
mutationConflict: true, mutationConflict: true,
}); });
}); });
@@ -52,12 +53,45 @@ describe("async UI state matrix", () => {
expect(screen.getByRole("status")).toHaveTextContent("refreshing"); expect(screen.getByRole("status")).toHaveTextContent("refreshing");
}); });
it("connects stale retry and conflict resolution to real callbacks", async () => {
const user = userEvent.setup();
const retry = vi.fn();
const resolveConflict = vi.fn();
const stale = deriveAsyncState({
data: ["value"],
isStale: true,
isDegraded: true,
});
const view = render(
<AsyncSurface state={stale} onRetry={retry}>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(retry).toHaveBeenCalledOnce();
const conflict = deriveAsyncState({
data: ["value"],
hasMutationConflict: true,
});
view.rerender(
<AsyncSurface
state={conflict}
onResolveConflict={resolveConflict}
>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
expect(resolveConflict).toHaveBeenCalledOnce();
});
it("renders only safe error vocabulary", () => { it("renders only safe error vocabulary", () => {
const failure = createFailure("SERVER_FAILURE", "LIST", 0, { const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
code: "SERVER_FAILURE", code: "SERVER_FAILURE",
}); });
const state = deriveAsyncState({ failure }); const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />); render(<AsyncSurface state={state} onRetry={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent( expect(screen.getByRole("alert")).toHaveTextContent(
"요청을 완료하지 못했습니다.", "요청을 완료하지 못했습니다.",
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
ChunkRecoveryBoundary,
isChunkLoadFailure,
} from "../../src/presentation/boundaries/chunk-recovery-boundary.js";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
function ChunkDefect(): never {
throw new TypeError("Failed to fetch dynamically imported module");
}
function RenderDefect(): never {
throw new Error("ordinary render defect");
}
describe("chunk recovery boundary classification", () => {
it("recognizes lazy module failures without classifying ordinary render errors", () => {
expect(
isChunkLoadFailure(
new TypeError("Failed to fetch dynamically imported module"),
),
).toBe(true);
expect(isChunkLoadFailure(new Error("ordinary render defect"))).toBe(false);
});
it("runs the recovery input only for a lazy chunk rejection", async () => {
const recover = vi.fn(async () => ({
action: "support" as const,
reason: "reload-already-attempted",
}));
render(
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
<ChunkDefect />
</ChunkRecoveryBoundary>,
);
expect(
await screen.findByRole("heading", {
name: "화면 자산을 복구하지 못했습니다.",
}),
).toBeVisible();
expect(recover).toHaveBeenCalledOnce();
expect(recover).toHaveBeenCalledWith({
chunkId: "route-home",
failureKind: "CHUNK_LOAD_FAILURE",
});
});
it("rethrows an ordinary component defect to the local render boundary", () => {
const recover = vi.fn();
render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
<RenderDefect />
</ChunkRecoveryBoundary>
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(recover).not.toHaveBeenCalled();
});
});
+30
View File
@@ -91,4 +91,34 @@ describe("render recovery boundaries", () => {
); );
expect(screen.getByText("recovered")).toBeVisible(); expect(screen.getByText("recovered")).toBeVisible();
}); });
it("resets a route failure when the registered location key changes", async () => {
let shouldThrow = true;
function RouteContent() {
if (shouldThrow) throw new Error("route defect");
return <p>next route</p>;
}
const view = render(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
resetKey="/first"
>
<RouteContent />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toBeVisible();
shouldThrow = false;
view.rerender(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
resetKey="/second"
>
<RouteContent />
</FeatureBoundary>,
);
expect(await screen.findByText("next route")).toBeVisible();
});
}); });
+37 -3
View File
@@ -2,7 +2,7 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
createAnonymousSessionAdapter, createAnonymousSessionAdapter,
@@ -14,10 +14,13 @@ import { createTestApplication } from "../helpers/create-test-application.js";
/** /**
* @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session * @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session
* @param {Parameters<typeof createTestApplication>[0]} [overrides]
*/ */
function renderRouter(session) { function renderRouter(session, overrides = {}) {
return render( return render(
<ApplicationProvider application={createTestApplication({ session })}> <ApplicationProvider
application={createTestApplication({ ...overrides, session })}
>
<AppRouter /> <AppRouter />
</ApplicationProvider>, </ApplicationProvider>,
); );
@@ -50,6 +53,10 @@ describe("application router", () => {
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }), await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
).toBeVisible(); ).toBeVisible();
expect(window.location.pathname).toBe("/examples/ui"); expect(window.location.pathname).toBe("/examples/ui");
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton");
expect(
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
).toHaveFocus();
}); });
it("reacts to demo sign-in and opens the protected integration route", async () => { it("reacts to demo sign-in and opens the protected integration route", async () => {
@@ -82,4 +89,31 @@ describe("application router", () => {
screen.getByRole("heading", { name: "세션이 필요합니다." }), screen.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible(); ).toBeVisible();
}); });
it("rejects invalid route search before any application query runs", async () => {
const getCurrent = vi.fn(async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: { "route-sample-resources": "assets/sample.js" },
}));
window.history.pushState({}, "", "/sample/resources?limit=invalid");
renderRouter(createDemoSessionAdapter("authenticated"), {
releaseInfo: { getCurrent, refresh: getCurrent },
});
expect(
await screen.findByRole("heading", {
name: "올바르지 않은 주소입니다.",
}),
).toBeVisible();
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByText("안전한 탐색 링크를 사용해 주세요.")).toHaveAttribute(
"data-route-error",
"ROUTE_SEARCH_INVALID",
);
expect(getCurrent).not.toHaveBeenCalled();
});
}); });
@@ -30,6 +30,14 @@ const releaseManifest = {
assetManifestHash: "test-hash", assetManifestHash: "test-hash",
releaseId: "local-release", releaseId: "local-release",
builtAt: "2026-07-26T00:00:00.000Z", builtAt: "2026-07-26T00:00:00.000Z",
routeChunks: {
"route-home": "assets/home.js",
"route-examples-ui": "assets/ui.js",
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-sample-resources": "assets/sample.js",
"route-not-found": "assets/not-found.js",
},
}; };
describe("production runtime application tree", () => { describe("production runtime application tree", () => {
@@ -0,0 +1,3 @@
import { useQuery } from "@tanstack/react-query";
export const leakedQueryHook = useQuery;
+59
View File
@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"sourceDirectories": [],
"registries": [
{
"registryId": "ROUTES",
"path": "tests/fixtures/registry/routes/invalid-routes.js",
"exportName": "INVALID_ROUTES",
"owner": "fixture",
"requiredFields": [
"routeId",
"path",
"paramsSchema",
"searchSchema",
"loadingSurface",
"errorSurface",
"chunkId"
],
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
"paramsSchema": [null],
"searchSchema": [null],
"loadingSurface": ["app-shell"],
"errorSurface": ["route-boundary"],
"chunkId": ["route-home"]
},
"references": [
{
"field": "routeId",
"registryId": "RUNTIME",
"targetField": "routeId"
}
]
},
{
"registryId": "RUNTIME",
"path": "tests/fixtures/registry/routes/invalid-routes.js",
"exportName": "INVALID_RUNTIME",
"owner": "fixture",
"requiredFields": ["routeId", "moduleId", "paramsCodec", "searchCodec"],
"allowedValues": {
"moduleId": ["home-page"],
"paramsCodec": ["none"],
"searchCodec": ["none"]
},
"references": [
{
"field": "routeId",
"registryId": "ROUTES",
"targetField": "routeId"
}
]
}
],
"compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "breaking"
}
}
+29
View File
@@ -0,0 +1,29 @@
export const INVALID_ROUTES = {
FIRST: {
routeId: "DUPLICATE",
path: "/duplicate",
paramsSchema: "UnknownParams",
searchSchema: "UnknownSearch",
loadingSurface: "unknown-loading",
errorSurface: "unknown-error",
chunkId: "unknown-chunk",
},
SECOND: {
routeId: "DUPLICATE",
path: "/duplicate",
paramsSchema: null,
searchSchema: null,
loadingSurface: "app-shell",
errorSurface: "route-boundary",
chunkId: "route-home",
},
};
export const INVALID_RUNTIME = {
ORPHAN: {
routeId: "ORPHAN",
moduleId: "unknown-module",
paramsCodec: "none",
searchCodec: "none",
},
};
+8
View File
@@ -0,0 +1,8 @@
import type { AsyncOverlay } from "../../../src/application/view-models/async-state.js";
export const invalidPendingConflict: AsyncOverlay = {
refreshing: false,
staleDegraded: false,
mutationPending: true,
mutationConflict: true,
};
+14
View File
@@ -0,0 +1,14 @@
import { ROUTE_RUNTIME_CONTRACT } from "../../../src/contracts/route-runtime-contract.js";
type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
export const missingRuntime = {
APP_HOME: true,
} satisfies Record<RouteId, boolean>;
export const orphanRuntime = {
...Object.fromEntries(
Object.keys(ROUTE_RUNTIME_CONTRACT).map((routeId) => [routeId, true]),
),
ORPHAN_ROUTE: true,
} satisfies Record<RouteId, boolean>;
+18 -1
View File
@@ -6,7 +6,8 @@ import { createApplication } from "../../src/application/create-application.js";
* session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort, * session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort,
* preferences?: import("../../src/application/ports/storage-port.js").StoragePort, * preferences?: import("../../src/application/ports/storage-port.js").StoragePort,
* diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort, * diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort * releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort,
* navigation?: { reload(): void }
* }} [overrides] * }} [overrides]
*/ */
export function createTestApplication(overrides = {}) { export function createTestApplication(overrides = {}) {
@@ -29,7 +30,23 @@ export function createTestApplication(overrides = {}) {
configSchemaVersion: "1", configSchemaVersion: "1",
apiContractVersion: "1", apiContractVersion: "1",
assetManifestHash: "test-hash", assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
"route-sample-resources": "assets/sample.js",
},
}),
refresh: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
"route-sample-resources": "assets/sample.js",
},
}), }),
}, },
navigation: overrides.navigation ?? { reload: () => {} },
}); });
} }
@@ -0,0 +1,244 @@
import { describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
/** @param {unknown} data */
function successResponse(data) {
return Response.json({
success: true,
data,
meta: { requestId: "request-1", traceId: "trace-1" },
});
}
/** @param {number} status */
function failureResponse(status) {
return Response.json(
{
success: false,
error: { code: "TEMPORARY" },
meta: { requestId: "request-1", traceId: "trace-1" },
},
{ status },
);
}
function immediateClock() {
return { now: () => 0, sleep: async () => {} };
}
function recordingScheduler() {
const callbacks = /** @type {Array<() => void>} */ ([]);
return {
callbacks,
setTimeout: vi.fn((callback) => {
callbacks.push(callback);
return callbacks.length - 1;
}),
clearTimeout: vi.fn(),
};
}
describe("HTTP operation execution contract", () => {
it("sends parsed search/body values and aligns canonical query identity", async () => {
const requests = /** @type {Request[]} */ ([]);
const scheduler = recordingScheduler();
const fetcher = vi.fn(async (request) => {
requests.push(/** @type {Request} */ (request));
if (/** @type {Request} */ (request).method === "POST") {
return successResponse({ id: "created", name: "Trimmed" });
}
return successResponse([]);
});
const client = createHttpClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
scheduler,
});
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
await client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
searchParams: filters,
});
await client.execute({
operationId: "CREATE_SAMPLE_RESOURCE",
routeId: "SAMPLE_RESOURCE_LIST",
body: { name: " Trimmed " },
idempotencyKey: "logical-command",
});
expect(requests[0].url).toBe(
"https://api.test/api/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
expect(queryKeys.resource.list(filters).at(-1)).toEqual(filters);
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
expect(scheduler.setTimeout).toHaveBeenCalledTimes(2);
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2);
});
it("performs no fetch or timer work for invalid request input", async () => {
const fetcher = vi.fn();
const scheduler = recordingScheduler();
const client = createHttpClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
});
await expect(
client.execute({
operationId: "CREATE_SAMPLE_RESOURCE",
routeId: "SAMPLE_RESOURCE_LIST",
body: { name: " " },
}),
).resolves.toMatchObject({
ok: false,
error: { kind: "VALIDATION_REJECTED" },
});
expect(fetcher).not.toHaveBeenCalled();
expect(scheduler.setTimeout).not.toHaveBeenCalled();
expect(scheduler.clearTimeout).not.toHaveBeenCalled();
});
it.each([
[0, 1],
[1, 2],
[2, 3],
])(
"applies runtime max retry count %i as %i total attempts",
async (maxRetryAttempts, totalAttempts) => {
const fetcher = vi.fn(async () => failureResponse(503));
const scheduler = recordingScheduler();
const client = createHttpClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
scheduler,
maxRetryAttempts,
});
await expect(
client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
}),
).resolves.toMatchObject({
ok: false,
error: { kind: "SERVER_FAILURE" },
});
expect(fetcher).toHaveBeenCalledTimes(totalAttempts);
expect(scheduler.setTimeout).toHaveBeenCalledTimes(totalAttempts);
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(totalAttempts);
},
);
it("distinguishes a runtime timeout from caller navigation abort and cleans listeners", async () => {
const scheduler = recordingScheduler();
const fetcher = vi.fn(
(request) =>
new Promise((_resolve, reject) => {
/** @type {Request} */ (request).signal.addEventListener(
"abort",
() => reject(new DOMException("aborted", "AbortError")),
{ once: true },
);
}),
);
const client = createHttpClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
maxRetryAttempts: 0,
});
const timeoutResult = client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
});
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
scheduler.callbacks[0]();
await expect(timeoutResult).resolves.toMatchObject({
ok: false,
error: { kind: "REQUEST_TIMEOUT" },
});
const caller = new AbortController();
const add = vi.spyOn(caller.signal, "addEventListener");
const remove = vi.spyOn(caller.signal, "removeEventListener");
const abortResult = client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
signal: caller.signal,
});
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
caller.abort("navigation");
await expect(abortResult).resolves.toMatchObject({
ok: false,
error: { kind: "REQUEST_ABORTED" },
});
expect(add).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalledOnce();
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2);
});
it("never retries unsafe, non-retryable status, or schema failures", async () => {
const unsafeOperation = /** @type {const} */ ({
method: "POST",
path: "/api/unsafe",
operationId: "UNSAFE",
auth: "none",
timeoutMs: null,
idempotency: "none",
retry: "never",
requestSource: "none",
requestSchema: "unused",
responseSchema: "SampleResourcePayload",
owner: "test",
});
const unsafeFetch = vi.fn(async () => failureResponse(503));
const unsafeClient = createHttpClient({
baseUrl: "https://api.test",
fetcher: unsafeFetch,
clock: immediateClock(),
getOperation: () => unsafeOperation,
});
await unsafeClient.execute({
operationId: "UNSAFE",
routeId: "TEST",
});
expect(unsafeFetch).toHaveBeenCalledOnce();
const statusFetch = vi.fn(async () => failureResponse(500));
const statusClient = createHttpClient({
baseUrl: "https://api.test",
fetcher: statusFetch,
clock: immediateClock(),
});
await statusClient.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
});
expect(statusFetch).toHaveBeenCalledOnce();
const schemaFetch = vi.fn(async () =>
successResponse([{ id: "one", name: 42 }]),
);
const schemaScheduler = recordingScheduler();
const schemaClient = createHttpClient({
baseUrl: "https://api.test",
fetcher: schemaFetch,
clock: immediateClock(),
scheduler: schemaScheduler,
});
await schemaClient.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
});
expect(schemaFetch).toHaveBeenCalledOnce();
expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce();
});
});
@@ -25,6 +25,7 @@ const manifest = {
assetManifestHash: "hash-a", assetManifestHash: "hash-a",
releaseId: "release-a", releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z", builtAt: "2026-07-25T00:00:00Z",
routeChunks: { "route-home": "assets/home.js" },
}; };
describe("release manifest boot boundary", () => { describe("release manifest boot boundary", () => {
@@ -46,6 +47,62 @@ describe("release manifest boot boundary", () => {
new Response(JSON.stringify({ ...manifest, buildId: "build-b" })), new Response(JSON.stringify({ ...manifest, buildId: "build-b" })),
}, },
), ),
).rejects.toMatchObject({
kind: "BUILD_MISMATCH",
code: "MANIFEST_BUILD_MISMATCH",
});
});
it.each([
[
{ configSchemaVersion: "2" },
{},
"CONFIG_MISMATCH",
"MANIFEST_CONFIG_SCHEMA_MISMATCH",
],
[
{ apiContractVersion: "2" },
{},
"API_CONTRACT_MISMATCH",
"MANIFEST_API_CONTRACT_MISMATCH",
],
[
{ releaseId: "release-b" },
{},
"RELEASE_MISMATCH",
"MANIFEST_RELEASE_MISMATCH",
],
[
{},
{ expectedAssetManifestHash: "different" },
"ASSET_MISMATCH",
"MANIFEST_ASSET_MISMATCH",
],
])(
"classifies tuple mismatch %# without a generic deploy error",
async (manifestOverride, options, kind, code) => {
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{
fetcher: async () =>
Response.json({ ...manifest, ...manifestOverride }),
...options,
},
),
).rejects.toMatchObject({ kind, code });
},
);
it("rejects a manifest without a complete route chunk map", async () => {
const malformed = Object.fromEntries(
Object.entries(manifest).filter(([key]) => key !== "routeChunks"),
);
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{ fetcher: async () => Response.json(malformed) },
),
).rejects.toBeInstanceOf(ReleaseManifestError); ).rejects.toBeInstanceOf(ReleaseManifestError);
}); });
}); });
+11
View File
@@ -15,6 +15,7 @@ describe("application input/output boundary", () => {
"preferences", "preferences",
"diagnostics", "diagnostics",
"runtime", "runtime",
"recovery",
]); ]);
expect(application).not.toHaveProperty("storage"); expect(application).not.toHaveProperty("storage");
expect(application).not.toHaveProperty("telemetry"); expect(application).not.toHaveProperty("telemetry");
@@ -51,8 +52,18 @@ describe("application input/output boundary", () => {
configSchemaVersion: "1", configSchemaVersion: "1",
apiContractVersion: "1", apiContractVersion: "1",
assetManifestHash: "hash-a", assetManifestHash: "hash-a",
routeChunks: { "route-home": "assets/home.js" },
}),
refresh: async () => ({
buildId: "build-a",
releaseId: "release-a",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
routeChunks: { "route-home": "assets/home.js" },
}), }),
}, },
navigation: { reload: () => {} },
} satisfies ApplicationOutputPorts; } satisfies ApplicationOutputPorts;
const application = createApplication(ports); const application = createApplication(ports);
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import { createApplication } from "../../src/application/create-application.js";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import type { StoragePort } from "../../src/application/ports/storage-port.js";
type ReleaseFixture = {
buildId: string;
releaseId: string;
configSchemaVersion: string;
apiContractVersion: string;
assetManifestHash: string;
routeChunks: Record<string, string>;
};
function release(buildId: string, releaseId: string): ReleaseFixture {
return {
buildId,
releaseId,
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: `${buildId}-assets`,
routeChunks: { "route-home": `assets/${buildId}-home.js` },
};
}
function memoryStorage(): StoragePort {
let value: unknown;
return {
read: () => ({ ok: true, value }),
write: (_name, next) => {
value = next;
return { ok: true };
},
remove: () => ({ ok: true }),
};
}
function applicationWith(options: {
storage?: StoragePort;
refresh?: () => Promise<ReturnType<typeof release>>;
reload?: () => void;
}) {
const current = release("build-a", "release-a");
return createApplication({
session: createAnonymousSessionAdapter(),
preferences: options.storage ?? memoryStorage(),
diagnostics: { emit: () => {} },
releaseInfo: {
getCurrent: async () => current,
refresh:
options.refresh ??
(async () => release("build-b", "release-b")),
},
navigation: { reload: options.reload ?? (() => {}) },
});
}
describe("production chunk recovery application input", () => {
it("reloads exactly once for one active build/release pair", async () => {
const reload = vi.fn();
const application = applicationWith({ reload });
const input = {
chunkId: "route-home",
failureKind: "CHUNK_LOAD_FAILURE" as const,
};
await expect(application.recovery.recoverChunk(input)).resolves.toEqual({
action: "reload-once",
releasePair: "build-a/release-a->build-b/release-b",
});
await expect(application.recovery.recoverChunk(input)).resolves.toEqual({
action: "support",
reason: "reload-already-attempted",
});
expect(reload).toHaveBeenCalledOnce();
});
it.each([
[
{
refresh: async () => {
throw new Error("offline");
},
},
"manifest-unavailable",
],
[
{
refresh: async () => ({
...release("build-b", "release-b"),
routeChunks: {},
}),
},
"active-chunk-unknown",
],
[
{
storage: {
read: () => ({
ok: false as const,
error: {
kind: "STORAGE_UNAVAILABLE",
code: "STORAGE_UNAVAILABLE",
retryable: false,
operationId: "STORAGE",
attemptCount: 1,
userMessageKey: "error.storage_unavailable",
action: "none" as const,
},
}),
write: () => ({ ok: true as const }),
remove: () => ({ ok: true as const }),
},
},
"guard-read-failed",
],
])("fails closed for recovery dependency case %#", async (options, reason) => {
const reload = vi.fn();
const application = applicationWith({ ...options, reload });
await expect(
application.recovery.recoverChunk({
chunkId: "route-home",
failureKind: "CHUNK_LOAD_FAILURE",
}),
).resolves.toEqual({ action: "support", reason });
expect(reload).not.toHaveBeenCalled();
});
});
+9 -2
View File
@@ -22,12 +22,14 @@ describe("controlled chunk recovery", () => {
failureKind: "CHUNK_LOAD_FAILURE", failureKind: "CHUNK_LOAD_FAILURE",
manifestLoaded: true, manifestLoaded: true,
currentBuildId: "build-a", currentBuildId: "build-a",
currentReleaseId: "release-a",
activeBuildId: "build-b",
activeReleaseId: "release-b", activeReleaseId: "release-b",
storage, storage,
}; };
expect(decideChunkRecovery(input)).toEqual({ expect(decideChunkRecovery(input)).toEqual({
action: "reload-once", action: "reload-once",
releasePair: "build-a->release-b", releasePair: "build-a/release-a->build-b/release-b",
}); });
expect(decideChunkRecovery(input)).toEqual({ expect(decideChunkRecovery(input)).toEqual({
action: "support", action: "support",
@@ -38,12 +40,17 @@ describe("controlled chunk recovery", () => {
it.each([ it.each([
[{ failureKind: "SERVER_FAILURE" }, "not-recoverable"], [{ failureKind: "SERVER_FAILURE" }, "not-recoverable"],
[{ manifestLoaded: false }, "manifest-unavailable"], [{ manifestLoaded: false }, "manifest-unavailable"],
[{ activeReleaseId: "build-a" }, "same-release"], [
{ activeBuildId: "build-a", activeReleaseId: "release-a" },
"same-release",
],
])("stops when a recovery invariant fails: %#", (override, reason) => { ])("stops when a recovery invariant fails: %#", (override, reason) => {
const result = decideChunkRecovery({ const result = decideChunkRecovery({
failureKind: "DEPLOY_MISMATCH", failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true, manifestLoaded: true,
currentBuildId: "build-a", currentBuildId: "build-a",
currentReleaseId: "release-a",
activeBuildId: "build-b",
activeReleaseId: "release-b", activeReleaseId: "release-b",
storage: memoryStorage(), storage: memoryStorage(),
...override, ...override,
+1 -1
View File
@@ -9,7 +9,7 @@ import {
describe("frontend failure classification", () => { describe("frontend failure classification", () => {
it("defines all 26 stable error kinds with the seven contract fields", () => { it("defines all 26 stable error kinds with the seven contract fields", () => {
expect(Object.keys(ERROR_REGISTRY)).toHaveLength(26); expect(Object.keys(ERROR_REGISTRY)).toHaveLength(31);
for (const definition of Object.values(ERROR_REGISTRY)) { for (const definition of Object.values(ERROR_REGISTRY)) {
expect(definition).toEqual( expect(definition).toEqual(
expect.objectContaining({ expect.objectContaining({
+5 -2
View File
@@ -72,7 +72,7 @@ describe("route registry", () => {
"loadingSurface": "none", "loadingSurface": "none",
"navigationLabel": null, "navigationLabel": null,
"navigationOrder": null, "navigationOrder": null,
"paramsSchema": null, "paramsSchema": "NotFoundSplat",
"path": "*", "path": "*",
"routeId": "NOT_FOUND", "routeId": "NOT_FOUND",
"searchSchema": null, "searchSchema": null,
@@ -121,9 +121,12 @@ describe("route registry", () => {
}); });
it("allows at most one automatic redirect per source-target pair", () => { it("allows at most one automatic redirect per source-target pair", () => {
const guard = createRedirectLoopGuard(); const guard = createRedirectLoopGuard(2);
expect(guard.allow("/private", "/signin")).toBe(true); expect(guard.allow("/private", "/signin")).toBe(true);
expect(guard.allow("/private", "/signin")).toBe(false); expect(guard.allow("/private", "/signin")).toBe(false);
expect(guard.allow("/signin", "/signin")).toBe(false); expect(guard.allow("/signin", "/signin")).toBe(false);
expect(guard.allow("/signin", "/continue")).toBe(true);
expect(guard.allow("/continue", "/final")).toBe(false);
expect(guard.hopCount).toBe(2);
}); });
}); });
+3 -3
View File
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("registry governance manifest", () => { describe("registry governance manifest", () => {
it("declares exactly eight single-owner registries and impact labels", async () => { it("declares exactly nine single-owner registries and impact labels", async () => {
const governance = JSON.parse( const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"), await readFile("config/contracts/registry-governance.json", "utf8"),
); );
@@ -10,9 +10,9 @@ describe("registry governance manifest", () => {
/** @type {Array<{registryId: string, owner: string}>} */ ( /** @type {Array<{registryId: string, owner: string}>} */ (
governance.registries governance.registries
); );
expect(governance.registries).toHaveLength(8); expect(governance.registries).toHaveLength(9);
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe( expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
8, 9,
); );
expect(registries.every((entry) => entry.owner)).toBe(true); expect(registries.every((entry) => entry.owner)).toBe(true);
expect(governance.compatibilityImpact.allowed).toEqual([ expect(governance.compatibilityImpact.allowed).toEqual([
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { buildRequestTarget } from "../../src/adapters/http/request-builder.js";
import type { ApiOperation } from "../../src/contracts/api-operations.js";
const operation: ApiOperation = {
method: "GET",
path: "/api/resources/{resourceId}",
operationId: "GET_RESOURCE",
auth: "none",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "search",
requestSchema: "ResourceQuery",
responseSchema: "ResourcePayload",
owner: "test",
};
describe("deterministic HTTP request target", () => {
it("escapes path values and serializes optional/array search in key order", () => {
const result = buildRequestTarget(
"https://api.test/base/",
operation,
{ resourceId: "folder/item" },
{
tags: ["beta", "alpha"],
omitted: undefined,
limit: 20,
cursor: "next page",
},
);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.url.href).toBe(
"https://api.test/api/resources/folder%2Fitem?cursor=next+page&limit=20&tags=beta&tags=alpha",
);
});
it("fails closed when a path value or scalar search value is invalid", () => {
expect(
buildRequestTarget("https://api.test", operation, {}, {}),
).toEqual({ success: false, code: "PATH_PARAMETER_MISSING" });
expect(
buildRequestTarget(
"https://api.test",
operation,
{ resourceId: "one" },
{ nested: { secret: true } },
),
).toEqual({ success: false, code: "SEARCH_PARAMETER_INVALID" });
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { ROUTE_RUNTIME_CONTRACT } from "../../src/contracts/route-runtime-contract.js";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
import {
buildRouteUrl,
parseRouteInput,
} from "../../src/presentation/routes/route-codecs.js";
import { ROUTE_RUNTIME } from "../../src/presentation/routes/route-runtime.js";
describe("typed route contract and runtime", () => {
it("keeps contract, runtime contribution, and executable module complete", () => {
expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual(
Object.keys(ROUTE_REGISTRY).sort(),
);
expect(Object.keys(ROUTE_RUNTIME).sort()).toEqual(
Object.keys(ROUTE_REGISTRY).sort(),
);
});
it("round-trips canonical search through the registered codec", () => {
const url = buildRouteUrl("SAMPLE_RESOURCE_LIST", {
search: {
tags: ["open", "new"],
cursor: "a/b",
limit: 5,
},
});
expect(url).toBe(
"/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
const parsedUrl = new URL(url, "https://app.test");
expect(
parseRouteInput(
"SAMPLE_RESOURCE_LIST",
{},
parsedUrl.searchParams,
),
).toEqual({
success: true,
data: {
routeId: "SAMPLE_RESOURCE_LIST",
params: {},
search: {
cursor: "a/b",
limit: 5,
tags: ["open", "new"],
},
},
});
});
it("rejects unknown search and accepts the not-found splat owner", () => {
expect(
parseRouteInput(
"SAMPLE_RESOURCE_LIST",
{},
new URLSearchParams("unknown=value"),
),
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
expect(
parseRouteInput(
"NOT_FOUND",
{ "*": "missing/path" },
new URLSearchParams(),
),
).toMatchObject({ success: true });
});
});
+93 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js"; import {
createRuntimeAdapters,
createRuntimeHttpClient,
} from "../../src/bootstrap/runtime-adapters.js";
const runtime = { const runtime = {
config: { config: {
@@ -8,6 +11,9 @@ const runtime = {
API_BASE_URL: "http://localhost:8080", API_BASE_URL: "http://localhost:8080",
TELEMETRY_ENABLED: false, TELEMETRY_ENABLED: false,
AUTH_MODE: "demo", AUTH_MODE: "demo",
REQUEST_TIMEOUT_MS: 4321,
MAX_RETRY_ATTEMPTS: 0,
RELEASE_MANIFEST_URL: "/release-manifest.json",
}, },
}; };
const release = /** @type {const} */ ({ const release = /** @type {const} */ ({
@@ -20,6 +26,7 @@ const release = /** @type {const} */ ({
assetManifestHash: "hash-a", assetManifestHash: "hash-a",
releaseId: "release-a", releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z", builtAt: "2026-07-25T00:00:00Z",
routeChunks: { "route-home": "assets/home.js" },
}); });
describe("runtime adapter composition", () => { describe("runtime adapter composition", () => {
@@ -53,4 +60,88 @@ describe("runtime adapter composition", () => {
}); });
expect(adapters.outputPorts.session.getState()).toBe("integration-failed"); expect(adapters.outputPorts.session.getState()).toBe("integration-failed");
}); });
it("refetches the active release manifest with no-store semantics", async () => {
const activeRelease = {
...release,
buildId: "build-b",
releaseId: "release-b",
routeChunks: { "route-home": "assets/home-b.js" },
};
const fetcher = vi.fn(async () => Response.json(activeRelease));
const adapters = await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
runtime
),
release,
fetcher,
host: {},
});
await expect(adapters.outputPorts.releaseInfo.refresh()).resolves.toMatchObject({
buildId: "build-b",
releaseId: "release-b",
});
expect(fetcher).toHaveBeenCalledWith("/release-manifest.json", {
cache: "no-store",
headers: { Accept: "application/json" },
});
});
it("injects runtime timeout and max-attempt policy into HTTP execution", async () => {
const scheduled =
/** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]);
const scheduler = {
setTimeout: vi.fn((callback, milliseconds) => {
scheduled.push({ callback, milliseconds });
return scheduled.length;
}),
clearTimeout: vi.fn(),
};
const fetcher = vi.fn(async () =>
Response.json(
{
success: false,
error: { code: "TEMPORARY" },
meta: { requestId: "request-1", traceId: "trace-1" },
},
{ status: 503 },
),
);
const authSession =
(await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
runtime
),
release,
host: {},
})).outputPorts.session;
const client = createRuntimeHttpClient({
runtime:
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
runtime
),
authSession:
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
authSession
),
fetcher,
clock: { now: () => 0, sleep: async () => {} },
scheduler,
});
await client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
});
expect(fetcher).toHaveBeenCalledOnce();
expect(scheduler.setTimeout).toHaveBeenCalledWith(
expect.any(Function),
4321,
);
expect(scheduler.clearTimeout).toHaveBeenCalledOnce();
});
}); });
+6 -1
View File
@@ -5,9 +5,14 @@ import { systemClock } from "../../src/adapters/platform/system-clock.js";
describe("systemClock", () => { describe("systemClock", () => {
it("resolves after the requested duration", async () => { it("resolves after the requested duration", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const sleeper = systemClock.sleep(250); const caller = new AbortController();
const add = vi.spyOn(caller.signal, "addEventListener");
const remove = vi.spyOn(caller.signal, "removeEventListener");
const sleeper = systemClock.sleep(250, caller.signal);
await vi.advanceTimersByTimeAsync(250); await vi.advanceTimersByTimeAsync(250);
await expect(sleeper).resolves.toBeUndefined(); await expect(sleeper).resolves.toBeUndefined();
expect(add).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalledOnce();
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });