Compare commits

..
49 changed files with 1761 additions and 373 deletions
+10 -4
View File
@@ -79,7 +79,8 @@
{ "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: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"],
@@ -144,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"
}, },
@@ -158,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": {
+63
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",
@@ -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 @@
특히 다음은 선행 해결이 필요하다. 특히 다음은 선행 해결이 필요하다.
RP-01~RP-03에서 TypeScript 도구 안전망, application runtime 주입, RP-01~RP-04에서 TypeScript 도구 안전망, application runtime 주입,
query/mutation inbound adapter HTTP 실행 계약은 구현됐다. 현재 선행 해결 query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
recovery 계약은 구현됐다. 현재 선행 해결
대상은 다음과 같다. 대상은 다음과 같다.
1. 선언과 실행이 일치하는 typed route 계약 1. 전체를 제거할 수 있는 실제 reference feature
2. 전체를 제거할 수 있는 실제 reference feature 2. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
3. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 3. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다. 더 정확한 표현은 다음과 같다.
@@ -72,7 +73,7 @@ query/mutation inbound adapter와 HTTP 실행 계약은 구현됐다. 현재 선
| 서버 상태 | 부분 준비 | 제한된 query/mutation bridge와 lifecycle test | RP-05 reference route에서 실제 feature 연결 | | 서버 상태 | 부분 준비 | 제한된 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단계 토큰 |
@@ -156,14 +157,16 @@ client를 거대한 범용 함수로 계속 확장하지 말고 transport, reque
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>`로 양방향 완전성을 검사한다.
@@ -171,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가 완전히 제거되지 않는다
@@ -204,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의 실행 깊이가 부족하다
@@ -378,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
@@ -154,11 +154,13 @@ RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다.
- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는 - 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. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, 1. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
`errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다.
2. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
operation, Zod schema, mapper, domain model과 query key는 다른 경로에 operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다. 남는다.
@@ -1212,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
@@ -370,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
@@ -1273,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을 분리한다.
+2
View File
@@ -25,6 +25,7 @@
"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: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",
@@ -40,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({
+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" };
} }
+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;
} }
+19
View File
@@ -9,6 +9,7 @@ import {
} 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
@@ -97,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({
@@ -105,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,
+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,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 });
}; };
@@ -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>;
@@ -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", () => {
+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",
},
};
+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: () => {} },
}); });
} }
@@ -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([
+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 });
});
});
+30
View File
@@ -13,6 +13,7 @@ const runtime = {
AUTH_MODE: "demo", AUTH_MODE: "demo",
REQUEST_TIMEOUT_MS: 4321, REQUEST_TIMEOUT_MS: 4321,
MAX_RETRY_ATTEMPTS: 0, MAX_RETRY_ATTEMPTS: 0,
RELEASE_MANIFEST_URL: "/release-manifest.json",
}, },
}; };
const release = /** @type {const} */ ({ const release = /** @type {const} */ ({
@@ -25,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", () => {
@@ -59,6 +61,34 @@ 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 () => { it("injects runtime timeout and max-attempt policy into HTTP execution", async () => {
const scheduled = const scheduled =
/** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]); /** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]);