Compare commits

...
163 changed files with 6413 additions and 1674 deletions
+42 -1
View File
@@ -20,7 +20,7 @@ module.exports = {
{ {
name: "presentation-does-not-know-adapters", name: "presentation-does-not-know-adapters",
severity: "error", severity: "error",
from: { path: "^src/presentation" }, from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" }, to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
}, },
{ {
@@ -29,6 +29,44 @@ module.exports = {
from: { path: "^src/adapters" }, from: { path: "^src/adapters" },
to: { path: "^src/(presentation|bootstrap)" }, to: { path: "^src/(presentation|bootstrap)" },
}, },
{
name: "feature-domain-is-framework-neutral",
severity: "error",
from: { path: "^src/features/[^/]+/domain" },
to: {
path: "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)",
},
},
{
name: "feature-application-does-not-know-runtime",
severity: "error",
from: { path: "^src/features/[^/]+/application" },
to: {
path: "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)",
},
},
{
name: "feature-presentation-does-not-know-outbound-adapters",
severity: "error",
from: { path: "^src/features/[^/]+/presentation" },
to: {
path: "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)",
},
},
{
name: "feature-adapters-do-not-know-presentation",
severity: "error",
from: { path: "^src/features/[^/]+/adapters" },
to: {
path: "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)",
},
},
{
name: "concrete-adapters-compose-only-in-bootstrap",
severity: "error",
from: { path: "^src/(domain|application|presentation|contracts)" },
to: { path: "^src/adapters" },
},
{ {
name: "no-circular-dependencies", name: "no-circular-dependencies",
severity: "error", severity: "error",
@@ -45,5 +83,8 @@ module.exports = {
exportsFields: ["exports"], exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"], conditionNames: ["import", "require", "node", "default"],
}, },
tsConfig: {
fileName: "tsconfig.app.json",
},
}, },
}; };
+12 -1
View File
@@ -6,7 +6,8 @@ operations are executable contracts rather than conventions.
## Start locally ## Start locally
Requirements: Node 24 and Corepack. The repository pins pnpm in `package.json`. Requirements: Node 24.11 or newer and Corepack. The repository pins pnpm in
`package.json`.
```bash ```bash
corepack pnpm install --frozen-lockfile corepack pnpm install --frozen-lockfile
@@ -80,6 +81,9 @@ Common local checks:
```bash ```bash
corepack pnpm lint corepack pnpm lint
corepack pnpm check:types corepack pnpm check:types
corepack pnpm check:types:app
corepack pnpm check:types:node
corepack pnpm check:types:test
corepack pnpm check:architecture corepack pnpm check:architecture
corepack pnpm test:all corepack pnpm test:all
corepack pnpm test:e2e corepack pnpm test:e2e
@@ -94,6 +98,13 @@ corepack pnpm drill:runbooks
corepack pnpm check:ci corepack pnpm check:ci
``` ```
`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript
project로 모두 검사한다. type/architecture/security/registry의 invalid
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
실행된다. 도구 호환성 결정은
[VD-01](docs/architecture/decisions/VD-01-typescript-lint-tooling.md)에 기록돼
있다.
Install the pinned Playwright browser engines before the first cross-browser Install the pinned Playwright browser engines before the first cross-browser
run: run:
@@ -1,7 +1,7 @@
# SAMPLE_RESOURCE_LIST accessibility review # SAMPLE_RESOURCE_LIST accessibility review
Status: pending-manual-review Status: pending-manual-review
Route ID: SAMPLE_RESOURCE_LIST Route ID: REFERENCE_RESOURCE_LIST
Release ID: Release ID:
Reviewer: Reviewer:
Reviewed at: Reviewed at:
+33 -8
View File
@@ -74,7 +74,13 @@
"name": "typecheck", "name": "typecheck",
"steps": [ "steps": [
{ "script": "check:types", "expect": "pass" }, { "script": "check:types", "expect": "pass" },
{ "script": "check:types:fixture", "expect": "fail" } { "script": "check:types:fixture", "expect": "fail" },
{ "script": "check:types:fixture:ts-port", "expect": "fail" },
{ "script": "check:types:fixture:ts-result", "expect": "fail" },
{ "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "expect": "fail" }
], ],
"logPath": "artifacts/quality/check-types.txt", "logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"], "evidence": ["artifacts/quality/check-types.txt"],
@@ -103,9 +109,15 @@
}, },
"FE-GATE-007": { "FE-GATE-007": {
"name": "integration", "name": "integration",
"steps": [{ "script": "test:integration", "expect": "pass" }], "steps": [
{ "script": "test:integration", "expect": "pass" },
{ "script": "test:reference-feature", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-007.txt", "logPath": "artifacts/quality/gates/FE-GATE-007.txt",
"evidence": ["artifacts/tests/integration.xml"], "evidence": [
"artifacts/tests/integration.xml",
"artifacts/tests/reference-feature.xml"
],
"retentionClass": "merge-cycle" "retentionClass": "merge-cycle"
}, },
"FE-GATE-008": { "FE-GATE-008": {
@@ -128,7 +140,7 @@
"artifacts/tests/a11y-manual/EXAMPLES_UI.md", "artifacts/tests/a11y-manual/EXAMPLES_UI.md",
"artifacts/tests/a11y-manual/EXAMPLES_STATES.md", "artifacts/tests/a11y-manual/EXAMPLES_STATES.md",
"artifacts/tests/a11y-manual/EXAMPLES_AUTH.md", "artifacts/tests/a11y-manual/EXAMPLES_AUTH.md",
"artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md", "artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md",
"artifacts/tests/a11y-manual/NOT_FOUND.md", "artifacts/tests/a11y-manual/NOT_FOUND.md",
"artifacts/tests/a11y-manual/report.json" "artifacts/tests/a11y-manual/report.json"
], ],
@@ -136,16 +148,29 @@
}, },
"FE-GATE-010": { "FE-GATE-010": {
"name": "architecture", "name": "architecture",
"steps": [{ "script": "check:architecture", "expect": "pass" }], "steps": [
{ "script": "check:architecture", "expect": "pass" },
{ "script": "check:registries", "expect": "pass" },
{ "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": ["artifacts/quality/dependency-report.json"], "evidence": [
"artifacts/quality/dependency-report.json",
"artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json"
],
"retentionClass": "merge-cycle" "retentionClass": "merge-cycle"
}, },
"FE-GATE-011": { "FE-GATE-011": {
"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": {
@@ -237,7 +262,7 @@
"retentionClass": "release-coherence" "retentionClass": "release-coherence"
}, },
"FE-GATE-020": { "FE-GATE-020": {
"name": "sample-removal", "name": "reference-feature-removal",
"steps": [{ "script": "test:sample-removal", "expect": "pass" }], "steps": [{ "script": "test:sample-removal", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-020.txt", "logPath": "artifacts/quality/gates/FE-GATE-020.txt",
"evidence": ["artifacts/tests/sample-removal.xml"], "evidence": ["artifacts/tests/sample-removal.xml"],
+69 -4
View File
@@ -3,9 +3,40 @@
"registries": [ "registries": [
{ {
"registryId": "FE-REG-ROUTE", "registryId": "FE-REG-ROUTE",
"path": "src/contracts/routes.js", "path": "src/features/installed-feature-contracts.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, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"reference-resource-list",
"none"
],
"errorSurface": [
"route-boundary",
"feature-boundary",
"not-found"
],
"chunkId": [
"route-home",
"route-examples-ui",
"route-examples-states",
"route-examples-auth",
"route-reference-resources",
"route-not-found"
]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE-RUNTIME",
"targetField": "routeId"
}
],
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
"path", "path",
@@ -17,9 +48,41 @@
"chunkId" "chunkId"
] ]
}, },
{
"registryId": "FE-REG-ROUTE-RUNTIME",
"path": "src/features/installed-feature-contracts.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",
"reference-resource-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat"],
"searchCodec": ["none", "ReferenceResourceListQuery"]
},
"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/features/installed-feature-contracts.js",
"exportName": "API_OPERATIONS", "exportName": "API_OPERATIONS",
"owner": "feature-api-client-response-envelope-contract", "owner": "feature-api-client-response-envelope-contract",
"requiredFields": [ "requiredFields": [
@@ -29,6 +92,8 @@
"auth", "auth",
"timeoutMs", "timeoutMs",
"idempotency", "idempotency",
"retry",
"requestSource",
"requestSchema", "requestSchema",
"responseSchema", "responseSchema",
"owner" "owner"
@@ -74,7 +139,7 @@
}, },
{ {
"registryId": "FE-REG-QUERY", "registryId": "FE-REG-QUERY",
"path": "src/contracts/query-keys.js", "path": "src/features/installed-feature-contracts.js",
"exportName": "QUERY_REGISTRY", "exportName": "QUERY_REGISTRY",
"owner": "feature-server-state-caching-contract", "owner": "feature-server-state-caching-contract",
"requiredFields": [ "requiredFields": [
@@ -111,6 +176,6 @@
], ],
"compatibilityImpact": { "compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"], "allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "additive" "current": "behavior-change"
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ Automated axe checks do not establish WCAG conformance. A human reviewer must
review all six route records in `artifacts/tests/a11y-manual/` against one review all six route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. The required scope is derived from the route release candidate and sign them. The required scope is derived from the route
registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`,
`SAMPLE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the `REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the
gate rejects blank identity/timestamp/signature fields, pending verdicts, gate rejects blank identity/timestamp/signature fields, pending verdicts,
mismatched release IDs, or missing routes. mismatched release IDs, or missing routes.
@@ -0,0 +1,54 @@
# VD-01: TypeScript 7과 ESLint 10의 점진적 전환 도구
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-typescript-tooling-foundation`
## 배경
저장소는 TypeScript `7.0.2`와 ESLint `10.8.0`을 고정하고 있다. 첫 전환
브랜치는 compiler를 변경하거나 production source를 일괄 변환하지 않고
JS/JSX/TS/TSX가 같은 품질 게이트를 통과하게 해야 한다.
결정 시점의 package peer contract는 다음과 같다.
- `typescript-eslint@8.65.0`과 canary는 TypeScript `<6.1.0`을 요구한다.
- `eslint-plugin-jsx-a11y@6.10.2`는 ESLint `<=9`를 요구한다.
- `eslint-plugin-react-hooks@7.1.1`은 ESLint 10을 지원한다.
- Babel 8 ESLint parser는 ESLint 10을 지원하고 Node `>=24.11.0`을 요구한다.
호환되지 않는 peer dependency를 강제 설치하면 lockfile 검증은 통과하더라도
지원되지 않는 parser와 rule 조합을 플랫폼 계약으로 만들게 된다.
## 결정
1. TypeScript `7.0.2`와 ESLint `10.8.0`을 유지한다.
2. TypeScript/TSX의 ESLint syntax parsing에는
`@babel/eslint-parser`와 TypeScript/JSX syntax plugin을 사용한다.
3. TypeScript의 이름 해석, unused 진단과 type semantics는 `tsc`가 소유한다.
Babel parser가 TypeScript scope manager를 제공하지 않으므로 TS 파일의
core `no-undef``no-unused-vars`는 끄고 분리된 app/node/test TypeScript
project를 필수 게이트로 실행한다.
4. React Hook 규칙은 호환되는 `eslint-plugin-react-hooks`로 즉시 적용한다.
5. JSX 접근성은 현재의 semantic component contract, Testing Library,
axe 기반 cross-browser gate와 수동 검토 계약이 계속 담당한다. 호환되지 않는
`eslint-plugin-jsx-a11y`는 설치하지 않는다.
6. Babel 8의 지원 범위에 맞춰 Node engine 하한을 `24.11.0`으로 명시한다.
7. production source의 대량 rename은 이 결정에 포함하지 않는다.
## 검증
- `check:types`는 app, Node scripts/config, tests project를 모두 검사한다.
- JS invalid-call, TS invalid port, TS discriminated-union fixture는 실패해야 한다.
- ESLint와 dependency-cruiser는 TS/TSX architecture fixture를 검사한다.
- registry scanner는 TS registry의 required field, uniqueness와 reference를
검증한다.
- browser security gate는 TSX의 금지된 raw HTML fixture를 거절한다.
## 후속 검토와 제거
`typescript-eslint`가 TypeScript 7을, JSX 접근성 plugin이 ESLint 10을 공식
지원하면 별도 dependency 브랜치에서 peer metadata와 전체 negative fixture를
재검증한다. 교체할 때는 Babel parser package와 TS 전용 ESLint override를
함께 제거한다. compiler downgrade나 `--force` 설치는 이 ADR의 rollback
방법이 아니다.
@@ -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,13 @@
특히 다음은 선행 해결이 필요하다. 특히 다음은 선행 해결이 필요하다.
1. React 화면이 호출할 application input API와 런타임 주입 경계 RP-01~RP-05에서 TypeScript 도구 안전망, application runtime 주입,
2. TanStack Query를 사용하는 표준 query/mutation inbound adapter query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
3. TypeScript 전환 전에 TS 파일까지 검사하도록 만드는 도구 안전망 recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결
4. path/query/body projection과 runtime timeout/retry가 정확히 연결된 HTTP 계층 대상은 다음과 같다.
5. 선언과 실행이 일치하는 typed route 계약
6. 전체를 제거할 수 있는 실제 reference feature 1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
7. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 2. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다. 더 정확한 표현은 다음과 같다.
@@ -63,16 +63,16 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 | | 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 |
| 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | | 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 |
| application facade | 부분 준비 | `create-application.js`는 있으나 `main.jsx`에서 우회 | UI에는 input API만 주입 | | application facade | 준비 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 |
| HTTP client | 부분 준비 | timeout, abort, retry, auth, envelope, Zod가 존재 | path/query, parsed body, runtime 설정, 정리 로직 보완 | | HTTP client | 준비 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 부분 준비 | safe/keyed 요청 정책 존재 | retry 소유자 단일화, 설정 연결, telemetry | | retry | 준비 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | | 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 부분 준비 | runtime/API Zod 존재 | route/form/domain 경계를 분리하고 실제 parse 결과 사용 | | 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 미제공에 가까운 부분 준비 | QueryClientProvider와 cache port는 존재 | query/mutation hook과 화면 reference flow | | 서버 상태 | 준비 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 |
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 부분 준비 | lazy route, access hint, registry 존재 | typed runtime map, codec, recovery, metadata 집행 | | 라우팅 | 준비 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 | | 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 |
| 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 | | 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 | | 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 |
@@ -82,10 +82,10 @@
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | | 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | | telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 |
| 비동기 상태 불변식 | 부분 준비 | 공통 model/surface는 있으나 일부 모순 상태를 허용 | query/mutation 상태 조합과 action latch를 닫음 | | 비동기 상태 불변식 | 준비 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
| 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 | | 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 |
| UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 | | UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 |
| 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 | | 샘플 제거 | 준비 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 8단계 검증 | 새 contribution도 같은 제거 gate에 포함 |
| registry·compatibility 집행 | 부분 준비 | registry와 gate는 있으나 실제 before/after 및 orphan 검사가 제한적 | type/reference/orphan/diff/migration을 자동 검증 | | registry·compatibility 집행 | 부분 준비 | registry와 gate는 있으나 실제 before/after 및 orphan 검사가 제한적 | type/reference/orphan/diff/migration을 자동 검증 |
| 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 | | 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 |
| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 | | realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 |
@@ -94,13 +94,12 @@
### 5.1 P0: 기능 개발을 막는 항목 ### 5.1 P0: 기능 개발을 막는 항목
#### application 계층이 런타임에서 우회된다 #### RP-02에서 application 런타임 우회 해결
`src/bootstrap/composition-root.js` application을 생성하지만 `src/bootstrap/composition-root.js`가 만든 typed application input API는
`src/bootstrap/main.jsx`는 이를 라우터에 주입하지 않는다. UI에는 auth, storage, production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와
telemetry 같은 raw outbound dependency와 concrete QueryClient가 전달된다. release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime
`src/application/create-application.js`도 use case 중심 input API보다 outbound query만 사용한다.
capability를 다시 노출하는 형태다.
목표 상태: 목표 상태:
@@ -110,12 +109,13 @@ capability를 다시 노출하는 형태다.
- bootstrap만 concrete outbound adapter를 알고 조합한다. - bootstrap만 concrete outbound adapter를 알고 조합한다.
- 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다. - 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다.
#### 서버 상태 라이브러리는 마운트됐지만 사용할 수 없다 #### RP-03에서 표준 서버 상태 bridge 구현
`QueryClientProvider`는 존재하지만 저장소의 제품 코드에서 `useQuery` `src/presentation/adapters/query` 한 경계만 `@tanstack/**`를 import한다.
`useMutation`을 사용하지 않는다. 동시에 presentation의 `@tanstack/**` import는 `useApplicationQuery``useApplicationMutation`은 application result를 React
금지돼 있다. 현재 `QueryCachePort`는 명령형 read/write/invalidate만 제공하여 lifecycle에 연결하며 cancellation, stale failure, duplicate submit, optimistic
React 구독, 요청 상태, cancellation, optimistic update를 대신할 수 없다. rollback, conflict resolution과 invalidation을 검증한다. 다른 presentation
경로의 직접 TanStack import는 negative fixture가 거절한다.
목표 상태: 목표 상태:
@@ -128,7 +128,7 @@ React 구독, 요청 상태, cancellation, optimistic update를 대신할 수
- loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을 - loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을
reference feature에서 보여 준다. reference feature에서 보여 준다.
#### TypeScript 전환 전에 검사 도구가 TS를 인식해야 한다 #### RP-01에서 TypeScript 검사 도구 안전망 구현
현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은 현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은
중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다. 중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다.
@@ -143,31 +143,29 @@ TypeScript 전환은
[TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html) [TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)
처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다. 처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다.
#### HTTP 계약에 선언과 실행의 차이가 있다 #### RP-03에서 HTTP 선언과 실행의 차이 해결
현재 HTTP 계층은 공통화 수준이 높지만 다음 정확성 문제가 남아 있다. HTTP request builder는 path segment escaping, canonical optional/array search,
Zod default/trim 결과의 실제 query/body 전송을 담당한다. runtime timeout과
- runtime config의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`가 client 생성에 0/1/N max retry가 client factory에 주입되고 caller abort와 timeout을 다른 typed
전달되지 않는다. failure로 투영한다. validation 조기 반환은 fetch/timer 0회이며 success, schema
- operation path에 path parameter와 search parameter를 투영하는 표준 builder가 failure, abort, timeout과 exhausted retry는 scheduler/listener cleanup을
다. 검증한다. HTTP 사건의 semantic telemetry 연결은 RP-09 범위다.
- sample filter는 cache key에만 반영되고 실제 요청 URL에는 반영되지 않는다.
- Zod의 parsed/transformed request body 대신 원본 body를 전송한다.
- request validation의 조기 반환 경로에서 timeout/listener 정리가 늦어진다.
- HTTP failure, retry, recovery가 telemetry 사건과 이어지지 않는다.
client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth, client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth,
timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP
메서드보다 feature가 요구하는 gateway interface를 노출한다. 메서드보다 feature가 요구하는 gateway interface를 노출한다.
#### route registry 실행 계약이 아니다 #### RP-04에서 route registry 실행 계약으로 전환
route registry에는 `paramsSchema`, `searchSchema`, `loadingSurface`, platform route 계약과 `src/features/installed-feature-contracts.js`의 직렬화
`errorSurface`, `chunkId`가 있지만 실제 router tree, lazy module, navigation 가능한 contribution을 기준으로
목록은 별도로 작성된다. 여러 필드는 선언만 되고 런타임에 사용되지 않는다. `src/presentation/routes/app-router.tsx`가 Data Router route object와
chunk recovery use case와 redirect loop guard도 실제 route flow에 연결되지 않는다. navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만
소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry
gate가 모두 거절한다.
목표 상태: 현재 보장:
- serializable contract와 executable runtime map을 분리한다. - serializable contract와 executable runtime map을 분리한다.
- `satisfies Record<RouteId, RouteRuntime>`로 양방향 완전성을 검사한다. - `satisfies Record<RouteId, RouteRuntime>`로 양방향 완전성을 검사한다.
@@ -175,19 +173,23 @@ 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가 완전히 제거되지 않는다 #### RP-05에서 제거 가능한 reference feature 구현
현재 sample removal gate는 `src/sample/contract-fixture`만 삭제한다. sample API `src/features/reference-feature`가 domain, application input, outbound gateway,
operation, schema, mapper, domain model, query key는 다른 production 경로에 남는다. DTO/schema, mapper, route/API/query contract, query/mutation controller와 page를
반면 화면에 노출된 `/sample/resources`는 실제 query 수직 흐름을 실행하지 않는다. 한 소유 경계에 둔다. production composition은 generic feature input catalog를
통해 이 input을 주입하며 UI는 HTTP나 output port를 직접 보지 않는다.
reference feature는 domain, application input/output, schemas, operation, `test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed
mapper, query controller, pages, tests를 한 소유 경계 아래 모아야 한다. 해당 모듈과 contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck,
registry contribution을 제거한 뒤 typecheck, architecture, test, build가 모두 architecture, registry, unit/integration, home smoke, build와 fixture ID 잔여
통과해야 “제거 가능”으로 판정한다. 0개를 검사한다. 설치 모드에서는 MSW를 사용한 bootstrap → router → application
→ HTTP → schema → mapper → query cache → page 수직 테스트가 실행된다.
#### 비동기·복구 상태의 불변식이 닫혀 있지 않다 #### 비동기·복구 상태의 불변식이 닫혀 있지 않다
@@ -208,9 +210,11 @@ mutation-pending
mutation-conflict mutation-conflict
``` ```
chunk recovery와 release coherence도 policy 함수가 존재하는 것으로 완료되지 RP-04에서 lazy import failure는 `ChunkRecoveryBoundary` → application recovery
않는다. 실제 lazy import failure가 manifest 재확인, build 비교, 단 한 번의 guarded input → `ReleaseInfoPort.refresh()`의 no-store manifest 조회 → build/release 쌍
reload, 반복 실패 지원 표면까지 이어지고 E2E로 검증되어야 한다. guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 render
failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage
실패는 지원 표면으로 fail-closed된다.
#### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다 #### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다
@@ -382,7 +386,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
@@ -116,7 +116,7 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다
| `src/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter | | `src/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter |
| `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root | | `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root |
| `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 | | `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 |
| `src/sample` | 제거 가능 예제 일부 | 완전한 removable reference feature | | `src/features/reference-feature` | 완전한 제거 가능 수직 예제 | installed contribution과 8단계 제거 gate 유지 |
현재 구조가 잘 제공하는 기반은 다음과 같다. 현재 구조가 잘 제공하는 기반은 다음과 같다.
@@ -130,22 +130,38 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다
- render boundary와 async state surface - render boundary와 async state surface
- route registry와 lazy page - route registry와 lazy page
그러나 현재 실행 경로에는 다음 불일치가 있다. RP-02 구현으로 다음 경계는 실행 경로에 연결됐다.
1. `src/bootstrap/composition-root.js``application`을 만들지만 - `src/bootstrap/composition-root.js`만든 application을 production
`src/bootstrap/main.jsx`는 이를 사용하지 않고 `authSession`, `ApplicationProvider`가 실제 React tree에 주입한다.
`storage`, `telemetry`를 presentation에 직접 전달한다. - `createApplication`은 session, preference, diagnostics, runtime query의
2. `createApplication`이 input use case 대신 cache, storage, telemetry input API만 반환하며 storage, telemetry, release output port를 숨긴다.
output port를 그대로 노출한다. - bootstrap composition 결과는 raw output port를 반환하지 않고
3. `QueryClientProvider`는 존재하지만 실제 product route에서 application과 React infrastructure만 반환한다.
`useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다. - presentation의 direct fetch/browser storage/concrete adapter/TanStack import와
4. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, application의 React/concrete adapter import는 negative fixture가 거절한다.
`errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다.
5. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다.
operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다. - `src/presentation/adapters/query`만 TanStack Query import를 허용하며
6. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만 application query/mutation을 cancellation, invalidation, deduplication,
concrete HTTP client 구성에 전달되지 않는다. optimistic rollback과 conflict 해제에 연결한다.
- HTTP request builder는 path escaping과 canonical search를 소유하고 Zod가
변환한 search/body를 실제 request에 사용한다.
- runtime timeout과 max retry attempts가 transport factory에 주입되며
validation, success, abort, timeout과 exhausted retry의 timer/listener
정리를 테스트한다.
- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는
비활성화한다.
RP-04에서 route 실행 불일치는 닫혔다. route registry와 runtime map은
Data Router tree, codec, surface, title, navigation, chunk/release recovery의
단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다.
RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/
contract/presentation은 `src/features/reference-feature`가 소유하고, generic
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다. 불일치를 제거하는 것이다.
@@ -1197,19 +1213,19 @@ 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에 중복 보관하지 않는다.
### 26.5 Reference feature ### 26.5 Reference feature
- [ ] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다. - [x] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다.
- [ ] list/create 등 최소 query와 mutation 예제가 있다. - [x] list/create 등 최소 query와 mutation 예제가 있다.
- [ ] loading/empty/error/refresh/conflict 상태가 있다. - [x] loading/empty/error/refresh/conflict 상태가 있다.
- [ ] reference feature 전체 삭제 후 typecheck/test/build가 통과한다. - [x] reference feature 전체 삭제 후 typecheck/architecture/registry/test/home/build가 통과한다.
- [ ] built asset에 reference operation, schema, mapper가 남지 않는다. - [x] 제거 모드 built asset에 reference operation, schema, mapper가 남지 않는다.
### 26.6 품질 ### 26.6 품질
+3 -2
View File
@@ -44,12 +44,13 @@ flowchart LR
The current executable route tree is mounted only after runtime configuration The current executable route tree is mounted only after runtime configuration
and release-manifest coherence pass. It receives the composed query client, and release-manifest coherence pass. It receives the composed query client,
credential-opaque session port, storage port, telemetry port, and immutable credential-opaque session port, storage port, telemetry port, and immutable
build ID. Visible starter pages do not depend on the removable sample fixture. build ID. Generic starter pages do not depend on the removable reference
feature.
This describes the current starter composition, not the completed target. The This describes the current starter composition, not the completed target. The
capability review found that raw outbound capabilities still reach the React capability review found that raw outbound capabilities still reach the React
tree, the composed application facade is not yet its entry point, and several tree, the composed application facade is not yet its entry point, and several
route, HTTP, recovery, telemetry, and sample-removal contracts are only route, HTTP, recovery, telemetry, and reference-feature removal contracts are only
partially connected. Use the following documents for the evidence and migration partially connected. Use the following documents for the evidence and migration
plan: plan:
+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와 platform route 계약, `src/features/installed-feature-contracts.js`,
`src/presentation/routes/app-router.jsx`의 executable route tree가 별도 수동 목록이다. `src/features/installed-feature-runtimes.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
+13 -11
View File
@@ -11,7 +11,8 @@ primitives, and state surfaces remain reusable.
validated config + coherent release manifest validated config + coherent release manifest
-> concrete adapters -> concrete adapters
-> QueryClientProvider -> QueryClientProvider
-> BrowserRouter -> ApplicationProvider
-> RouterProvider
-> ThemeProvider -> ThemeProvider
-> SessionProvider -> SessionProvider
-> AppShell -> AppShell
@@ -31,7 +32,7 @@ remain stable in the shell.
| `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens | | `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens |
| `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix | | `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix |
| `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls | | `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls |
| `SAMPLE_RESOURCE_LIST` | `/sample/resources` | integration-defined | protected integration seam | | `REFERENCE_RESOURCE_LIST` | `/examples/reference-resources` | integration-defined | removable vertical slice |
| `NOT_FOUND` | `*` | public | safe navigation recovery | | `NOT_FOUND` | `*` | public | safe navigation recovery |
Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not
@@ -54,24 +55,25 @@ An external owner implements `readState`, `subscribe`, `beginSignIn`,
## Extending the starter ## Extending the starter
The steps below describe the current extension path. The platform review found The steps below describe the current extension path. New platform work should follow
that several route metadata fields and the composed application facade are not
yet connected end to end. New platform work should follow
[routing, page templates, and reusable patterns](./routing-pages-and-patterns.md) [routing, page templates, and reusable patterns](./routing-pages-and-patterns.md)
and the and the
[TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md) [TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md)
rather than adding another independent route or data-loading convention. rather than adding another independent route or data-loading convention.
1. Register the route path, access hint, title, chunk, loading surface, error 1. Add a serializable contribution under the feature ownership boundary and
surface, and optional navigation metadata in `src/contracts/routes.js`. install it through `src/features/installed-feature-contracts.js`.
2. Add a lazy page in `src/presentation/` and render it through `RouteSurface`. 2. Add the lazy component and route codecs through
3. Use application ports or use cases; do not import concrete adapters. `src/features/installed-feature-runtimes.tsx`.
3. Compose feature application inputs and outbound gateways only through
`src/features/installed-feature-adapters.ts`.
4. Use the semantic tokens, UI primitives, and state surfaces before adding a 4. Use the semantic tokens, UI primitives, and state surfaces before adding a
project-specific variant. project-specific variant.
5. Add component behavior, all-engine E2E, automated axe, and signed manual 5. Add component behavior, all-engine E2E, automated axe, and signed manual
route evidence. route evidence.
6. Run `test:sample-removal` to prove the visible starter still builds without 6. Run `test:sample-removal` to prove the generic starter typechecks, passes
`src/sample/contract-fixture`. architecture/registry/tests/home smoke, and builds without the complete
reference feature.
Theme preference is the public `COLOR_SCHEME` storage contract. Authentication Theme preference is the public `COLOR_SCHEME` storage contract. Authentication
tokens and other secrets remain forbidden storage keys. tokens and other secrets remain forbidden storage keys.
@@ -36,7 +36,8 @@ TypeScript 전환 목적은 확장자 변경이 아니라 이 계약을 컴파
다음 변경이 첫 브랜치에서 완료되기 전에는 source rename을 시작하지 않는다. 다음 변경이 첫 브랜치에서 완료되기 전에는 source rename을 시작하지 않는다.
1. ESLint가 `js`, `jsx`, `mjs`, `ts`, `tsx`, `mts`를 모두 검사한다. 1. ESLint가 `js`, `jsx`, `mjs`, `ts`, `tsx`, `mts`를 모두 검사한다.
2. `typescript-eslint`, React Hooks, JSX accessibility 규칙을 추가한다. 2. React Hooks 규칙을 추가하고 TypeScript/ESLint parser와 JSX accessibility
도구는 설치된 compiler/linter의 공식 peer 범위 안에서 선택한다.
3. dependency-cruiser의 extension과 resolver가 TS/TSX를 포함한다. 3. dependency-cruiser의 extension과 resolver가 TS/TSX를 포함한다.
4. `scripts/check-registries.mjs`가 TS/TSX를 검색한다. 4. `scripts/check-registries.mjs`가 TS/TSX를 검색한다.
5. `config/contracts/registry-governance.json`의 경로 갱신 절차를 만든다. 5. `config/contracts/registry-governance.json`의 경로 갱신 절차를 만든다.
@@ -77,6 +78,13 @@ tsconfig.json # project references only
확정한다. 옵션을 한꺼번에 켜서 수백 개 예외를 만들지 말고, 각 단계에서 새 확정한다. 옵션을 한꺼번에 켜서 수백 개 예외를 만들지 말고, 각 단계에서 새
예외를 금지한다. 예외를 금지한다.
현재 저장소의 VD-01 결정은
[TypeScript 7과 ESLint 10의 점진적 전환 도구](./decisions/VD-01-typescript-lint-tooling.md)에
기록돼 있다. app, Node scripts/config, tests는 각각 독립된 project로
typecheck하며 JS에는 `checkJs`, TS에는 `strict`를 적용한다. TypeScript 7을
아직 지원하지 않는 parser plugin을 강제 설치하지 않고 Babel parser는 lint
syntax/import/security 검사, `tsc`는 type semantics를 소유한다.
### 2.3 전환 순서 ### 2.3 전환 순서
| 단계 | 대상 | 이유 | 종료 조건 | | 단계 | 대상 | 이유 | 종료 조건 |
@@ -379,6 +387,20 @@ retry 조건:
timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든 timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든
경로에서 정리되어야 한다. 경로에서 정리되어야 한다.
RP-03의 현재 구현은 다음 계약을 자동 검증한다.
- `request-builder.ts`가 path 값을 escape하고 search key를 정렬하며 array 순서를
보존한다.
- operation의 `requestSource`가 search/body schema를 선택하고 Zod의
default/trim 결과만 URL 또는 JSON payload에 전달한다.
- runtime `REQUEST_TIMEOUT_MS``MAX_RETRY_ATTEMPTS`가 transport factory에
주입된다.
- query adapter의 자동 retry는 끄고 HTTP만 bounded network retry를 소유한다.
- `AsyncOverlay`는 refreshing/stale-degraded/mutation-pending/
mutation-conflict를 TypeScript union으로 배타화한다.
- `useApplicationQuery``useApplicationMutation`은 cancellation, stale latch,
duplicate submit, optimistic rollback, conflict resolution을 제공한다.
## 6. 인증과 token 소유권 ## 6. 인증과 token 소유권
기본 skeleton은 token manager를 제공하지 않는다. 기본 skeleton은 token manager를 제공하지 않는다.
+3 -2
View File
@@ -37,8 +37,9 @@ Arbitrary-value policy:
- user-controlled or runtime-composed class strings are forbidden - user-controlled or runtime-composed class strings are forbidden
- class variants must be selected from a closed static map - class variants must be selected from a closed static map
The removable sample may demonstrate tokens, but production starter modules do The removable reference feature may demonstrate tokens, but generic production
not import from `src/sample/contract-fixture`. starter modules do not import its domain, application, adapter, or presentation
implementation.
This file documents the currently implemented token and primitive baseline. This file documents the currently implemented token and primitive baseline.
The [design-system platform contract](./design-system-platform.md) defines the The [design-system platform contract](./design-system-platform.md) defines the
@@ -69,12 +69,12 @@ field/documentation 단계를 구성한다.
### 2.3 확인된 공백 ### 2.3 확인된 공백
#### 테스트 TypeScript typecheck 대상이 아니다 #### 테스트 TypeScript typecheck 기반
현재 `check:types``tsconfig.json`의 include 범위에 의존하며 tests 전체의 `check:types`app, Node scripts/config, tests project를 순서대로 검사한다.
callback, mock, fixture, custom matcher type을 별도 프로젝트로 검사하지 않는다. 테스트 project는 JS/JSX/TS/TSX의 callback, mock, fixture config type을
TypeScript 전환 뒤 Vitest가 test file을 변환해 실행할 수 있다는 사실은 검사하되 실패를 의도한 `tests/fixtures`는 별도 negative command가 소유한다.
`tsc` typecheck 대체하지 않는다. Vitest의 변환 성공을 TypeScript typecheck 대체물로 취급하지 않는다.
#### 실제 bootstrap integration test가 없다 #### 실제 bootstrap integration test가 없다
@@ -91,12 +91,14 @@ TypeScript 전환 뒤 Vitest가 test file을 변환해 실행할 수 있다는
- boot error shell의 safe metadata - boot error shell의 safe metadata
- StrictMode와 unmount cleanup - StrictMode와 unmount cleanup
#### TanStack Query의 React integration test가 없다 #### TanStack Query의 React integration test 기반
Query cache adapter의 명령형 `read/write/invalidate` unit test는 있지만 `tests/component/application-query.test.jsx`는 production query inbound
`useQuery`, `useMutation`, cancellation, stale/background refresh, optimistic adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal
rollback을 사용하는 production presentation adapter가 아직 없다. 따라서 query failure, background stale-failure latch와 retry 복구, duplicate submit,
provider가 마운트되어도 React 사용자의 실제 상태 전환은 검증되지 않는다. optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제
QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의
query/mutation vendor retry는 꺼져 있다.
#### Form 테스트가 단일 TextField 흐름에 머문다 #### Form 테스트가 단일 TextField 흐름에 머문다
@@ -368,10 +370,9 @@ QueryClientProvider
-> AppShell -> AppShell
``` ```
04 routing branch에서 Data Mode로 전환할 때 `BrowserRouter` RP-04에서 `RouterProvider` 기반 Data Mode로 전환했다. router component test와
`RouterProvider`로 바꾸고 테스트 fixture도 같은 composition factory에서 runtime composition test는 production `AppRouter` composition 함수를 사용하며,
생성한다. 문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 말고 문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 않는다.
production composition 함수를 호출한다.
### 6.3 Boot E2E ### 6.3 Boot E2E
@@ -445,6 +446,14 @@ tests/support/query/
helper는 production default를 복사하지 않는다. production factory를 호출하고 helper는 production default를 복사하지 않는다. production factory를 호출하고
필요한 시간·retry만 test override로 주입한다. 필요한 시간·retry만 test override로 주입한다.
RP-05의 executable reference matrix는
`tests/features/reference-feature`에 모여 있다. 설치 모드에서는 URL codec과
query key/HTTP search의 동일성, DTO 차단, loading/success/empty/terminal,
refreshing/stale, pending/duplicate/optimistic/conflict/rollback과 MSW production
composition을 검증한다. 제거 모드는 feature source/tests와 installed
contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 built asset
잔여 0개를 다시 검증한다.
## 8. Form 테스트 ## 8. Form 테스트
Form test는 field primitive, form controller, application command mapping을 Form test는 field primitive, form controller, application command mapping을
@@ -1271,7 +1280,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을 분리한다.
+196 -34
View File
@@ -1,6 +1,10 @@
import babelParser from "@babel/eslint-parser";
import eslint from "@eslint/js"; import eslint from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals"; import globals from "globals";
const sourceExtensions = "{js,jsx,mjs,ts,tsx,mts}";
const layerPatterns = { const layerPatterns = {
domain: [ domain: [
"**/application/**", "**/application/**",
@@ -19,7 +23,12 @@ const layerPatterns = {
"react-dom", "react-dom",
"@tanstack/**", "@tanstack/**",
], ],
presentation: ["**/adapters/**", "**/bootstrap/**", "@tanstack/**"], presentation: [
"**/adapters/**",
"**/bootstrap/**",
"**/application/ports/out/**",
"@tanstack/**",
],
adapters: ["**/presentation/**", "**/bootstrap/**"], adapters: ["**/presentation/**", "**/bootstrap/**"],
}; };
@@ -27,6 +36,38 @@ function restrictedImports(patterns) {
return ["error", { patterns }]; return ["error", { patterns }];
} }
const commonLanguageOptions = {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
};
const commonSecurityRules = {
"no-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-restricted-syntax": [
"error",
{
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
message: "Raw HTML injection is prohibited by FE-OC-019.",
},
{
selector:
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
],
};
const hookRules = {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
};
export default [ export default [
{ {
ignores: [ ignores: [
@@ -40,47 +81,179 @@ export default [
}, },
eslint.configs.recommended, eslint.configs.recommended,
{ {
files: ["**/*.{js,jsx,mjs}"], files: [`**/*.${sourceExtensions}`],
languageOptions: { languageOptions: {
ecmaVersion: "latest", ...commonLanguageOptions,
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: { parserOptions: {
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
}, },
}, },
plugins: {
"react-hooks": reactHooks,
},
rules: {
...commonSecurityRules,
...hookRules,
},
}, },
{ {
files: ["src/domain/**/*.{js,jsx}"], files: ["**/*.{ts,mts}"],
languageOptions: {
...commonLanguageOptions,
parser: babelParser,
parserOptions: {
requireConfigFile: false,
babelOptions: {
plugins: [
["@babel/plugin-syntax-typescript", { isTSX: false }],
],
},
},
},
rules: {
"no-undef": "off",
"no-unused-vars": "off",
},
},
{
files: ["**/*.tsx"],
languageOptions: {
...commonLanguageOptions,
parser: babelParser,
parserOptions: {
requireConfigFile: false,
babelOptions: {
plugins: [
[
"@babel/plugin-syntax-typescript",
{ allExtensions: true, isTSX: true },
],
"@babel/plugin-syntax-jsx",
],
},
},
},
rules: {
"no-undef": "off",
"no-unused-vars": "off",
},
},
{
files: [`src/domain/**/*.${sourceExtensions}`],
rules: { rules: {
"no-restricted-imports": restrictedImports(layerPatterns.domain), "no-restricted-imports": restrictedImports(layerPatterns.domain),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"], "no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
}, },
}, },
{ {
files: ["src/application/**/*.{js,jsx}"], files: [`src/application/**/*.${sourceExtensions}`],
rules: { rules: {
"no-restricted-imports": restrictedImports(layerPatterns.application), "no-restricted-imports": restrictedImports(layerPatterns.application),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"], "no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
}, },
}, },
{ {
files: ["src/presentation/**/*.{js,jsx}"], files: [`src/presentation/**/*.${sourceExtensions}`],
rules: { rules: {
"no-restricted-imports": restrictedImports(layerPatterns.presentation), "no-restricted-imports": restrictedImports(layerPatterns.presentation),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
}, },
}, },
{ {
files: ["src/adapters/**/*.{js,jsx}"], files: [
`src/presentation/adapters/query/**/*.${sourceExtensions}`,
],
rules: {
"no-restricted-imports": restrictedImports([
"**/adapters/http/**",
"**/adapters/storage/**",
"**/adapters/auth/**",
"**/bootstrap/**",
"**/application/ports/out/**",
]),
},
},
{
files: [`src/adapters/**/*.${sourceExtensions}`],
rules: { rules: {
"no-restricted-imports": restrictedImports(layerPatterns.adapters), "no-restricted-imports": restrictedImports(layerPatterns.adapters),
}, },
}, },
{ {
files: ["tests/**/*.{js,jsx}"], files: [`src/features/*/domain/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.domain),
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: [`src/features/*/application/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.application),
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: [`src/features/*/presentation/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/features/*/adapters/**",
"**/adapters/http/**",
"**/adapters/storage/**",
"**/adapters/auth/**",
"**/bootstrap/**",
"**/application/ports/out/**",
"@tanstack/**",
]),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
},
},
{
files: [`src/features/*/adapters/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/presentation/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{
files: [`tests/**/*.${sourceExtensions}`],
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
@@ -89,33 +262,22 @@ export default [
}, },
}, },
{ {
files: ["tests/fixtures/architecture/forbidden/**/*.{js,jsx}"], files: [
`tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`,
],
rules: { rules: {
"no-restricted-imports": restrictedImports([ "no-restricted-imports": restrictedImports([
"**/adapters/**", "**/adapters/**",
"**/application/ports/out/**",
"@tanstack/**", "@tanstack/**",
"react", "react",
"react-dom", "react-dom",
]), ]),
}, "no-restricted-globals": [
},
{
files: ["**/*.{js,jsx}"],
rules: {
"no-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-restricted-syntax": [
"error", "error",
{ "fetch",
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", "localStorage",
message: "Raw HTML injection is prohibited by FE-OC-019.", "sessionStorage",
},
{
selector:
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
], ],
}, },
}, },
+21 -4
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"packageManager": "pnpm@11.17.0", "packageManager": "pnpm@11.17.0",
"engines": { "engines": {
"node": ">=24.0.0 <25.0.0", "node": ">=24.11.0 <25.0.0",
"pnpm": ">=11.0.0 <12.0.0" "pnpm": ">=11.0.0 <12.0.0"
}, },
"scripts": { "scripts": {
@@ -15,8 +15,17 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0", "lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0",
"check:architecture": "node scripts/check-architecture.mjs", "check:architecture": "node scripts/check-architecture.mjs",
"check:types": "tsc --allowJs --checkJs --noEmit", "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test",
"check:types:app": "tsc --project tsconfig.app.json",
"check:types:node": "tsc --project tsconfig.node.json",
"check:types:test": "tsc --project tsconfig.test.json",
"check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js", "check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts",
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
"check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
@@ -25,12 +34,15 @@
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs", "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs", "review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
"test:sample-removal": "node scripts/test-sample-removal.mjs", "test:sample-removal": "node scripts/test-sample-removal.mjs",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration", "test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature",
"verify:lockfile": "corepack pnpm install --frozen-lockfile", "verify:lockfile": "corepack pnpm install --frozen-lockfile",
"generate:supply-chain": "node scripts/generate-supply-chain.mjs", "generate:supply-chain": "node scripts/generate-supply-chain.mjs",
"scan:security": "node scripts/security-scan.mjs", "scan:security": "node scripts/security-scan.mjs",
"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: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",
@@ -52,18 +64,23 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.12.1", "@axe-core/playwright": "4.12.1",
"@babel/core": "8.0.1",
"@babel/eslint-parser": "8.0.1",
"@babel/plugin-syntax-jsx": "8.0.1",
"@babel/plugin-syntax-typescript": "8.0.3",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.0", "@playwright/test": "1.62.0",
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.0", "@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1", "@testing-library/user-event": "14.6.1",
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/react": "19.2.8", "@types/react": "19.2.8",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.4", "@vitejs/plugin-react": "6.0.4",
"dependency-cruiser": "18.1.0", "dependency-cruiser": "18.1.0",
"eslint": "10.8.0", "eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.7.0", "globals": "17.7.0",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"msw": "2.15.0", "msw": "2.15.0",
+495
View File
@@ -27,6 +27,18 @@ importers:
'@axe-core/playwright': '@axe-core/playwright':
specifier: 4.12.1 specifier: 4.12.1
version: 4.12.1(playwright-core@1.62.0) version: 4.12.1(playwright-core@1.62.0)
'@babel/core':
specifier: 8.0.1
version: 8.0.1
'@babel/eslint-parser':
specifier: 8.0.1
version: 8.0.1(@babel/core@8.0.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))
'@babel/plugin-syntax-jsx':
specifier: 8.0.1
version: 8.0.1(@babel/core@8.0.1)
'@babel/plugin-syntax-typescript':
specifier: 8.0.3
version: 8.0.3(@babel/core@8.0.1)
'@eslint/js': '@eslint/js':
specifier: 10.0.1 specifier: 10.0.1
version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))
@@ -63,6 +75,9 @@ importers:
eslint: eslint:
specifier: 10.8.0 specifier: 10.8.0
version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
eslint-plugin-react-hooks:
specifier: 7.1.1
version: 7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
globals: globals:
specifier: 17.7.0 specifier: 17.7.0
version: 17.7.0 version: 17.7.0
@@ -114,14 +129,155 @@ packages:
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/code-frame@8.0.0':
resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/compat-data@7.29.7':
resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@8.0.0':
resolution: {integrity: sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/core@7.29.7':
resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
engines: {node: '>=6.9.0'}
'@babel/core@8.0.1':
resolution: {integrity: sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/eslint-parser@8.0.1':
resolution: {integrity: sha512-2javO8pAQv/ld6sS6OcxoLAlzZEZy+xm99bnoAfMhzKSumKhdF5wylpbZB7XTorWr3KLPtx5K95eduJPOy1mzA==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
eslint: ^9.0.0 || ^10.0.0
'@babel/generator@7.29.7':
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
engines: {node: '>=6.9.0'}
'@babel/generator@8.0.0':
resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-compilation-targets@7.29.7':
resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@8.0.0':
resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-globals@7.29.7':
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@8.0.0':
resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-module-imports@7.29.7':
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.29.7':
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-plugin-utils@8.0.1':
resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@8.0.0':
resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-validator-identifier@7.29.7': '@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@8.0.4':
resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-validator-option@7.29.7':
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@8.0.0':
resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helpers@7.29.7':
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@8.0.0':
resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@8.0.4':
resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==}
engines: {node: ^22.18.0 || >=24.11.0}
hasBin: true
'@babel/plugin-syntax-jsx@8.0.1':
resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
'@babel/plugin-syntax-typescript@8.0.3':
resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
'@babel/runtime@7.29.7': '@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
'@babel/template@8.0.0':
resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/traverse@7.29.7':
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
engines: {node: '>=6.9.0'}
'@babel/traverse@8.0.4':
resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@babel/types@8.0.4':
resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==}
engines: {node: ^22.18.0 || >=24.11.0}
'@bramus/specificity@2.4.2': '@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true hasBin: true
@@ -572,6 +728,12 @@ packages:
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/gensync@1.0.5':
resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==}
'@types/jsesc@2.5.1':
resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -809,6 +971,11 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
baseline-browser-mapping@2.11.1:
resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==}
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.0.3: bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -816,6 +983,14 @@ packages:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
browserslist@4.28.7:
resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
chai@6.2.2: chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -902,9 +1077,16 @@ packages:
dom-accessibility-api@0.6.3: dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
electron-to-chromium@1.5.396:
resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
empathic@2.0.1:
resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
engines: {node: '>=14'}
enhanced-resolve@5.24.2: enhanced-resolve@5.24.2:
resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -928,6 +1110,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
eslint-plugin-react-hooks@7.1.1:
resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
engines: {node: '>=18'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
eslint-scope@9.1.2: eslint-scope@9.1.2:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -1032,6 +1220,10 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5: get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*} engines: {node: 6.* || 8.* || >= 10.*}
@@ -1066,6 +1258,12 @@ packages:
headers-polyfill@5.0.1: headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1078,6 +1276,9 @@ packages:
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
imurmurhash@0.1.4: imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
@@ -1131,6 +1332,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1143,6 +1347,11 @@ packages:
canvas: canvas:
optional: true optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
json-buffer@3.0.1: json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -1324,6 +1533,9 @@ packages:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lz-string@1.5.0: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@@ -1370,6 +1582,10 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-releases@2.0.51:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
obug@2.1.4: obug@2.1.4:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -1518,6 +1734,10 @@ packages:
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.5: semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1666,6 +1886,12 @@ packages:
until-async@3.0.2: until-async@3.0.2:
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -1803,6 +2029,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@21.1.1: yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1815,6 +2044,12 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
zod@4.4.3: zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@@ -1853,10 +2088,205 @@ snapshots:
js-tokens: 4.0.0 js-tokens: 4.0.0
picocolors: 1.1.1 picocolors: 1.1.1
'@babel/code-frame@8.0.0':
dependencies:
'@babel/helper-validator-identifier': 8.0.4
js-tokens: 10.0.0
'@babel/compat-data@7.29.7': {}
'@babel/compat-data@8.0.0': {}
'@babel/core@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7(supports-color@7.2.0)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/core@8.0.1':
dependencies:
'@babel/code-frame': 8.0.0
'@babel/generator': 8.0.0
'@babel/helper-compilation-targets': 8.0.0
'@babel/helpers': 8.0.0
'@babel/parser': 8.0.4
'@babel/template': 8.0.0
'@babel/traverse': 8.0.4
'@babel/types': 8.0.4
'@types/gensync': 1.0.5
convert-source-map: 2.0.0
empathic: 2.0.1
gensync: 1.0.0-beta.2
import-meta-resolve: 4.2.0
json5: 2.2.3
obug: 2.1.4
semver: 7.8.5
'@babel/eslint-parser@8.0.1(@babel/core@8.0.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
'@babel/core': 8.0.1
eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
semver: 7.8.5
'@babel/generator@7.29.7':
dependencies:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/generator@8.0.0':
dependencies:
'@babel/parser': 8.0.4
'@babel/types': 8.0.4
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@types/jsesc': 2.5.1
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.29.7':
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.7
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-compilation-targets@8.0.0':
dependencies:
'@babel/compat-data': 8.0.0
'@babel/helper-validator-option': 8.0.0
browserslist: 4.28.7
lru-cache: 11.5.2
semver: 7.8.5
'@babel/helper-globals@7.29.7': {}
'@babel/helper-globals@8.0.0': {}
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/traverse': 7.29.7(supports-color@7.2.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
'@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.1)':
dependencies:
'@babel/core': 8.0.1
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-string-parser@8.0.0': {}
'@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {}
'@babel/helper-validator-identifier@8.0.4': {}
'@babel/helper-validator-option@7.29.7': {}
'@babel/helper-validator-option@8.0.0': {}
'@babel/helpers@7.29.7':
dependencies:
'@babel/template': 7.29.7
'@babel/types': 7.29.7
'@babel/helpers@8.0.0':
dependencies:
'@babel/template': 8.0.0
'@babel/types': 8.0.4
'@babel/parser@7.29.7':
dependencies:
'@babel/types': 7.29.7
'@babel/parser@8.0.4':
dependencies:
'@babel/types': 8.0.4
'@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.1)':
dependencies:
'@babel/core': 8.0.1
'@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1)
'@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.1)':
dependencies:
'@babel/core': 8.0.1
'@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1)
'@babel/runtime@7.29.7': {} '@babel/runtime@7.29.7': {}
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/template@8.0.0':
dependencies:
'@babel/code-frame': 8.0.0
'@babel/parser': 8.0.4
'@babel/types': 8.0.4
'@babel/traverse@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
'@babel/traverse@8.0.4':
dependencies:
'@babel/code-frame': 8.0.0
'@babel/generator': 8.0.0
'@babel/helper-globals': 8.0.0
'@babel/parser': 8.0.4
'@babel/template': 8.0.0
'@babel/types': 8.0.4
obug: 2.1.4
'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@babel/types@8.0.4':
dependencies:
'@babel/helper-string-parser': 8.0.0
'@babel/helper-validator-identifier': 8.0.4
'@bramus/specificity@2.4.2': '@bramus/specificity@2.4.2':
dependencies: dependencies:
css-tree: 3.2.1 css-tree: 3.2.1
@@ -2213,6 +2643,10 @@ snapshots:
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/gensync@1.0.5': {}
'@types/jsesc@2.5.1': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@24.13.3': '@types/node@24.13.3':
@@ -2383,6 +2817,8 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
baseline-browser-mapping@2.11.1: {}
bidi-js@1.0.3: bidi-js@1.0.3:
dependencies: dependencies:
require-from-string: 2.0.2 require-from-string: 2.0.2
@@ -2391,6 +2827,16 @@ snapshots:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
browserslist@4.28.7:
dependencies:
baseline-browser-mapping: 2.11.1
caniuse-lite: 1.0.30001806
electron-to-chromium: 1.5.396
node-releases: 2.0.51
update-browserslist-db: 1.2.3(browserslist@4.28.7)
caniuse-lite@1.0.30001806: {}
chai@6.2.2: {} chai@6.2.2: {}
chalk@4.1.2: chalk@4.1.2:
@@ -2479,8 +2925,12 @@ snapshots:
dom-accessibility-api@0.6.3: {} dom-accessibility-api@0.6.3: {}
electron-to-chromium@1.5.396: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
empathic@2.0.1: {}
enhanced-resolve@5.24.2: enhanced-resolve@5.24.2:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -2496,6 +2946,17 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/parser': 7.29.7
eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
hermes-parser: 0.25.1
zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
eslint-scope@9.1.2: eslint-scope@9.1.2:
dependencies: dependencies:
'@types/esrecurse': 4.3.1 '@types/esrecurse': 4.3.1
@@ -2612,6 +3073,8 @@ snapshots:
function-bind@1.1.2: {} function-bind@1.1.2: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
glob-parent@6.0.2: glob-parent@6.0.2:
@@ -2639,6 +3102,12 @@ snapshots:
'@types/set-cookie-parser': 2.4.10 '@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.2 set-cookie-parser: 3.1.2
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
dependencies:
hermes-estree: 0.25.1
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
dependencies: dependencies:
'@exodus/bytes': 1.15.1 '@exodus/bytes': 1.15.1
@@ -2649,6 +3118,8 @@ snapshots:
ignore@7.0.6: {} ignore@7.0.6: {}
import-meta-resolve@4.2.0: {}
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
indent-string@4.0.0: {} indent-string@4.0.0: {}
@@ -2684,6 +3155,8 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
jsdom@29.1.1: jsdom@29.1.1:
@@ -2712,6 +3185,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@noble/hashes' - '@noble/hashes'
jsesc@3.1.0: {}
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
@@ -2835,6 +3310,10 @@ snapshots:
lru-cache@11.5.2: {} lru-cache@11.5.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
@@ -2884,6 +3363,8 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
node-releases@2.0.51: {}
obug@2.1.4: {} obug@2.1.4: {}
optionator@0.9.4: optionator@0.9.4:
@@ -3030,6 +3511,8 @@ snapshots:
scheduler@0.27.0: {} scheduler@0.27.0: {}
semver@6.3.1: {}
semver@7.8.5: {} semver@7.8.5: {}
set-cookie-parser@2.7.2: {} set-cookie-parser@2.7.2: {}
@@ -3166,6 +3649,12 @@ snapshots:
until-async@3.0.2: {} until-async@3.0.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.7):
dependencies:
browserslist: 4.28.7
escalade: 3.2.0
picocolors: 1.1.1
uri-js@4.4.1: uri-js@4.4.1:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
@@ -3251,6 +3740,8 @@ snapshots:
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@3.1.1: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs@17.7.3: yargs@17.7.3:
@@ -3265,4 +3756,8 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
zod@4.4.3: {} zod@4.4.3: {}
+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-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.jsx"
}
} }
+44 -3
View File
@@ -1,4 +1,4 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, readdir, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
await mkdir("artifacts/quality", { recursive: true }); await mkdir("artifacts/quality", { recursive: true });
@@ -60,10 +60,51 @@ const forbidden = runPnpm(
], ],
); );
if (allowed.status !== 0 || forbidden.status === 0) { /** @param {string} directory @returns {Promise<string[]>} */
async function fixtureFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const target = `${directory}/${entry.name}`;
return entry.isDirectory()
? fixtureFiles(target)
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
? [target]
: [];
}),
);
return files.flat();
}
const forbiddenResults = await Promise.all(
(await fixtureFiles("tests/fixtures/architecture/forbidden")).map((file) => ({
file,
result: runPnpm([
"exec",
"eslint",
file,
"--no-ignore",
"--max-warnings=0",
]),
})),
);
const acceptedForbidden = forbiddenResults.filter(
({ result }) => result.status === 0,
);
if (
allowed.status !== 0 ||
forbidden.status === 0 ||
acceptedForbidden.length > 0
) {
process.stderr.write(allowed.stderr || allowed.stdout); process.stderr.write(allowed.stderr || allowed.stdout);
process.stderr.write(forbidden.stderr || forbidden.stdout); process.stderr.write(forbidden.stderr || forbidden.stdout);
for (const { file } of acceptedForbidden) {
process.stderr.write(`Forbidden fixture was accepted: ${file}\n`);
}
process.exit(1); process.exit(1);
} }
process.stdout.write("Architecture fixtures: allowed PASS, forbidden rejected\n"); process.stdout.write(
`Architecture fixtures: allowed PASS, ${forbiddenResults.length} forbidden rejected\n`,
);
+114 -7
View File
@@ -2,12 +2,53 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
/** @param {string} name @param {string} fallback */
function argumentValue(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
const governancePath = argumentValue(
"--governance",
"config/contracts/registry-governance.json",
);
const artifactPath = argumentValue(
"--artifact",
"artifacts/quality/registries.json",
);
const governance = JSON.parse( const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"), await readFile(governancePath, "utf8"),
); );
const failures = []; const failures = [];
const owners = new Map(); const owners = new Map();
const snapshots = []; const snapshots = [];
const rowsByRegistry = new Map();
const registryExtensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"];
/** @param {string} declaredPath */
async function resolveRegistrySource(declaredPath) {
const extension = path.extname(declaredPath);
const basePath = extension
? declaredPath.slice(0, -extension.length)
: declaredPath;
const candidates = [];
for (const candidateExtension of registryExtensions) {
const candidate = `${basePath}${candidateExtension}`;
try {
await access(candidate);
candidates.push(candidate);
} catch {
// A migration may legitimately replace the declared extension.
}
}
if (candidates.length > 1) {
failures.push(
`ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`,
);
return null;
}
return candidates[0] ?? null;
}
for (const specification of governance.registries) { for (const specification of governance.registries) {
if (owners.has(specification.registryId)) { if (owners.has(specification.registryId)) {
@@ -16,10 +57,11 @@ for (const specification of governance.registries) {
owners.set(specification.registryId, specification.owner); owners.set(specification.registryId, specification.owner);
let rows = specification.declaredRows; let rows = specification.declaredRows;
const sourcePath = await resolveRegistrySource(specification.path);
try { try {
await access(specification.path); if (!sourcePath) throw new Error("missing registry source");
const module = await import( const module = await import(
`${pathToFileURL(path.resolve(specification.path)).href}?registry-check=${Date.now()}` `${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
); );
rows = module[specification.exportName]; rows = module[specification.exportName];
} catch { } catch {
@@ -31,6 +73,8 @@ for (const specification of governance.registries) {
continue; continue;
} }
rowsByRegistry.set(specification.registryId, rows);
for (const [rowName, row] of Object.entries(rows)) { for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) { if (!row || typeof row !== "object" || Array.isArray(row)) {
failures.push(`${specification.registryId}.${rowName} is not an object`); failures.push(`${specification.registryId}.${rowName} is not an object`);
@@ -43,16 +87,79 @@ for (const specification of governance.registries) {
} }
} }
for (const field of specification.uniqueFields ?? []) {
const values = new Map();
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[field];
if (value === undefined) continue;
if (values.has(value)) {
failures.push(
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(value)}`,
);
} else {
values.set(value, rowName);
}
}
}
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,
source: specification.path, source: sourcePath ?? specification.path,
rowCount: Object.keys(rows).length, rowCount: Object.keys(rows).length,
rows, rows,
}); });
} }
const sourceFiles = [ for (const specification of governance.registries) {
const rows = rowsByRegistry.get(specification.registryId);
if (!rows) continue;
for (const reference of specification.references ?? []) {
const targetRows = rowsByRegistry.get(reference.registryId);
if (!targetRows) {
failures.push(
`${specification.registryId} references unknown registry ${reference.registryId}`,
);
continue;
}
const targetValues = new Set(
Object.values(targetRows)
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => row[reference.targetField])
.filter((value) => value !== undefined),
);
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[reference.field];
if (value !== undefined && !targetValues.has(value)) {
failures.push(
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`,
);
}
}
}
}
const sourceFiles = governance.sourceDirectories ?? [
"src/application", "src/application",
"src/presentation", "src/presentation",
"src/domain", "src/domain",
@@ -75,7 +182,7 @@ async function scanDirectory(directory) {
await scanDirectory(target); await scanDirectory(target);
continue; continue;
} }
if (!/\.(js|jsx|mjs)$/.test(entry.name)) continue; if (!/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) continue;
const content = await readFile(target, "utf8"); const content = await readFile(target, "utf8");
for (const pattern of adHocPatterns) { for (const pattern of adHocPatterns) {
if (pattern.expression.test(content)) { if (pattern.expression.test(content)) {
@@ -91,7 +198,7 @@ for (const sourceDirectory of sourceFiles) {
await mkdir("artifacts/quality", { recursive: true }); await mkdir("artifacts/quality", { recursive: true });
await writeFile( await writeFile(
"artifacts/quality/registries.json", artifactPath,
`${JSON.stringify( `${JSON.stringify(
{ {
schemaVersion: 1, schemaVersion: 1,
+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,
}; };
+39 -1
View File
@@ -1,6 +1,13 @@
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,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.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 +16,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 +58,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 +73,7 @@ const releaseManifest = {
assetManifestHash, assetManifestHash,
releaseId, releaseId,
builtAt, builtAt,
routeChunks,
}; };
await mkdir("artifacts/release", { recursive: true }); await mkdir("artifacts/release", { recursive: true });
@@ -52,6 +82,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`,
+5 -8
View File
@@ -1,11 +1,8 @@
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([ import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
"APP_HOME",
"EXAMPLES_UI", export const MANUAL_A11Y_ROUTE_IDS = Object.freeze(
"EXAMPLES_STATES", Object.values(ROUTE_REGISTRY).map((route) => route.routeId),
"EXAMPLES_AUTH", );
"SAMPLE_RESOURCE_LIST",
"NOT_FOUND",
]);
const REVIEW_FIELDS = Object.freeze([ const REVIEW_FIELDS = Object.freeze([
"M1 Keyboard", "M1 Keyboard",
+4 -3
View File
@@ -6,7 +6,7 @@ import process from "node:process";
import { chromium } from "@playwright/test"; import { chromium } from "@playwright/test";
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js"; import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js";
import { ROUTE_REGISTRY } from "../src/contracts/routes.js"; import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.js";
const server = spawn( const server = spawn(
"corepack", "corepack",
@@ -67,8 +67,9 @@ try {
}).observe({ type: "layout-shift", buffered: true }); }).observe({ type: "layout-shift", buffered: true });
}); });
await page.goto(baseUrl, { waitUntil: "networkidle" }); await page.goto(baseUrl, { waitUntil: "networkidle" });
const targetLabel = const targetLabel = Object.values(ROUTE_REGISTRY).find(
ROUTE_REGISTRY.SAMPLE_RESOURCE_LIST.navigationLabel; (definition) => definition.access === "integration-defined",
)?.navigationLabel;
if (!targetLabel) { if (!targetLabel) {
throw new Error("Performance route must be present in navigation."); throw new Error("Performance route must be present in navigation.");
} }
+185 -45
View File
@@ -1,78 +1,218 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import {
cp,
mkdir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import path from "node:path"; import path from "node:path";
const fixtureRoot = path.resolve(".tmp/sample-removal"); const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
const sampleRoot = path.resolve("src/sample/contract-fixture");
const sourceRoot = path.resolve("src");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath); const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const featureSource = "src/features/reference-feature";
const featureTests = "tests/features/reference-feature";
const copyTargets = [
"src",
"tests",
"scripts",
"config",
"public",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"vite.config.js",
"vitest.config.js",
"playwright.config.js",
"eslint.config.js",
".dependency-cruiser.cjs",
];
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js";
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js";
export const INSTALLED_FEATURE_CONTRACTS =
/** @type {readonly unknown[]} */ (Object.freeze([]));
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
export const API_OPERATIONS = Object.freeze({});
export const QUERY_REGISTRY = Object.freeze({});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry =
/** @type {Readonly<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(\`Unregistered route: \${routeId}\`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
`;
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js";
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
`;
const emptyAdapters = `type FeatureContext = Readonly<{
createHttpClient(contract: Readonly<Record<string, unknown>>): unknown;
}>;
export function createInstalledFeatureInputs(_context: FeatureContext) {
void _context;
return Object.freeze({});
}
`;
/** @param {string} directory @returns {Promise<string[]>} */ /** @param {string} directory @returns {Promise<string[]>} */
async function sourceFiles(directory) { async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true }); const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all( const groups = await Promise.all(
entries.map((entry) => { entries.map((entry) => {
const target = path.join(directory, entry.name); const target = path.join(directory, entry.name);
return entry.isDirectory() ? sourceFiles(target) : [target]; return entry.isDirectory() ? filesBelow(target) : [target];
}), }),
)); );
return nested.flat(); return groups.flat();
}
/** @param {string} script @param {string[]} [extra] */
function runPnpm(script, extra = []) {
const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], {
cwd: fixtureRoot,
stdio: "inherit",
});
return result.status === 0;
} }
await rm(fixtureRoot, { recursive: true, force: true }); await rm(fixtureRoot, { recursive: true, force: true });
await mkdir(fixtureRoot, { recursive: true }); await mkdir(fixtureRoot, { recursive: true });
for (const target of copyTargets) {
await cp(target, path.join(fixtureRoot, target), { recursive: true });
}
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
const incomingImports = []; await rm(path.join(fixtureRoot, featureSource), {
for (const sourceFile of await sourceFiles(sourceRoot)) { recursive: true,
if (sourceFile.startsWith(sampleRoot)) continue; force: true,
const content = await readFile(sourceFile, "utf8"); });
if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) { await rm(path.join(fixtureRoot, featureTests), {
incomingImports.push(path.relative(".", sourceFile)); recursive: true,
force: true,
});
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
emptyContracts,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"),
emptyRuntimes,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"),
emptyAdapters,
);
/** @type {string[]} */
const residue = [];
for (const root of ["src", "tests"]) {
for (const file of await filesBelow(path.join(fixtureRoot, root))) {
const relative = path.relative(fixtureRoot, file);
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(
`${relative}\n${content}`,
)
) {
residue.push(relative);
}
} }
} }
let buildStatus = 1; const checks = [
if (incomingImports.length === 0) { ["typecheck", runPnpm("check:types")],
await cp("src", path.join(fixtureRoot, "src"), { ["architecture", runPnpm("check:architecture")],
recursive: true, ["registry", runPnpm("check:registries")],
filter: (source) => !source.startsWith(sampleRoot), ["unit-integration", runPnpm("test:all")],
}); [
await cp("public", path.join(fixtureRoot, "public"), { recursive: true }); "home-smoke",
await cp("index.html", path.join(fixtureRoot, "index.html")); runPnpm("exec", [
await cp("vite.config.js", path.join(fixtureRoot, "vite.config.js")); "vitest",
"run",
const result = spawnSync( "tests/component/router.test.jsx",
process.execPath, "--reporter=default",
[ ]),
pnpmCli, ],
"exec", ["build", runPnpm("build")],
"vite", ];
"build", /** @type {string[]} */
fixtureRoot, const builtResidue = [];
"--outDir", for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
path.join(fixtureRoot, "dist"), if (!/\.(?:js|css|html|json)$/.test(file)) continue;
], const content = await readFile(file, "utf8");
{ stdio: "inherit" }, if (
); /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
buildStatus = result.status ?? 1; ) {
builtResidue.push(path.relative(fixtureRoot, file));
}
} }
const routeCatalog = await import(
`${new URL(
"../src/features/installed-feature-contracts.js",
`file://${fixtureRoot}/scripts/`,
).href}?removed=${Date.now()}`
);
const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY);
const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_"));
checks.push(["route-absent", routeAbsent]);
checks.push(["fixture-id-residue", residue.length === 0]);
checks.push(["built-fixture-id-residue", builtResidue.length === 0]);
const passed = checks.every(([, result]) => result);
await mkdir("artifacts/tests", { recursive: true }); await mkdir("artifacts/tests", { recursive: true });
const passed = incomingImports.length === 0 && buildStatus === 0;
await writeFile( await writeFile(
"artifacts/tests/sample-removal.xml", "artifacts/tests/sample-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` + `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` + `<testsuite name="reference-feature-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
`<testcase name="no-product-import"/>` + checks
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` + .map(
([name, result]) =>
`<testcase name="${name}">${result ? "" : `<failure>${[...residue, ...builtResidue].join(", ")}</failure>`}</testcase>`,
)
.join("") +
`</testsuite>\n`, `</testsuite>\n`,
); );
await rm(fixtureRoot, { recursive: true, force: true }); await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) { if (!passed) {
const failures = checks
.filter(([, result]) => !result)
.map(([name]) => name);
process.stderr.write( process.stderr.write(
`Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`, `Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`,
); );
process.exit(1); process.exit(1);
} }
process.stdout.write("Sample removal smoke: PASS\n"); process.stdout.write(
`Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`,
);
+67 -1
View File
@@ -6,6 +6,10 @@ import {
compareReleaseToRuntime, compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY, RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.js"; } from "../src/contracts/release-tokens.js";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.js";
const fixturesDocument = const fixturesDocument =
/** @type {{ /** @type {{
@@ -34,7 +38,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 +66,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({
+113 -45
View File
@@ -1,4 +1,4 @@
import { systemClock } from "../../application/ports/clock-port.js"; import { systemClock } from "../platform/system-clock.js";
import { getApiOperation } from "../../contracts/api-operations.js"; import { getApiOperation } from "../../contracts/api-operations.js";
import { import {
createFailure as failure, createFailure as failure,
@@ -12,6 +12,7 @@ import {
validateOperationPayload, validateOperationPayload,
validateOperationRequest, validateOperationRequest,
} from "./schema-registry.js"; } from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js";
const noAuthSession = const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
@@ -21,20 +22,14 @@ const noAuthSession =
onUnauthenticated: () => {}, onUnauthenticated: () => {},
}); });
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */
/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */
/** /**
* @typedef {{ * @typedef {{
* kind: string, * setTimeout(callback: () => void, milliseconds: number): unknown,
* code: string, * clearTimeout(handle: unknown): void
* retryable: boolean, * }} Scheduler
* operationId: string,
* attemptCount: number,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: string
* }} HttpFailure
*/ */
/** /**
@@ -51,8 +46,14 @@ const noAuthSession =
* random?: () => number, * random?: () => number,
* validatePayload?: (schemaId: string, value: unknown) => * validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }, * { success: true, data: unknown } | { success: false },
* validateRequest?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown, * mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string * idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
* maxRetryAttempts?: number,
* scheduler?: Scheduler,
* getOperation?: typeof getApiOperation
* }} dependencies * }} dependencies
*/ */
export function createHttpClient(dependencies) { export function createHttpClient(dependencies) {
@@ -62,21 +63,51 @@ export function createHttpClient(dependencies) {
const random = dependencies.random ?? Math.random; const random = dependencies.random ?? Math.random;
const validatePayload = const validatePayload =
dependencies.validatePayload ?? validateOperationPayload; dependencies.validatePayload ?? validateOperationPayload;
const validateRequest =
dependencies.validateRequest ?? validateOperationRequest;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload; const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory = const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const selectOperation = dependencies.getOperation ?? getApiOperation;
const scheduler =
dependencies.scheduler ??
/** @type {Scheduler} */ ({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
/** @type {ReturnType<typeof setTimeout>} */ (handle),
),
});
/** /**
* @param {string} operationId * @param {string | OperationRequestInput} request
* @param {{ * @param {{
* body?: unknown, * body?: unknown,
* routeId?: string, * routeId?: string,
* signal?: AbortSignal, * pathParams?: Record<string, string | number>,
* idempotencyKey?: string * searchParams?: unknown,
* }} [input] * signal?: AbortSignal,
*/ * idempotencyKey?: string
async function execute(operationId, input = {}) { * }} [legacyInput]
const operation = getApiOperation(operationId); * @returns {Promise<HttpResult>}
*/
async function execute(request, legacyInput = {}) {
const input =
typeof request === "string"
? {
operationId: request,
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
pathParams: legacyInput.pathParams,
searchParams: legacyInput.searchParams,
body: legacyInput.body,
signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey,
}
: request;
const operation = selectOperation(input.operationId);
const logicalIdempotencyKey = const logicalIdempotencyKey =
operation.idempotency === "keyed" operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory() ? input.idempotencyKey ?? idempotencyKeyFactory()
@@ -118,7 +149,14 @@ export function createHttpClient(dependencies) {
return outcome; return outcome;
} }
if (!shouldRetry(operation, outcome.error, retryCount)) { if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return outcome; return outcome;
} }
@@ -130,7 +168,7 @@ export function createHttpClient(dependencies) {
} catch { } catch {
return { return {
ok: false, ok: false,
error: failure("REQUEST_ABORTED", operationId, retryCount, { error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED", code: "REQUEST_ABORTED",
}), }),
}; };
@@ -141,7 +179,7 @@ export function createHttpClient(dependencies) {
/** /**
* @param {{ * @param {{
* operation: ReturnType<typeof getApiOperation>, * operation: ReturnType<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal }, * input: OperationRequestInput,
* attempt: number, * attempt: number,
* idempotencyKey?: string * idempotencyKey?: string
* }} context * }} context
@@ -149,23 +187,19 @@ export function createHttpClient(dependencies) {
*/ */
async function performAttempt(context) { async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context; const { operation, input, attempt, idempotencyKey } = context;
const controller = new AbortController(); /** @type {unknown} */
let timedOut = false; let parsedSearch = {};
const timeout = setTimeout(() => { let parsedBody;
timedOut = true; const requestValue =
controller.abort("timeout"); operation.requestSource === "search"
}, operation.timeoutMs); ? input.searchParams ?? {}
const onExternalAbort = () => controller.abort(input.signal?.reason); : operation.requestSource === "body"
input.signal?.addEventListener("abort", onExternalAbort, { once: true }); ? input.body
: {};
const headers = new Headers({ Accept: "application/json" }); if (operation.requestSource !== "none") {
if (input.body !== undefined) headers.set("Content-Type", "application/json"); const requestValidation = validateRequest(
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (input.body !== undefined) {
const requestValidation = validateOperationRequest(
operation.requestSchema, operation.requestSchema,
input.body, requestValue,
); );
if (!requestValidation.success) { if (!requestValidation.success) {
return { return {
@@ -175,12 +209,46 @@ export function createHttpClient(dependencies) {
}), }),
}; };
} }
if (operation.requestSource === "search") {
parsedSearch = requestValidation.data;
} else {
parsedBody = requestValidation.data;
}
} }
let request = new Request(new URL(operation.path, dependencies.baseUrl), { const target = buildRequestTarget(
dependencies.baseUrl,
operation,
input.pathParams,
parsedSearch,
);
if (!target.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: target.code,
}),
};
}
const controller = new AbortController();
let timedOut = false;
const timeout = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs ?? defaultTimeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
if (input.signal?.aborted) onExternalAbort();
const headers = new Headers({ Accept: "application/json" });
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
let request = new Request(target.url, {
method: operation.method, method: operation.method,
headers, headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body), body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal, signal: controller.signal,
}); });
@@ -250,7 +318,7 @@ export function createHttpClient(dependencies) {
}), }),
}; };
} finally { } finally {
clearTimeout(timeout); scheduler.clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort); input.signal?.removeEventListener("abort", onExternalAbort);
} }
} }
+73
View File
@@ -0,0 +1,73 @@
import type { ApiOperation } from "../../contracts/api-operations.js";
export type OperationRequestInput = Readonly<{
operationId: string;
routeId: string;
pathParams?: Readonly<Record<string, string | number>>;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
}>;
export type RequestTargetResult =
| Readonly<{ success: true; url: URL }>
| Readonly<{
success: false;
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
}>;
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
export function buildRequestTarget(
baseUrl: string,
operation: ApiOperation,
pathParams: Readonly<Record<string, string | number>> = {},
parsedSearch: unknown = {},
): RequestTargetResult {
let missingPathParameter = false;
const pathname = operation.path.replace(
pathParameterPattern,
(_token, colonName: string | undefined, braceName: string | undefined) => {
const name = colonName ?? braceName ?? "";
const value = pathParams[name];
if (value === undefined) {
missingPathParameter = true;
return "";
}
return encodeURIComponent(String(value));
},
);
if (missingPathParameter) {
return { success: false, code: "PATH_PARAMETER_MISSING" };
}
if (
parsedSearch === null ||
typeof parsedSearch !== "object" ||
Array.isArray(parsedSearch)
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
const url = new URL(pathname, baseUrl);
const search = parsedSearch as Readonly<Record<string, unknown>>;
for (const key of Object.keys(search).sort((left, right) =>
left.localeCompare(right),
)) {
const value = search[key];
if (value === undefined || value === null) continue;
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
if (
typeof item !== "string" &&
typeof item !== "number" &&
typeof item !== "boolean"
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
url.searchParams.append(key, String(item));
}
}
return { success: true, url };
}
+1 -26
View File
@@ -1,30 +1,5 @@
import { createResource } from "../../domain/models/resource.js";
/** @param {unknown} value */
export function mapResourceDto(value) {
if (!value || typeof value !== "object") {
throw new TypeError("Validated resource DTO is required");
}
const dto = /** @type {Record<string, unknown>} */ (value);
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
throw new TypeError("Validated resource DTO invariants were breached");
}
return createResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
});
}
/** @param {string} operationId @param {unknown} payload */ /** @param {string} operationId @param {unknown} payload */
export function mapOperationPayload(operationId, payload) { export function mapOperationPayload(operationId, payload) {
if (operationId === "LIST_SAMPLE_RESOURCES") { void payload;
if (!Array.isArray(payload)) throw new TypeError("Expected a resource list");
return payload.map(mapResourceDto);
}
if (operationId === "CREATE_SAMPLE_RESOURCE") {
return mapResourceDto(payload);
}
throw new TypeError(`No boundary mapper registered for ${operationId}`); throw new TypeError(`No boundary mapper registered for ${operationId}`);
} }
+2 -1
View File
@@ -35,12 +35,13 @@ export function parseRetryAfter(value, now = Date.now()) {
} }
/** /**
* @param {{ idempotency: "safe" | "keyed" | "none" }} operation * @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure * @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount * @param {number} retryCount
* @param {number} [maxRetries] * @param {number} [maxRetries]
*/ */
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) { export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
if (operation.retry === "never") return false;
if (retryCount >= maxRetries) return false; if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false; if (!retryKinds.has(failure.kind)) return false;
if ( if (
+2 -25
View File
@@ -37,34 +37,11 @@ export const responseEnvelopeSchema = z.discriminatedUnion("success", [
failureEnvelopeSchema, failureEnvelopeSchema,
]); ]);
const sampleResourceSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.string().optional(),
})
.passthrough();
const payloadSchemas = const payloadSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({ /** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
SampleResourceListPayload: z.array(sampleResourceSchema),
SampleResourcePayload: sampleResourceSchema,
}));
const requestSchemas = const requestSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({ /** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
SampleResourceListQuery: z
.object({
cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20),
})
.strict(),
CreateSampleResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
})
.strict(),
}));
/** @param {unknown} value */ /** @param {unknown} value */
export function validateEnvelope(value) { export function validateEnvelope(value) {
+23
View File
@@ -0,0 +1,23 @@
/** @type {import("../../application/ports/clock-port.js").ClockPort} */
export const systemClock = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(signal?.reason);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
},
});
-46
View File
@@ -1,46 +0,0 @@
/**
* Application facade factory. Concrete dependencies are supplied by bootstrap.
*
* @param {{
* resources?: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* },
* cache: import("./ports/query-cache-port.js").QueryCachePort,
* storage: import("./ports/storage-port.js").StoragePort,
* telemetry: import("./ports/telemetry-port.js").TelemetryPort
* }} ports
*/
export function createApplication(ports) {
/**
* @param {unknown} query
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function queryResources(query, context) {
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).query.execute(query, context);
}
/**
* @param {unknown} command
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function commandResources(command, context) {
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).command.execute(command, context);
}
return Object.freeze({
resources: ports.resources
? Object.freeze({
query: queryResources,
command: commandResources,
})
: null,
cache: ports.cache,
storage: ports.storage,
telemetry: ports.telemetry,
});
}
+132
View File
@@ -0,0 +1,132 @@
import { normalizeColorSchemePreference } from "./policies/color-scheme.js";
import type {
ApplicationApi,
ColorSchemePreference,
RenderFailureReport,
} from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js";
export type { ApplicationApi, ApplicationOutputPorts };
/**
* Builds the driving API consumed by inbound adapters. Concrete output ports
* remain inside these closures and are never returned to React.
*/
export function createApplication(
outputPorts: ApplicationOutputPorts,
featureInputs: Readonly<Record<string, unknown>> = {},
): ApplicationApi {
const session = Object.freeze({
getSnapshot: () => outputPorts.session.getState(),
subscribe: (listener: () => void) =>
outputPorts.session.subscribe(listener),
beginSignIn: (returnTo?: string) =>
outputPorts.session.beginSignIn(returnTo),
signOut: () => outputPorts.session.signOut(),
recover: () => outputPorts.session.recover(),
});
const preferences = Object.freeze({
getColorScheme(): ColorSchemePreference {
const result = outputPorts.preferences.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result.ok ? result.value : undefined,
);
},
setColorScheme(preference: ColorSchemePreference) {
const normalized = normalizeColorSchemePreference(preference);
return outputPorts.preferences.write("COLOR_SCHEME", normalized);
},
});
const diagnostics = Object.freeze({
reportRenderFailure(report: RenderFailureReport) {
try {
outputPorts.diagnostics.emit("ui.render.failed", {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
});
} catch {
// Diagnostics are best-effort and cannot become an application failure.
}
},
});
const runtime = Object.freeze({
async getReleaseSummary() {
const release = await outputPorts.releaseInfo.getCurrent();
return Object.freeze({
buildId: release.buildId,
releaseId: release.releaseId,
configSchemaVersion: release.configSchemaVersion,
apiContractVersion: release.apiContractVersion,
});
},
});
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",
};
}
},
});
const installedFeatureInputs = Object.freeze({ ...featureInputs });
const features = Object.freeze({
has(featureId: string) {
return Object.hasOwn(installedFeatureInputs, featureId);
},
get(featureId: string) {
if (!Object.hasOwn(installedFeatureInputs, featureId)) {
throw new Error(`Application feature is not installed: ${featureId}`);
}
return installedFeatureInputs[featureId];
},
});
return Object.freeze({
session,
preferences,
diagnostics,
runtime,
recovery,
features,
});
}
+16 -2
View File
@@ -10,10 +10,24 @@
* subscribe(listener: () => void): () => void, * subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>, * beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>, * signOut(): Promise<void>,
* recover(): Promise<"restored" | "no-session">
* }} SessionGateway
*/
/**
* Credential attachment is an HTTP-adapter collaboration, not an application
* input capability.
*
* @typedef {{
* attach(request: Request): Promise<Request>, * attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void * onUnauthenticated(): void
* }} AuthSessionPort * }} CredentialAttacher
*/
/**
* External auth adapters implement both segregated capabilities.
*
* @typedef {SessionGateway & CredentialAttacher} AuthSessionPort
*/ */
export {}; export {};
+1 -22
View File
@@ -5,25 +5,4 @@
* }} ClockPort * }} ClockPort
*/ */
/** @type {ClockPort} */ export {};
export const systemClock = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, milliseconds);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
},
});
@@ -0,0 +1,54 @@
import type { SessionState } from "../auth-session-port.js";
import type { StoragePort } from "../storage-port.js";
export type { SessionState } from "../auth-session-port.js";
export type ColorSchemePreference = "system" | "light" | "dark";
export type RenderFailureReport = Readonly<{
routeId: string;
buildId: string;
boundaryName: "route" | "feature";
}>;
export type ReleaseSummary = Readonly<{
buildId: string;
releaseId: string;
configSchemaVersion: string;
apiContractVersion: string;
}>;
export type ApplicationApi = Readonly<{
session: Readonly<{
getSnapshot(): SessionState;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
recover(): Promise<"restored" | "no-session">;
}>;
preferences: Readonly<{
getColorScheme(): ColorSchemePreference;
setColorScheme(
preference: ColorSchemePreference,
): ReturnType<StoragePort["write"]>;
}>;
diagnostics: Readonly<{
reportRenderFailure(report: RenderFailureReport): void;
}>;
runtime: Readonly<{
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 }>
>;
}>;
features: Readonly<{
has(featureId: string): boolean;
get(featureId: string): unknown;
}>;
}>;
+7
View File
@@ -0,0 +1,7 @@
export type {
ApplicationApi,
ColorSchemePreference,
ReleaseSummary,
RenderFailureReport,
SessionState,
} from "./application-api.js";
@@ -0,0 +1,19 @@
import type { AuthSessionPort } from "../auth-session-port.js";
import type { ReleaseInfoPort } from "../release-info-port.js";
import type { StoragePort } from "../storage-port.js";
import type { TelemetryPort } from "../telemetry-port.js";
/**
* Capabilities required by application use cases. Implementations live in
* outbound adapters and are selected only by bootstrap.
*/
export type ApplicationOutputPorts = Readonly<{
session: Pick<
AuthSessionPort,
"getState" | "subscribe" | "beginSignIn" | "signOut" | "recover"
>;
preferences: StoragePort;
diagnostics: TelemetryPort;
releaseInfo: ReleaseInfoPort;
navigation: Readonly<{ reload(): void }>;
}>;
+11
View File
@@ -0,0 +1,11 @@
export type { ApplicationOutputPorts } from "./application-output-ports.js";
export type {
AuthSessionPort,
CredentialAttacher,
SessionGateway,
} from "../auth-session-port.js";
export type { ClockPort } from "../clock-port.js";
export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js";
export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js";
+14 -1
View File
@@ -1,11 +1,24 @@
/** /**
* @typedef {{ * @typedef {{
* getCurrent(): Promise<{ * getCurrent(): Promise<{
* schemaVersion?: number,
* appVersion?: string,
* buildId: string,
* commitSha?: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string,
* builtAt?: string,
* routeChunks: Record<string, string>
* }>,
* refresh(): Promise<{
* buildId: string, * buildId: string,
* configSchemaVersion: string, * configSchemaVersion: string,
* apiContractVersion: string, * apiContractVersion: string,
* assetManifestHash: string, * assetManifestHash: string,
* releaseId: string * releaseId: string,
* routeChunks: Record<string, string>
* }> * }>
* }} ReleaseInfoPort * }} ReleaseInfoPort
*/ */
-28
View File
@@ -1,28 +0,0 @@
/**
* @template Query
* @template Model
* @typedef {{ execute(query: Query, context?: RequestContext): Promise<Result<Model>> }} ResourceQueryPort
*/
/**
* @template Command
* @template Model
* @typedef {{ execute(command: Command, context?: RequestContext): Promise<Result<Model>> }} ResourceCommandPort
*/
/**
* @typedef {{
* operationId: string,
* routeId: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} RequestContext
*/
/**
* @template Value
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result
*/
export {};
@@ -1,13 +1,21 @@
const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]); const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]);
/**
* @typedef {{action: "reload-once", releasePair: string} |
* {action: "support", reason: string}} ChunkRecoveryDecision
*/
/** /**
* @param {{ * @param {{
* failureKind: string, * failureKind: string,
* manifestLoaded: boolean, * manifestLoaded: boolean,
* currentBuildId: string, * currentBuildId: string,
* currentReleaseId: string,
* activeBuildId: string,
* activeReleaseId: string, * activeReleaseId: string,
* storage: import("../ports/storage-port.js").StoragePort * storage: import("../ports/storage-port.js").StoragePort
* }} input * }} input
* @returns {ChunkRecoveryDecision}
*/ */
export function decideChunkRecovery(input) { export function decideChunkRecovery(input) {
if (!RECOVERABLE_KINDS.has(input.failureKind)) { if (!RECOVERABLE_KINDS.has(input.failureKind)) {
@@ -16,13 +24,21 @@ export function decideChunkRecovery(input) {
if (!input.manifestLoaded) { if (!input.manifestLoaded) {
return { action: "support", reason: "manifest-unavailable" }; return { action: "support", reason: "manifest-unavailable" };
} }
if (input.activeReleaseId === input.currentBuildId) { if (
input.activeBuildId === input.currentBuildId &&
input.activeReleaseId === input.currentReleaseId
) {
return { action: "support", reason: "same-release" }; return { action: "support", reason: "same-release" };
} }
const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`; const releasePair =
`${input.currentBuildId}/${input.currentReleaseId}` +
`->${input.activeBuildId}/${input.activeReleaseId}`;
const guard = input.storage.read("CHUNK_RELOAD_GUARD"); const guard = input.storage.read("CHUNK_RELOAD_GUARD");
if (!guard.ok || guard.value === releasePair) { if (!guard.ok) {
return { action: "support", reason: "guard-read-failed" };
}
if (guard.value === releasePair) {
return { action: "support", reason: "reload-already-attempted" }; return { action: "support", reason: "reload-already-attempted" };
} }
@@ -1,81 +0,0 @@
export const ASYNC_BASE_STATES = Object.freeze([
"initial-loading",
"success",
"empty",
"terminal-error",
]);
export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-conflict",
]);
/**
* @typedef {{
* data?: unknown,
* isInitialLoading?: boolean,
* failure?: import("../../contracts/errors.js").ApiFailure,
* isFetching?: boolean,
* isStale?: boolean,
* isDegraded?: boolean,
* isMutationPending?: boolean,
* hasMutationConflict?: boolean
* }} AsyncSignals
*/
/** @param {AsyncSignals} signals */
export function deriveAsyncState(signals) {
const hasData = signals.data !== undefined && signals.data !== null;
const empty =
hasData &&
((Array.isArray(signals.data) && signals.data.length === 0) ||
signals.data === "");
let base;
if (signals.isInitialLoading && !hasData) {
base = "initial-loading";
} else if (signals.failure && !hasData) {
base = "terminal-error";
} else if (empty) {
base = "empty";
} else if (hasData) {
base = "success";
} else {
base = "initial-loading";
}
const overlay = Object.freeze({
refreshing: Boolean(signals.isFetching && hasData),
staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData),
mutationPending: Boolean(signals.isMutationPending && hasData),
mutationConflict: Boolean(signals.hasMutationConflict && hasData),
});
const state = {
base,
data: base === "success" || base === "empty" ? signals.data : undefined,
failure: base === "terminal-error" ? signals.failure : undefined,
overlay,
indicator: selectOverlayIndicator(overlay),
};
return Object.freeze(state);
}
/**
* @param {{
* refreshing: boolean,
* staleDegraded: boolean,
* mutationPending: boolean,
* mutationConflict: boolean
* }} overlay
*/
export function selectOverlayIndicator(overlay) {
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
if (overlay.refreshing) return "refreshing";
return null;
}
+156
View File
@@ -0,0 +1,156 @@
import type { ApiFailure } from "../../contracts/errors.js";
export const ASYNC_BASE_STATES = Object.freeze([
"initial-loading",
"success",
"empty",
"terminal-error",
] as const);
export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-conflict",
] as const);
export type AsyncOverlay =
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: true;
staleDegraded: false;
mutationPending: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: true;
mutationPending: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: true;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: false;
mutationConflict: true;
}>;
export type AsyncSignals = Readonly<{
data?: unknown;
isInitialLoading?: boolean;
failure?: ApiFailure;
isFetching?: boolean;
isStale?: boolean;
isDegraded?: boolean;
isMutationPending?: boolean;
hasMutationConflict?: boolean;
}>;
export type AsyncState = Readonly<{
base: (typeof ASYNC_BASE_STATES)[number];
data?: unknown;
failure?: ApiFailure;
overlay: AsyncOverlay;
indicator: (typeof ASYNC_OVERLAYS)[number] | null;
}>;
export function deriveAsyncState(signals: AsyncSignals): AsyncState {
const hasData = signals.data !== undefined && signals.data !== null;
const empty =
hasData &&
((Array.isArray(signals.data) && signals.data.length === 0) ||
signals.data === "");
const base =
signals.isInitialLoading && !hasData
? "initial-loading"
: signals.failure && !hasData
? "terminal-error"
: empty
? "empty"
: hasData
? "success"
: "initial-loading";
const indicator =
signals.hasMutationConflict && hasData
? "mutation-conflict"
: signals.isMutationPending && hasData
? "mutation-pending"
: signals.isStale && signals.isDegraded && hasData
? "stale-degraded"
: signals.isFetching && hasData
? "refreshing"
: null;
const overlay = overlayFor(indicator);
return Object.freeze({
base,
data: base === "success" || base === "empty" ? signals.data : undefined,
failure: base === "terminal-error" ? signals.failure : undefined,
overlay,
indicator,
});
}
export function selectOverlayIndicator(
overlay: AsyncOverlay,
): AsyncState["indicator"] {
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
if (overlay.refreshing) return "refreshing";
return null;
}
function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
if (indicator === "refreshing") {
return Object.freeze({
refreshing: true,
staleDegraded: false,
mutationPending: false,
mutationConflict: false,
});
}
if (indicator === "stale-degraded") {
return Object.freeze({
refreshing: false,
staleDegraded: true,
mutationPending: false,
mutationConflict: false,
});
}
if (indicator === "mutation-pending") {
return Object.freeze({
refreshing: false,
staleDegraded: false,
mutationPending: true,
mutationConflict: false,
});
}
if (indicator === "mutation-conflict") {
return Object.freeze({
refreshing: false,
staleDegraded: false,
mutationPending: false,
mutationConflict: true,
});
}
return Object.freeze({
refreshing: false,
staleDegraded: false,
mutationPending: false,
mutationConflict: false,
});
}
@@ -1,16 +0,0 @@
/**
* @param {import("../../domain/models/resource.js").Resource} resource
* @param {(value: Date) => string} [formatDate]
*/
export function toResourceViewModel(
resource,
formatDate = (value) => new Intl.DateTimeFormat("ko-KR").format(value),
) {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAtLabel: resource.createdAt
? formatDate(new Date(resource.createdAt))
: null,
});
}
+19 -6
View File
@@ -6,27 +6,40 @@ import { createApplication } from "../application/create-application.js";
* *
* @template Config * @template Config
* @template Release * @template Release
* @template {Parameters<typeof createApplication>[0]} Ports * @template {Parameters<typeof createApplication>[0]} OutputPorts
* @template Infrastructure
* @param {{ * @param {{
* loadConfig(): Promise<Config>, * loadConfig(): Promise<Config>,
* loadRelease(config: Config): Promise<Release>, * loadRelease(config: Config): Promise<Release>,
* createAdapters(context: { * createAdapters(context: {
* config: Config, * config: Config,
* release: Release * release: Release
* }): Promise<Ports> * }): Promise<{
* outputPorts: OutputPorts,
* infrastructure: Infrastructure,
* featureInputs?: Readonly<Record<string, unknown>>
* }>
* }} factories * }} factories
* @returns {Promise<Readonly<{ * @returns {Promise<Readonly<{
* config: Config, * config: Config,
* release: Release, * release: Release,
* ports: Ports, * infrastructure: Infrastructure,
* application: ReturnType<typeof createApplication> * application: ReturnType<typeof createApplication>
* }>>} * }>>}
*/ */
export async function createCompositionRoot(factories) { export async function createCompositionRoot(factories) {
const config = await factories.loadConfig(); const config = await factories.loadConfig();
const release = await factories.loadRelease(config); const release = await factories.loadRelease(config);
const ports = await factories.createAdapters({ config, release }); const adapters = await factories.createAdapters({ config, release });
const application = createApplication(ports); const application = createApplication(
adapters.outputPorts,
adapters.featureInputs,
);
return Object.freeze({ config, release, ports, application }); return Object.freeze({
config,
release,
infrastructure: adapters.infrastructure,
application,
});
} }
+3 -4
View File
@@ -6,22 +6,21 @@ import {
/** /**
* Applies the persisted public preference before React paints. * Applies the persisted public preference before React paints.
* *
* @param {import("../application/ports/storage-port.js").StoragePort} storage * @param {Pick<import("../application/ports/in/application-api.js").ApplicationApi["preferences"], "getColorScheme">} preferences
* @param {{ * @param {{
* documentElement?: HTMLElement, * documentElement?: HTMLElement,
* matchMedia?: (query: string) => MediaQueryList * matchMedia?: (query: string) => MediaQueryList
* }} [browser] * }} [browser]
*/ */
export function initializeColorScheme(storage, browser = {}) { export function initializeColorScheme(preferences, browser = {}) {
const documentElement = browser.documentElement ?? document.documentElement; const documentElement = browser.documentElement ?? document.documentElement;
const matchMedia = const matchMedia =
browser.matchMedia ?? browser.matchMedia ??
(typeof window.matchMedia === "function" (typeof window.matchMedia === "function"
? window.matchMedia.bind(window) ? window.matchMedia.bind(window)
: () => /** @type {MediaQueryList} */ ({ matches: false })); : () => /** @type {MediaQueryList} */ ({ matches: false }));
const stored = storage.read("COLOR_SCHEME");
const preference = normalizeColorSchemePreference( const preference = normalizeColorSchemePreference(
stored.ok ? stored.value : undefined, preferences.getColorScheme(),
); );
const resolved = resolveColorScheme( const resolved = resolveColorScheme(
preference, preference,
+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;
} }
+3 -17
View File
@@ -1,13 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx";
import { createRuntimeComposition } from "./create-runtime-composition.js"; import { createRuntimeComposition } from "./create-runtime-composition.js";
import { initializeColorScheme } from "./initialize-color-scheme.js"; import { initializeColorScheme } from "./initialize-color-scheme.js";
import { BootConfigError } from "./load-runtime-config.js"; import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js"; import { ReleaseManifestError } from "./load-release-manifest.js";
import { RuntimeApplication } from "./runtime-application.jsx";
import "../presentation/styles/theme.css"; import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
@@ -21,20 +19,8 @@ const root = createRoot(rootElement);
async function boot() { async function boot() {
try { try {
const composition = await createRuntimeComposition(); const composition = await createRuntimeComposition();
initializeColorScheme(composition.ports.storage); initializeColorScheme(composition.application.preferences);
root.render( root.render(<RuntimeApplication composition={composition} />);
<StrictMode>
<QueryClientProvider client={composition.ports.queryClient}>
<AppRouter
authSession={composition.ports.authSession}
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
storage={composition.ports.storage}
telemetry={composition.ports.telemetry}
/>
</QueryClientProvider>
</StrictMode>,
);
} catch (error) { } catch (error) {
const safe = const safe =
error instanceof BootConfigError || error instanceof ReleaseManifestError error instanceof BootConfigError || error instanceof ReleaseManifestError
+66 -14
View File
@@ -5,11 +5,12 @@ import {
} from "../adapters/auth/external-session-adapter.js"; } from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js"; import { createHttpClient } from "../adapters/http/client.js";
import { import {
createQueryCacheAdapter,
createQueryClient, createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js"; } from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js"; import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js"; import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
import { fetchReleaseManifest } from "./load-release-manifest.js";
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.js";
/** /**
* @param {Record<string, unknown>} host * @param {Record<string, unknown>} host
@@ -42,6 +43,31 @@ function storageOrUndefined(value) {
: undefined; : undefined;
} }
/**
* Runtime-aware transport factory. Feature gateway composition calls this
* factory when a registered API capability is installed.
*
* @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
* authSession: import("../application/ports/auth-session-port.js").AuthSessionPort,
* fetcher?: typeof fetch,
* clock?: import("../application/ports/clock-port.js").ClockPort,
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
* }} context
*/
export function createRuntimeHttpClient(context, contract = {}) {
return createHttpClient({
baseUrl: context.runtime.config.API_BASE_URL,
timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS,
maxRetryAttempts: context.runtime.config.MAX_RETRY_ATTEMPTS,
authSession: context.authSession,
fetcher: context.fetcher,
clock: context.clock,
scheduler: context.scheduler,
...contract,
});
}
/** /**
* @param {{ * @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>, * runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
@@ -61,7 +87,6 @@ export async function createRuntimeAdapters(context) {
? createExternalAuthSessionAdapter(externalOwner) ? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter(); : createUnavailableSessionAdapter();
const queryClient = createQueryClient(); const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const storage = createBrowserStorageAdapter({ const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage), localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage), sessionStorage: storageOrUndefined(host.sessionStorage),
@@ -71,24 +96,51 @@ export async function createRuntimeAdapters(context) {
endpoint: config.TELEMETRY_ENDPOINT, endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher, fetcher: context.fetcher,
}); });
const http = createHttpClient({
baseUrl: config.API_BASE_URL,
authSession,
fetcher: context.fetcher,
});
const releaseInfo = Object.freeze({ const releaseInfo = Object.freeze({
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();
},
});
const featureInputs = createInstalledFeatureInputs({
createHttpClient: (contract) =>
createRuntimeHttpClient(
{
runtime: context.runtime,
authSession,
fetcher: context.fetcher,
},
contract,
),
}); });
return Object.freeze({ return Object.freeze({
authSession, outputPorts: Object.freeze({
cache, session: authSession,
http, preferences: storage,
queryClient, diagnostics: telemetry,
releaseInfo, releaseInfo,
storage, navigation,
telemetry, }),
infrastructure: Object.freeze({
queryClient,
}),
featureInputs,
}); });
} }
+28
View File
@@ -0,0 +1,28 @@
import { StrictMode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ApplicationProvider } from "../presentation/providers/application-provider.js";
import { AppRouter } from "../presentation/routes/app-router.jsx";
/**
* Production provider tree. Tests import this component so the validated
* composition is proven against the same provider order used by main.
*
* @param {{
* composition: Awaited<ReturnType<typeof import("./create-runtime-composition.js").createRuntimeComposition>>
* }} props
*/
export function RuntimeApplication({ composition }) {
return (
<StrictMode>
<QueryClientProvider client={composition.infrastructure.queryClient}>
<ApplicationProvider application={composition.application}>
<AppRouter
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
/>
</ApplicationProvider>
</QueryClientProvider>
</StrictMode>
);
}
+6 -30
View File
@@ -4,45 +4,21 @@
* path: string, * path: string,
* operationId: string, * operationId: string,
* auth: "none" | "external-session", * auth: "none" | "external-session",
* timeoutMs: number, * timeoutMs: number | null,
* idempotency: "safe" | "keyed" | "none", * idempotency: "safe" | "keyed" | "none",
* retry: "runtime" | "never",
* requestSource: "search" | "body" | "none",
* requestSchema: string, * requestSchema: string,
* responseSchema: string, * responseSchema: string,
* owner: string * owner: string
* }} ApiOperation * }} ApiOperation
*/ */
/** @param {ApiOperation} definition */ export const API_OPERATIONS = Object.freeze({});
const operation = (definition) => Object.freeze(definition);
export const API_OPERATIONS = Object.freeze({
LIST_SAMPLE_RESOURCES: operation({
method: "GET",
path: "/api/sample/resources",
operationId: "LIST_SAMPLE_RESOURCES",
auth: "external-session",
timeoutMs: 10_000,
idempotency: "safe",
requestSchema: "SampleResourceListQuery",
responseSchema: "SampleResourceListPayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
CREATE_SAMPLE_RESOURCE: operation({
method: "POST",
path: "/api/sample/resources",
operationId: "CREATE_SAMPLE_RESOURCE",
auth: "external-session",
timeoutMs: 10_000,
idempotency: "keyed",
requestSchema: "CreateSampleResourceCommand",
responseSchema: "SampleResourcePayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
});
/** @param {string} operationId */ /** @param {string} operationId */
export function getApiOperation(operationId) { export function getApiOperation(operationId, operations = API_OPERATIONS) {
const registry = /** @type {Record<string, ApiOperation>} */ (API_OPERATIONS); const registry = /** @type {Record<string, ApiOperation>} */ (operations);
const selected = registry[operationId]; const selected = registry[operationId];
if (!selected) { if (!selected) {
throw new Error(`Unregistered API operation: ${operationId}`); throw new Error(`Unregistered API operation: ${operationId}`);
+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,
+1 -23
View File
@@ -1,26 +1,4 @@
const RESOURCE_NAMESPACE = Object.freeze(["resource", 1]); export const QUERY_REGISTRY = Object.freeze({});
export const queryKeys = Object.freeze({
resource: Object.freeze({
all: () => RESOURCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...RESOURCE_NAMESPACE, "list", canonicalize(filters)]),
/** @param {string} resourceId */
detail: (resourceId) =>
Object.freeze([...RESOURCE_NAMESPACE, "detail", String(resourceId)]),
}),
});
export const QUERY_REGISTRY = Object.freeze({
RESOURCE: Object.freeze({
namespace: RESOURCE_NAMESPACE,
serialization: "canonical-object-order",
identity: "no-pii-token-or-raw-url",
invalidation: "resource namespace after successful mutation",
version: 1,
persistence: "disabled",
}),
});
/** @param {unknown} value @returns {unknown} */ /** @param {unknown} value @returns {unknown} */
export function canonicalize(value) { export function canonicalize(value) {
+39
View File
@@ -0,0 +1,39 @@
/**
* @typedef {"none" | "NotFoundSplat"} RouteCodecId
*/
/** @param {Readonly<{routeId: string, moduleId: string, paramsCodec: RouteCodecId, searchCodec: RouteCodecId}>} value */
const runtime = (value) => Object.freeze(value);
export const PLATFORM_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",
}),
NOT_FOUND: runtime({
routeId: "NOT_FOUND",
moduleId: "not-found-page",
paramsCodec: "NotFoundSplat",
searchCodec: "none",
}),
});
+2 -40
View File
@@ -17,7 +17,7 @@
/** @param {RouteDefinition} definition */ /** @param {RouteDefinition} definition */
const route = (definition) => Object.freeze(definition); const route = (definition) => Object.freeze(definition);
export const ROUTE_REGISTRY = Object.freeze({ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
APP_HOME: route({ APP_HOME: route({
routeId: "APP_HOME", routeId: "APP_HOME",
path: "/", path: "/",
@@ -70,23 +70,10 @@ export const ROUTE_REGISTRY = Object.freeze({
navigationLabel: "인증 연동", navigationLabel: "인증 연동",
navigationOrder: 40, navigationOrder: 40,
}), }),
SAMPLE_RESOURCE_LIST: route({
routeId: "SAMPLE_RESOURCE_LIST",
path: "/sample/resources",
paramsSchema: null,
searchSchema: "SampleResourceListQuery",
access: "integration-defined",
loadingSurface: "sample-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-sample-resources",
title: "보호된 연동 지점",
navigationLabel: "보호된 연동 지점",
navigationOrder: 50,
}),
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",
@@ -97,28 +84,3 @@ export const ROUTE_REGISTRY = Object.freeze({
navigationOrder: null, navigationOrder: null,
}), }),
}); });
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(`Unregistered route: ${routeId}`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
-19
View File
@@ -1,19 +0,0 @@
/**
* @typedef {{
* id: string,
* displayName: string,
* createdAt: string | null
* }} Resource
*/
/** @param {Resource} values @returns {Readonly<Resource>} */
export function createResource(values) {
if (!values.id || !values.displayName) {
throw new TypeError("Resource invariants require id and displayName");
}
return Object.freeze({
id: values.id,
displayName: values.displayName,
createdAt: values.createdAt,
});
}
@@ -0,0 +1,15 @@
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.js";
export function createInstalledFeatureInputs(
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
): Readonly<Record<string, unknown>> {
const installed = [createReferenceFeatureInstalledInput(context)];
return Object.freeze(
Object.fromEntries(
installed.map((contribution) => [
contribution.featureId,
contribution.input,
]),
),
);
}
@@ -0,0 +1,48 @@
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js";
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js";
import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.js";
export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([
REFERENCE_FEATURE_CONTRACT,
]);
export const ROUTE_REGISTRY = Object.freeze({
...PLATFORM_ROUTE_REGISTRY,
...REFERENCE_FEATURE_CONTRACT.routes,
});
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
...PLATFORM_ROUTE_RUNTIME_CONTRACT,
...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts,
});
export const API_OPERATIONS = Object.freeze({
...REFERENCE_FEATURE_CONTRACT.apiOperations,
});
export const QUERY_REGISTRY = Object.freeze({
...REFERENCE_FEATURE_CONTRACT.queryRegistry,
});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry =
/** @type {Readonly<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(`Unregistered route: ${routeId}`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
@@ -0,0 +1,16 @@
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js";
import {
REFERENCE_FEATURE_ROUTE_CODECS,
REFERENCE_FEATURE_ROUTE_RUNTIME,
} from "./reference-feature/presentation/reference-feature-runtime.js";
export const ROUTE_CODECS = Object.freeze({
...PLATFORM_ROUTE_CODECS,
...REFERENCE_FEATURE_ROUTE_CODECS,
});
export const ROUTE_RUNTIME = Object.freeze({
...PLATFORM_ROUTE_RUNTIME,
...REFERENCE_FEATURE_ROUTE_RUNTIME,
});
+39
View File
@@ -0,0 +1,39 @@
# Reference feature ownership and removal
이 모듈은 제품 도메인이 아니라 새 기능의 수직 경계를 검증하는 제거 가능한
reference implementation이다.
## 소유 경계
- `domain`: 외부 DTO와 React를 모르는 불변 model
- `application`: UI가 호출하는 list/create input과 gateway 계약
- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter
- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper
- `presentation`: route input을 query/mutation controller로 연결하는 inbound
adapter와 page
generic application은 `features.get(featureId)` catalog만 제공한다. feature hook이
자신의 input shape를 확인하며 page는 HTTP client, storage, auth owner, output
port나 TanStack API를 직접 import하지 않는다.
## 설치 지점
- 직렬화 계약: `src/features/installed-feature-contracts.js`
- component/codec: `src/features/installed-feature-runtimes.tsx`
- bootstrap input 조립: `src/features/installed-feature-adapters.ts`
새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application,
router나 HTTP client에 하드코딩하지 않는다.
## 검증과 제거
```sh
corepack pnpm test:reference-feature
corepack pnpm test:sample-removal
```
첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든
query/mutation 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke,
production build와 fixture ID 잔여 0개를 검사한다.
@@ -0,0 +1,43 @@
import { createReferenceFeatureInput } from "../application/reference-feature-api.js";
import {
REFERENCE_FEATURE_CONTRACT,
REFERENCE_FEATURE_ID,
} from "../contracts/reference-feature-contract.js";
import { mapReferenceOperation } from "../contracts/reference-mapper.js";
import {
validateReferencePayload,
validateReferenceRequest,
} from "../contracts/reference-schemas.js";
import { createReferenceHttpGateway } from "./reference-http-gateway.js";
type HttpContract = Readonly<{
getOperation(operationId: string): unknown;
validatePayload: typeof validateReferencePayload;
validateRequest: typeof validateReferenceRequest;
mapPayload: typeof mapReferenceOperation;
}>;
type HttpExecutor = Parameters<typeof createReferenceHttpGateway>[0];
export function createReferenceFeatureInstalledInput(context: Readonly<{
createHttpClient(contract: HttpContract): HttpExecutor;
}>) {
const operations =
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<Record<string, unknown>>;
const http = context.createHttpClient({
getOperation(operationId) {
const operation = operations[operationId];
if (!operation) {
throw new Error(`Unknown reference operation: ${operationId}`);
}
return operation;
},
validatePayload: validateReferencePayload,
validateRequest: validateReferenceRequest,
mapPayload: mapReferenceOperation,
});
return Object.freeze({
featureId: REFERENCE_FEATURE_ID,
input: createReferenceFeatureInput(createReferenceHttpGateway(http)),
});
}
@@ -0,0 +1,56 @@
import type { ApiFailure } from "../../../contracts/errors.js";
import type {
ReferenceGateway,
ReferenceListFilters,
} from "../application/reference-feature-api.js";
import type { ReferenceResource } from "../domain/reference-resource.js";
type HttpResult =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; error: ApiFailure }>;
type HttpExecutor = Readonly<{
execute(
request: Readonly<{
operationId: string;
routeId: string;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
}>,
): Promise<HttpResult>;
}>;
export function createReferenceHttpGateway(
http: HttpExecutor,
): ReferenceGateway {
return Object.freeze({
async list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "LIST_REFERENCE_RESOURCES",
routeId: "REFERENCE_RESOURCE_LIST",
searchParams: filters,
signal: context?.signal,
});
return result.ok
? {
ok: true as const,
value: result.value as readonly ReferenceResource[],
}
: result;
},
async create(command: Readonly<{ name: string }>) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
body: command,
});
return result.ok
? { ok: true as const, value: result.value as ReferenceResource }
: result;
},
});
}
@@ -0,0 +1,58 @@
import type { ApiFailure } from "../../../contracts/errors.js";
import {
toReferenceView,
type ReferenceResourceView,
} from "../contracts/reference-mapper.js";
import type { ReferenceResource } from "../domain/reference-resource.js";
export type ReferenceListFilters = Readonly<{
cursor?: string;
limit: number;
tags?: readonly string[];
}>;
export type ReferenceResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type ReferenceFeatureInput = Readonly<{
listResources(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
createResource(
command: Readonly<{ name: string }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
}>;
export type ReferenceGateway = Readonly<{
list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create(
command: Readonly<{ name: string }>,
): Promise<ReferenceResult<ReferenceResource>>;
}>;
export function createReferenceFeatureInput(
gateway: ReferenceGateway,
): ReferenceFeatureInput {
return Object.freeze({
async listResources(filters, context) {
const result = await gateway.list(filters, context);
return result.ok
? {
ok: true as const,
value: result.value.map((resource) => toReferenceView(resource)),
}
: result;
},
async createResource(command) {
const result = await gateway.create(command);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
});
}
@@ -0,0 +1,75 @@
import { canonicalize } from "../../../contracts/query-keys.js";
export const REFERENCE_FEATURE_ID = "reference-feature";
const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1]);
export const referenceQueryKeys = Object.freeze({
all: () => REFERENCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]),
});
export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
featureId: REFERENCE_FEATURE_ID,
routes: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
path: "/examples/reference-resources",
paramsSchema: null,
searchSchema: "ReferenceResourceListQuery",
access: "integration-defined",
loadingSurface: "reference-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-reference-resources",
title: "Reference feature",
navigationLabel: "Reference feature",
navigationOrder: 50,
}),
}),
routeRuntimeContracts: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
moduleId: "reference-resource-page",
paramsCodec: "none",
searchCodec: "ReferenceResourceListQuery",
}),
}),
apiOperations: Object.freeze({
LIST_REFERENCE_RESOURCES: Object.freeze({
method: "GET",
path: "/api/reference-resources",
operationId: "LIST_REFERENCE_RESOURCES",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "search",
requestSchema: "ReferenceResourceListQuery",
responseSchema: "ReferenceResourceListPayload",
owner: "feature-frontend-reference-feature-vertical-slice",
}),
CREATE_REFERENCE_RESOURCE: Object.freeze({
method: "POST",
path: "/api/reference-resources",
operationId: "CREATE_REFERENCE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "keyed",
retry: "runtime",
requestSource: "body",
requestSchema: "CreateReferenceResourceCommand",
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-reference-feature-vertical-slice",
}),
}),
queryRegistry: Object.freeze({
REFERENCE_RESOURCE: Object.freeze({
namespace: REFERENCE_NAMESPACE,
serialization: "canonical-object-order",
identity: "no-pii-token-or-raw-url",
invalidation: "reference resource namespace after successful mutation",
version: 1,
persistence: "disabled",
}),
}),
});
@@ -0,0 +1,54 @@
import {
createReferenceResource,
type ReferenceResource,
} from "../domain/reference-resource.js";
export type ReferenceResourceView = Readonly<{
resourceId: string;
title: string;
createdAtLabel: string | null;
optimistic?: boolean;
}>;
function mapReferenceDto(value: unknown): ReferenceResource {
if (!value || typeof value !== "object") {
throw new TypeError("Validated reference DTO is required");
}
const dto = value as Record<string, unknown>;
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
throw new TypeError("Validated reference DTO invariants were breached");
}
return createReferenceResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
});
}
export function mapReferenceOperation(
operationId: string,
payload: unknown,
): ReferenceResource | readonly ReferenceResource[] {
if (operationId === "LIST_REFERENCE_RESOURCES") {
if (!Array.isArray(payload)) throw new TypeError("Expected a reference list");
return payload.map(mapReferenceDto);
}
if (operationId === "CREATE_REFERENCE_RESOURCE") {
return mapReferenceDto(payload);
}
throw new TypeError(`No reference mapper registered for ${operationId}`);
}
export function toReferenceView(
resource: ReferenceResource,
formatDate: (value: Date) => string = (value) =>
new Intl.DateTimeFormat("ko-KR").format(value),
): ReferenceResourceView {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAtLabel: resource.createdAt
? formatDate(new Date(resource.createdAt))
: null,
});
}
@@ -0,0 +1,67 @@
import { z } from "zod";
export const referenceResourceListQuerySchema = 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 referenceResourceDtoSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.string().datetime().optional(),
})
.strict();
const payloadSchemas = {
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema),
ReferenceResourcePayload: referenceResourceDtoSchema,
} satisfies Record<string, z.ZodType>;
const requestSchemas = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
CreateReferenceResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
})
.strict(),
} satisfies Record<string, z.ZodType>;
function project(result: z.ZodSafeParseResult<unknown>) {
return result.success
? ({ success: true as const, data: structuredClone(result.data) })
: ({
success: false as const,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
});
}
export function validateReferencePayload(schemaId: string, value: unknown) {
const schema = payloadSchemas[schemaId as keyof typeof payloadSchemas];
return schema
? project(schema.safeParse(value))
: { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] };
}
export function validateReferenceRequest(schemaId: string, value: unknown) {
const schema = requestSchemas[schemaId as keyof typeof requestSchemas];
return schema
? project(schema.safeParse(value))
: { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] };
}
@@ -0,0 +1,14 @@
export type ReferenceResource = Readonly<{
id: string;
displayName: string;
createdAt: string | null;
}>;
export function createReferenceResource(
values: ReferenceResource,
): ReferenceResource {
if (!values.id || !values.displayName) {
throw new TypeError("Reference resource invariants require id and displayName");
}
return Object.freeze({ ...values });
}
@@ -0,0 +1,14 @@
import { lazy } from "react";
import { referenceResourceListQuerySchema } from "../contracts/reference-schemas.js";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
}),
} as const;
@@ -0,0 +1,60 @@
import { useState, type FormEvent } from "react";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { PageHeader } from "../../../presentation/components/page-header.jsx";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
const { filters, query, mutation } = useReferenceFeature();
const [name, setName] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const result = await mutation.submit({ name });
if (result.ok) setName("");
}
return (
<section className="ui-page">
<PageHeader
eyebrow="제거 가능한 수직 슬라이스"
title="Reference feature"
description="URL codec, application input, HTTP/schema/mapper와 query/mutation 상태를 한 경로로 검증합니다."
/>
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
<AsyncSurface state={query.state} onRetry={query.retry}>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li
key={resource.resourceId}
data-optimistic={resource.optimistic || undefined}
>
{resource.title}
</li>
))}
</ul>
</AsyncSurface>
<AsyncSurface
state={mutation.state}
onResolveConflict={mutation.resolveConflict}
>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="reference-resource-name"> </label>
<input
id="reference-resource-name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
required
/>
<Button type="submit" disabled={mutation.state.overlay.mutationPending}>
</Button>
</form>
</AsyncSurface>
</section>
);
}
@@ -0,0 +1,62 @@
import { useApplication } from "../../../presentation/providers/application-provider.js";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../../presentation/adapters/query/application-query.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import type { ReferenceResourceView } from "../contracts/reference-mapper.js";
import {
REFERENCE_FEATURE_ID,
referenceQueryKeys,
} from "../contracts/reference-feature-contract.js";
import type {
ReferenceFeatureInput,
ReferenceListFilters,
} from "../application/reference-feature-api.js";
function useReferenceFeatureInput(): ReferenceFeatureInput {
const candidate = useApplication().features.get(REFERENCE_FEATURE_ID);
if (
!candidate ||
typeof candidate !== "object" ||
typeof (candidate as ReferenceFeatureInput).listResources !== "function" ||
typeof (candidate as ReferenceFeatureInput).createResource !== "function"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
}
export function useReferenceFeature() {
const input = useReferenceFeatureInput();
const routeInput = useRouteInput();
const filters = routeInput.search as ReferenceListFilters;
const queryKey = referenceQueryKeys.list(filters);
const query = useApplicationQuery({
queryKey,
execute: ({ signal }) => input.listResources(filters, { signal }),
});
const mutation = useApplicationMutation({
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
optimistic: {
queryKey,
update(previous, command: Readonly<{ name: string }>) {
const current = Array.isArray(previous)
? (previous as readonly ReferenceResourceView[])
: [];
return [
...current,
{
resourceId: `optimistic:${command.name}`,
title: command.name,
createdAtLabel: null,
optimistic: true,
},
];
},
},
});
return Object.freeze({ filters, query, mutation });
}
@@ -0,0 +1,194 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deriveAsyncState,
type AsyncState,
} from "../../../application/view-models/async-state.js";
import type { ApiFailure } from "../../../contracts/errors.js";
export type ApplicationResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
class ApplicationQueryError extends Error {
readonly failure: ApiFailure;
constructor(failure: ApiFailure) {
super(failure.kind);
this.name = "ApplicationQueryError";
this.failure = failure;
}
}
export function useApplicationQuery<Value>(
options: Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
): Readonly<{
data: Value | undefined;
state: AsyncState;
retry(): Promise<void>;
}> {
const { queryKey, execute, enabled = true } = options;
const [staleFailure, setStaleFailure] = useState(false);
const query = useQuery<Value, ApplicationQueryError>({
queryKey,
enabled,
retry: false,
queryFn: async ({ signal }) => {
const result = await execute({ signal });
if (result.ok) return result.value;
if (signal.aborted || result.error.kind === "REQUEST_ABORTED") {
throw new DOMException("Query cancelled", "AbortError");
}
throw new ApplicationQueryError(result.error);
},
});
const hasData = query.data !== undefined && query.data !== null;
useEffect(() => {
if (query.isError && hasData) {
setStaleFailure(true);
} else if (query.isSuccess && !query.isFetching) {
setStaleFailure(false);
}
}, [hasData, query.isError, query.isFetching, query.isSuccess]);
const retry = useCallback(async () => {
setStaleFailure(false);
await query.refetch();
}, [query]);
return Object.freeze({
data: query.data,
state: deriveAsyncState({
data: query.data,
isInitialLoading: query.isPending,
failure:
!hasData && query.error instanceof ApplicationQueryError
? query.error.failure
: undefined,
isFetching: query.isFetching && !query.isPending,
isStale: staleFailure,
isDegraded: staleFailure,
}),
retry,
});
}
export function useApplicationMutation<Input, Value>(
options: Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly (readonly unknown[])[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>,
): Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
}> {
const queryClient = useQueryClient();
const { execute, invalidate = [], optimistic, currentData } = options;
const [conflict, setConflict] = useState<ApiFailure | null>(null);
const inFlight = useRef<Promise<ApplicationResult<Value>> | null>(null);
const mutation = useMutation<Value, ApplicationQueryError, Input>({
retry: false,
mutationFn: async (input) => {
const result = await execute(input);
if (result.ok) return result.value;
throw new ApplicationQueryError(result.error);
},
});
const submit = useCallback(
(input: Input): Promise<ApplicationResult<Value>> => {
if (inFlight.current) return inFlight.current;
setConflict(null);
mutation.reset();
const previous = optimistic
? queryClient.getQueryData(optimistic.queryKey)
: undefined;
if (optimistic) {
queryClient.setQueryData(
optimistic.queryKey,
optimistic.update(previous, input),
);
}
const pending = mutation
.mutateAsync(input)
.then(async (value) => {
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
}
return { ok: true as const, value };
})
.catch((error: unknown) => {
if (optimistic) {
queryClient.setQueryData(optimistic.queryKey, previous);
}
const failure =
error instanceof ApplicationQueryError
? error.failure
: unexpectedMutationFailure();
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false as const, error: failure };
})
.finally(() => {
inFlight.current = null;
});
inFlight.current = pending;
return pending;
},
[invalidate, mutation, optimistic, queryClient],
);
const resolveConflict = useCallback(async () => {
setConflict(null);
mutation.reset();
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
}
}, [invalidate, mutation, queryClient]);
return Object.freeze({
state: deriveAsyncState({
data: currentData ?? true,
isMutationPending: mutation.isPending,
hasMutationConflict: conflict !== null,
}),
submit,
resolveConflict,
});
}
function unexpectedMutationFailure(): ApiFailure {
return {
kind: "UNKNOWN_FAILURE",
code: "UNKNOWN_FAILURE",
retryable: false,
operationId: "APPLICATION_MUTATION",
attemptCount: 1,
userMessageKey: "error.unknown_failure",
action: "contact-support",
};
}
+5
View File
@@ -0,0 +1,5 @@
export {
useApplicationMutation,
useApplicationQuery,
type ApplicationResult,
} from "./application-query.js";
@@ -0,0 +1,97 @@
import {
Component,
type ErrorInfo,
type ReactNode,
} from "react";
type RecoveryResult =
| Readonly<{ action: "reload-once"; releasePair: string }>
| Readonly<{ action: "support"; reason: string }>;
type Props = Readonly<{
children: ReactNode;
chunkId: string;
recover(input: Readonly<{
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE";
}>): Promise<RecoveryResult>;
}>;
type State = Readonly<{
error: unknown | null;
recovery: "idle" | "checking" | "reload-requested" | "support";
reason?: string;
}>;
export function isChunkLoadFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const value = `${error.name} ${error.message}`.toLowerCase();
return (
value.includes("chunkloaderror") ||
value.includes("loading chunk") ||
value.includes("dynamically imported module") ||
value.includes("failed to fetch module script")
);
}
export class ChunkRecoveryBoundary extends Component<Props, State> {
state: State = { error: null, recovery: "idle" };
static getDerivedStateFromError(error: unknown): State {
return { error, recovery: "checking" };
}
componentDidCatch(error: unknown, _info: ErrorInfo) {
if (!isChunkLoadFailure(error)) return;
void this.props
.recover({
chunkId: this.props.chunkId,
failureKind: "CHUNK_LOAD_FAILURE",
})
.then((result) => {
this.setState({
error,
recovery:
result.action === "reload-once"
? "reload-requested"
: "support",
...(result.action === "support" ? { reason: result.reason } : {}),
});
})
.catch(() => {
this.setState({
error,
recovery: "support",
reason: "recovery-controller-failed",
});
});
}
render() {
const { error, recovery, reason } = this.state;
if (error && !isChunkLoadFailure(error)) throw error;
if (error && recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
.
</section>
);
}
if (error && recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
.
</section>
);
}
if (error && recovery === "support") {
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1> .</h1>
<p> .</p>
</section>
);
}
return this.props.children;
}
}
@@ -6,7 +6,8 @@ import { Component } from "react";
* boundaryName: string, * boundaryName: string,
* routeId: string, * routeId: string,
* buildId: string, * buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, * resetKey?: string,
* onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
* fallback?: React.ReactNode * fallback?: React.ReactNode
* }} RenderBoundaryProps * }} RenderBoundaryProps
* @typedef {{ hasError: boolean }} RenderBoundaryState * @typedef {{ hasError: boolean }} RenderBoundaryState
@@ -26,13 +27,24 @@ export class RenderErrorBoundary extends Component {
componentDidCatch() { componentDidCatch() {
try { try {
this.props.telemetry?.emit("ui.render.failed", { this.props.onRenderFailure?.({
route_id: this.props.routeId, routeId: this.props.routeId,
build_id: this.props.buildId, buildId: this.props.buildId,
component_boundary: this.props.boundaryName, boundaryName:
/** @type {"route" | "feature"} */ (this.props.boundaryName),
}); });
} catch { } catch {
// Telemetry must never recurse into another render failure. // Diagnostics must never recurse into another render failure.
}
}
/** @param {RenderBoundaryProps} previous */
componentDidUpdate(previous) {
if (
this.state.hasError &&
previous.resetKey !== this.props.resetKey
) {
this.setState({ hasError: false });
} }
} }
+23 -9
View File
@@ -65,7 +65,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
data-message-key={userMessageKey} data-message-key={userMessageKey}
> >
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2> <h2 id={messageId}>{errorMessage(userMessageKey)}</h2>
{action !== "none" && ( {action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button> <Button onClick={onAction}>{actionLabels[action]}</Button>
)} )}
</section> </section>
@@ -76,10 +76,18 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
* @param {{ * @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>, * state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode, * children?: React.ReactNode,
* onAction?: () => void * onAction?: () => void,
* onRetry?: () => void,
* onResolveConflict?: () => void
* }} props * }} props
*/ */
export function AsyncSurface({ state, children, onAction }) { export function AsyncSurface({
state,
children,
onAction,
onRetry,
onResolveConflict,
}) {
if (state.base === "initial-loading") return <LoadingSurface />; if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />; if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) { if (state.base === "terminal-error" && state.failure) {
@@ -87,18 +95,24 @@ export function AsyncSurface({ state, children, onAction }) {
<TerminalErrorSurface <TerminalErrorSurface
userMessageKey={state.failure.userMessageKey} userMessageKey={state.failure.userMessageKey}
action={state.failure.action} action={state.failure.action}
onAction={onAction} onAction={onRetry ?? onAction}
/> />
); );
} }
return ( return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}> <section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator && ( {state.indicator ? (
<p role="status" aria-live="polite"> <div role="status" aria-live="polite">
{state.indicator} <span>{state.indicator}</span>
</p> {state.indicator === "stale-degraded" && onRetry ? (
)} <Button onClick={onRetry}>다시 시도</Button>
) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>충돌 해결</Button>
) : null}
</div>
) : null}
{children} {children}
</section> </section>
); );
@@ -8,6 +8,11 @@ const ERROR_MESSAGES = Object.freeze({
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.", "error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.", "error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.", "error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.",
"error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.",
"error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.",
"error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.",
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.", "error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.", "error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
}); });
@@ -11,7 +11,6 @@ export function PageHeader({ title, description, eyebrow }) {
const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null)); const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null));
useEffect(() => { useEffect(() => {
document.title = `${title} · Frontend Skeleton`;
headingRef.current?.focus(); headingRef.current?.focus();
}, [title]); }, [title]);
+4 -1
View File
@@ -1,7 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js"; import {
NAVIGATION_ROUTES,
routePath,
} from "../../features/installed-feature-contracts.js";
import { useSession } from "../providers/session-provider.jsx"; import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx"; import { useTheme } from "../providers/theme-provider.jsx";
+25 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js"; import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
const READINESS_ITEMS = Object.freeze([ const READINESS_ITEMS = Object.freeze([
{ {
@@ -19,6 +21,23 @@ const READINESS_ITEMS = Object.freeze([
]); ]);
export default function HomePage() { export default function HomePage() {
const { runtime } = useApplication();
const [release, setRelease] = useState(
/** @type {Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null} */ (
null
),
);
useEffect(() => {
let active = true;
void runtime.getReleaseSummary().then((summary) => {
if (active) setRelease(summary);
});
return () => {
active = false;
};
}, [runtime]);
return ( return (
<section className="ui-page"> <section className="ui-page">
<PageHeader <PageHeader
@@ -34,6 +53,11 @@ export default function HomePage() {
</article> </article>
))} ))}
</div> </div>
<p className="ui-runtime-summary" aria-live="polite">
{release
? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
: "검증된 런타임 정보를 확인하고 있습니다."}
</p>
<section className="ui-panel starter-actions" aria-labelledby="starter-title"> <section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div> <div>
<h2 id="starter-title">준비된 화면 살펴보기</h2> <h2 id="starter-title">준비된 화면 살펴보기</h2>
+1 -1
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js"; import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../components/page-header.jsx";
export default function NotFoundPage() { export default function NotFoundPage() {
@@ -1,23 +0,0 @@
import { PageHeader } from "../components/page-header.jsx";
import { useSession } from "../providers/session-provider.jsx";
export default function SampleContractPage() {
const { sessionState } = useSession();
return (
<section className="ui-page">
<PageHeader
eyebrow="보호 라우트"
title="보호된 연동 지점"
description="실제 도메인 기능이 인증된 세션과 연결되는 위치를 보여주는 중립적인 계약 화면입니다."
/>
<section className="ui-panel" aria-labelledby="protected-state-title">
<h2 id="protected-state-title">라우트 접근 허용</h2>
<p>
현재 세션 상태는 <strong>{sessionState}</strong>입니다. 서버의
권한 검증은 클라이언트 라우트 정책과 별도로 유지해야 합니다.
</p>
</section>
</section>
);
}
@@ -0,0 +1,31 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { ApplicationApi } from "../../application/create-application.js";
const ApplicationContext = createContext<ApplicationApi | null>(null);
export function ApplicationProvider({
application,
children,
}: Readonly<{
application: ApplicationApi;
children: ReactNode;
}>) {
return (
<ApplicationContext.Provider value={application}>
{children}
</ApplicationContext.Provider>
);
}
export function useApplication(): ApplicationApi {
const application = useContext(ApplicationContext);
if (!application) {
throw new Error("ApplicationProvider is required");
}
return application;
}
+15 -16
View File
@@ -1,11 +1,12 @@
import { createContext, useContext, useMemo, useSyncExternalStore } from "react"; import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { useApplication } from "./application-provider.js";
/** /**
* @typedef {{ * @typedef {{
* sessionState: import("../../application/ports/auth-session-port.js").SessionState, * sessionState: import("../../application/ports/in/application-api.js").SessionState,
* beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"], * beginSignIn: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["beginSignIn"],
* signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"], * signOut: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["signOut"],
* recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"] * recover: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["recover"]
* }} SessionContextValue * }} SessionContextValue
*/ */
@@ -14,26 +15,24 @@ const SessionContext = createContext(
); );
/** /**
* @param {{ * @param {{ children: React.ReactNode }} props
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* children: React.ReactNode
* }} props
*/ */
export function SessionProvider({ authSession, children }) { export function SessionProvider({ children }) {
const { session } = useApplication();
const sessionState = useSyncExternalStore( const sessionState = useSyncExternalStore(
authSession.subscribe, session.subscribe,
authSession.getState, session.getSnapshot,
authSession.getState, session.getSnapshot,
); );
const value = useMemo( const value = useMemo(
() => () =>
Object.freeze({ Object.freeze({
sessionState, sessionState,
beginSignIn: authSession.beginSignIn, beginSignIn: session.beginSignIn,
signOut: authSession.signOut, signOut: session.signOut,
recover: authSession.recover, recover: session.recover,
}), }),
[authSession, sessionState], [session, sessionState],
); );
return ( return (
+9 -13
View File
@@ -11,6 +11,7 @@ import {
normalizeColorSchemePreference, normalizeColorSchemePreference,
resolveColorScheme, resolveColorScheme,
} from "../../application/policies/color-scheme.js"; } from "../../application/policies/color-scheme.js";
import { useApplication } from "./application-provider.js";
/** /**
* @typedef {{ * @typedef {{
@@ -30,18 +31,13 @@ function systemPrefersDark() {
} }
/** /**
* @param {{ * @param {{ children: React.ReactNode }} props
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* children: React.ReactNode
* }} props
*/ */
export function ThemeProvider({ storage, children }) { export function ThemeProvider({ children }) {
const [preference, updatePreference] = useState(() => { const { preferences } = useApplication();
const result = storage?.read("COLOR_SCHEME"); const [preference, updatePreference] = useState(
return normalizeColorSchemePreference( preferences.getColorScheme,
result?.ok ? result.value : undefined, );
);
});
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark); const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
const resolvedTheme = resolveColorScheme(preference, darkSystemTheme); const resolvedTheme = resolveColorScheme(preference, darkSystemTheme);
@@ -70,10 +66,10 @@ export function ThemeProvider({ storage, children }) {
setPreference(next) { setPreference(next) {
const normalized = normalizeColorSchemePreference(next); const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized); updatePreference(normalized);
storage?.write("COLOR_SCHEME", normalized); preferences.setColorScheme(normalized);
}, },
}), }),
[preference, resolvedTheme, storage], [preference, preferences, resolvedTheme],
); );
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
-272
View File
@@ -1,272 +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 { 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,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function RouteSurface({ routeId, buildId, telemetry, children }) {
return (
<RouteBoundary
routeId={routeId}
buildId={buildId}
telemetry={telemetry}
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,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function PublicRoute({ routeId, buildId, telemetry, children }) {
return (
<RouteSurface routeId={routeId} buildId={buildId} telemetry={telemetry}>
{children}
</RouteSurface>
);
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string,
* buildId?: string,
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort
* }} props
*/
export function AppRouter({
authSession,
basename = "/",
buildId = "local-build",
storage,
telemetry,
}) {
return (
<BrowserRouter basename={basename}>
<ThemeProvider storage={storage}>
<SessionProvider authSession={authSession}>
<Routes>
<Route element={<AppShell />}>
<Route
index
element={
<PublicRoute
routeId="APP_HOME"
buildId={buildId}
telemetry={telemetry}
>
<HomePage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_UI")}
element={
<PublicRoute
routeId="EXAMPLES_UI"
buildId={buildId}
telemetry={telemetry}
>
<UiGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_STATES")}
element={
<PublicRoute
routeId="EXAMPLES_STATES"
buildId={buildId}
telemetry={telemetry}
>
<StateGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_AUTH")}
element={
<PublicRoute
routeId="EXAMPLES_AUTH"
buildId={buildId}
telemetry={telemetry}
>
<AuthExamplePage />
</PublicRoute>
}
/>
<Route
path={routePath("SAMPLE_RESOURCE_LIST")}
element={
<RouteSurface
routeId="SAMPLE_RESOURCE_LIST"
buildId={buildId}
telemetry={telemetry}
>
<ProtectedRoute routeId="SAMPLE_RESOURCE_LIST">
<SampleContractPage />
</ProtectedRoute>
</RouteSurface>
}
/>
<Route
path={routePath("NOT_FOUND")}
element={
<PublicRoute
routeId="NOT_FOUND"
buildId={buildId}
telemetry={telemetry}
>
<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,
} from "../../features/installed-feature-contracts.js";
import 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 "../../features/installed-feature-runtimes.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>
);
}
+17 -4
View File
@@ -1,8 +1,8 @@
import { getRoute } from "../../contracts/routes.js"; import { getRoute } from "../../features/installed-feature-contracts.js";
/** /**
* @param {string} routeId * @param {string} routeId
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState * @param {import("../../application/ports/in/application-api.js").SessionState} sessionState
*/ */
export function decideRouteAccess(routeId, sessionState) { export function decideRouteAccess(routeId, sessionState) {
const route = getRoute(routeId); const route = getRoute(routeId);
@@ -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;
}, },
}); });
} }
@@ -0,0 +1,6 @@
import { z } from "zod";
export const PLATFORM_ROUTE_CODECS = {
none: z.object({}).strict(),
NotFoundSplat: z.object({ "*": z.string().optional() }).strict(),
} as const;
+110
View File
@@ -0,0 +1,110 @@
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.js";
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.js";
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
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";
}>;
function codecById(codecId: string) {
const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS];
if (!codec) throw new TypeError(`Unregistered route codec: ${codecId}`);
return codec;
}
export function parseRouteInput(
routeId: RouteId,
rawParams: Readonly<Record<string, string | undefined>>,
rawSearch: URLSearchParams,
): RouteInputResult {
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecById(runtime.paramsCodec).safeParse(rawParams);
if (!params.success) {
return { success: false, code: "ROUTE_PARAMS_INVALID" };
}
const search = codecById(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 = codecById(runtime.paramsCodec).parse(input.params ?? {});
const search = codecById(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: string,
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;
}
+43
View File
@@ -0,0 +1,43 @@
import {
lazy,
type ComponentType,
type LazyExoticComponent,
} from "react";
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
type RouteModule = Readonly<{ default: ComponentType }>;
type RouteRuntime = Readonly<{
moduleId: string;
Component: LazyExoticComponent<ComponentType>;
}>;
function runtime(
routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
load: () => Promise<RouteModule>,
): RouteRuntime {
return Object.freeze({
moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
Component: lazy(load),
});
}
export const PLATFORM_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"),
),
NOT_FOUND: runtime(
"NOT_FOUND",
() => import("../pages/not-found-page.jsx"),
),
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
@@ -1,15 +0,0 @@
export function DesignTokenShowcase() {
return (
<section className="ui-panel" aria-labelledby="token-showcase-title">
<h2 id="token-showcase-title" className="text-xl font-semibold">
Design token fixture
</h2>
<p className="text-content-muted">
Semantic tokens style loading, empty, and terminal surfaces.
</p>
<button className="ui-button" type="button">
Token action
</button>
</section>
);
}
@@ -1,53 +0,0 @@
import { toResourceViewModel } from "../../application/view-models/resource-view-model.js";
import { queryKeys } from "../../contracts/query-keys.js";
/**
* @param {{
* http: { execute(operationId: string, input?: Record<string, unknown>): Promise<
* {ok: true, value: unknown} | {ok: false, error: import("../../contracts/errors.js").ApiFailure}
* > },
* cache: import("../../application/ports/query-cache-port.js").QueryCachePort
* }} ports
*/
export function createSampleFacade(ports) {
return Object.freeze({
async listResources(filters = {}) {
const key = queryKeys.resource.list(filters);
const result = await ports.http.execute("LIST_SAMPLE_RESOURCES", {
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const models =
/** @type {Array<import("../../domain/models/resource.js").Resource>} */ (
result.value
);
const cached = ports.cache.write(key, models);
if (!cached.ok) return cached;
return {
ok: /** @type {true} */ (true),
value: models.map((model) => toResourceViewModel(model)),
};
},
/** @param {{ name: string }} command */
async createResource(command) {
const result = await ports.http.execute("CREATE_SAMPLE_RESOURCE", {
body: command,
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const invalidated = await ports.cache.invalidate(queryKeys.resource.all());
if (!invalidated.ok) return invalidated;
return {
ok: /** @type {true} */ (true),
value: toResourceViewModel(
/** @type {import("../../domain/models/resource.js").Resource} */ (
result.value
),
),
};
},
});
}
@@ -1,61 +0,0 @@
import { useEffect, useState } from "react";
import { deriveAsyncState } from "../../application/view-models/async-state.js";
import { AsyncSurface } from "../../presentation/components/async-surface.jsx";
/**
* @typedef {{
* loading: boolean,
* resources?: Array<{resourceId: string, title: string, createdAtLabel: string | null}>,
* failure?: import("../../contracts/errors.js").ApiFailure
* }} SamplePageState
*/
/**
* @param {{
* facade: ReturnType<typeof import("./sample-facade.js").createSampleFacade>
* }} props
*/
export function SampleResourcePage({ facade }) {
const [result, setResult] = useState(
/** @type {SamplePageState} */ ({
loading: true,
resources: undefined,
failure: undefined,
}),
);
useEffect(() => {
let active = true;
void facade.listResources().then((outcome) => {
if (!active) return;
setResult(
outcome.ok
? { loading: false, resources: outcome.value, failure: undefined }
: { loading: false, resources: undefined, failure: outcome.error },
);
});
return () => {
active = false;
};
}, [facade]);
const state = deriveAsyncState({
isInitialLoading: result.loading,
data: result.resources,
failure: result.failure,
});
return (
<main>
<h1>샘플 리소스</h1>
<AsyncSurface state={state}>
<ul>
{(result.resources ?? []).map((resource) => (
<li key={resource.resourceId}>{resource.title}</li>
))}
</ul>
</AsyncSurface>
</main>
);
}
+218
View File
@@ -0,0 +1,218 @@
// @vitest-environment jsdom
import {
act,
renderHook,
waitFor,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../src/presentation/adapters/query/application-query.js";
import { createFailure } from "../../src/contracts/errors.js";
function queryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, gcTime: Infinity },
mutations: { retry: false },
},
});
}
/** @param {QueryClient} client */
function wrapper(client) {
/** @param {{children: React.ReactNode}} props */
return function QueryWrapper({ children }) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};
}
describe("application query inbound bridge", () => {
it("latches a background failure over stale data and clears it on retry success", async () => {
const client = queryClient();
const responses = [
{ ok: /** @type {const} */ (true), value: ["first"] },
{
ok: /** @type {const} */ (false),
error: createFailure("SERVER_FAILURE", "LIST", 0),
},
{ ok: /** @type {const} */ (true), value: ["recovered"] },
];
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["resource", "list"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
);
expect(hook.result.current.state.base).toBe("success");
expect(hook.result.current.data).toEqual(["first"]);
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.data).toEqual(["recovered"]),
);
expect(hook.result.current.state.indicator).toBeNull();
expect(execute).toHaveBeenCalledTimes(3);
});
it("projects an initial application failure into terminal state", async () => {
const client = queryClient();
const failure = createFailure("FORBIDDEN", "LIST", 0);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["forbidden"],
execute: async () => ({ ok: false, error: failure }),
}),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.base).toBe("terminal-error"),
);
expect(hook.result.current.state.failure).toBe(failure);
});
it("passes cancellation to the application and does not retain an unmounted error", async () => {
const client = queryClient();
let aborted = false;
const execute = vi.fn(
({ signal }) =>
new Promise((resolve) => {
signal.addEventListener(
"abort",
() => {
aborted = true;
resolve({
ok: false,
error: createFailure("REQUEST_ABORTED", "LIST", 0),
});
},
{ once: true },
);
}),
);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["cancelled"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
hook.unmount();
await waitFor(() => expect(aborted).toBe(true));
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
});
});
describe("application mutation inbound bridge", () => {
it("deduplicates submit and commits one optimistic mutation", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
/** @type {(value: {ok: true, value: string}) => void} */
let complete = () => {};
const execute = vi.fn(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation({
execute,
invalidate: [["resource"]],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
.../** @type {string[]} */ (previous),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
let first = null;
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
let duplicate = null;
act(() => {
first = hook.result.current.submit("created");
duplicate = hook.result.current.submit("created");
});
expect(first).toBe(duplicate);
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
);
complete({ ok: true, value: "created" });
if (!first) throw new Error("expected pending mutation");
await act(() => first);
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
await waitFor(() =>
expect(hook.result.current.state.indicator).toBeNull(),
);
});
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
const conflict = createFailure("CONFLICT", "CREATE", 0);
const hook = renderHook(
() =>
useApplicationMutation({
execute: async () => ({ ok: false, error: conflict }),
invalidate: [["resource"]],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
.../** @type {string[]} */ (previous),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("conflicting");
});
expect(outcome).toEqual({ ok: false, error: conflict });
expect(client.getQueryData(key)).toEqual(["existing"]);
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
expect(hook.result.current.state.overlay).toMatchObject({
mutationPending: false,
mutationConflict: true,
});
await act(() => hook.result.current.resolveConflict());
expect(hook.result.current.state.indicator).toBeNull();
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
});
});
+39 -5
View File
@@ -1,7 +1,8 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js"; import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx"; import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
@@ -29,7 +30,7 @@ describe("async UI state matrix", () => {
expect(deriveAsyncState(signals).indicator).toBe(indicator); expect(deriveAsyncState(signals).indicator).toBe(indicator);
}); });
it("uses deterministic overlay priority for crossed states", () => { it("makes crossed overlay inputs mutually exclusive by priority", () => {
const state = deriveAsyncState({ const state = deriveAsyncState({
data: ["value"], data: ["value"],
isFetching: true, isFetching: true,
@@ -38,8 +39,8 @@ describe("async UI state matrix", () => {
}); });
expect(state.indicator).toBe("mutation-conflict"); expect(state.indicator).toBe("mutation-conflict");
expect(state.overlay).toMatchObject({ expect(state.overlay).toMatchObject({
refreshing: true, refreshing: false,
mutationPending: true, mutationPending: false,
mutationConflict: true, mutationConflict: true,
}); });
}); });
@@ -52,12 +53,45 @@ describe("async UI state matrix", () => {
expect(screen.getByRole("status")).toHaveTextContent("refreshing"); expect(screen.getByRole("status")).toHaveTextContent("refreshing");
}); });
it("connects stale retry and conflict resolution to real callbacks", async () => {
const user = userEvent.setup();
const retry = vi.fn();
const resolveConflict = vi.fn();
const stale = deriveAsyncState({
data: ["value"],
isStale: true,
isDegraded: true,
});
const view = render(
<AsyncSurface state={stale} onRetry={retry}>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(retry).toHaveBeenCalledOnce();
const conflict = deriveAsyncState({
data: ["value"],
hasMutationConflict: true,
});
view.rerender(
<AsyncSurface
state={conflict}
onResolveConflict={resolveConflict}
>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
expect(resolveConflict).toHaveBeenCalledOnce();
});
it("renders only safe error vocabulary", () => { it("renders only safe error vocabulary", () => {
const failure = createFailure("SERVER_FAILURE", "LIST", 0, { const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
code: "SERVER_FAILURE", code: "SERVER_FAILURE",
}); });
const state = deriveAsyncState({ failure }); const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />); render(<AsyncSurface state={state} onRetry={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent( expect(screen.getByRole("alert")).toHaveTextContent(
"요청을 완료하지 못했습니다.", "요청을 완료하지 못했습니다.",
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
ChunkRecoveryBoundary,
isChunkLoadFailure,
} from "../../src/presentation/boundaries/chunk-recovery-boundary.js";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
function ChunkDefect(): never {
throw new TypeError("Failed to fetch dynamically imported module");
}
function RenderDefect(): never {
throw new Error("ordinary render defect");
}
describe("chunk recovery boundary classification", () => {
it("recognizes lazy module failures without classifying ordinary render errors", () => {
expect(
isChunkLoadFailure(
new TypeError("Failed to fetch dynamically imported module"),
),
).toBe(true);
expect(isChunkLoadFailure(new Error("ordinary render defect"))).toBe(false);
});
it("runs the recovery input only for a lazy chunk rejection", async () => {
const recover = vi.fn(async () => ({
action: "support" as const,
reason: "reload-already-attempted",
}));
render(
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
<ChunkDefect />
</ChunkRecoveryBoundary>,
);
expect(
await screen.findByRole("heading", {
name: "화면 자산을 복구하지 못했습니다.",
}),
).toBeVisible();
expect(recover).toHaveBeenCalledOnce();
expect(recover).toHaveBeenCalledWith({
chunkId: "route-home",
failureKind: "CHUNK_LOAD_FAILURE",
});
});
it("rethrows an ordinary component defect to the local render boundary", () => {
const recover = vi.fn();
render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
<RenderDefect />
</ChunkRecoveryBoundary>
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(recover).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More