Compare commits

...
209 changed files with 12189 additions and 1899 deletions
+51 -1
View File
@@ -23,16 +23,66 @@ module.exports = {
from: { path: "^src/presentation/(?!adapters/query)" }, from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" }, to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
}, },
{
name: "page-templates-own-layout-only",
severity: "error",
from: { path: "^src/presentation/templates" },
to: {
path: "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)",
},
},
{
name: "icon-vendor-is-facade-only",
severity: "error",
from: {
path: "^src",
pathNot:
"^src/presentation/design-system/icons/vendors/lucide\\.tsx$",
},
to: { path: "^lucide-react$" },
},
{ {
name: "adapters-do-not-know-presentation", name: "adapters-do-not-know-presentation",
severity: "error", severity: "error",
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", name: "concrete-adapters-compose-only-in-bootstrap",
severity: "error", severity: "error",
from: { path: "^src/(domain|application|presentation|contracts|sample)" }, from: { path: "^src/(domain|application|presentation|contracts)" },
to: { path: "^src/adapters" }, to: { path: "^src/adapters" },
}, },
{ {
+1 -1
View File
@@ -15,4 +15,4 @@ M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Review the text-field error association and modal focus containment/restoration. Notes: Review form primitives, Menu/Tabs keyboard behavior, Toast announcements, Tooltip supplemental copy, text-field error association and modal focus containment/restoration.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_DETAIL accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_DETAIL
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_FORM accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_FORM
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -1,7 +1,7 @@
# SAMPLE_RESOURCE_LIST accessibility review # REFERENCE_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:
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_STATUS accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_STATUS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
+37 -8
View File
@@ -79,7 +79,13 @@
{ "script": "check:types:fixture:ts-result", "expect": "fail" }, { "script": "check:types:fixture:ts-result", "expect": "fail" },
{ "script": "check:types:fixture:application-output", "expect": "fail" }, { "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" }, { "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" } { "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "expect": "fail" },
{ "script": "check:types:fixture:page-action", "expect": "fail" },
{ "script": "check:types:fixture:icon-button", "expect": "fail" },
{ "script": "check:types:fixture:i18n-key", "expect": "fail" },
{ "script": "check:types:fixture:i18n-params", "expect": "fail" },
{ "script": "check:types:fixture:diagnostics", "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"],
@@ -108,9 +114,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": {
@@ -133,7 +145,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"
], ],
@@ -143,14 +155,28 @@
"name": "architecture", "name": "architecture",
"steps": [ "steps": [
{ "script": "check:architecture", "expect": "pass" }, { "script": "check:architecture", "expect": "pass" },
{ "script": "check:design-system", "expect": "pass" },
{ "script": "check:design-system:fixture", "expect": "fail" },
{ "script": "check:i18n", "expect": "pass" },
{ "script": "check:i18n:fixture", "expect": "fail" },
{ "script": "check:diagnostics", "expect": "pass" },
{ "script": "check:diagnostics:fixture", "expect": "fail" },
{ "script": "check:registries", "expect": "pass" }, { "script": "check:registries", "expect": "pass" },
{ "script": "check:registries:fixture", "expect": "fail" } { "script": "check:registries:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" }
], ],
"logPath": "artifacts/quality/gates/FE-GATE-010.txt", "logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": [ "evidence": [
"artifacts/quality/dependency-report.json", "artifacts/quality/dependency-report.json",
"artifacts/quality/design-system.json",
"artifacts/quality/design-system-fixture.json",
"artifacts/quality/i18n.json",
"artifacts/quality/i18n-fixture.json",
"artifacts/quality/diagnostics.json",
"artifacts/quality/diagnostics-fixture.json",
"artifacts/quality/registries.json", "artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json" "artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json"
], ],
"retentionClass": "merge-cycle" "retentionClass": "merge-cycle"
}, },
@@ -158,7 +184,10 @@
"name": "build", "name": "build",
"steps": [{ "script": "build", "expect": "pass" }], "steps": [{ "script": "build", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-011.txt", "logPath": "artifacts/quality/gates/FE-GATE-011.txt",
"evidence": ["artifacts/release/build-manifest.json"], "evidence": [
"artifacts/release/build-manifest.json",
"artifacts/release/runtime-config.schema.json"
],
"retentionClass": "release-coherence" "retentionClass": "release-coherence"
}, },
"FE-GATE-012": { "FE-GATE-012": {
@@ -250,7 +279,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"],
+75 -3
View File
@@ -3,9 +3,46 @@
"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", "ReferenceResourceParams"],
"searchSchema": [null, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"reference-resource-list",
"reference-resource-detail",
"reference-resource-form",
"reference-resource-status",
"none"
],
"errorSurface": [
"route-boundary",
"feature-boundary",
"not-found"
],
"chunkId": [
"route-home",
"route-examples-ui",
"route-examples-states",
"route-examples-auth",
"route-reference-resources",
"route-reference-resource-detail",
"route-reference-resource-form",
"route-reference-resource-status",
"route-not-found"
]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE-RUNTIME",
"targetField": "routeId"
}
],
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
"path", "path",
@@ -17,9 +54,44 @@
"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",
"reference-resource-detail-page",
"reference-resource-form-page",
"reference-resource-status-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat", "ReferenceResourceParams"],
"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": [
@@ -76,7 +148,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": [
+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,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 범위에 포함하지 않는다.
@@ -0,0 +1,55 @@
# VD-04: Native form controller와 local facade
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-form-page-platform`
## 배경
플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
field orchestration까지 플랫폼 기본값으로 고정하게 된다.
## 결정
1. RP-06은 React native form event와 controlled value를 사용하는 local
`useAppForm` facade를 기본 엔진으로 채택한다.
2. Zod presentation schema, application command mapper와 domain invariant는 서로
다른 소유물로 유지한다.
3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
`mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
4. 422 details는 승인된 `path``code`만 HTTP 경계에서 투영한다. backend
message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
error로 이동한다.
5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
dirty 상태를 해제한다.
7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`
layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
## React Hook Form 도입 조건
다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
React Hook Form과 Zod resolver를 평가한다.
- 동적 field array와 중첩 object를 함께 다루는 복합 form
- field 단위 비동기 validation 취소와 의존 validation
- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
- uncontrolled input 또는 vendor extension이 필요한 경우
도입하더라도 이 문서의 public API와 component/application tests를 유지해야
한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
## 검증과 rollback
- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
reset, dirty guard와 focus를 component test로 검증한다.
- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
- architecture gate가 template의 application/HTTP/query vendor import를
거절한다.
- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
application input과 outbound gateway 계약은 유지되며, form facade와 template
commit은 독립적으로 되돌릴 수 있다.
@@ -0,0 +1,57 @@
# VD-05: Semantic icon facade와 native-first interaction
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-design-system-platform`
- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
## 배경
앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
의존성과 중복 interaction 모델이 생긴다.
## 결정
1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
고정한다.
2. `lucide-react`의 static named import는
`design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
노출한다. vendor component type, icon name, stroke API와 dynamic icon
registry는 노출하지 않는다.
4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
5. 현재 복합 control은 native `dialog`, form control, `details`와 local
TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
Tabs는 manual/automatic activation, Drawer는 modal/background
비활성화/focus restore 계약을 가진다.
6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
collision, nested overlay, virtualized collection 또는 복합 select 요구를
충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
평가한다.
7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
주장하지 않는다.
## 경계와 검증
- 제품 코드는 `presentation/design-system/index`만 import한다.
- design-system 검사기는 direct icon/headless import, deep import, raw palette,
undefined token과 tooltip-only required information fixture를 거절한다.
- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
검증한다.
- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
restore, gallery keyboard interaction과 axe를 검증한다.
- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
## Rollback
기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
도입해도 public props와 interaction test를 유지한다.
@@ -0,0 +1,96 @@
# VD-06: Intl과 typed local message catalog
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract`
- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때
## 배경
공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와
JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로
가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패
정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차,
복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본
번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다.
## 결정
1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`,
`ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다.
2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke
catalog는 `satisfies Record<MessageKey, string>`으로 compile-time parity를
강제한다.
3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를
선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가
거절한다.
4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은
language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은
raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다.
5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`
RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지
않는다.
6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지
않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid
date/number/timezone은 `—`를 반환하고 throw하지 않는다.
7. locale state는 React inbound concern이다. `LocaleProvider`가 copy,
formatter와 `<html lang/dir>`을 제공하며 application/domain은 미리 번역된
문자열 대신 의미 값과 timestamp를 반환한다.
8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지
않는다. transport/application failure kind를 등록된 사용자 message key로
매핑한 뒤 presentation이 해석한다.
9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를
둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만
사용한다.
10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면
`presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와
design-system public prop으로 노출하지 않는다.
## 실행 경계
```text
route/form/failure 의미 값
-> presentation message key
-> LocaleProvider
-> typed catalog / Intl formatter
-> text node와 accessible name
```
- canonical catalog: `src/presentation/i18n/catalog.ts`
- feature contribution: `src/features/*/contracts/*-message-catalog.js`
`src/features/installed-feature-messages.js`에서 조립
- key/보간/fallback/alias: `message-contract.ts`
- locale-safe value formatting: `formatters.ts`
- React composition과 document metadata: `locale-provider.tsx`
- public entry: `src/presentation/i18n/index.ts`
## 검증
- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal,
backend message JSX 렌더링과 raw HTML 사용을 검사한다.
- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다.
- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다.
- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/
list/plural/select의 결정성을 검증한다.
- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination,
Drawer semantics를 검증한다.
- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를
Chromium, Firefox, WebKit project에서 실행한다.
## 한계와 재검토 조건
현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합,
메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중
하나가 확인되면 별도 ADR로 엔진을 재평가한다.
- 세 개 이상의 실제 승인 locale과 번역 담당 workflow
- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy
- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동
- SSR locale negotiation과 hydration 일치가 필요한 rendering mode
## Rollback
`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로
되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`,
`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative
fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다.
@@ -0,0 +1,97 @@
# VD-07: Diagnostics와 telemetry exporter 경계
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-diagnostics-telemetry-runtime`
- 재검토: 실제 운영 sink, consent가 필요한 analytics 또는 분산 tracing provider가
선정될 때
## 배경
기존 telemetry registry와 best-effort HTTP queue는 있었지만 운영 진단 record와
semantic event의 책임이 하나의 telemetry port에 섞여 있었다. boot, HTTP,
cache, storage, route와 release failure의 선언도 실제 production producer와
완전히 연결되지 않았다. 이 상태에서는 retry attempt마다 같은 사건을 발행하거나
raw URL, query, request body와 오류 객체가 queue에 들어갈 위험이 있다.
반면 skeleton 단계에는 실제 관측 vendor, endpoint의 운영 보안 정책, analytics
consent와 보존 기간이 결정되지 않았다. 특정 SDK를 기본 번들에 설치하는 것은
vendor 결정 전에는 안전한 기본값이 아니다.
## 결정
1. level 기반 운영 진단은 `DiagnosticsPort`, registry 기반 semantic event는
`TelemetryPort`로 분리한다. application은 두 port의 concrete adapter나
exporter SDK를 알지 못한다.
2. diagnostics의 level, event ID와 context key는 닫힌 registry/allowlist다.
telemetry도 event별 required/optional attribute와 value policy를 적용한다.
등록되지 않은 event·context·고카디널리티 값은 전송하지 않는다.
3. 기본 diagnostics adapter는 bounded in-memory evidence이고 telemetry는
설정이 없으면 true no-op이다. endpoint가 있을 때만 bounded oldest-drop
queue와 best-effort HTTP sink를 사용한다.
4. raw path/URL/query/body/response/storage value, credential, cookie, email,
stack과 오류 객체 전체는 context에 넣지 않는다. route ID, operation ID,
correlation ID, release ID, error kind, status/attempt/duration bucket만
허용한다.
5. HTTP logical execution은 success, retry recovery, terminal failure 또는
abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다.
`api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만
정확히 한 번 발행한다.
6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`,
`telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage
실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다.
7. queue full, invalid event/context, serialization과 sink failure는 제한된
reason bucket으로 집계한다. drop observer의 failure는 다시 telemetry를
발행하지 않는 nonrecursive 경계다.
8. diagnostics와 telemetry failure는 제품 흐름, HTTP 결과, route transition,
storage/cache fallback과 React error surface를 바꾸지 않는다.
9. mount 전 bootstrap failure는 안전한 build/config/error kind만 별도 evidence로
만들며 untrusted error message, stack과 support 입력을 serialize하지 않는다.
10. 실제 error reporter, RUM, analytics나 tracing SDK는 같은 port 뒤의 외부
adapter로만 추가한다. SDK type과 event API를 application/feature/presentation
public contract에 노출하지 않는다.
## 실행 경계
```text
route/application/HTTP/cache/storage/bootstrap
-> typed DiagnosticsPort 또는 TelemetryPort
-> registry + allowlist + value policy
-> bounded memory/no-op 또는 best-effort HTTP adapter
-> 프로젝트가 선택한 외부 sink
```
- diagnostics contract: `src/contracts/diagnostics.ts`
- telemetry contract: `src/contracts/telemetry.js`
- application ports: `src/application/ports/diagnostics-port.ts`,
`telemetry-port.ts`
- bounded diagnostics: `src/adapters/diagnostics/bounded-diagnostics.ts`
- best-effort telemetry: `src/adapters/telemetry/best-effort-telemetry.js`
- composition: `src/bootstrap/runtime-adapters.js`
## 검증
- `check:diagnostics`는 모든 registry event에 production producer가 있는지와
source의 direct console/sensitive context 우회를 검사한다.
- negative source fixture는 direct console, unknown event와 raw context를 실제로
거절하며 TypeScript fixture는 잘못된 level/event ID를 거절한다.
- unit test는 allowlist, hostile/circular error, bounded diagnostics, no-op,
queue full, sink/observer failure와 pre-mount boot evidence를 검증한다.
- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer
횟수, route/operation/correlation context와 요청 값 비노출을 검증한다.
- cache/storage/release/application/runtime test는 각 production wiring과
diagnostics failure isolation을 검증한다.
## 한계와 재검토 조건
기본 adapter는 운영 log 검색, source map 연계, session replay, distributed span,
analytics consent, sampling budget과 장기 보존을 제공하지 않는다. 실제 sink를
선정할 때 데이터 처리 지역, 보존 기간, consent, CSP, source map 접근 제어,
sampling과 비용 상한을 별도 결정해야 한다.
## Rollback
telemetry exporter는 runtime 설정을 끄거나 adapter wiring을 `noOpTelemetry`
바꾸어 독립적으로 제거할 수 있다. 이때도 `DiagnosticsPort`, registry,
redaction/value policy, producer-count와 negative fixture는 유지한다. 외부 SDK
문제로 application producer와 안전 계약을 함께 되돌리지 않는다.
@@ -12,9 +12,9 @@
- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다. - 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다.
- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다. - 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다.
검토 기준 브랜치는 `develop`, 기준 커밋은 `cb195f8`다. 이후 구현으로 경로나 최초 검토 기준 `develop` `cb195f8`며, RP-01~RP-08 구현 결과를 이 문서에
세부 내용이 달라질 수 있으므로, 각 항목은 문서의 경로뿐 아니라 해당 테스트와 누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은
아키텍처 게이트로 계속 검증해야 한다. 문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다.
## 2. 결론 ## 2. 결론
@@ -33,13 +33,14 @@
특히 다음은 선행 해결이 필요하다. 특히 다음은 선행 해결이 필요하다.
RP-01~RP-03에서 TypeScript 도구 안전망, application runtime 주입, RP-01~RP-09에서 TypeScript 도구 안전망, application runtime 주입,
query/mutation inbound adapter HTTP 실행 계약은 구현됐다. 현재 선행 해결 query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과
i18n 실행 경계와 diagnostics/telemetry production wiring은 구현됐다. 현재 선행 해결
대상은 다음과 같다. 대상은 다음과 같다.
1. 선언과 실행이 일치하는 typed route 계약 1. registry evidence와 실제 compatibility diff
2. 전체를 제거할 수 있는 실제 reference feature 2. 공급망과 optional adapter recipe 심화 게이트
3. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다. 더 정확한 표현은 다음과 같다.
@@ -65,27 +66,27 @@ query/mutation inbound adapter와 HTTP 실행 계약은 구현됐다. 현재 선
| 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | | 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 |
| application facade | 준비됨 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 | | application facade | 준비됨 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 |
| HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 | | HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 | | retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off, logical execution당 bounded diagnostics | terminal event 중복 방지 계약 유지 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | | 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 | | 검증 | 준비 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 부분 준비 | 제한된 query/mutation bridge와 lifecycle test | RP-05 reference route에서 실제 feature 연결 | | 서버 상태 | 준비 | 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 복원 | | 앱 셸·반응형 | 준비 | native modal Drawer, compact/desktop layout, Escape/link dismiss/focus restore, pseudo reflow와 RTL direction | compact browser matrix 유지 |
| 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 | | 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 | | 디자인 토큰 | 준비 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 |
| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 | | 공통 UI | 준비 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | Storybook/visual은 RP-10 |
| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 | | 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 |
| 폼 | 미제공 | 수동 문자열 검증 예제만 존재 | schema 기반 form facade와 422/dirty/pending 정책 | | 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | | 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | logging/diagnostics | 준비됨 | 별도 `DiagnosticsPort`, 8-event registry, allowlist, bounded/no-op adapter와 production producer | 실제 프로젝트의 remote sink는 port 뒤에서 선택 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | | telemetry | 준비됨/프로젝트 선택 | 5-event registry, bounded queue, redaction/value policy, boot·HTTP·render·release·drop producer | analytics/RUM/error vendor와 consent는 프로젝트에서 선택 |
| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 | | 비동기 상태 불변식 | 준비됨 | 배타적 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와 선택 기준 제공 |
@@ -156,14 +157,16 @@ client를 거대한 범용 함수로 계속 확장하지 말고 transport, reque
timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP
메서드보다 feature가 요구하는 gateway interface를 노출한다. 메서드보다 feature가 요구하는 gateway interface를 노출한다.
#### route registry 실행 계약이 아니다 #### RP-04에서 route registry 실행 계약으로 전환
route registry에는 `paramsSchema`, `searchSchema`, `loadingSurface`, 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>`로 양방향 완전성을 검사한다.
@@ -171,19 +174,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 수직 테스트가 실행된다.
#### 비동기·복구 상태의 불변식이 닫혀 있지 않다 #### 비동기·복구 상태의 불변식이 닫혀 있지 않다
@@ -204,15 +211,20 @@ 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의 실행 깊이가 부족하다 #### RP-09에서 diagnostics/telemetry 실행 깊이 보강
telemetry registry에는 여러 사건이 있지만 실제 production producer는 제한적이다. `DiagnosticsPort``TelemetryPort`를 분리하고 boot, HTTP logical outcome,
boot, API attempt/final failure, auth recovery, storage/cache degradation, release/chunk render, cache, storage, route, release mismatch와 delivery drop을 production
recovery를 registry 사건에 연결해야 한다. producer에 연결했다. HTTP retry는 attempt별 terminal event를 발행하지 않고
logical execution 종료 시 한 번만 bounded outcome을 남긴다. allowlist와
value policy가 raw URL/query/body/storage value/error object를 거절하고 queue와
sink failure는 nonrecursive drop evidence로 제한된다.
registry/compatibility 검사는 다음까지 확장한다. registry/compatibility 검사는 다음까지 확장한다.
@@ -228,13 +240,13 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
### 5.2 P1: 공통 플랫폼 기본 제공 항목 ### 5.2 P1: 공통 플랫폼 기본 제공 항목
- schema 기반 form facade와 field/error/pending/dirty/422 정책 - RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지
- standard, collection, detail, form, status page template - RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리
- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive - 접근 가능한 drawer, menu, popover, select 같은 interaction primitive
- token → primitive → pattern → template로 이어지는 디자인 시스템 - token → primitive → pattern → template로 이어지는 디자인 시스템
- Lucide를 감싼 local icon registry와 `IconButton` - Lucide를 감싼 local icon registry와 `IconButton`
- typed message key, locale provider, formatter, pseudo-locale/RTL smoke - typed message key, locale provider, formatter, pseudo-locale/RTL smoke
- redacted structured diagnostics/logger와 telemetry wiring - RP-09에서 완료한 redacted structured diagnostics와 telemetry wiring 유지
- Storybook 또는 동급 isolated UI workshop - Storybook 또는 동급 isolated UI workshop
- Playwright visual baseline, shared MSW scenarios, built-dist E2E - Playwright visual baseline, shared MSW scenarios, built-dist E2E
- React Hooks, JSX accessibility, TanStack Query 관련 lint - React Hooks, JSX accessibility, TanStack Query 관련 lint
@@ -242,6 +254,24 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
- registry/compatibility의 실제 diff와 orphan reference 검사 - registry/compatibility의 실제 diff와 orphan reference 검사
- transitive vulnerability, license, SBOM/provenance 공급망 gate - transitive vulnerability, license, SBOM/provenance 공급망 gate
#### RP-08에서 국제화 실행 경계 구현
`src/presentation/i18n`은 shell, route, async/form error, page template와
design-system 기본 copy의 canonical 경계다. `MessageKey`와 key별
`MessageParameters`가 잘못된 key/보간을 compile time에 막고, runtime
`resolveMessage`는 unknown locale/key와 누락 보간에서 raw 값 대신 안전한
fallback을 반환한다. application mapper는 locale-formatted date를 반환하지
않고 timestamp를 유지하며 presentation formatter가 `UTC` 또는 명시 timezone을
적용한다.
`LocaleProvider``ko-KR`, `en-US`, `en-XA`, `ar-EG` smoke set과 document
`lang/dir`을 동기화한다. `en-XA`는 긴 문구 reflow, `ar-EG`는 logical CSS,
Drawer, Tabs arrow와 pagination 방향 icon을 검증하기 위한 개발 locale이다.
실제 아랍어 번역 완료를 뜻하지 않는다. `check:i18n`과 negative fixture는 common
UI literal, backend raw message render와 raw HTML interpolation을 거절한다.
새 key rename은 canonical type에는 넣지 않고 runtime alias/migration window로
호환한다.
### 5.3 P2: 경계와 recipe를 제공할 선택 항목 ### 5.3 P2: 경계와 recipe를 제공할 선택 항목
다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local 다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local
@@ -353,7 +383,8 @@ bootstrap → React TSX → tests 순서로 이동한다.
- retry: `src/adapters/http/retry-policy.js`, 부분 준비 - retry: `src/adapters/http/retry-policy.js`, 부분 준비
- API client: `src/adapters/http/client.js`, 부분 준비 - API client: `src/adapters/http/client.js`, 부분 준비
- logger: 없음. telemetry와 분리하거나 diagnostics port로 합치는 결정 필요 - logger: `DiagnosticsPort`로 telemetry와 분리해 구현. closed event/level,
allowlist와 bounded/no-op adapter 제공
- token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유 - token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유
- error: `src/contracts/errors.js`와 HTTP normalization, 부분 준비 - error: `src/contracts/errors.js`와 HTTP normalization, 부분 준비
- validation: runtime/API Zod는 존재, route/form/domain 분리는 미완성 - validation: runtime/API Zod는 존재, route/form/domain 분리는 미완성
@@ -378,7 +409,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
@@ -508,6 +508,22 @@ RP-06은 기존 reference controls/layout으로 돌아가도 controller/applicat
경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해 경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해
부분 revert가 가능하게 한다. 부분 revert가 가능하게 한다.
**구현 증거 (2026-07-26)**
- VD-04에서 dependency 추가 없는 native controller + Zod local facade를
채택했고 vendor 도입 조건을 문서화했다.
- `src/presentation/forms`가 field registration, parse/error map,
dirty/touched/reset/pending/result, 422 allowlist, first-error focus,
duplicate submit과 dirty navigation을 제공한다.
- `src/presentation/templates`가 다섯 page 유형의 slot/landmark/responsive
계약을 제공하며 architecture negative fixture가 application/vendor import를
거절한다.
- reference list/detail/create/status route가 네 구체 template를 사용하고
production composition test가 list → form → command → HTTP → invalidation →
list 경로를 실행한다.
- component/integration/E2E test가 validation, transform, 422, conflict,
secret 비노출, navigation focus와 320px reflow를 검증한다.
### 07. `feature-frontend-design-system-platform` ### 07. `feature-frontend-design-system-platform`
**목표** **목표**
@@ -572,6 +588,22 @@ RP-07은 compatibility export로 기존 primitive import를 복구할 수 있어
token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을 token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을
분리한다. 분리한다.
**구현 증거 (2026-07-26)**
- VD-05에서 `lucide-react@1.25.0` static semantic facade와 native-first
interaction을 채택하고 headless vendor 재평가 조건을 닫았다.
- `src/presentation/design-system`이 48개 필수 token, public TypeScript barrel,
action/form/feedback/overlay/navigation primitive와 공통 pattern을 제공한다.
- 기존 `components/ui` 경로는 compatibility export로 유지하고 앱 셸, gallery와
reference feature는 public entry를 소비한다.
- 모바일 navigation은 native modal Drawer로 전환되어 배경 비활성화, Escape,
route dismiss와 trigger focus restore를 제공한다.
- source/negative fixture gate가 undefined token, raw palette, direct vendor,
deep import, tooltip-only 정보와 accessible name 누락을 거절한다.
- component/browser test가 Menu typeahead, Tabs activation, Toast queue,
form controls, compact reflow와 open-dialog axe를 실행한다. 로컬 WebKit은 host
`libevent-2.1.so.7` 부재로 환경 검증 상태를 유지한다.
### 08. `feature-frontend-i18n-message-formatting-contract` ### 08. `feature-frontend-i18n-message-formatting-contract`
**목표** **목표**
@@ -619,6 +651,26 @@ token rename은 alias/migration 기간을 두고, vendor adapter와 local API co
RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를 RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를
파괴적으로 덮어쓰지 않는다. 파괴적으로 덮어쓰지 않는다.
**구현 증거 (2026-07-26)**
- VD-06에서 dependency를 추가하지 않는 browser `Intl` + typed local catalog를
채택하고 vendor 재평가 조건, fallback과 key migration 정책을 문서화했다.
- `src/presentation/i18n`이 137개 common key, key별 interpolation,
`ko-KR` fallback, compatibility alias, date/number/relative/list/plural/select
formatter와 `LocaleProvider`를 제공한다.
- shell, route lifecycle/access/recovery, async/form error, page template와
design-system default copy가 catalog consumer로 연결됐다.
- feature route copy는 feature-owned catalog contribution으로 분리되어 reference
feature 제거 시 source와 built artifact에 전용 message key가 남지 않는다.
- reference application mapper는 locale-formatted date 대신 timestamp를
반환하고 presentation formatter가 UTC 또는 명시 timezone을 적용한다.
- `check:i18n`과 type negative fixture가 catalog/placeholder 불일치,
hardcoded common literal, backend raw message render, unsafe HTML,
unknown key와 interpolation mismatch를 거절한다.
- unit/component/browser test가 safe fallback, pseudo 320px reflow,
document `lang/dir`, RTL Drawer/Tabs/directional icon과 deterministic formatter를
검증한다.
### 09. `feature-frontend-diagnostics-telemetry-runtime` ### 09. `feature-frontend-diagnostics-telemetry-runtime`
**목표** **목표**
@@ -671,6 +723,28 @@ render와 release 사건을 실제 producer에 연결한다.
RP-09는 exporter를 제거하고 즉시 no-op adapter로 전환할 수 있어야 한다. RP-09는 exporter를 제거하고 즉시 no-op adapter로 전환할 수 있어야 한다.
diagnostics port와 redaction test는 유지한다. diagnostics port와 redaction test는 유지한다.
**구현 증거 (2026-07-26)**
- VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic `TelemetryPort`
분리하고 bounded memory/no-op 또는 설정 기반 best-effort HTTP exporter를
채택했다.
- diagnostics 8종과 telemetry 5종의 registry, context/attribute allowlist,
고카디널리티 value policy, timestamp와 status/attempt/duration/queue bucket을
구현했다.
- boot, route, render, HTTP logical outcome, cache, storage, release mismatch와
telemetry drop을 production composition에 연결했다. HTTP는 success/recovery/
terminal/abort 각각 logical execution당 diagnostics 한 번, terminal
non-abort failure telemetry 한 번만 발행한다.
- bounded oldest-drop queue, drop reason 집계, sink/observer failure 격리와
nonrecursive delivery evidence를 구현했다. exporter가 없으면 network와 queue
side effect가 없는 true no-op이다.
- `check:diagnostics`, source negative fixture와 TypeScript negative fixture가
producer 누락, direct console, unknown event/context, raw URL/query/body/
credential 경계를 거절한다.
- unit/integration test가 circular/hostile error, pre-mount boot, queue/sink
failure, no-op, cache/storage/release producer, success/retry recovery/terminal/
abort 횟수와 reference route/operation/correlation context를 검증한다.
### 10. `feature-frontend-test-registry-evidence-hardening` ### 10. `feature-frontend-test-registry-evidence-hardening`
**목표** **목표**
@@ -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 유지 |
현재 구조가 잘 제공하는 기반은 다음과 같다. 현재 구조가 잘 제공하는 기반은 다음과 같다.
@@ -154,13 +154,38 @@ RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다.
- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는 - 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을 거절한다.
1. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/
`errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다. contract/presentation은 `src/features/reference-feature`가 소유하고, generic
2. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
operation, Zod schema, mapper, domain model과 query key는 다른 경로에 삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
남는다.
RP-06에서 inbound form/page 경계도 실행됐다. `src/presentation/forms`는 Zod
presentation schema, controlled field state, error focus, 422 allowlist,
pending/deduplication과 dirty navigation을 local facade로 감싼다.
`src/presentation/templates`는 slot과 landmark만 소유하고 application, HTTP,
query vendor import는 architecture gate가 거절한다. reference feature의
list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
실제 consumer다.
RP-07에서 React inbound adapter 안의 UI 공급자 경계도 닫혔다.
`src/presentation/design-system/index.ts`는 token → primitive → pattern →
template public API이며 feature와 shell은 이 entry만 소비한다. Lucide는
`icons/vendors/lucide.tsx`에 격리된 inbound vendor facade이므로 application
port가 아니다. native Dialog/Drawer/Menu/Tabs의 focus·keyboard 상태도
presentation이 소유하고 use case나 outbound adapter로 올리지 않는다.
RP-08에서 i18n은 application output port가 아니라 React inbound adapter의
local facade로 확정됐다. domain/application은 locale이나 번역 문장을 알지 않고
timestamp, number, failure kind 같은 의미 값만 반환한다.
`src/presentation/i18n`이 typed message catalog, formatter, fallback,
`<html lang/dir>`과 pseudo/RTL smoke를 소유한다. backend raw `message`
application failure registry를 우회해 렌더링할 수 없으며 `check:i18n` negative
fixture가 이 경계를 집행한다. 번역 vendor를 나중에 선택해도 이 facade 뒤의
adapter만 교체한다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다. 불일치를 제거하는 것이다.
@@ -754,22 +779,31 @@ Logger와 telemetry는 같은 것이 아니다.
- Telemetry: registry에 정의된 semantic event와 metric - Telemetry: registry에 정의된 semantic event와 metric
- Error reporter: 예외 집계와 release correlation - Error reporter: 예외 집계와 release correlation
기본 `Logger` output port는 safe context만 받는다. VD-07에 따라 기본 구현은 임의 message 문자열을 받는 `Logger`가 아니라 닫힌
event ID와 safe context만 받는 `DiagnosticsPort`다.
```ts ```ts
export interface Logger { export interface DiagnosticsPort {
debug(message: string, context?: SafeLogContext): void; record(input: {
info(message: string, context?: SafeLogContext): void; level: DiagnosticLevel;
warn(message: string, context?: SafeLogContext): void; eventId: DiagnosticEventId;
error(message: string, context?: SafeLogContext): void; context?: DiagnosticContext;
}): void;
} }
``` ```
개발 환경에는 redacted console adapter, production에는 allowlist 기반 기본 runtime에는 bounded in-memory diagnostics와 설정 기반 best-effort
remote adapter, 테스트에는 recording 또는 no-op adapter를 연결한다. telemetry adapter, 테스트에는 recording 또는 no-op adapter를 연결한다.
direct `console`은 redaction 경계를 우회하므로 source gate가 거절한다.
민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서 민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서
강제한다. 강제한다.
현재 production producer는 boot, logical HTTP outcome, cache, storage, route,
render, release mismatch와 telemetry delivery drop을 포함한다. HTTP terminal
telemetry는 모든 retry가 끝난 뒤 한 번만 발행하며 abort에는 발행하지 않는다.
raw path/query/body/error 대신 route/operation/correlation ID와
status/attempt/duration bucket만 전달한다.
## 16. Feature 경계와 removable reference feature ## 16. Feature 경계와 removable reference feature
Reference feature는 단순 UI fixture가 아니라 다음 경로를 모두 실행해야 Reference feature는 단순 UI fixture가 아니라 다음 경로를 모두 실행해야
@@ -1198,9 +1232,10 @@ contract와 실패 분기를 우선한다.
- [ ] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다. - [ ] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다.
- [ ] QueryClient와 feature query bridge가 실제 route에서 동작한다. - [ ] QueryClient와 feature query bridge가 실제 route에서 동작한다.
- [ ] session UI API와 credential attachment가 분리되어 있다. - [ ] session UI API와 credential attachment가 분리되어 있다.
- [ ] logger와 telemetry가 HTTP/render/storage/cache failure 경로에 - [x] diagnostics와 telemetry가 HTTP/render/storage/cache failure 경로에
연결된다. 연결된다.
- [ ] page lifecycle에서 필요한 telemetry flush/cleanup이 수행된다. - [x] `pagehide`에서 bounded telemetry queue를 flush하고 adapter `dispose`
lifecycle listener를 정리한다.
### 26.3 HTTP와 validation ### 26.3 HTTP와 validation
@@ -1212,19 +1247,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 품질
@@ -1248,8 +1283,8 @@ contract와 실패 분기를 우선한다.
### P1: 기본 플랫폼 완성도 ### P1: 기본 플랫폼 완성도
1. error/result/validation/form kernel 1. 완료: error/result/validation/form kernel
2. 배타적인 async 상태와 page template 2. 완료: 배타적인 async 상태와 page template
3. Logger와 telemetry 실제 wiring 3. Logger와 telemetry 실제 wiring
4. design-system public API, icon wrapper와 headless interaction 4. design-system public API, icon wrapper와 headless interaction
5. locale/message/formatter와 pseudo-locale/RTL 경계 5. locale/message/formatter와 pseudo-locale/RTL 경계
+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:
+37 -24
View File
@@ -12,30 +12,36 @@
- page controller와 application input use case의 연결 - page controller와 application input use case의 연결
- 프론트엔드에서 반복 사용하는 설계 패턴 - 프론트엔드에서 반복 사용하는 설계 패턴
## 2. 현재 상태와 문제 ## 2. 현재 상태와 구현 기준
현재 구현에는 다음 장점이 있다. RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다.
- 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/presentation/templates`가 standard/collection/detail/form/status slot,
landmark와 responsive layout을 제공한다.
- `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping,
duplicate submit과 dirty route blocker를 소유한다.
- reference feature의 list/detail/create/status route가 네 template variation을
production composition에서 실행한다.
하지만 `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는
`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite
manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
- params/search schema template는 데이터를 가져오지 않는다. reference page controller가 route input과
- loading/error surface application input을 query/form facade에 연결하고, template에는 render할 slot과
- chunk ID 안전한 callback만 전달한다. 이 분리는
- route title/navigation label `page-templates-own-layout-only` dependency rule과 forbidden import fixture가
- redirect loop guard 검증한다.
- chunk recovery policy
페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록,
상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시
구현해야 한다.
## 3. React Router mode 결정 ## 3. React Router mode 결정
@@ -50,15 +56,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 +73,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
@@ -441,6 +447,13 @@ controller가 소유하지 않는 것:
| Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 | | Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 |
| Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 | | Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 |
RP-08 이후 route contract의 `title`/`navigation` 필드는 fallback metadata이며
실제 document title, navigation, loading/error/access surface는
`route.<ROUTE_ID>.title|navigation` typed catalog key를 해석한다. route params,
search, backend message를 translation key로 조립하지 않는다. locale 변경은
현재 route를 재요청하거나 query key를 바꾸지 않고 document title과 화면 copy만
다시 렌더링한다.
패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative 패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative
architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다. architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다.
+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.
@@ -468,27 +468,65 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log
### 7.3 diagnostics와 telemetry ### 7.3 diagnostics와 telemetry
현재 telemetry event contract와 별도로 개발·진단용 structured logger가 필요하다. VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic
다음 두 설계 중 하나를 ADR로 결정한다. `TelemetryPort`를 분리했다. diagnostics는 8개 event ID와 safe context
allowlist를, telemetry는 event별 required/optional attribute와 value policy를
1. `DiagnosticsPort`가 log/event/span을 내부 method로 구분 사용한다.
2. `LoggerPort``TelemetryPort`를 분리
공통 요구: 공통 요구:
- log level과 event key는 닫힌 union - log level과 event key는 닫힌 union
- attribute allowlist와 중앙 redaction - attribute allowlist와 중앙 redaction
- dev adapter는 console을 사용하되 동일 redaction 적용 - 기본 adapter는 bounded memory/no-op이고 endpoint가 있을 때만 best-effort
- production adapter는 provider SDK를 감싸며 앱 코드는 SDK를 import하지 않음 HTTP queue를 사용
- production provider SDK를 추가할 때도 port 뒤에서 감싸며 앱 코드는 SDK를
import하지 않음
- 오류 객체 전체를 그대로 serialize하지 않음 - 오류 객체 전체를 그대로 serialize하지 않음
- trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결 - trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결
- logging failure가 제품 flow를 실패시키지 않음 - logging failure가 제품 flow를 실패시키지 않음
- consent가 필요한 analytics와 essential diagnostics를 분리 - consent가 필요한 analytics와 essential diagnostics를 분리
HTTP는 route/operation/correlation ID를 logical execution context로 생성하고
success/recovered/failed/aborted 종료 시 diagnostics를 한 번만 기록한다.
terminal non-abort failure만 telemetry를 한 번 발행한다. cache/storage와 boot
producer는 raw key, value, message, stack을 버리고 error kind와 bounded
operation만 남긴다. queue full과 sink failure는 제한된 reason bucket이며 drop
observer가 실패해도 재귀 발행하지 않는다.
### 7.4 locale, message와 표시 값
RP-08부터 locale은 presentation-owned React context다. server/application
state에 번역된 문자열을 저장하거나 query key에 locale을 넣는 것은 응답 자체가
locale별 데이터인 경우에만 허용한다. 공통 UI copy 변경 때문에 query cache를
복제하지 않는다.
```text
API timestamp/number/failure kind
-> schema + mapper (의미 값 유지)
-> application result
-> presentation controller
-> useLocale().date/number/message
```
- message key는 `MessageKey` union이며 interpolation은 key별 tuple type이다.
- unknown external key는 `resolveMessage`에 전달해도 raw key가 표시되지 않는다.
- backend `message`는 diagnostic input일 수 있지만 사용자 copy가 아니다.
- form validation code는 `ParameterlessMessageKey` allowlist로 mapping한다.
- date의 기본 timezone은 UTC이고 제품 timezone은 presentation 호출자가
명시한다.
- pseudo/RTL locale state는 local interaction state이며 persistence와 server
synchronization을 기본 제공하지 않는다.
- key rename은 typed canonical key를 먼저 이동하고 runtime alias에 migration
기간을 둔다.
## 8. 폼 표준 ## 8. 폼 표준
form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local facade 뒤에 VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며
둔다. vendor 선택과 무관하게 다음 API를 제공한다. Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation,
대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook
Form 또는 TanStack Form adapter를 평가한다.
현재 public API는 다음과 같다.
- `Form` - `Form`
- `FormField` - `FormField`
@@ -500,6 +538,18 @@ form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local fa
- `mapValidationFailureToFields` - `mapValidationFailureToFields`
- `useDirtyNavigationGuard` - `useDirtyNavigationGuard`
구현 위치:
- controller와 mapping: `src/presentation/forms`
- layout-only template: `src/presentation/templates`
- feature form schema/command mapper:
`src/features/reference-feature/presentation/reference-resource-form.ts`
- 실제 create page:
`src/features/reference-feature/presentation/reference-resource-form-page.tsx`
`ApiFailure.validationIssues`는 HTTP 경계가 투영한 `path``code`만 담는다.
backend message와 알 수 없는 path는 field copy로 사용하지 않는다.
필수 동작: 필수 동작:
1. label, description, error를 stable ID와 `aria-describedby`로 연결 1. label, description, error를 stable ID와 `aria-describedby`로 연결
@@ -568,4 +618,5 @@ form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local fa
- 상태 종류별 소유권이 테스트와 문서에서 확인된다. - 상태 종류별 소유권이 테스트와 문서에서 확인된다.
- token은 UI와 일반 storage/store에 노출되지 않는다. - token은 UI와 일반 storage/store에 노출되지 않는다.
- failure와 validation의 각 계층이 typed mapper로 분리된다. - failure와 validation의 각 계층이 typed mapper로 분리된다.
- common UI copy, locale formatter와 direction이 typed i18n facade를 통과한다.
- query/mutation/form recipe만으로 새 기능을 만들 수 있다. - query/mutation/form recipe만으로 새 기능을 만들 수 있다.
+32 -16
View File
@@ -34,6 +34,10 @@
- 초기 로딩, 빈 화면, terminal error, background 상태 - 초기 로딩, 빈 화면, terminal error, background 상태
- `src/presentation/components/state-surfaces.jsx` - `src/presentation/components/state-surfaces.jsx`
- 인증 필요, 권한 없음, 찾을 수 없음 - 인증 필요, 권한 없음, 찾을 수 없음
- `src/presentation/forms`
- local form facade, field/error summary, dirty navigation dialog
- `src/presentation/templates`
- Standard, Collection, Detail, Form, Status page template
- `/examples/ui`, `/examples/states` - `/examples/ui`, `/examples/states`
- 실행 가능한 primitive와 상태 예제 - 실행 가능한 primitive와 상태 예제
- component/E2E/axe 테스트 - component/E2E/axe 테스트
@@ -43,23 +47,23 @@
- Chromium, Firefox, WebKit - Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사 - 등록 라우트의 자동 접근성 검사
이 기반은 유효하지만 아직 디자인 시스템 플랫폼 전체는 아니다. RP-07 구현 이후 이 기반은 저장소 내부 디자인 시스템 플랫폼 계약을 충족한다.
아래 목록은 구현 전 공백과 현재 해결 상태를 함께 보존한다.
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size, 1. typography, elevation, motion, z-layer, control size와 breakpoint는
breakpoint가 계약으로 닫혀 있지 않다. `design-system/tokens`의 3계층과 자동 gate로 닫혔다.
2. 공통 입력은 `TextField` 하나뿐이어서 일반적인 폼을 공통 규칙으로 만들 수 2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
다. RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
3. 앱 셸 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크 3. 앱 셸, 예제와 reference feature는 public design-system barrel을 소비한다.
class를 다시 작성하는 곳이 있다. 4. 문자 glyph는 semantic Lucide facade로 교체됐다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다. 5. token은 세 CSS 파일로 분리됐고 `theme.css`는 layout/component styling만
5. `theme.css` 하나가 token, layout, primitive, pattern 스타일을 모두 소유한다. 소유한다.
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이 6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다. 없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다. 7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
8. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage` 같은 공통 페이지 8. page template, form과 pattern은 public barrel에서 제공된다.
템플릿이 없다. 9. 모바일 navigation은 native modal Drawer로 focus 이동, 배경 비활성화,
9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus Escape/link dismiss와 trigger focus restore를 제공한다.
복원, 배경 비활성화 계약은 없다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다. 따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
@@ -530,8 +534,9 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
## 9. 국제화 계약 ## 9. 국제화 계약
디자인 시스템 primitive는 한국어 문구를 내부 기본값으로 숨기지 않는다. RP-08에서 디자인 시스템 primitive의 닫기, alert, toast, 글자 수 같은 기본
접근성 label이나 오류 문구가 필요하면 명시적인 prop 또는 message key를 받는다. 문구는 `useLocale()` typed catalog로 이동했다. 제품 의미를 가진 label은 여전히
명시적인 prop으로 받으며 primitive가 feature message key를 소유하지 않는다.
필수 국제화 기반: 필수 국제화 기반:
@@ -547,6 +552,17 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
- pseudo-locale - pseudo-locale
- RTL story와 E2E smoke - RTL story와 E2E smoke
현재 구현 위치와 실패 정책:
- `presentation/i18n/catalog.ts`: 137개 canonical common key와 locale parity
- `message-contract.ts`: key별 interpolation, fallback과 compatibility alias
- `formatters.ts`: UTC-default date, number, relative time, list, plural/select
- `locale-provider.tsx`: document `lang/dir`과 React consumer API
- missing key/parameter와 formatter 예외: 빈 문자열이나 raw key 대신 안전한
fallback
- `en-XA`: 긴 문자열/320px reflow, `ar-EG`: logical layout/RTL keyboard smoke
- backend raw message와 translated HTML: 정적 negative gate에서 거절
금지 패턴: 금지 패턴:
- 번역 문장 중간에 JSX 문자열을 연결한다. - 번역 문장 중간에 JSX 문자열을 연결한다.
@@ -555,7 +571,7 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
- route title, navigation label, toast message를 JSX literal로 분산한다. - route title, navigation label, toast message를 JSX literal로 분산한다.
- 번역 누락 시 빈 문자열을 렌더링한다. - 번역 누락 시 빈 문자열을 렌더링한다.
Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다. RP-10의 Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다.
- `ko-KR`, light - `ko-KR`, light
- `en-US`, light - `en-US`, light
+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
@@ -370,10 +370,9 @@ QueryClientProvider
-> AppShell -> AppShell
``` ```
04 routing branch에서 Data Mode로 전환할 때 `BrowserRouter` RP-04에서 `RouterProvider` 기반 Data Mode로 전환했다. router component test와
`RouterProvider`로 바꾸고 테스트 fixture도 같은 composition factory에서 runtime composition test는 production `AppRouter` composition 함수를 사용하며,
생성한다. 문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 말고 문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 않는다.
production composition 함수를 호출한다.
### 6.3 Boot E2E ### 6.3 Boot E2E
@@ -411,6 +410,8 @@ Page / AsyncSurface / Form pattern
- filter 순서가 달라도 canonical key가 같다. - filter 순서가 달라도 canonical key가 같다.
- route unmount 또는 superseded input에서 request를 abort한다. - route unmount 또는 superseded input에서 request를 abort한다.
- aborted request는 terminal error나 telemetry failure로 오분류되지 않는다. - aborted request는 terminal error나 telemetry failure로 오분류되지 않는다.
- success와 retry recovery는 terminal failure telemetry를 만들지 않고,
exhausted retry는 logical execution당 한 번만 발행한다.
- offline/paused와 loading을 구분한다. - offline/paused와 loading을 구분한다.
- 401 복구는 한 번만 수행한다. - 401 복구는 한 번만 수행한다.
- 재로그인 후 허용된 operation만 다시 실행한다. - 재로그인 후 허용된 operation만 다시 실행한다.
@@ -447,6 +448,24 @@ 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개를 다시 검증한다.
RP-06 form/page matrix는 `tests/component/form-foundation.test.tsx`,
`tests/component/page-templates.test.tsx`,
`tests/features/reference-feature/reference-page.test.tsx`
`tests/e2e/reference-form.spec.js`에 있다. client validation에서 command 0회와
첫 오류 focus, Zod transform/default, pending 중 중복 제출, 승인된 422
field/unknown field mapping, conflict 입력 보존, reset/dirty, navigation
confirmation/focus restore, URL/storage 비노출과 320px reflow를 검증한다.
HTTP integration test는 backend copy를 버리고 422 `path`/`code`만 전달하는지
별도로 검사한다.
## 8. Form 테스트 ## 8. Form 테스트
Form test는 field primitive, form controller, application command mapping을 Form test는 field primitive, form controller, application command mapping을
@@ -545,6 +564,30 @@ client route guard는 UX이며 authorization이 아님을 test 이름과 문서
공용 컴포넌트는 한 번의 결함이 모든 페이지로 전파되므로 위험도가 높다. 공용 컴포넌트는 한 번의 결함이 모든 페이지로 전파되므로 위험도가 높다.
RP-07의 실행 경로는 `check:design-system`,
`check:design-system:fixture`, `check:types:fixture:icon-button`,
`tests/component/design-system-platform.test.tsx`
`tests/e2e/design-system-interactions.spec.js`다. Story interaction/visual
baseline은 VD-08/RP-10에서 추가하며 현재 runtime gallery를 isolated workshop
완료 증거로 사용하지 않는다.
RP-08의 국제화 실행 경로는 `check:i18n`, `check:i18n:fixture`,
`check:types:fixture:i18n-key`, `check:types:fixture:i18n-params`,
`tests/unit/i18n-contract.test.ts`, `tests/component/locale-platform.test.tsx`
`tests/e2e/i18n.spec.js`다.
- unit: locale catalog/placeholder parity, safe fallback/alias, pseudo expansion,
direction과 UTC date/number/relative/list/plural/select
- component: document `lang/dir`, RTL tab arrow semantics, direction-aware
pagination과 modal Drawer
- E2E: 320px pseudo reflow와 compact RTL shell/Drawer/focus restore
- negative: common UI locale literal, backend message JSX render, raw translated
HTML, unknown key와 잘못된 interpolation parameter
`ar-EG` catalog가 영어 smoke copy를 재사용하는 것은 RTL behavior 검증용이며
번역 품질 PASS가 아니다. 실제 locale catalog에는 언어 담당 승인 evidence가
추가되어야 한다.
### 10.1 Component behavior ### 10.1 Component behavior
- native role/name/value - native role/name/value
@@ -1242,7 +1285,8 @@ CI registry에 추가한다.
- [ ] high-risk module branch 목표를 충족한다. - [ ] high-risk module branch 목표를 충족한다.
- [ ] 신규 코드의 미검증 branch에 승인 없는 예외가 없다. - [ ] 신규 코드의 미검증 branch에 승인 없는 예외가 없다.
- [ ] bundle/performance budget을 통과한다. - [ ] bundle/performance budget을 통과한다.
- [ ] telemetry/error output에 민감 정보가 없다. - [x] diagnostics/telemetry output의 allowlist, value policy와 negative fixture가
raw URL/query/body/storage value/error object를 거절한다.
- [ ] 관련 gate와 evidence registry가 갱신되었다. - [ ] 관련 gate와 evidence registry가 갱신되었다.
## 19. 금지 패턴 ## 19. 금지 패턴
@@ -1273,7 +1317,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을 분리한다.
+89
View File
@@ -76,6 +76,8 @@ export default [
"artifacts/**", "artifacts/**",
"tests/fixtures/typecheck/**", "tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**", "tests/fixtures/architecture/forbidden/**",
"tests/fixtures/diagnostics/forbidden/**",
"tests/fixtures/i18n/forbidden/**",
"tests/fixtures/security/forbidden/**", "tests/fixtures/security/forbidden/**",
], ],
}, },
@@ -115,6 +117,25 @@ export default [
"no-unused-vars": "off", "no-unused-vars": "off",
}, },
}, },
{
files: ["**/*.d.ts"],
languageOptions: {
...commonLanguageOptions,
parser: babelParser,
parserOptions: {
requireConfigFile: false,
babelOptions: {
plugins: [
["@babel/plugin-syntax-typescript", { dts: true }],
],
},
},
},
rules: {
"no-undef": "off",
"no-unused-vars": "off",
},
},
{ {
files: ["**/*.tsx"], files: ["**/*.tsx"],
languageOptions: { languageOptions: {
@@ -190,12 +211,79 @@ export default [
]), ]),
}, },
}, },
{
files: [`src/presentation/templates/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/application/**",
"**/adapters/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{ {
files: [`src/adapters/**/*.${sourceExtensions}`], files: [`src/adapters/**/*.${sourceExtensions}`],
rules: { rules: {
"no-restricted-imports": restrictedImports(layerPatterns.adapters), "no-restricted-imports": restrictedImports(layerPatterns.adapters),
}, },
}, },
{
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}`], files: [`tests/**/*.${sourceExtensions}`],
languageOptions: { languageOptions: {
@@ -216,6 +304,7 @@ export default [
"@tanstack/**", "@tanstack/**",
"react", "react",
"react-dom", "react-dom",
"**/application/**",
]), ]),
"no-restricted-globals": [ "no-restricted-globals": [
"error", "error",
+16 -1
View File
@@ -15,6 +15,12 @@
"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:design-system": "node scripts/check-design-system.mjs",
"check:design-system:fixture": "node scripts/check-design-system.mjs --fixture",
"check:i18n": "node scripts/check-i18n.mjs",
"check:i18n:fixture": "node scripts/check-i18n.mjs --fixture",
"check:diagnostics": "node scripts/check-diagnostics.mjs",
"check:diagnostics:fixture": "node scripts/check-diagnostics.mjs --fixture",
"check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test", "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:app": "tsc --project tsconfig.app.json",
"check:types:node": "tsc --project tsconfig.node.json", "check:types:node": "tsc --project tsconfig.node.json",
@@ -25,6 +31,12 @@
"check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", "check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", "check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", "check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
"check:types:fixture:icon-button": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx",
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts",
"check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.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",
@@ -33,13 +45,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: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",
@@ -54,6 +68,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
"lucide-react": "1.25.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-router-dom": "7.18.1", "react-router-dom": "7.18.1",
+12
View File
@@ -11,6 +11,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: 5.101.4 specifier: 5.101.4
version: 5.101.4(react@19.2.8) version: 5.101.4(react@19.2.8)
lucide-react:
specifier: 1.25.0
version: 1.25.0(react@19.2.8)
react: react:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8 version: 19.2.8
@@ -1536,6 +1539,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.25.0:
resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
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
@@ -3314,6 +3322,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.25.0(react@19.2.8):
dependencies:
react: 19.2.8
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
+12 -1
View File
@@ -7,5 +7,16 @@
"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-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
"route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
"route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.jsx"
}
} }
+148
View File
@@ -0,0 +1,148 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const tokenFiles = {
primitive: "src/presentation/design-system/tokens/primitive.css",
semantic: "src/presentation/design-system/tokens/semantic.css",
component: "src/presentation/design-system/tokens/component.css",
};
/** @type {Array<readonly [string, string, readonly string[]]>} */
const tokenLayers = [
["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS],
["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS],
["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS],
];
for (const [layer, file, tokens] of tokenLayers) {
const source = await readFile(file, "utf8");
for (const token of tokens) {
if (!source.includes(`${token}:`)) {
failures.push(`${layer} token is missing: ${token}`);
}
}
}
const cssSources = await Promise.all(
[...Object.values(tokenFiles), "src/presentation/styles/theme.css"].map(
async (file) => ({ file, source: await readFile(file, "utf8") }),
),
);
const definitions = new Set(
cssSources.flatMap(({ source }) =>
[...source.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]),
),
);
for (const { file, source } of cssSources) {
for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) {
if (!definitions.has(match[1])) {
failures.push(`${file} uses undefined token ${match[1]}`);
}
}
}
const semanticSource = await readFile(tokenFiles.semantic, "utf8");
const darkSource =
semanticSource.match(/:root\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/)?.[1] ??
"";
for (const token of [
"--color-surface",
"--color-surface-muted",
"--color-surface-elevated",
"--color-content",
"--color-content-muted",
"--color-content-inverse",
"--color-border",
"--color-border-strong",
"--color-action",
"--color-action-hover",
"--color-action-pressed",
"--color-danger",
"--color-focus",
"--color-disabled-content",
"--color-disabled-surface",
]) {
if (!darkSource.includes(`${token}:`)) {
failures.push(`dark theme token is missing: ${token}`);
}
}
const componentSource = await readFile(tokenFiles.component, "utf8");
if (!componentSource.includes("@media (forced-colors: active)")) {
failures.push("forced-colors token fallback is missing");
}
/** @param {string} directory @returns {Promise<string[]>} */
async function listSourceFiles(directory) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
else if (/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) result.push(target);
}
return result;
}
const sources = fixtureMode
? await listSourceFiles("tests/fixtures/design-system/forbidden")
: await listSourceFiles("src");
for (const file of sources) {
const source = await readFile(file, "utf8");
const vendorFacade =
file === "src/presentation/design-system/icons/vendors/lucide.tsx";
if (!vendorFacade && /from\s+["']lucide-react["']/.test(source)) {
failures.push(`direct icon vendor import in ${file}`);
}
if (
/from\s+["'](?:react-aria-components|@radix-ui\/[^"']+)["']/.test(source)
) {
failures.push(`direct headless vendor import in ${file}`);
}
if (
!file.includes("src/presentation/design-system/") &&
/presentation\/design-system\/(?!index(?:\.js)?["'])/.test(source)
) {
failures.push(`design-system deep import in ${file}`);
}
if (
!file.includes("src/presentation/design-system/tokens/") &&
/(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source)
) {
failures.push(`raw palette value in ${file}`);
}
if (
file.includes("tests/fixtures/design-system/forbidden") &&
source.includes("TOOLTIP_ONLY_REQUIRED_INFORMATION")
) {
failures.push(`tooltip-only required information in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
checkedTokenCount:
REQUIRED_PRIMITIVE_TOKENS.length +
REQUIRED_SEMANTIC_TOKENS.length +
REQUIRED_COMPONENT_TOKENS.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/design-system-fixture.json"
: "artifacts/quality/design-system.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(
`Design system contract: ${report.checkedTokenCount} tokens and vendor boundaries PASS\n`,
);
+120
View File
@@ -0,0 +1,120 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts";
import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.js";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const extensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
/** @param {string} directory @returns {Promise<string[]>} */
async function filesBelow(directory) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) result.push(...(await filesBelow(target)));
else if (extensions.test(entry.name)) result.push(target);
}
return result;
}
const telemetryProducerFiles =
/** @type {Readonly<Record<string, string>>} */ ({
"app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts",
"api.request.failed": "src/adapters/http/client.js",
"ui.render.failed": "src/application/create-application.ts",
"release.mismatch.detected": "src/application/create-application.ts",
"telemetry.delivery.dropped":
"src/adapters/telemetry/best-effort-telemetry.js",
});
const diagnosticProducerFiles =
/** @type {Readonly<Record<string, string>>} */ ({
"app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts",
"http.request.completed": "src/adapters/http/client.js",
"cache.operation.failed":
"src/adapters/query-cache/tanstack-query-cache.js",
"storage.operation.failed":
"src/adapters/storage/browser-storage-adapter.js",
"route.changed": "src/application/create-application.ts",
"ui.render.failed": "src/application/create-application.ts",
"release.mismatch.detected": "src/application/create-application.ts",
"telemetry.delivery.dropped": "src/bootstrap/runtime-adapters.js",
});
if (!fixtureMode) {
for (const eventName of Object.keys(TELEMETRY_REGISTRY)) {
const producer = telemetryProducerFiles[eventName];
if (!producer) {
failures.push(`telemetry event has no declared producer: ${eventName}`);
continue;
}
const source = await readFile(producer, "utf8");
if (!source.includes(`"${eventName}"`)) {
failures.push(`telemetry producer is not executable: ${eventName}`);
}
}
for (const eventId of Object.keys(DIAGNOSTIC_EVENT_REGISTRY)) {
const producer = diagnosticProducerFiles[eventId];
if (!producer) {
failures.push(`diagnostic event has no declared producer: ${eventId}`);
continue;
}
const source = await readFile(producer, "utf8");
if (!source.includes(`"${eventId}"`)) {
failures.push(`diagnostic producer is not executable: ${eventId}`);
}
}
}
const sources = fixtureMode
? await filesBelow("tests/fixtures/diagnostics/forbidden")
: await filesBelow("src");
const sensitiveContext =
/\b(?:authorization|cookie|access_token|refresh_token|request_body|response_body|raw_url|query_string|email|user_name)\b/i;
for (const file of sources) {
const source = await readFile(file, "utf8");
if (file.includes("contracts/telemetry.js")) continue;
if (source.includes("console.")) {
failures.push(`direct console diagnostics bypass in ${file}`);
}
const calls = source.match(
/(?:\.record\(\{|\.emit\()[\s\S]{0,700}?(?:\}\)|\}\);)/g,
) ?? [];
if (calls.some((call) => sensitiveContext.test(call))) {
failures.push(`sensitive diagnostic or telemetry context in ${file}`);
}
if (
file.includes("tests/fixtures/diagnostics/forbidden") &&
source.includes("UNKNOWN_DIAGNOSTIC_EVENT")
) {
failures.push(`unknown diagnostic event in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
telemetryEventCount: Object.keys(TELEMETRY_REGISTRY).length,
diagnosticEventCount: Object.keys(DIAGNOSTIC_EVENT_REGISTRY).length,
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/diagnostics-fixture.json"
: "artifacts/quality/diagnostics.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(
`Diagnostics contract failed:\n${failures.join("\n")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Diagnostics contract: ${report.diagnosticEventCount} diagnostics and ${report.telemetryEventCount} telemetry producers PASS\n`,
);
+108
View File
@@ -0,0 +1,108 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const sourceExtensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
const koreanLiteral = /[가-힣]/;
const rawFailureRender =
/(?<!\$)\{\s*(?:failure|error|response|backend)(?:\?\.|\.)[\w?.]*message\s*\}/;
/** @param {string} directory @returns {Promise<string[]>} */
async function listSourceFiles(directory) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
else if (sourceExtensions.test(entry.name)) result.push(target);
}
return result;
}
/** @param {string} template */
function placeholders(template) {
return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)]
.map((match) => match[1])
.sort();
}
if (!fixtureMode) {
const canonicalKeys = Object.keys(KO_MESSAGES).sort();
for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) {
const readableCatalog =
/** @type {Readonly<Record<string, string>>} */ (catalog);
const canonicalCatalog =
/** @type {Readonly<Record<string, string>>} */ (KO_MESSAGES);
const keys = Object.keys(catalog).sort();
if (JSON.stringify(keys) !== JSON.stringify(canonicalKeys)) {
failures.push(`${locale} catalog keys do not match ko-KR`);
}
for (const key of canonicalKeys) {
if (
JSON.stringify(placeholders(readableCatalog[key] ?? "")) !==
JSON.stringify(placeholders(canonicalCatalog[key] ?? ""))
) {
failures.push(`${locale}:${key} interpolation parameters do not match`);
}
}
}
if (Object.keys(EN_MESSAGES).length !== canonicalKeys.length) {
failures.push("en-US catalog key count does not match ko-KR");
}
}
const commonDirectories = [
"src/presentation/boundaries",
"src/presentation/components",
"src/presentation/design-system",
"src/presentation/forms",
"src/presentation/layouts",
"src/presentation/routes",
"src/presentation/templates",
];
const sources = fixtureMode
? await listSourceFiles("tests/fixtures/i18n/forbidden")
: (
await Promise.all(commonDirectories.map((directory) => listSourceFiles(directory)))
).flat();
for (const file of sources) {
const source = await readFile(file, "utf8");
if (koreanLiteral.test(source)) {
failures.push(`hardcoded common user-facing locale literal in ${file}`);
}
if (rawFailureRender.test(source)) {
failures.push(`raw backend/error message rendered in ${file}`);
}
if (source.includes("dangerouslySetInnerHTML")) {
failures.push(`untrusted HTML interpolation boundary in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
localeCount: Object.keys(MESSAGE_CATALOGS).length + 1,
messageKeyCount: Object.keys(KO_MESSAGES).length,
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/i18n-fixture.json"
: "artifacts/quality/i18n.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(
`I18n contract: ${report.messageKeyCount} keys across ${report.localeCount} locales PASS\n`,
);
+23
View File
@@ -103,6 +103,24 @@ for (const specification of governance.registries) {
} }
} }
for (const [field, allowed] of Object.entries(
specification.allowedValues ?? {},
)) {
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
if (
!allowed.some(
/** @param {unknown} value */
(value) => Object.is(value, row[field]),
)
) {
failures.push(
`${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`,
);
}
}
}
snapshots.push({ snapshots.push({
registryId: specification.registryId, registryId: specification.registryId,
owner: specification.owner, owner: specification.owner,
@@ -155,6 +173,11 @@ const adHocPatterns = [
/** @param {string} directory */ /** @param {string} directory */
async function scanDirectory(directory) { async function scanDirectory(directory) {
try {
await access(directory);
} catch {
return;
}
const entries = await import("node:fs/promises").then(({ readdir }) => const entries = await import("node:fs/promises").then(({ readdir }) =>
readdir(directory, { withFileTypes: true }), readdir(directory, { withFileTypes: true }),
); );
+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.");
} }
+197 -44
View File
@@ -1,78 +1,231 @@
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 featureOwnedPaths = [
featureSource,
featureTests,
"tests/e2e/reference-form.spec.js",
];
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({});
}
`;
const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({}),
"en-US": 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) {
const incomingImports = []; await cp(target, path.join(fixtureRoot, target), { recursive: true });
for (const sourceFile of await sourceFiles(sourceRoot)) {
if (sourceFile.startsWith(sampleRoot)) continue;
const content = await readFile(sourceFile, "utf8");
if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) {
incomingImports.push(path.relative(".", sourceFile));
}
} }
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
let buildStatus = 1; for (const ownedPath of featureOwnedPaths) {
if (incomingImports.length === 0) { await rm(path.join(fixtureRoot, ownedPath), {
await cp("src", path.join(fixtureRoot, "src"), {
recursive: true, recursive: true,
filter: (source) => !source.startsWith(sampleRoot), force: true,
}); });
await cp("public", path.join(fixtureRoot, "public"), { recursive: true }); }
await cp("index.html", path.join(fixtureRoot, "index.html")); await writeFile(
await cp("vite.config.js", path.join(fixtureRoot, "vite.config.js")); path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
emptyContracts,
const result = spawnSync(
process.execPath,
[
pnpmCli,
"exec",
"vite",
"build",
fixtureRoot,
"--outDir",
path.join(fixtureRoot, "dist"),
],
{ stdio: "inherit" },
); );
buildStatus = result.status ?? 1; await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"),
emptyRuntimes,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"),
emptyAdapters,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-messages.js"),
emptyMessages,
);
/** @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);
}
}
} }
const checks = [
["typecheck", runPnpm("check:types")],
["architecture", runPnpm("check:architecture")],
["registry", runPnpm("check:registries")],
["unit-integration", runPnpm("test:all")],
[
"home-smoke",
runPnpm("exec", [
"vitest",
"run",
"tests/component/router.test.jsx",
"--reporter=default",
]),
],
["build", runPnpm("build")],
];
/** @type {string[]} */
const builtResidue = [];
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
) {
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({
@@ -0,0 +1,102 @@
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.js";
import {
projectDiagnosticRecord,
safeErrorKind,
type DiagnosticRecord,
type DiagnosticRecordInput,
} from "../../contracts/diagnostics.js";
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
record() {},
});
export function createDiagnosticsAdapter(
options: Readonly<{
maxEntries?: number;
now?: () => number;
sink?: (record: DiagnosticRecord) => void;
}> = {},
) {
const maxEntries = Math.max(1, options.maxEntries ?? 100);
const entries: DiagnosticRecord[] = [];
const droppedReasons = new Map<string, number>();
function drop(reason: string) {
droppedReasons.set(reason, (droppedReasons.get(reason) ?? 0) + 1);
}
function record(input: DiagnosticRecordInput) {
try {
const projected = projectDiagnosticRecord(input, options.now);
if (!projected.success) {
drop(projected.reason);
return;
}
if (entries.length >= maxEntries) {
entries.shift();
drop("queue-full");
}
entries.push(projected.record);
try {
options.sink?.(projected.record);
} catch {
drop("sink-failure");
}
} catch {
drop("serialization-failure");
}
}
return Object.freeze({
record,
entries: () => structuredClone(entries) as readonly DiagnosticRecord[],
dropped: () => Object.freeze(Object.fromEntries(droppedReasons)),
});
}
type BootSafeContext = Readonly<{
kind?: string;
buildId?: string;
configSchemaVersion?: string;
supportReference?: string;
}>;
let lastBootEvidence:
| Readonly<{
diagnostic: DiagnosticRecord | null;
telemetry: Readonly<Record<string, unknown>> | null;
}>
| undefined;
export function recordBootFailure(
error: unknown,
safe: BootSafeContext,
now: () => number = Date.now,
) {
const errorKind =
typeof safe.kind === "string" ? safe.kind : safeErrorKind(error);
const attributes = {
error_kind: errorKind,
build_id: safe.buildId ?? "unknown",
config_schema_version: safe.configSchemaVersion ?? "unknown",
};
const diagnostic = projectDiagnosticRecord(
{
level: "error",
eventId: "app.boot.failed",
context: attributes,
},
now,
);
const telemetry = projectTelemetryEvent("app.boot.failed", attributes, now);
lastBootEvidence = Object.freeze({
diagnostic: diagnostic.success ? diagnostic.record : null,
telemetry: telemetry.success ? telemetry.event : null,
});
return lastBootEvidence;
}
export function getLastBootEvidence() {
return lastBootEvidence ? structuredClone(lastBootEvidence) : undefined;
}
+103 -12
View File
@@ -4,6 +4,7 @@ import {
createFailure as failure, createFailure as failure,
kindForStatus as statusKind, kindForStatus as statusKind,
normalizeUnknownFailure, normalizeUnknownFailure,
safeValidationIssues,
} from "../../contracts/errors.js"; } from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js"; import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js"; import { retryDelay, shouldRetry } from "./retry-policy.js";
@@ -13,6 +14,11 @@ import {
validateOperationRequest, validateOperationRequest,
} from "./schema-registry.js"; } from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js"; import { buildRequestTarget } from "./request-builder.js";
import {
attemptBucket,
durationBucket,
statusGroup,
} from "../../contracts/diagnostics.js";
const noAuthSession = const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
@@ -46,12 +52,17 @@ 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, * timeoutMs?: number,
* maxRetryAttempts?: number, * maxRetryAttempts?: number,
* scheduler?: Scheduler, * scheduler?: Scheduler,
* getOperation?: typeof getApiOperation * getOperation?: typeof getApiOperation,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* correlationIdFactory?: () => string
* }} dependencies * }} dependencies
*/ */
export function createHttpClient(dependencies) { export function createHttpClient(dependencies) {
@@ -61,12 +72,19 @@ 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 defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2; const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const selectOperation = dependencies.getOperation ?? getApiOperation; const selectOperation = dependencies.getOperation ?? getApiOperation;
const diagnostics = dependencies.diagnostics;
const telemetry = dependencies.telemetry;
const correlationIdFactory =
dependencies.correlationIdFactory ??
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
const scheduler = const scheduler =
dependencies.scheduler ?? dependencies.scheduler ??
/** @type {Scheduler} */ ({ /** @type {Scheduler} */ ({
@@ -86,7 +104,8 @@ export function createHttpClient(dependencies) {
* pathParams?: Record<string, string | number>, * pathParams?: Record<string, string | number>,
* searchParams?: unknown, * searchParams?: unknown,
* signal?: AbortSignal, * signal?: AbortSignal,
* idempotencyKey?: string * idempotencyKey?: string,
* correlationId?: string
* }} [legacyInput] * }} [legacyInput]
* @returns {Promise<HttpResult>} * @returns {Promise<HttpResult>}
*/ */
@@ -101,9 +120,55 @@ export function createHttpClient(dependencies) {
body: legacyInput.body, body: legacyInput.body,
signal: legacyInput.signal, signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey, idempotencyKey: legacyInput.idempotencyKey,
correlationId: legacyInput.correlationId,
} }
: request; : request;
const operation = selectOperation(input.operationId); const operation = selectOperation(input.operationId);
const startedAt = clock.now();
const correlationId = input.correlationId ?? correlationIdFactory();
/**
* @param {HttpResult} outcome
* @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind
*/
function finalize(outcome, outcomeKind) {
const error = outcome.ok ? undefined : outcome.error;
const context = {
route_id: input.routeId,
operation_id: input.operationId,
correlation_id: correlationId,
outcome: outcomeKind,
error_kind: error?.kind ?? "NONE",
http_status_group: statusGroup(error?.httpStatus),
attempt_count_bucket: attemptBucket(
error?.attemptCount ?? retryCount + 1,
),
duration_bucket: durationBucket(clock.now() - startedAt),
};
try {
diagnostics?.record({
level: error ? "warn" : "info",
eventId: "http.request.completed",
context,
});
} catch {
// Diagnostics cannot change the HTTP result.
}
if (error && outcomeKind !== "aborted") {
try {
telemetry?.emit("api.request.failed", {
error_kind: context.error_kind,
http_status_group: context.http_status_group,
attempt_count_bucket: context.attempt_count_bucket,
route_id: context.route_id,
operation_id: context.operation_id,
duration_bucket: context.duration_bucket,
});
} catch {
// Telemetry cannot change the HTTP result.
}
}
return outcome;
}
const logicalIdempotencyKey = const logicalIdempotencyKey =
operation.idempotency === "keyed" operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory() ? input.idempotencyKey ?? idempotencyKeyFactory()
@@ -121,28 +186,40 @@ export function createHttpClient(dependencies) {
idempotencyKey: logicalIdempotencyKey, idempotencyKey: logicalIdempotencyKey,
}); });
if (outcome.ok) return outcome; if (outcome.ok) {
return finalize(
outcome,
retryCount > 0 || recoveryUsed ? "recovered" : "success",
);
}
if (outcome.error.httpStatus === 401 && !recoveryUsed) { if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true; recoveryUsed = true;
const recovered = await recoverSession(authSession, operation, outcome.error); const recovered = await recoverSession(
if (!recovered.ok) return recovered; authSession,
operation,
outcome.error,
);
if (!recovered.ok) return finalize(recovered, "failed");
if (operation.idempotency === "none") { if (operation.idempotency === "none") {
return { return finalize(
{
ok: false, ok: false,
error: { error: {
...outcome.error, ...outcome.error,
retryable: false, retryable: false,
action: "retry", action: "retry",
}, },
}; },
"failed",
);
} }
continue; continue;
} }
if (outcome.error.httpStatus === 401 && recoveryUsed) { if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated(); authSession.onUnauthenticated();
return outcome; return finalize(outcome, "failed");
} }
if ( if (
@@ -153,7 +230,10 @@ export function createHttpClient(dependencies) {
maxRetryAttempts, maxRetryAttempts,
) )
) { ) {
return outcome; return finalize(
outcome,
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
);
} }
const delay = retryDelay(outcome.error, retryCount, random, clock.now()); const delay = retryDelay(outcome.error, retryCount, random, clock.now());
@@ -162,12 +242,15 @@ export function createHttpClient(dependencies) {
try { try {
await clock.sleep(delay, input.signal); await clock.sleep(delay, input.signal);
} catch { } catch {
return { return finalize(
{
ok: false, ok: false,
error: failure("REQUEST_ABORTED", input.operationId, retryCount, { error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED", code: "REQUEST_ABORTED",
}), }),
}; },
"aborted",
);
} }
} }
} }
@@ -193,7 +276,7 @@ export function createHttpClient(dependencies) {
? input.body ? input.body
: {}; : {};
if (operation.requestSource !== "none") { if (operation.requestSource !== "none") {
const requestValidation = validateOperationRequest( const requestValidation = validateRequest(
operation.requestSchema, operation.requestSchema,
requestValue, requestValue,
); );
@@ -411,6 +494,10 @@ async function parseResponse(
const kind = statusKind(response.status); const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after"); const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
: {};
return { return {
ok: false, ok: false,
error: failure(kind, operation.operationId, attempt, { error: failure(kind, operation.operationId, attempt, {
@@ -422,6 +509,10 @@ async function parseResponse(
response.status === 429 && retryAfter response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter) ? parseRetryAfterHeader(retryAfter)
: undefined, : undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}), }),
}; };
} }
+1
View File
@@ -8,6 +8,7 @@ export type OperationRequestInput = Readonly<{
body?: unknown; body?: unknown;
signal?: AbortSignal; signal?: AbortSignal;
idempotencyKey?: string; idempotencyKey?: string;
correlationId?: string;
}>; }>;
export type RequestTargetResult = export type RequestTargetResult =
+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 -26
View File
@@ -37,35 +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),
tags: z.array(z.string().trim().min(1)).optional(),
})
.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) {
@@ -1,6 +1,7 @@
import { QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { createFailure } from "../../contracts/errors.js"; import { createFailure } from "../../contracts/errors.js";
import { safeErrorKind } from "../../contracts/diagnostics.js";
export const QUERY_CACHE_DEFAULTS = Object.freeze({ export const QUERY_CACHE_DEFAULTS = Object.freeze({
staleTime: 30_000, staleTime: 30_000,
@@ -11,8 +12,32 @@ export const QUERY_CACHE_DEFAULTS = Object.freeze({
persistence: false, persistence: false,
}); });
export function createQueryClient() { /**
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
*/
export function createQueryClient(dependencies = {}) {
/** @param {string} operation @param {unknown} error */
function report(operation, error) {
try {
dependencies.diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation,
error_kind: safeErrorKind(error),
},
});
} catch {
// Query behavior remains independent from diagnostics.
}
}
return new QueryClient({ return new QueryClient({
queryCache: new QueryCache({
onError: (error) => report("query", error),
}),
mutationCache: new MutationCache({
onError: (error) => report("mutation", error),
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: QUERY_CACHE_DEFAULTS.staleTime, staleTime: QUERY_CACHE_DEFAULTS.staleTime,
@@ -29,15 +54,16 @@ export function createQueryClient() {
/** /**
* @param {QueryClient} queryClient * @param {QueryClient} queryClient
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
* @returns {import("../../application/ports/query-cache-port.js").QueryCachePort} * @returns {import("../../application/ports/query-cache-port.js").QueryCachePort}
*/ */
export function createQueryCacheAdapter(queryClient) { export function createQueryCacheAdapter(queryClient, dependencies = {}) {
return Object.freeze({ return Object.freeze({
read(key) { read(key) {
try { try {
return { ok: true, value: queryClient.getQueryData(key) }; return { ok: true, value: queryClient.getQueryData(key) };
} catch { } catch {
return cacheFailure("read", key); return cacheFailure("read", key, dependencies.diagnostics);
} }
}, },
write(key, value) { write(key, value) {
@@ -45,7 +71,7 @@ export function createQueryCacheAdapter(queryClient) {
queryClient.setQueryData(key, structuredClone(value)); queryClient.setQueryData(key, structuredClone(value));
return { ok: true }; return { ok: true };
} catch { } catch {
return cacheFailure("write", key); return cacheFailure("write", key, dependencies.diagnostics);
} }
}, },
async invalidate(namespace) { async invalidate(namespace) {
@@ -53,15 +79,31 @@ export function createQueryCacheAdapter(queryClient) {
await queryClient.invalidateQueries({ queryKey: namespace, exact: false }); await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
return { ok: true }; return { ok: true };
} catch { } catch {
return cacheFailure("invalidate", namespace); return cacheFailure("invalidate", namespace, dependencies.diagnostics);
} }
}, },
}); });
} }
/** @param {string} phase @param {readonly unknown[]} key */ /**
function cacheFailure(phase, key) { * @param {string} phase
* @param {readonly unknown[]} key
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function cacheFailure(phase, key, diagnostics) {
const namespace = typeof key[0] === "string" ? key[0] : "unknown"; const namespace = typeof key[0] === "string" ? key[0] : "unknown";
try {
diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation: phase,
error_kind: "QUERY_CACHE_FAILURE",
},
});
} catch {
// Cache behavior remains independent from diagnostics.
}
return { return {
ok: /** @type {false} */ (false), ok: /** @type {false} */ (false),
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, { error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
@@ -5,7 +5,8 @@ import { getStorageDefinition } from "../../contracts/storage-keys.js";
* @param {{ * @param {{
* localStorage?: Storage, * localStorage?: Storage,
* sessionStorage?: Storage, * sessionStorage?: Storage,
* now?: () => number * now?: () => number,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort
* }} [dependencies] * }} [dependencies]
* @returns {import("../../application/ports/storage-port.js").StoragePort} * @returns {import("../../application/ports/storage-port.js").StoragePort}
*/ */
@@ -26,7 +27,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try { try {
definition = getStorageDefinition(logicalName); definition = getStorageDefinition(logicalName);
} catch { } catch {
return unavailable("read", logicalName); return unavailable("read", logicalName, dependencies.diagnostics);
} }
const backend = backendFor(definition.backend); const backend = backendFor(definition.backend);
@@ -50,7 +51,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
} }
return { ok: true, value: structuredClone(envelope.value) }; return { ok: true, value: structuredClone(envelope.value) };
} catch { } catch {
return unavailable("read", logicalName); return unavailable("read", logicalName, dependencies.diagnostics);
} }
}, },
@@ -59,7 +60,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try { try {
definition = getStorageDefinition(logicalName); definition = getStorageDefinition(logicalName);
} catch { } catch {
return unavailable("write", logicalName); return unavailable("write", logicalName, dependencies.diagnostics);
} }
const expiresAt = const expiresAt =
@@ -82,12 +83,24 @@ export function createBrowserStorageAdapter(dependencies = {}) {
if (definition.quotaFallback === "memory") { if (definition.quotaFallback === "memory") {
memory.set(definition.physicalKey, structuredClone(value)); memory.set(definition.physicalKey, structuredClone(value));
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return { return {
ok: false, ok: false,
error: storageFailure(quota, "write", logicalName), error: storageFailure(quota, "write", logicalName),
fallback: "memory", fallback: "memory",
}; };
} }
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return { return {
ok: false, ok: false,
error: storageFailure(quota, "write", logicalName), error: storageFailure(quota, "write", logicalName),
@@ -101,14 +114,14 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try { try {
definition = getStorageDefinition(logicalName); definition = getStorageDefinition(logicalName);
} catch { } catch {
return unavailable("remove", logicalName); return unavailable("remove", logicalName, dependencies.diagnostics);
} }
try { try {
backendFor(definition.backend)?.removeItem(definition.physicalKey); backendFor(definition.backend)?.removeItem(definition.physicalKey);
memory.delete(definition.physicalKey); memory.delete(definition.physicalKey);
return { ok: true }; return { ok: true };
} catch { } catch {
return unavailable("remove", logicalName); return unavailable("remove", logicalName, dependencies.diagnostics);
} }
}, },
}); });
@@ -128,10 +141,38 @@ function storageFailure(quota, phase, logicalName) {
); );
} }
/** @param {string} phase @param {string} logicalName */ /**
function unavailable(phase, logicalName) { * @param {string} phase
* @param {string} logicalName
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function unavailable(phase, logicalName, diagnostics) {
recordStorageFailure(diagnostics, phase, logicalName, false);
return { return {
ok: /** @type {false} */ (false), ok: /** @type {false} */ (false),
error: storageFailure(false, phase, logicalName), error: storageFailure(false, phase, logicalName),
}; };
} }
/**
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
* @param {string} phase
* @param {string} logicalName
* @param {boolean} quota
*/
function recordStorageFailure(diagnostics, phase, logicalName, quota) {
try {
diagnostics?.record({
level: "warn",
eventId: "storage.operation.failed",
context: {
operation: `${phase}:${logicalName}`,
error_kind: quota
? "STORAGE_QUOTA_EXCEEDED"
: "STORAGE_UNAVAILABLE",
},
});
} catch {
// Storage behavior remains independent from diagnostics.
}
}
+76 -13
View File
@@ -1,7 +1,14 @@
import { projectTelemetryEvent } from "../../contracts/telemetry.js"; import { projectTelemetryEvent } from "../../contracts/telemetry.js";
import { queueSizeBucket } from "../../contracts/diagnostics.js";
export const noOpTelemetry = Object.freeze({ export const noOpTelemetry = Object.freeze({
emit: () => {}, emit: () => {},
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
dropReasons: () => Object.freeze({}),
deliveryEvidence: () => null,
dispose: () => {},
}); });
/** /**
@@ -10,22 +17,20 @@ export const noOpTelemetry = Object.freeze({
* endpoint?: string, * endpoint?: string,
* fetcher?: typeof fetch, * fetcher?: typeof fetch,
* maxQueue?: number, * maxQueue?: number,
* schedule?: (callback: () => void) => void * schedule?: (callback: () => void) => void,
* now?: () => number,
* onDrop?: (event: Readonly<Record<string, unknown>>) => void,
* lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">
* }} options * }} options
*/ */
export function createTelemetryAdapter(options) { export function createTelemetryAdapter(options) {
if (!options.enabled || !options.endpoint) { if (!options.enabled || !options.endpoint) {
return Object.freeze({ return noOpTelemetry;
...noOpTelemetry,
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
});
} }
const endpoint = /** @type {string} */ (options.endpoint); const endpoint = /** @type {string} */ (options.endpoint);
const fetcher = options.fetcher ?? fetch; const fetcher = options.fetcher ?? fetch;
const maxQueue = options.maxQueue ?? 100; const maxQueue = Math.max(1, options.maxQueue ?? 100);
const schedule = options.schedule ?? queueMicrotask; const schedule = options.schedule ?? queueMicrotask;
const queue = const queue =
/** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ ( /** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ (
@@ -34,18 +39,63 @@ export function createTelemetryAdapter(options) {
let scheduled = false; let scheduled = false;
let flushing = false; let flushing = false;
let dropped = 0; let dropped = 0;
const dropReasons = new Map();
let lastDeliveryEvidence =
/** @type {Readonly<Record<string, unknown>> | null} */ (null);
const lifecycle =
options.lifecycle ??
(typeof globalThis.addEventListener === "function" &&
typeof globalThis.removeEventListener === "function"
? globalThis
: undefined);
/** @param {string} reason @param {number} count */
function recordDrop(reason, count = 1) {
const safeReason =
{
"queue-full": "queue-full",
"sink-failure": "sink-failure",
"serialization-failure": "serialization-failure",
"unknown-attributes": "invalid-context",
"invalid-attribute-value": "invalid-context",
"missing-required-attributes": "invalid-context",
"unregistered-event": "invalid-event",
}[reason] ?? "invalid-event";
dropped += count;
dropReasons.set(safeReason, (dropReasons.get(safeReason) ?? 0) + count);
const internal = projectTelemetryEvent(
"telemetry.delivery.dropped",
{
reason: safeReason,
queue_size_bucket: queueSizeBucket(queue.length),
},
options.now,
);
if (internal.success) {
lastDeliveryEvidence = internal.event;
try {
options.onDrop?.(internal.event);
} catch {
// Drop observers are deliberately nonrecursive.
}
}
}
/** @param {string} eventName @param {Record<string, unknown>} attributes */ /** @param {string} eventName @param {Record<string, unknown>} attributes */
function emit(eventName, attributes) { function emit(eventName, attributes) {
const projected = projectTelemetryEvent(eventName, attributes); const projected = projectTelemetryEvent(
eventName,
attributes,
options.now,
);
if (!projected.success) { if (!projected.success) {
dropped += 1; recordDrop(projected.reason);
return; return;
} }
if (queue.length >= maxQueue) { if (queue.length >= maxQueue) {
queue.shift(); queue.shift();
dropped += 1; recordDrop("queue-full");
} }
queue.push(projected.event); queue.push(projected.event);
@@ -69,19 +119,32 @@ export function createTelemetryAdapter(options) {
body: JSON.stringify({ events: batch }), body: JSON.stringify({ events: batch }),
keepalive: true, keepalive: true,
}); });
if (!response.ok) dropped += batch.length; if (!response.ok) recordDrop("sink-failure", batch.length);
} catch { } catch {
dropped += batch.length; recordDrop("sink-failure", batch.length);
} finally { } finally {
flushing = false; flushing = false;
} }
} }
const flushBeforePageExit = () => {
void flush();
};
lifecycle?.addEventListener("pagehide", flushBeforePageExit);
function dispose() {
lifecycle?.removeEventListener("pagehide", flushBeforePageExit);
}
return Object.freeze({ return Object.freeze({
emit, emit,
flush, flush,
pendingCount: () => queue.length, pendingCount: () => queue.length,
droppedCount: () => dropped, droppedCount: () => dropped,
dropReasons: () => Object.freeze(Object.fromEntries(dropReasons)),
deliveryEvidence: () =>
lastDeliveryEvidence ? structuredClone(lastDeliveryEvidence) : null,
dispose,
}); });
} }
+119 -1
View File
@@ -3,8 +3,10 @@ import type {
ApplicationApi, ApplicationApi,
ColorSchemePreference, ColorSchemePreference,
RenderFailureReport, RenderFailureReport,
RouteChangedReport,
} from "./ports/in/application-api.js"; } from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js"; import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js";
export type { ApplicationApi, ApplicationOutputPorts }; export type { ApplicationApi, ApplicationOutputPorts };
@@ -14,6 +16,7 @@ export type { ApplicationApi, ApplicationOutputPorts };
*/ */
export function createApplication( export function createApplication(
outputPorts: ApplicationOutputPorts, outputPorts: ApplicationOutputPorts,
featureInputs: Readonly<Record<string, unknown>> = {},
): ApplicationApi { ): ApplicationApi {
const session = Object.freeze({ const session = Object.freeze({
getSnapshot: () => outputPorts.session.getState(), getSnapshot: () => outputPorts.session.getState(),
@@ -41,7 +44,20 @@ export function createApplication(
const diagnostics = Object.freeze({ const diagnostics = Object.freeze({
reportRenderFailure(report: RenderFailureReport) { reportRenderFailure(report: RenderFailureReport) {
try { try {
outputPorts.diagnostics.emit("ui.render.failed", { outputPorts.diagnostics.record({
level: "error",
eventId: "ui.render.failed",
context: {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
},
});
} catch {
// Diagnostics are best-effort and cannot become an application failure.
}
try {
outputPorts.telemetry.emit("ui.render.failed", {
route_id: report.routeId, route_id: report.routeId,
build_id: report.buildId, build_id: report.buildId,
component_boundary: report.boundaryName, component_boundary: report.boundaryName,
@@ -50,6 +66,20 @@ export function createApplication(
// Diagnostics are best-effort and cannot become an application failure. // Diagnostics are best-effort and cannot become an application failure.
} }
}, },
reportRouteChanged(report: RouteChangedReport) {
try {
outputPorts.diagnostics.record({
level: "info",
eventId: "route.changed",
context: {
route_id: report.routeId,
build_id: report.buildId,
},
});
} catch {
// Diagnostics are best-effort and cannot become navigation failure.
}
},
}); });
const runtime = Object.freeze({ const runtime = Object.freeze({
@@ -64,10 +94,98 @@ export function createApplication(
}, },
}); });
const recovery = Object.freeze({
async recoverChunk(input: {
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH";
}) {
try {
const current = await outputPorts.releaseInfo.getCurrent();
const active = await outputPorts.releaseInfo.refresh();
if (
current.buildId !== active.buildId ||
current.releaseId !== active.releaseId
) {
const mismatchKind =
current.buildId !== active.buildId
? "BUILD_MISMATCH"
: "RELEASE_MISMATCH";
try {
outputPorts.diagnostics.record({
level: "warn",
eventId: "release.mismatch.detected",
context: {
build_id: current.buildId,
active_release_id: active.releaseId,
mismatch_kind: mismatchKind,
},
});
} catch {
// Recovery remains independent from diagnostics.
}
try {
outputPorts.telemetry.emit("release.mismatch.detected", {
build_id: current.buildId,
active_release_id: active.releaseId,
mismatch_kind: mismatchKind,
});
} catch {
// Recovery remains independent from telemetry.
}
}
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({ return Object.freeze({
session, session,
preferences, preferences,
diagnostics, diagnostics,
runtime, runtime,
recovery,
features,
}); });
} }
@@ -0,0 +1,5 @@
import type { DiagnosticRecordInput } from "../../contracts/diagnostics.js";
export type DiagnosticsPort = Readonly<{
record(input: DiagnosticRecordInput): void;
}>;
@@ -11,6 +11,11 @@ export type RenderFailureReport = Readonly<{
boundaryName: "route" | "feature"; boundaryName: "route" | "feature";
}>; }>;
export type RouteChangedReport = Readonly<{
routeId: string;
buildId: string;
}>;
export type ReleaseSummary = Readonly<{ export type ReleaseSummary = Readonly<{
buildId: string; buildId: string;
releaseId: string; releaseId: string;
@@ -34,8 +39,22 @@ export type ApplicationApi = Readonly<{
}>; }>;
diagnostics: Readonly<{ diagnostics: Readonly<{
reportRenderFailure(report: RenderFailureReport): void; reportRenderFailure(report: RenderFailureReport): void;
reportRouteChanged(report: RouteChangedReport): void;
}>; }>;
runtime: Readonly<{ runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>; getReleaseSummary(): Promise<ReleaseSummary>;
}>; }>;
recovery: Readonly<{
recoverChunk(input: Readonly<{
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH";
}>): Promise<
| Readonly<{ action: "reload-once"; releasePair: string }>
| Readonly<{ action: "support"; reason: string }>
>;
}>;
features: Readonly<{
has(featureId: string): boolean;
get(featureId: string): unknown;
}>;
}>; }>;
@@ -2,6 +2,7 @@ import type { AuthSessionPort } from "../auth-session-port.js";
import type { ReleaseInfoPort } from "../release-info-port.js"; import type { ReleaseInfoPort } from "../release-info-port.js";
import type { StoragePort } from "../storage-port.js"; import type { StoragePort } from "../storage-port.js";
import type { TelemetryPort } from "../telemetry-port.js"; import type { TelemetryPort } from "../telemetry-port.js";
import type { DiagnosticsPort } from "../diagnostics-port.js";
/** /**
* Capabilities required by application use cases. Implementations live in * Capabilities required by application use cases. Implementations live in
@@ -13,6 +14,8 @@ export type ApplicationOutputPorts = Readonly<{
"getState" | "subscribe" | "beginSignIn" | "signOut" | "recover" "getState" | "subscribe" | "beginSignIn" | "signOut" | "recover"
>; >;
preferences: StoragePort; preferences: StoragePort;
diagnostics: TelemetryPort; diagnostics: DiagnosticsPort;
telemetry: TelemetryPort;
releaseInfo: ReleaseInfoPort; releaseInfo: ReleaseInfoPort;
navigation: Readonly<{ reload(): void }>;
}>; }>;
+1 -6
View File
@@ -7,11 +7,6 @@ export type {
export type { ClockPort } from "../clock-port.js"; export type { ClockPort } from "../clock-port.js";
export type { QueryCachePort } from "../query-cache-port.js"; export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js"; export type { ReleaseInfoPort } from "../release-info-port.js";
export type {
RequestContext,
ResourceCommandPort,
ResourceQueryPort,
Result,
} from "../resource-ports.js";
export type { StoragePort } from "../storage-port.js"; export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js"; export type { TelemetryPort } from "../telemetry-port.js";
export type { DiagnosticsPort } from "../diagnostics-port.js";
+10 -1
View File
@@ -9,7 +9,16 @@
* apiContractVersion: string, * apiContractVersion: string,
* assetManifestHash: string, * assetManifestHash: string,
* releaseId: string, * releaseId: string,
* builtAt?: string * builtAt?: string,
* routeChunks: Record<string, string>
* }>,
* refresh(): Promise<{
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string,
* routeChunks: Record<string, string>
* }> * }>
* }} ReleaseInfoPort * }} ReleaseInfoPort
*/ */
-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 {};
-5
View File
@@ -1,5 +0,0 @@
/**
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
*/
export {};
+10
View File
@@ -0,0 +1,10 @@
import type { TelemetryEventName } from "../../contracts/telemetry.js";
export type { TelemetryEventName };
export type TelemetryPort = Readonly<{
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void;
}>;
@@ -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,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,
});
}
+6 -2
View File
@@ -16,7 +16,8 @@ import { createApplication } from "../application/create-application.js";
* release: Release * release: Release
* }): Promise<{ * }): Promise<{
* outputPorts: OutputPorts, * outputPorts: OutputPorts,
* infrastructure: Infrastructure * infrastructure: Infrastructure,
* featureInputs?: Readonly<Record<string, unknown>>
* }> * }>
* }} factories * }} factories
* @returns {Promise<Readonly<{ * @returns {Promise<Readonly<{
@@ -30,7 +31,10 @@ 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 adapters = await factories.createAdapters({ config, release }); const adapters = await factories.createAdapters({ config, release });
const application = createApplication(adapters.outputPorts); const application = createApplication(
adapters.outputPorts,
adapters.featureInputs,
);
return Object.freeze({ return Object.freeze({
config, config,
+66 -35
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);
buildId: runtime.build.buildId, }
releaseId: runtime.config.RELEASE_ID, return Object.freeze(structuredClone(parsed.data));
});
} }
const manifest = parsed.data; /**
const mismatches = []; * @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId"); * @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,
releaseId: runtime.config.RELEASE_ID,
},
);
let mismatchCode = null;
if (manifest.buildId !== runtime.build.buildId) {
mismatchCode = "MANIFEST_BUILD_MISMATCH";
}
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;
} }
+2
View File
@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.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";
@@ -27,6 +28,7 @@ async function boot() {
? error.safe ? error.safe
: { supportReference: "boot:unknown" }; : { supportReference: "boot:unknown" };
recordBootFailure(error, safe);
root.render(<BootErrorShell {...safe} />); root.render(<BootErrorShell {...safe} />);
} }
} }
+61 -7
View File
@@ -4,11 +4,14 @@ import {
createUnavailableSessionAdapter, createUnavailableSessionAdapter,
} 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 { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.js";
import { import {
createQueryClient, createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js"; } from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js"; import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js"; import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
import { fetchReleaseManifest } from "./load-release-manifest.js";
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.js";
/** /**
* @param {Record<string, unknown>} host * @param {Record<string, unknown>} host
@@ -51,9 +54,11 @@ function storageOrUndefined(value) {
* fetcher?: typeof fetch, * fetcher?: typeof fetch,
* clock?: import("../application/ports/clock-port.js").ClockPort, * clock?: import("../application/ports/clock-port.js").ClockPort,
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"] * scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
* diagnostics?: Parameters<typeof createHttpClient>[0]["diagnostics"],
* telemetry?: Parameters<typeof createHttpClient>[0]["telemetry"]
* }} context * }} context
*/ */
export function createRuntimeHttpClient(context) { export function createRuntimeHttpClient(context, contract = {}) {
return createHttpClient({ return createHttpClient({
baseUrl: context.runtime.config.API_BASE_URL, baseUrl: context.runtime.config.API_BASE_URL,
timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS, timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS,
@@ -62,6 +67,9 @@ export function createRuntimeHttpClient(context) {
fetcher: context.fetcher, fetcher: context.fetcher,
clock: context.clock, clock: context.clock,
scheduler: context.scheduler, scheduler: context.scheduler,
diagnostics: context.diagnostics,
telemetry: context.telemetry,
...contract,
}); });
} }
@@ -83,31 +91,77 @@ export async function createRuntimeAdapters(context) {
: externalOwner : externalOwner
? createExternalAuthSessionAdapter(externalOwner) ? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter(); : createUnavailableSessionAdapter();
const queryClient = createQueryClient(); const diagnostics = createDiagnosticsAdapter();
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
});
const telemetry = createTelemetryAdapter({ const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED, enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT, endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher, fetcher: context.fetcher,
onDrop(event) {
const attributes =
event.attributes && typeof event.attributes === "object"
? event.attributes
: {};
diagnostics.record({
level: "warn",
eventId: "telemetry.delivery.dropped",
context: /** @type {Record<string, unknown>} */ (attributes),
});
},
});
const queryClient = createQueryClient({ diagnostics });
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
diagnostics,
}); });
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,
diagnostics,
telemetry,
},
contract,
),
}); });
return Object.freeze({ return Object.freeze({
outputPorts: Object.freeze({ outputPorts: Object.freeze({
session: authSession, session: authSession,
preferences: storage, preferences: storage,
diagnostics: telemetry, diagnostics,
telemetry,
releaseInfo, releaseInfo,
navigation,
}), }),
infrastructure: Object.freeze({ infrastructure: Object.freeze({
queryClient, queryClient,
}), }),
featureInputs,
}); });
} }
+3 -33
View File
@@ -14,41 +14,11 @@
* }} 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: null,
idempotency: "safe",
retry: "runtime",
requestSource: "search",
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: null,
idempotency: "keyed",
retry: "runtime",
requestSource: "body",
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}`);
+183
View File
@@ -0,0 +1,183 @@
export const DIAGNOSTIC_LEVELS = Object.freeze([
"debug",
"info",
"warn",
"error",
] as const);
export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number];
export const DIAGNOSTIC_EVENT_REGISTRY = Object.freeze({
"app.boot.failed": Object.freeze({ level: "error" }),
"http.request.completed": Object.freeze({ level: "info" }),
"cache.operation.failed": Object.freeze({ level: "warn" }),
"storage.operation.failed": Object.freeze({ level: "warn" }),
"route.changed": Object.freeze({ level: "info" }),
"ui.render.failed": Object.freeze({ level: "error" }),
"release.mismatch.detected": Object.freeze({ level: "warn" }),
"telemetry.delivery.dropped": Object.freeze({ level: "warn" }),
});
export type DiagnosticEventId = keyof typeof DIAGNOSTIC_EVENT_REGISTRY;
export const DIAGNOSTIC_CONTEXT_ALLOWLIST = Object.freeze([
"app_version",
"build_id",
"release_id",
"active_release_id",
"config_schema_version",
"api_contract_version",
"route_id",
"operation_id",
"correlation_id",
"error_kind",
"outcome",
"http_status_group",
"attempt_count_bucket",
"duration_bucket",
"component_boundary",
"mismatch_kind",
"operation",
"reason",
"queue_size_bucket",
] as const);
export type DiagnosticContextKey =
(typeof DIAGNOSTIC_CONTEXT_ALLOWLIST)[number];
export type DiagnosticContext = Readonly<
Partial<Record<DiagnosticContextKey, string | number | boolean>>
>;
export type DiagnosticRecordInput = Readonly<{
level: DiagnosticLevel;
eventId: DiagnosticEventId;
context?: Readonly<Record<string, unknown>>;
}>;
export type DiagnosticRecord = Readonly<{
level: DiagnosticLevel;
eventId: DiagnosticEventId;
timestamp: string;
context: DiagnosticContext;
}>;
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/;
function projectDiagnosticRecordUnsafe(
input: DiagnosticRecordInput,
now: () => number = Date.now,
):
| Readonly<{ success: true; record: DiagnosticRecord }>
| Readonly<{ success: false; reason: string }> {
if (!Object.hasOwn(DIAGNOSTIC_EVENT_REGISTRY, input.eventId)) {
return { success: false, reason: "unregistered-event" };
}
if (!DIAGNOSTIC_LEVELS.includes(input.level)) {
return { success: false, reason: "invalid-level" };
}
const contextEntries = Object.entries(input.context ?? {});
if (contextEntries.length > DIAGNOSTIC_CONTEXT_ALLOWLIST.length) {
return { success: false, reason: "invalid-context" };
}
const projected: Partial<
Record<DiagnosticContextKey, string | number | boolean>
> = {};
for (const [key, value] of contextEntries) {
if (
!DIAGNOSTIC_CONTEXT_ALLOWLIST.includes(key as DiagnosticContextKey)
) {
return { success: false, reason: "unknown-context" };
}
if (typeof value === "string") {
if (!SAFE_VALUE.test(value)) {
return { success: false, reason: "invalid-context" };
}
projected[key as DiagnosticContextKey] = value;
} else if (typeof value === "number" && Number.isFinite(value)) {
projected[key as DiagnosticContextKey] = value;
} else if (typeof value === "boolean") {
projected[key as DiagnosticContextKey] = value;
} else {
return { success: false, reason: "invalid-context" };
}
}
let timestamp: string;
try {
timestamp = new Date(now()).toISOString();
} catch {
timestamp = new Date(0).toISOString();
}
return {
success: true,
record: Object.freeze({
level: input.level,
eventId: input.eventId,
timestamp,
context: Object.freeze(projected),
}),
};
}
export function projectDiagnosticRecord(
input: DiagnosticRecordInput,
now: () => number = Date.now,
): ReturnType<typeof projectDiagnosticRecordUnsafe> {
try {
return projectDiagnosticRecordUnsafe(input, now);
} catch {
return { success: false, reason: "serialization-failure" };
}
}
export function safeErrorKind(error: unknown): string {
try {
if (error && typeof error === "object") {
const record = error as Readonly<Record<string, unknown>>;
if (
typeof record.kind === "string" &&
/^[A-Z][A-Z0-9_]{0,63}$/.test(record.kind)
) {
return record.kind;
}
if (
typeof record.name === "string" &&
/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(record.name)
) {
const normalized = record.name
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toUpperCase();
return /^[A-Z][A-Z0-9_]{0,63}$/.test(normalized)
? normalized
: "UNKNOWN_FAILURE";
}
}
} catch {
return "UNKNOWN_FAILURE";
}
return "UNKNOWN_FAILURE";
}
export function statusGroup(status: number | undefined): string {
return typeof status === "number" && Number.isFinite(status)
? `${Math.max(0, Math.min(9, Math.floor(status / 100)))}xx`
: "none";
}
export function attemptBucket(attemptCount: number): string {
if (attemptCount <= 1) return "1";
if (attemptCount === 2) return "2";
if (attemptCount <= 4) return "3-4";
return "5+";
}
export function durationBucket(durationMs: number): string {
if (!Number.isFinite(durationMs) || durationMs < 0) return "unknown";
if (durationMs < 100) return "lt100ms";
if (durationMs < 500) return "100-499ms";
if (durationMs < 2_000) return "500-1999ms";
return "gte2000ms";
}
export function queueSizeBucket(size: number): string {
if (size <= 0) return "0";
if (size <= 10) return "1-10";
if (size <= 50) return "11-50";
return "51+";
}
+93
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,
@@ -160,6 +195,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string, * requestId?: string,
* traceId?: string, * traceId?: string,
* retryAfterMs?: number, * retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* userMessageKey: string, * userMessageKey: string,
* action: ErrorAction, * action: ErrorAction,
* causeClass?: string * causeClass?: string
@@ -176,6 +212,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string, * requestId?: string,
* traceId?: string, * traceId?: string,
* retryAfterMs?: number, * retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* causeClass?: string * causeClass?: string
* }} [details] * }} [details]
* @returns {ApiFailure} * @returns {ApiFailure}
@@ -201,6 +238,24 @@ export function createFailure(kind, operationId, attempt, details = {}) {
...(typeof details.retryAfterMs === "number" ...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs } ? { retryAfterMs: details.retryAfterMs }
: {}), : {}),
...(Array.isArray(details.validationIssues)
? {
validationIssues: Object.freeze(
details.validationIssues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string",
)
.slice(0, 50)
.map((issue) =>
Object.freeze({ path: issue.path, code: issue.code }),
),
),
}
: {}),
...(typeof details.causeClass === "string" ...(typeof details.causeClass === "string"
? { causeClass: details.causeClass } ? { causeClass: details.causeClass }
: {}), : {}),
@@ -209,6 +264,44 @@ export function createFailure(kind, operationId, attempt, details = {}) {
}); });
} }
/**
* Projects an untrusted 422 details payload into the only validation metadata
* allowed to cross the HTTP boundary. Backend copy and additional values are
* deliberately discarded.
*
* @param {unknown} value
* @returns {readonly Readonly<{path: string, code: string}>[]}
*/
export function safeValidationIssues(value) {
if (!value || typeof value !== "object") return Object.freeze([]);
const candidate =
/** @type {{issues?: unknown, fieldErrors?: unknown}} */ (value);
const issues = Array.isArray(candidate.issues)
? candidate.issues
: Array.isArray(candidate.fieldErrors)
? candidate.fieldErrors
: [];
return Object.freeze(
issues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string" &&
issue.path.length <= 120 &&
issue.code.length <= 80,
)
.slice(0, 50)
.map((issue) =>
Object.freeze({
path: issue.path,
code: issue.code,
}),
),
);
}
/** @param {number} status */ /** @param {number} status */
export function kindForStatus(status) { export function kindForStatus(status) {
if (status === 401) return "AUTH_REQUIRED"; if (status === 401) return "AUTH_REQUIRED";
+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;
}
+36
View File
@@ -0,0 +1,36 @@
export type TelemetryEventName =
| "app.boot.failed"
| "api.request.failed"
| "ui.render.failed"
| "release.mismatch.detected"
| "telemetry.delivery.dropped";
export type TelemetryDefinition = Readonly<{
eventName: TelemetryEventName;
trigger: string;
requiredAttributes: readonly string[];
optionalAttributes: readonly string[];
forbiddenAttributes: readonly string[];
sampling: string;
delivery: "best-effort";
}>;
export type TelemetryEvent = Readonly<{
eventName: TelemetryEventName;
timestamp: string;
attributes: Readonly<Record<string, unknown>>;
}>;
export const TELEMETRY_ATTRIBUTE_ALLOWLIST: readonly string[];
export const TELEMETRY_FORBIDDEN_ATTRIBUTES: readonly string[];
export const TELEMETRY_REGISTRY: Readonly<
Record<TelemetryEventName, TelemetryDefinition>
>;
export function projectTelemetryEvent(
eventName: string,
attributes: Record<string, unknown>,
now?: () => number,
):
| Readonly<{ success: true; event: TelemetryEvent }>
| Readonly<{ success: false; reason: string }>;
+94 -4
View File
@@ -81,7 +81,7 @@ export const TELEMETRY_REGISTRY = Object.freeze({
"http_status_group", "http_status_group",
"attempt_count_bucket", "attempt_count_bucket",
"route_id", "route_id",
]), ], ["operation_id", "duration_bucket"]),
"ui.render.failed": event("ui.render.failed", "React boundary catch", [ "ui.render.failed": event("ui.render.failed", "React boundary catch", [
"route_id", "route_id",
"build_id", "build_id",
@@ -101,11 +101,44 @@ export const TELEMETRY_REGISTRY = Object.freeze({
), ),
}); });
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/;
const ATTRIBUTE_VALUE_POLICIES =
/** @type {Readonly<Record<string, (value: string) => boolean>>} */ (
Object.freeze({
route_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
operation_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
error_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
http_status_group: (value) => /^(?:[1-5]xx|none)$/.test(value),
attempt_count_bucket: (value) => /^(?:1|2|3|3-4|5\+)$/.test(value),
duration_bucket: (value) =>
/^(?:lt100ms|100-499ms|500-1999ms|gte2000ms|unknown)$/.test(
value,
),
component_boundary: (value) =>
/^(?:route|feature|boot)$/.test(value),
mismatch_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
reason: (value) =>
/^(?:queue-full|sink-failure|invalid-event|invalid-context|serialization-failure)$/.test(
value,
),
queue_size_bucket: (value) =>
/^(?:0|1-10|11-50|51\+)$/.test(value),
})
);
/** @param {string} key @param {unknown} value */
function validAttributeValue(key, value) {
if (typeof value !== "string") return false;
const policy = ATTRIBUTE_VALUE_POLICIES[key];
return policy ? policy(value) : SAFE_IDENTIFIER.test(value);
}
/** /**
* @param {string} eventName * @param {string} eventName
* @param {Record<string, unknown>} attributes * @param {Record<string, unknown>} attributes
* @param {() => number} [now]
*/ */
export function projectTelemetryEvent(eventName, attributes) { function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) {
const registry = const registry =
/** @type {Record<string, (typeof TELEMETRY_REGISTRY)[keyof typeof TELEMETRY_REGISTRY]>} */ ( /** @type {Record<string, (typeof TELEMETRY_REGISTRY)[keyof typeof TELEMETRY_REGISTRY]>} */ (
TELEMETRY_REGISTRY TELEMETRY_REGISTRY
@@ -118,13 +151,47 @@ export function projectTelemetryEvent(eventName, attributes) {
}; };
} }
const attributeKeys = Object.keys(attributes);
if (
attributeKeys.length >
TELEMETRY_ATTRIBUTE_ALLOWLIST.length +
TELEMETRY_FORBIDDEN_ATTRIBUTES.length
) {
return {
success: /** @type {false} */ (false),
reason: "invalid-attribute-value",
};
}
const unknown = attributeKeys.filter(
(key) =>
!TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key),
);
if (unknown.length > 0) {
return {
success: /** @type {false} */ (false),
reason: "unknown-attributes",
};
}
const projected = Object.fromEntries( const projected = Object.fromEntries(
Object.entries(attributes).filter( Object.entries(attributes).filter(
([key]) => ([key, value]) =>
TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) && TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key), !TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key) &&
validAttributeValue(key, value),
), ),
); );
const invalid = Object.entries(attributes).filter(
([key, value]) =>
TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!validAttributeValue(key, value),
);
if (invalid.length > 0) {
return {
success: /** @type {false} */ (false),
reason: "invalid-attribute-value",
};
}
const missing = definition.requiredAttributes.filter( const missing = definition.requiredAttributes.filter(
(key) => projected[key] === undefined, (key) => projected[key] === undefined,
); );
@@ -135,11 +202,34 @@ export function projectTelemetryEvent(eventName, attributes) {
}; };
} }
let timestamp;
try {
timestamp = new Date(now()).toISOString();
} catch {
timestamp = new Date(0).toISOString();
}
return { return {
success: /** @type {true} */ (true), success: /** @type {true} */ (true),
event: Object.freeze({ event: Object.freeze({
eventName, eventName,
timestamp,
attributes: Object.freeze(projected), attributes: Object.freeze(projected),
}), }),
}; };
} }
/**
* @param {string} eventName
* @param {Record<string, unknown>} attributes
* @param {() => number} [now]
*/
export function projectTelemetryEvent(eventName, attributes, now = Date.now) {
try {
return projectTelemetryEventUnsafe(eventName, attributes, now);
} catch {
return {
success: /** @type {false} */ (false),
reason: "serialization-failure",
};
}
}
-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,10 @@
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.js";
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
}),
"en-US": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["en-US"],
}),
});
@@ -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/get/create input과 gateway 계약
- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter
- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper
- `presentation`: route input을 query/form controller로 연결하는 inbound
adapter, 독립 form schema/command mapper와 list/detail/form/status 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/form 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke,
production build와 source/built 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,71 @@
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;
pathParams?: Record<string, string | number>;
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; note?: 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;
},
async get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL",
pathParams: { resourceId },
signal: context?.signal,
});
return result.ok
? { ok: true as const, value: result.value as ReferenceResource }
: result;
},
});
}
@@ -0,0 +1,72 @@
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; note?: string }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
getResource(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
}>;
export type ReferenceGateway = Readonly<{
list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create(
command: Readonly<{ name: string; note?: string }>,
): Promise<ReferenceResult<ReferenceResource>>;
get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): 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;
},
async getResource(resourceId, context) {
const result = await gateway.get(resourceId, context);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
});
}
@@ -0,0 +1,148 @@
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)]),
/** @param {string} resourceId */
detail: (resourceId) =>
Object.freeze([...REFERENCE_NAMESPACE, "detail", String(resourceId)]),
});
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,
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
path: "/examples/reference-resources/:resourceId",
paramsSchema: "ReferenceResourceParams",
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-detail",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-detail",
title: "Reference detail",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
path: "/examples/reference-resources/new",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-form",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-form",
title: "Reference form",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
path: "/examples/reference-resources/status",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-status",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-status",
title: "Reference status",
navigationLabel: null,
navigationOrder: null,
}),
}),
routeRuntimeContracts: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
moduleId: "reference-resource-page",
paramsCodec: "none",
searchCodec: "ReferenceResourceListQuery",
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
moduleId: "reference-resource-detail-page",
paramsCodec: "ReferenceResourceParams",
searchCodec: "none",
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
moduleId: "reference-resource-form-page",
paramsCodec: "none",
searchCodec: "none",
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
moduleId: "reference-resource-status-page",
paramsCodec: "none",
searchCodec: "none",
}),
}),
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",
}),
GET_REFERENCE_RESOURCE: Object.freeze({
method: "GET",
path: "/api/reference-resources/{resourceId}",
operationId: "GET_REFERENCE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "none",
requestSchema: "NoRequest",
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-form-page-platform",
}),
}),
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,53 @@
import {
createReferenceResource,
type ReferenceResource,
} from "../domain/reference-resource.js";
export type ReferenceResourceView = Readonly<{
resourceId: string;
title: string;
createdAt: 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" ||
operationId === "GET_REFERENCE_RESOURCE"
) {
return mapReferenceDto(payload);
}
throw new TypeError(`No reference mapper registered for ${operationId}`);
}
export function toReferenceView(
resource: ReferenceResource,
): ReferenceResourceView {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAt: resource.createdAt,
});
}
@@ -0,0 +1,22 @@
export const REFERENCE_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
"route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature",
"route.REFERENCE_RESOURCE_LIST.title": "Reference resources",
"route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail",
"route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail",
"route.REFERENCE_RESOURCE_FORM.navigation": "Reference form",
"route.REFERENCE_RESOURCE_FORM.title": "Create reference resource",
"route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status",
"route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status",
}),
"en-US": Object.freeze({
"route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature",
"route.REFERENCE_RESOURCE_LIST.title": "Reference resources",
"route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail",
"route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail",
"route.REFERENCE_RESOURCE_FORM.navigation": "Reference form",
"route.REFERENCE_RESOURCE_FORM.title": "Create reference resource",
"route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status",
"route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status",
}),
});
@@ -0,0 +1,74 @@
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();
export const referenceResourceParamsSchema = z
.object({
resourceId: z.string().trim().min(1).max(120),
})
.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),
note: z.string().trim().max(500).optional(),
})
.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,30 @@
import { lazy } from "react";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "../contracts/reference-schemas.js";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
ReferenceResourceParams: referenceResourceParamsSchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
moduleId: "reference-resource-detail-page",
Component: lazy(() => import("./reference-resource-detail-page.js")),
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
moduleId: "reference-resource-form-page",
Component: lazy(() => import("./reference-resource-form-page.js")),
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
moduleId: "reference-resource-status-page",
Component: lazy(() => import("./reference-resource-status-page.js")),
}),
} as const;
@@ -0,0 +1,55 @@
import { Link } from "react-router-dom";
import {
AsyncSurface,
DetailPage,
} from "../../../presentation/design-system/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useLocale } from "../../../presentation/i18n/index.js";
import { useReferenceDetail } from "./use-reference-feature.js";
export default function ReferenceResourceDetailPage() {
const { date, message } = useLocale();
const route = useRouteInput();
const resourceId = String(route.params.resourceId);
const { query } = useReferenceDetail(resourceId);
const resource = query.data;
return (
<DetailPage
key={resourceId}
breadcrumb={
<Link to="/examples/reference-resources">Reference resources</Link>
}
heading={{
eyebrow: "DetailPage",
title: resource?.title ?? "Reference detail",
description: "route param과 detail query의 reset 경계를 확인합니다.",
}}
metadata={
resource ? (
<dl>
<dt>Resource ID</dt>
<dd>{resource.resourceId}</dd>
<dt>Created</dt>
<dd>
{resource.createdAt
? date(new Date(resource.createdAt))
: message("common.noDisplayValue")}
</dd>
</dl>
) : (
<p> .</p>
)
}
feedback={
<AsyncSurface state={query.state} onRetry={query.retry}>
{resource ? (
<p> section을 .</p>
) : null}
</AsyncSurface>
}
aside={<p> slot입니다.</p>}
/>
);
}
@@ -0,0 +1,131 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormPage,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/design-system/index.js";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
toCreateReferenceCommand,
type ReferenceResourceFormValues,
} from "./reference-resource-form.js";
import { useReferenceCreate } from "./use-reference-feature.js";
const FIELD_LABELS = Object.freeze({
name: "새 항목 이름",
note: "설명",
}) satisfies Record<keyof ReferenceResourceFormValues, string>;
export default function ReferenceResourceFormPage() {
const navigate = useNavigate();
const mutation = useReferenceCreate();
const submit = useCallback(
(command: ReturnType<typeof toCreateReferenceCommand>) =>
mutation.submit(command),
[mutation],
);
const form = useAppForm({
schema: referenceResourceFormSchema,
defaultValues: REFERENCE_FORM_DEFAULTS,
allowedServerFields: ["name", "note"],
mapToCommand: toCreateReferenceCommand,
submit,
});
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
return (
<Form
id={form.formId}
pending={form.pending}
onSubmit={(event) => void form.submitForm(event)}
>
<FormPage
breadcrumb={
<button
className="ui-button ui-button--ghost"
type="button"
onClick={() => navigate("/examples/reference-resources")}
>
</button>
}
heading={{
eyebrow: "FormPage",
title: "Reference resource 만들기",
description:
"presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.",
}}
errorSummary={
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={FIELD_LABELS}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
}
fields={
<>
<FormField
{...form.field("name")}
label={FIELD_LABELS.name}
description="앞뒤 공백은 command mapper 전에 제거됩니다."
autoComplete="off"
required
/>
<FormField
{...form.field("note")}
label={FIELD_LABELS.note}
description="선택 입력이며 비어 있으면 command에 포함되지 않습니다."
autoComplete="off"
/>
</>
}
formActions={
<FormActions sticky>
<Button
variant="secondary"
onClick={() => navigate("/examples/reference-resources")}
disabled={form.pending}
>
</Button>
<Button type="submit" disabled={form.pending}>
{form.pending ? "저장 중…" : "저장"}
</Button>
<Button
variant="ghost"
onClick={() => form.reset()}
disabled={!form.dirty || form.pending}
>
</Button>
</FormActions>
}
feedback={
form.result === "success" ? (
<p role="status">.</p>
) : form.result === "conflict" ? (
<p role="status"> .</p>
) : null
}
aside={
<p>
form value는 URL, storage, telemetry에 submit
application command로 .
</p>
}
guard={<DirtyNavigationDialog guard={guard} />}
/>
</Form>
);
}
@@ -0,0 +1,29 @@
import { z } from "zod";
export const referenceResourceFormSchema = z
.object({
name: z
.string()
.trim()
.min(2, "이름은 두 글자 이상이어야 합니다.")
.max(120),
note: z.string().trim().max(500).default(""),
})
.strict();
export type ReferenceResourceFormValues = z.infer<
typeof referenceResourceFormSchema
>;
export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
Object.freeze({
name: "",
note: "",
});
export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
return Object.freeze({
name: values.name,
...(values.note ? { note: values.note } : {}),
});
}
@@ -0,0 +1,55 @@
import { Link, useNavigate } from "react-router-dom";
import {
AsyncSurface,
Button,
CollectionPage,
} from "../../../presentation/design-system/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
const navigate = useNavigate();
const { filters, query } = useReferenceFeature();
return (
<CollectionPage
heading={{
eyebrow: "제거 가능한 수직 슬라이스",
title: "Reference feature",
description:
"URL codec, application input, HTTP/schema/mapper와 query 상태를 한 경로로 검증합니다.",
}}
actions={[
{
kind: "button",
label: "새 항목 만들기",
onAction: () => navigate("/examples/reference-resources/new"),
},
]}
activeFilters={
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
}
toolbar={<Button onClick={() => void query.retry()}></Button>}
resultCount={
query.data ? `${query.data.length}개 항목` : "결과 확인 중"
}
>
<AsyncSurface state={query.state} onRetry={query.retry}>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li key={resource.resourceId}>
<Link
to={`/examples/reference-resources/${encodeURIComponent(resource.resourceId)}`}
>
{resource.title}
</Link>
</li>
))}
</ul>
</AsyncSurface>
</CollectionPage>
);
}
@@ -0,0 +1,25 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/design-system/index.js";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
return (
<StatusPage
variant="maintenance"
heading={{
eyebrow: "StatusPage · maintenance",
title: "잠시 사용할 수 없습니다.",
description:
"도메인 데이터나 raw 오류를 노출하지 않는 중립적인 상태 페이지 예시입니다.",
}}
primaryAction={{
kind: "button",
label: "목록으로 이동",
onAction: () => navigate("/examples/reference-resources"),
}}
supportReference="REFERENCE-STATUS-DEMO"
/>
);
}
@@ -0,0 +1,81 @@
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";
export 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" ||
typeof (candidate as ReferenceFeatureInput).getResource !== "function"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
}
export function useReferenceDetail(resourceId: string) {
const input = useReferenceFeatureInput();
const query = useApplicationQuery({
queryKey: referenceQueryKeys.detail(resourceId),
execute: ({ signal }) => input.getResource(resourceId, { signal }),
});
return Object.freeze({ query });
}
export function useReferenceCreate() {
const input = useReferenceFeatureInput();
return useApplicationMutation({
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
});
}
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,
createdAt: null,
optimistic: true,
},
];
},
},
});
return Object.freeze({ filters, query, mutation });
}
@@ -1,3 +1,5 @@
import { formatMessage } from "../i18n/index.js";
/** /**
* @param {{ * @param {{
* kind?: string, * kind?: string,
@@ -18,32 +20,36 @@ export function BootErrorShell({
}) { }) {
return ( return (
<main role="alert"> <main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1> <h1>{formatMessage("ko-KR", "boot.failure.title")}</h1>
<dl> <dl>
<dt>오류</dt> <dt>{formatMessage("ko-KR", "boot.field.error")}</dt>
<dd>{kind}</dd> <dd>{kind}</dd>
<dt>코드</dt> <dt>{formatMessage("ko-KR", "boot.field.code")}</dt>
<dd>{code}</dd> <dd>{code}</dd>
{buildId && ( {buildId && (
<> <>
<dt>빌드</dt> <dt>{formatMessage("ko-KR", "boot.field.build")}</dt>
<dd>{buildId}</dd> <dd>{buildId}</dd>
</> </>
)} )}
{configSchemaVersion && ( {configSchemaVersion && (
<> <>
<dt>설정 스키마</dt> <dt>{formatMessage("ko-KR", "boot.field.configSchema")}</dt>
<dd>{configSchemaVersion}</dd> <dd>{configSchemaVersion}</dd>
</> </>
)} )}
{releaseId && ( {releaseId && (
<> <>
<dt>릴리스</dt> <dt>{formatMessage("ko-KR", "boot.field.release")}</dt>
<dd>{releaseId}</dd> <dd>{releaseId}</dd>
</> </>
)} )}
</dl> </dl>
<p>지원 참조: {supportReference}</p> <p>
{formatMessage("ko-KR", "boot.supportReference", {
reference: supportReference,
})}
</p>
</main> </main>
); );
} }
@@ -0,0 +1,115 @@
import {
Component,
type ErrorInfo,
type ReactNode,
} from "react";
import { useLocale } from "../i18n/index.js";
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 <ChunkRecoverySurface recovery="checking" />;
}
if (error && recovery === "reload-requested") {
return <ChunkRecoverySurface recovery="reload-requested" />;
}
if (error && recovery === "support") {
return <ChunkRecoverySurface recovery="support" reason={reason} />;
}
return this.props.children;
}
}
function ChunkRecoverySurface({
recovery,
reason,
}: Readonly<{
recovery: Exclude<State["recovery"], "idle">;
reason?: string;
}>) {
const { message } = useLocale();
if (recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
{message("chunk.checking")}
</section>
);
}
if (recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
{message("chunk.reloadOnce")}
</section>
);
}
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1>{message("chunk.failure.title")}</h1>
<p>{message("chunk.failure.description")}</p>
</section>
);
}
@@ -1,4 +1,5 @@
import { Component } from "react"; import { Component } from "react";
import { formatMessage } from "../i18n/index.js";
/** /**
* @typedef {{ * @typedef {{
@@ -6,6 +7,7 @@ import { Component } from "react";
* boundaryName: string, * boundaryName: string,
* routeId: string, * routeId: string,
* buildId: string, * buildId: string,
* resetKey?: string,
* onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void, * onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
* fallback?: React.ReactNode * fallback?: React.ReactNode
* }} RenderBoundaryProps * }} RenderBoundaryProps
@@ -37,6 +39,16 @@ export class RenderErrorBoundary extends Component {
} }
} }
/** @param {RenderBoundaryProps} previous */
componentDidUpdate(previous) {
if (
this.state.hasError &&
previous.resetKey !== this.props.resetKey
) {
this.setState({ hasError: false });
}
}
reset = () => { reset = () => {
this.setState({ hasError: false }); this.setState({ hasError: false });
}; };
@@ -46,9 +58,9 @@ export class RenderErrorBoundary extends Component {
return ( return (
this.props.fallback ?? ( this.props.fallback ?? (
<section role="alert"> <section role="alert">
<p>error.render_failure</p> <p>{formatMessage("ko-KR", "error.render_failure")}</p>
<button type="button" onClick={this.reset}> <button type="button" onClick={this.reset}>
retry {formatMessage("ko-KR", "action.retry")}
</button> </button>
</section> </section>
) )
+32 -14
View File
@@ -2,19 +2,22 @@ import { useId } from "react";
import { errorMessage } from "./error-copy.js"; import { errorMessage } from "./error-copy.js";
import { Button } from "./ui/button.jsx"; import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
/** @param {{ label?: string }} props */ /** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) { export function LoadingSurface({ label }) {
const { message } = useLocale();
const accessibleLabel = label ?? message("async.loading");
return ( return (
<section <section
className="state-surface state-surface--loading" className="state-surface state-surface--loading"
aria-busy="true" aria-busy="true"
aria-label={label} aria-label={accessibleLabel}
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
> >
<div className="ui-skeleton" aria-hidden="true" /> <div className="ui-skeleton" aria-hidden="true" />
<span className="visually-hidden">{label}</span> <span className="visually-hidden">{accessibleLabel}</span>
</section> </section>
); );
} }
@@ -27,13 +30,14 @@ export function LoadingSurface({ label = "불러오는 중" }) {
* }} props * }} props
*/ */
export function EmptySurface({ export function EmptySurface({
title = "표시할 항목이 없습니다.", title,
description, description,
action, action,
}) { }) {
const { message } = useLocale();
return ( return (
<section className="ui-empty state-surface" aria-live="polite"> <section className="ui-empty state-surface" aria-live="polite">
<h2>{title}</h2> <h2>{title ?? message("async.empty")}</h2>
{description ? <p>{description}</p> : null} {description ? <p>{description}</p> : null}
{action} {action}
</section> </section>
@@ -49,13 +53,14 @@ export function EmptySurface({
* }} props * }} props
*/ */
export function TerminalErrorSurface({ userMessageKey, action, onAction }) { export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
const { locale, message } = useLocale();
const messageId = useId(); const messageId = useId();
const actionLabels = Object.freeze({ const actionLabels = Object.freeze({
retry: "다시 시도", retry: message("action.retry"),
reauth: "로그인", reauth: message("action.reauth"),
navigate: "안전한 화면으로 이동", navigate: message("action.navigateSafe"),
"reload-once": "한 번 새로고침", "reload-once": message("action.reloadOnce"),
"contact-support": "지원 정보 확인", "contact-support": message("action.contactSupport"),
}); });
return ( return (
<section <section
@@ -64,7 +69,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
aria-labelledby={messageId} aria-labelledby={messageId}
data-message-key={userMessageKey} data-message-key={userMessageKey}
> >
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2> <h2 id={messageId}>{errorMessage(userMessageKey, locale)}</h2>
{action !== "none" && onAction && ( {action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button> <Button onClick={onAction}>{actionLabels[action]}</Button>
)} )}
@@ -88,6 +93,7 @@ export function AsyncSurface({
onRetry, onRetry,
onResolveConflict, onResolveConflict,
}) { }) {
const { message } = useLocale();
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) {
@@ -104,12 +110,24 @@ export function AsyncSurface({
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}> <section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator ? ( {state.indicator ? (
<div role="status" aria-live="polite"> <div role="status" aria-live="polite">
<span>{state.indicator}</span> <span>
{message(
state.indicator === "stale-degraded"
? "async.staleDegraded"
: state.indicator === "mutation-conflict"
? "async.mutationConflict"
: state.indicator === "mutation-pending"
? "async.mutationPending"
: "async.refreshing",
)}
</span>
{state.indicator === "stale-degraded" && onRetry ? ( {state.indicator === "stale-degraded" && onRetry ? (
<Button onClick={onRetry}>다시 시도</Button> <Button onClick={onRetry}>{message("action.retry")}</Button>
) : null} ) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? ( {state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>충돌 해결</Button> <Button onClick={onResolveConflict}>
{message("action.resolveConflict")}
</Button>
) : null} ) : null}
</div> </div>
) : null} ) : null}
+4 -19
View File
@@ -1,21 +1,6 @@
const ERROR_MESSAGES = Object.freeze({ import { resolveMessage } from "../i18n/index.js";
"error.network_unreachable": "네트워크에 연결할 수 없습니다.",
"error.request_timeout": "요청 시간이 초과되었습니다.",
"error.auth_required": "계속하려면 로그인이 필요합니다.",
"error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.",
"error.forbidden": "이 작업을 수행할 권한이 없습니다.",
"error.not_found": "요청한 항목을 찾을 수 없습니다.",
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
});
/** @param {string} messageKey */ /** @param {string} messageKey @param {string} [locale] */
export function errorMessage(messageKey) { export function errorMessage(messageKey, locale = "ko-KR") {
const messages = /** @type {Readonly<Record<string, string>>} */ ( return resolveMessage(locale, messageKey);
ERROR_MESSAGES
);
return messages[messageKey] ?? "요청을 완료하지 못했습니다.";
} }
@@ -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]);
+16 -12
View File
@@ -1,4 +1,5 @@
import { Button } from "./ui/button.jsx"; import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
/** /**
* @param {{ * @param {{
@@ -30,12 +31,13 @@ function StateSurface({
/** @param {{ onSignIn?: () => void }} props */ /** @param {{ onSignIn?: () => void }} props */
export function AuthRequiredSurface({ onSignIn }) { export function AuthRequiredSurface({ onSignIn }) {
const { message } = useLocale();
return ( return (
<StateSurface <StateSurface
eyebrow="401 · 인증 필요" eyebrow={message("access.auth.eyebrow")}
title="로그인이 필요합니다." title={message("access.auth.title")}
description="세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다." description={message("access.auth.description")}
actionLabel="로그인 시작" actionLabel={message("action.signIn")}
onAction={onSignIn} onAction={onSignIn}
/> />
); );
@@ -43,12 +45,13 @@ export function AuthRequiredSurface({ onSignIn }) {
/** @param {{ onNavigate?: () => void }} props */ /** @param {{ onNavigate?: () => void }} props */
export function ForbiddenSurface({ onNavigate }) { export function ForbiddenSurface({ onNavigate }) {
const { message } = useLocale();
return ( return (
<StateSurface <StateSurface
eyebrow="403 · 권한 없음" eyebrow={message("access.forbidden.eyebrow")}
title="접근할 수 없습니다." title={message("access.forbidden.title")}
description="권한을 확인하거나 접근 가능한 화면으로 이동하세요." description={message("access.forbidden.description")}
actionLabel="안전한 화면으로 이동" actionLabel={message("action.navigateSafe")}
onAction={onNavigate} onAction={onNavigate}
tone="warning" tone="warning"
/> />
@@ -57,12 +60,13 @@ export function ForbiddenSurface({ onNavigate }) {
/** @param {{ onNavigate?: () => void }} props */ /** @param {{ onNavigate?: () => void }} props */
export function NotFoundSurface({ onNavigate }) { export function NotFoundSurface({ onNavigate }) {
const { message } = useLocale();
return ( return (
<StateSurface <StateSurface
eyebrow="404 · 찾을 수 없음" eyebrow={message("access.notFound.eyebrow")}
title="요청한 화면이 없습니다." title={message("access.notFound.title")}
description="주소를 확인하거나 시작 화면으로 돌아가세요." description={message("access.notFound.description")}
actionLabel="시작 화면으로 이동" actionLabel={message("action.goHome")}
onAction={onNavigate} onAction={onNavigate}
/> />
); );
+1 -36
View File
@@ -1,36 +1 @@
/** export { Alert } from "../../design-system/primitives/core.js";
* @param {{
* title: string,
* children?: React.ReactNode,
* variant?: "info" | "success" | "warning" | "danger",
* onDismiss?: () => void
* }} props
*/
export function Alert({
title,
children,
variant = "info",
onDismiss,
}) {
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<button
className="ui-alert__dismiss"
type="button"
aria-label={`${title} 알림 닫기`}
onClick={onDismiss}
>
×
</button>
) : null}
</section>
);
}
+1 -9
View File
@@ -1,9 +1 @@
/** export { Badge } from "../../design-system/primitives/core.js";
* @param {{
* children: React.ReactNode,
* variant?: "neutral" | "info" | "success" | "warning" | "danger"
* }} props
*/
export function Badge({ children, variant = "neutral" }) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
+1 -24
View File
@@ -1,24 +1 @@
/** export { Button } from "../../design-system/primitives/core.js";
* @param {React.ButtonHTMLAttributes<HTMLButtonElement> & {
* variant?: "primary" | "secondary" | "danger" | "ghost",
* size?: "default" | "compact"
* }} props
*/
export function Button({
variant = "primary",
size = "default",
className = "",
type = "button",
...props
}) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ");
return <button {...props} className={classes} type={type} />;
}

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