From c6da03369cd4889191a215e04542f3568665be0e Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 1 Aug 2026 19:39:59 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=A6=AC=ED=8E=99=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dependency-cruiser.json | 123 +- .gitea/workflows/quality-gates.yml | 15 +- .gitignore | 3 + .storybook/preview.tsx | 20 + README.md | 2 +- .../tests/a11y-manual/EXAMPLES_PLATFORM.md | 18 + config/ci/gates.json | 48 +- .../contracts/registry-baseline.approval.json | 6 +- config/contracts/registry-baseline.json | 11 +- .../contracts/registry-change-evidence.json | 16 + config/contracts/registry-governance.json | 2 +- ...igned-implementation-closed-deep-design.md | 5957 +++++++++++++++++ ...contract-schema-mapper-and-server-state.md | 8 + .../backend-api-and-server-state-contract.md | 8 + docs/architecture/client-cache-and-storage.md | 8 + .../frontend-platform-capability-review.md | 8 + ...rontend-platform-implementation-roadmap.md | 8 + .../frontend-ports-adapters-and-boundaries.md | 8 + ...ime-events-web-push-and-bounded-polling.md | 8 + .../routing-pages-and-patterns.md | 8 + docs/architecture/starter-experience.md | 2 +- docs/architecture/static-delivery.md | 8 + .../typescript-state-and-data-flow.md | 8 + ...2026-07-31-platform-overview-completion.md | 856 +++ .../2026-08-01-scope-owned-server-state.md | 99 + ...026-07-31-platform-overview-page-design.md | 82 + eslint.config.ts | 6 + package.json | 10 +- public/config.json | 13 +- public/release-manifest.json | 11 +- schemas/artifacts/build-manifest.schema.json | 117 +- scripts/build-frontend.ts | 60 + scripts/check-ci-contract.ts | 49 +- scripts/check-optional-recipes.ts | 21 +- scripts/check-registries.ts | 17 + scripts/contracts/release-artifacts.ts | 1 + scripts/generate-build-manifest.ts | 25 +- scripts/generate-contract-set.ts | 64 + scripts/generate-service-worker-assets.ts | 144 + scripts/lib/ci-step-result.ts | 82 + scripts/lib/json-schema.ts | 24 + scripts/lib/service-worker-build-input.ts | 101 + scripts/run-ci-gate.ts | 117 +- ...st-browser-file-storage-runtime-removal.ts | 3 + scripts/test-optional-recipe-removal.ts | 3 + scripts/test-performance.ts | 8 +- scripts/test-realtime-runtime-removal.ts | 3 + scripts/test-sample-removal.ts | 24 + scripts/verify-release.ts | 115 +- scripts/verify-supply-chain-artifacts.ts | 22 +- .../browser-cross-context-host.ts | 3 + src/adapters/http/bounded-body-reader.ts | 160 + src/adapters/http/http-contract-bridge.ts | 288 + src/adapters/http/http-effect-certainty.ts | 151 + src/adapters/http/http-execution-v3.ts | 1095 +++ src/adapters/platform/browser-lifecycle.ts | 186 + .../query-cache/server-state-scope-runtime.ts | 130 +- .../query-cache/tanstack-cache-coordinator.ts | 13 +- .../service-worker/service-worker-entry.ts | 170 + .../service-worker-lifecycle.ts | 442 ++ .../service-worker-page-controller.ts | 399 ++ .../service-worker/service-worker-protocol.ts | 178 + .../service-worker/service-worker-removal.ts | 169 + .../service-worker-static-assets.ts | 359 + .../storage/indexeddb/indexeddb-runtime.ts | 147 +- .../storage/opfs/opfs-worker-client.ts | 53 +- .../web-push/push-association-fence-store.ts | 81 +- .../web-push/service-worker-scope-host.ts | 57 + src/application/create-application.ts | 10 +- src/application/ports/in/application-api.ts | 12 +- .../ports/out/application-output-ports.ts | 2 + src/application/ports/release-info-port.ts | 9 +- .../ports/runtime-capabilities-port.ts | 12 + src/bootstrap/create-runtime-composition.ts | 46 +- src/bootstrap/load-release-manifest.ts | 202 +- src/bootstrap/load-runtime-config.ts | 91 +- src/bootstrap/main.tsx | 2 +- src/bootstrap/optional-runtime-host.ts | 152 + src/bootstrap/read-bounded-boot-json.ts | 189 + src/bootstrap/register-service-worker.ts | 57 + src/bootstrap/runtime-adapters.ts | 274 +- src/bootstrap/runtime-application.tsx | 55 +- src/bootstrap/runtime-config-schema.ts | 187 +- .../server-state-generation-store.ts | 74 + src/contracts/contract-set-canonical.ts | 111 + src/contracts/contract-set.ts | 129 + src/contracts/env.ts | 47 +- src/contracts/errors.ts | 20 +- src/contracts/external-contract-runtime.ts | 582 ++ src/contracts/offline-command.ts | 154 + src/contracts/query-invalidation.ts | 144 + src/contracts/release-artifacts.ts | 268 + src/contracts/release-tokens.ts | 25 +- src/contracts/route-runtime-contract.ts | 6 + src/contracts/routes.ts | 15 +- src/contracts/runtime-capabilities.ts | 266 + src/contracts/server-state-scope.ts | 22 + src/contracts/server-state.ts | 176 +- src/contracts/service-worker.ts | 152 + src/contracts/web-worker.ts | 139 + .../installed-contract-contributions.ts | 27 + .../installed-runtime-capabilities.ts | 49 + .../create-reference-feature-input.ts | 199 +- .../adapters/reference-http-gateway.ts | 7 +- .../application/reference-feature-api.ts | 6 +- ...reference-feature-contract-contribution.ts | 255 + .../contracts/reference-feature-contract.ts | 8 +- .../presentation/use-reference-feature.ts | 50 +- .../adapters/query/application-query.ts | 126 +- .../query/optimistic-layer-runtime.ts | 54 + .../server-state-generation-provider.tsx | 52 + .../query/server-state-scope-provider.tsx | 16 +- .../examples/platform-overview-page.tsx | 484 ++ src/presentation/i18n/catalog.ts | 4 + src/presentation/pages/home-page.tsx | 13 +- src/presentation/routes/app-router.tsx | 4 +- src/presentation/routes/route-runtime.tsx | 4 + src/presentation/styles/theme.css | 40 + .../cross-context-invalidation.spec.ts | 2 +- tests/component/application-query.test.tsx | 332 + .../component/platform-overview-page.test.tsx | 164 + tests/component/router.test.tsx | 52 +- .../server-state-generation-provider.test.tsx | 74 + .../server-state-scope-provider.test.tsx | 83 + tests/e2e/accessibility.spec.ts | 2 +- tests/e2e/compact-smoke.spec.ts | 10 +- tests/e2e/platform-overview.spec.ts | 58 + tests/e2e/reference-route.spec.ts | 10 +- .../reference-contract.test.ts | 28 +- tests/fixtures/coverage/below-threshold.json | 8 + tests/helpers/create-test-application.ts | 3 + tests/helpers/runtime-capabilities-stub.ts | 34 + tests/mocks/handlers/reference-resources.ts | 37 +- tests/runtime-schema/release-manifest.test.ts | 158 +- tests/runtime-schema/runtime-config.test.ts | 115 +- tests/unit/application-boundary.test.ts | 2 + tests/unit/chunk-recovery-runtime.test.ts | 2 + tests/unit/ci-step-result.test.ts | 148 + .../unit/conditional-validator-store.test.ts | 3 + tests/unit/cross-tab-invalidation.test.ts | 29 +- tests/unit/external-contract-runtime.test.ts | 85 + tests/unit/http-execution-v3.test.ts | 283 + tests/unit/indexeddb-runtime.test.ts | 86 + tests/unit/json-schema.test.ts | 58 + tests/unit/navigation-policy.test.ts | 12 + tests/unit/opfs-worker-runtime.test.ts | 81 + tests/unit/optimistic-layer-runtime.test.ts | 3 + tests/unit/optional-runtime-host.test.ts | 90 + .../unit/query-invalidation-registry.test.ts | 26 + tests/unit/read-bounded-boot-json.test.ts | 31 + tests/unit/realtime/fixture.ts | 8 +- .../realtime/realtime-stream-registry.test.ts | 2 +- tests/unit/release-artifacts.test.ts | 129 + tests/unit/release-coherence.test.ts | 8 +- tests/unit/rest-profile-contract.test.ts | 58 +- tests/unit/runtime-adapters.test.ts | 171 +- .../unit/runtime-capability-snapshot.test.ts | 104 + tests/unit/server-state-scope-runtime.test.ts | 161 +- tests/unit/service-worker-build-input.test.ts | 87 + tests/unit/service-worker-runtime.test.ts | 567 ++ tests/unit/tanstack-cache-coordinator.test.ts | 35 + .../web-push-store-port-compatibility.test.ts | 28 + ...ct-pseudo-drawer-chromium-visual-linux.png | Bin 21273 -> 24400 bytes ...shell-wide-light-chromium-visual-linux.png | Bin 26374 -> 92844 bytes ...m-overview-light-chromium-visual-linux.png | Bin 0 -> 405018 bytes tests/visual/platform.visual.spec.ts | 11 + tsconfig.app.json | 21 +- tsconfig.json | 4 +- tsconfig.service-worker.json | 17 + tsconfig.web-worker.json | 10 + vite.service-worker.config.ts | 92 + 171 files changed, 20329 insertions(+), 782 deletions(-) create mode 100644 artifacts/tests/a11y-manual/EXAMPLES_PLATFORM.md create mode 100644 docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md create mode 100644 docs/superpowers/plans/2026-07-31-platform-overview-completion.md create mode 100644 docs/superpowers/plans/2026-08-01-scope-owned-server-state.md create mode 100644 docs/superpowers/specs/2026-07-31-platform-overview-page-design.md create mode 100644 scripts/build-frontend.ts create mode 100644 scripts/contracts/release-artifacts.ts create mode 100644 scripts/generate-contract-set.ts create mode 100644 scripts/generate-service-worker-assets.ts create mode 100644 scripts/lib/ci-step-result.ts create mode 100644 scripts/lib/json-schema.ts create mode 100644 scripts/lib/service-worker-build-input.ts create mode 100644 src/adapters/http/bounded-body-reader.ts create mode 100644 src/adapters/http/http-contract-bridge.ts create mode 100644 src/adapters/http/http-effect-certainty.ts create mode 100644 src/adapters/http/http-execution-v3.ts create mode 100644 src/adapters/platform/browser-lifecycle.ts create mode 100644 src/adapters/service-worker/service-worker-entry.ts create mode 100644 src/adapters/service-worker/service-worker-lifecycle.ts create mode 100644 src/adapters/service-worker/service-worker-page-controller.ts create mode 100644 src/adapters/service-worker/service-worker-protocol.ts create mode 100644 src/adapters/service-worker/service-worker-removal.ts create mode 100644 src/adapters/service-worker/service-worker-static-assets.ts create mode 100644 src/adapters/web-push/service-worker-scope-host.ts create mode 100644 src/application/ports/runtime-capabilities-port.ts create mode 100644 src/bootstrap/optional-runtime-host.ts create mode 100644 src/bootstrap/read-bounded-boot-json.ts create mode 100644 src/bootstrap/register-service-worker.ts create mode 100644 src/bootstrap/server-state-generation-store.ts create mode 100644 src/contracts/contract-set-canonical.ts create mode 100644 src/contracts/contract-set.ts create mode 100644 src/contracts/external-contract-runtime.ts create mode 100644 src/contracts/offline-command.ts create mode 100644 src/contracts/release-artifacts.ts create mode 100644 src/contracts/runtime-capabilities.ts create mode 100644 src/contracts/service-worker.ts create mode 100644 src/contracts/web-worker.ts create mode 100644 src/features/installed-contract-contributions.ts create mode 100644 src/features/installed-runtime-capabilities.ts create mode 100644 src/features/reference-feature/contracts/reference-feature-contract-contribution.ts create mode 100644 src/presentation/adapters/query/server-state-generation-provider.tsx create mode 100644 src/presentation/examples/platform-overview-page.tsx create mode 100644 tests/component/platform-overview-page.test.tsx create mode 100644 tests/component/server-state-generation-provider.test.tsx create mode 100644 tests/component/server-state-scope-provider.test.tsx create mode 100644 tests/e2e/platform-overview.spec.ts create mode 100644 tests/fixtures/coverage/below-threshold.json create mode 100644 tests/helpers/runtime-capabilities-stub.ts create mode 100644 tests/unit/ci-step-result.test.ts create mode 100644 tests/unit/external-contract-runtime.test.ts create mode 100644 tests/unit/http-execution-v3.test.ts create mode 100644 tests/unit/json-schema.test.ts create mode 100644 tests/unit/optional-runtime-host.test.ts create mode 100644 tests/unit/query-invalidation-registry.test.ts create mode 100644 tests/unit/read-bounded-boot-json.test.ts create mode 100644 tests/unit/release-artifacts.test.ts create mode 100644 tests/unit/runtime-capability-snapshot.test.ts create mode 100644 tests/unit/service-worker-build-input.test.ts create mode 100644 tests/unit/service-worker-runtime.test.ts create mode 100644 tests/unit/web-push-store-port-compatibility.test.ts create mode 100644 tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png create mode 100644 tsconfig.service-worker.json create mode 100644 tsconfig.web-worker.json create mode 100644 vite.service-worker.config.ts diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json index b78e1a2..55ee8b7 100644 --- a/.dependency-cruiser.json +++ b/.dependency-cruiser.json @@ -3,7 +3,9 @@ { "name": "domain-is-framework-neutral", "severity": "error", - "from": { "path": "^src/domain" }, + "from": { + "path": "^src/domain" + }, "to": { "path": "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)" } @@ -11,7 +13,9 @@ { "name": "application-does-not-know-concrete-runtime", "severity": "error", - "from": { "path": "^src/application" }, + "from": { + "path": "^src/application" + }, "to": { "path": "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)" } @@ -19,13 +23,19 @@ { "name": "presentation-does-not-know-adapters", "severity": "error", - "from": { "path": "^src/presentation/(?!adapters/query)" }, - "to": { "path": "^(src/(adapters|bootstrap)|@tanstack)" } + "from": { + "path": "^src/presentation/(?!adapters/query)" + }, + "to": { + "path": "^(src/(adapters|bootstrap)|@tanstack)" + } }, { "name": "page-templates-own-layout-only", "severity": "error", - "from": { "path": "^src/presentation/templates" }, + "from": { + "path": "^src/presentation/templates" + }, "to": { "path": "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)" } @@ -35,21 +45,28 @@ "severity": "error", "from": { "path": "^src", - "pathNot": - "^src/presentation/design-system/icons/vendors/lucide\\.tsx$" + "pathNot": "^src/presentation/design-system/icons/vendors/lucide\\.tsx$" }, - "to": { "path": "^lucide-react$" } + "to": { + "path": "^lucide-react$" + } }, { "name": "adapters-do-not-know-presentation", "severity": "error", - "from": { "path": "^src/adapters" }, - "to": { "path": "^src/(presentation|bootstrap)" } + "from": { + "path": "^src/adapters" + }, + "to": { + "path": "^src/(presentation|bootstrap)" + } }, { "name": "feature-domain-is-framework-neutral", "severity": "error", - "from": { "path": "^src/features/[^/]+/domain" }, + "from": { + "path": "^src/features/[^/]+/domain" + }, "to": { "path": "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)" } @@ -57,7 +74,9 @@ { "name": "feature-application-does-not-know-runtime", "severity": "error", - "from": { "path": "^src/features/[^/]+/application" }, + "from": { + "path": "^src/features/[^/]+/application" + }, "to": { "path": "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)" } @@ -65,7 +84,9 @@ { "name": "feature-presentation-does-not-know-outbound-adapters", "severity": "error", - "from": { "path": "^src/features/[^/]+/presentation" }, + "from": { + "path": "^src/features/[^/]+/presentation" + }, "to": { "path": "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)" } @@ -73,7 +94,9 @@ { "name": "feature-adapters-do-not-know-presentation", "severity": "error", - "from": { "path": "^src/features/[^/]+/adapters" }, + "from": { + "path": "^src/features/[^/]+/adapters" + }, "to": { "path": "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)" } @@ -81,24 +104,84 @@ { "name": "concrete-adapters-compose-only-in-bootstrap", "severity": "error", - "from": { "path": "^src/(domain|application|presentation|contracts)" }, - "to": { "path": "^src/adapters" } + "from": { + "path": "^src/(domain|application|presentation|contracts)" + }, + "to": { + "path": "^src/adapters" + } + }, + { + "name": "external-contract-package-single-import-path", + "comment": "§4.1: a generated service package may only be imported from src/features//contracts/*-contract-contribution.ts", + "severity": "error", + "from": { + "path": "^src", + "pathNot": "^src/features/[^/]+/contracts/[^/]+-contract-contribution\\.ts$" + }, + "to": { + "path": "^@org-contracts/" + } + }, + { + "name": "presentation-does-not-fetch-directly", + "comment": "§9.2 / appendix B: a page or hook never opens a socket, worker or HTTP adapter itself", + "severity": "error", + "from": { + "path": "^src/(presentation|features/[^/]+/presentation)" + }, + "to": { + "path": "^src/adapters/(http|realtime|service-worker|web-worker|storage)" + } + }, + { + "name": "generic-worker-has-no-network-or-credentials", + "comment": "§16.13 / §21.10: a CPU worker never imports HTTP, realtime or auth", + "severity": "error", + "from": { + "path": "^src/adapters/web-worker" + }, + "to": { + "path": "^src/adapters/(http|realtime|auth|web-push)" + } + }, + { + "name": "service-worker-entry-is-not-page-code", + "comment": "§17.2.1: the worker realm never imports React, presentation or bootstrap page code", + "severity": "error", + "from": { + "path": "^src/adapters/service-worker" + }, + "to": { + "path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)" + } }, { "name": "no-circular-dependencies", "severity": "error", "from": {}, - "to": { "circular": true } + "to": { + "circular": true + } } ], "options": { - "doNotFollow": { "path": "node_modules" }, + "doNotFollow": { + "path": "node_modules" + }, "exclude": { "path": "^(dist|artifacts|tests/fixtures)" }, "enhancedResolveOptions": { - "exportsFields": ["exports"], - "conditionNames": ["import", "require", "node", "default"] + "exportsFields": [ + "exports" + ], + "conditionNames": [ + "import", + "require", + "node", + "default" + ] }, "tsConfig": { "fileName": "tsconfig.app.json" diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index e4613df..fb8a13f 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -34,6 +34,7 @@ jobs: name: ${{ matrix.gate }} / ${{ matrix.name }} if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }} runs-on: ubuntu-latest + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -71,13 +72,14 @@ jobs: with: name: ${{ matrix.gate }}-${{ gitea.run_id }} path: artifacts/ - if-no-files-found: warn + if-no-files-found: error release_gate: name: ${{ matrix.gate }} / ${{ matrix.name }} needs: merge_gate if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} runs-on: ubuntu-latest + timeout-minutes: 45 env: HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }} strategy: @@ -109,13 +111,14 @@ jobs: with: name: ${{ matrix.gate }}-${{ gitea.run_id }} path: artifacts/ - if-no-files-found: warn + if-no-files-found: error production_gate: name: ${{ matrix.gate }} / ${{ matrix.name }} needs: release_gate if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }} runs-on: ubuntu-latest + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -143,13 +146,14 @@ jobs: with: name: ${{ matrix.gate }}-${{ gitea.run_id }} path: artifacts/ - if-no-files-found: warn + if-no-files-found: error field_gate: name: FE-GATE-018 / field-web-vitals needs: production_gate if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }} runs-on: ubuntu-latest + timeout-minutes: 45 env: FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }} MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }} @@ -170,12 +174,13 @@ jobs: with: name: FE-GATE-018-${{ gitea.run_id }} path: artifacts/ - if-no-files-found: warn + if-no-files-found: error documentation_gate: name: FE-GATE-017 / diagram-review if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }} runs-on: ubuntu-latest + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -193,4 +198,4 @@ jobs: with: name: FE-GATE-017-${{ gitea.run_id }} path: artifacts/ - if-no-files-found: warn + if-no-files-found: error diff --git a/.gitignore b/.gitignore index eda4cdd..e6b3beb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ node_modules/ dist/ .vite/ +.generated/ .tmp/ playwright-report/ test-results/ coverage/ +!tests/fixtures/coverage/ +!tests/fixtures/coverage/below-threshold.json artifacts/**/*.json artifacts/**/*.xml artifacts/**/*.txt diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 454ed29..7aca4b3 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -45,6 +45,26 @@ const application = createApplication({ routeChunks: {}, }), }, + runtimeCapabilities: { + getSnapshot: () => + Object.freeze( + ( + [ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", + ] as const + ).map((capabilityId) => + Object.freeze({ + capabilityId, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + ), + ), + }, navigation: { reload() {} }, }); const queryClient = createQueryClient(); diff --git a/README.md b/README.md index 2a3ee0d..a4127ce 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ persistent `system` / `light` / `dark` theme selector. | `/examples/ui` | buttons, fields, cards, alerts, badges, modal, and tokens | | `/examples/states` | loading, refresh, empty, error, auth, forbidden, and not-found states | | `/examples/auth` | reactive external-auth integration seam | -| `/examples/reference-resources` | removable, integration-defined reference feature | +| `/examples/reference-resources` | removable, session-required reference feature | `AUTH_MODE=demo` is credential-free and accepted only in local/development environments. Deployments use `AUTH_MODE=external` and provide the opaque auth diff --git a/artifacts/tests/a11y-manual/EXAMPLES_PLATFORM.md b/artifacts/tests/a11y-manual/EXAMPLES_PLATFORM.md new file mode 100644 index 0000000..d6280cb --- /dev/null +++ b/artifacts/tests/a11y-manual/EXAMPLES_PLATFORM.md @@ -0,0 +1,18 @@ +# EXAMPLES_PLATFORM accessibility review + +Status: pending-manual-review +Route ID: EXAMPLES_PLATFORM +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: Review the horizontally scrollable registry tables for keyboard reachability of the scroll container, table caption and header association announced per row, capability status badges carrying their meaning in text rather than colour alone, and the release identity region announcing its update through aria-live without interrupting a reader mid row. diff --git a/config/ci/gates.json b/config/ci/gates.json index 6930458..a5401a3 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -78,21 +78,21 @@ "steps": [ { "script": "check:types", "expect": "pass" }, { "script": "check:types:recipes", "expect": "pass" }, - { "script": "check:types:fixture", "expect": "fail" }, - { "script": "check:types:fixture:ts-port", "expect": "fail" }, - { "script": "check:types:fixture:ts-result", "expect": "fail" }, - { "script": "check:types:fixture:application-output", "expect": "fail" }, - { "script": "check:types:fixture:application-input", "expect": "fail" }, - { "script": "check:types:fixture:feature-input", "expect": "fail" }, - { "script": "check:types:fixture:failure-kind", "expect": "fail" }, - { "script": "check:types:fixture:reference-operation", "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" } + { "script": "check:types:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, + { "script": "check:types:fixture:ts-port", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, + { "script": "check:types:fixture:ts-result", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2339:" }, + { "script": "check:types:fixture:application-output", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, + { "script": "check:types:fixture:application-input", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2739:" }, + { "script": "check:types:fixture:feature-input", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2345:" }, + { "script": "check:types:fixture:failure-kind", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, + { "script": "check:types:fixture:reference-operation", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2345:" }, + { "script": "check:types:fixture:async-overlay", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, + { "script": "check:types:fixture:route-runtime", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2353:" }, + { "script": "check:types:fixture:page-action", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS1360:" }, + { "script": "check:types:fixture:icon-button", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2741:" }, + { "script": "check:types:fixture:i18n-key", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2345:" }, + { "script": "check:types:fixture:i18n-params", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2554:" }, + { "script": "check:types:fixture:diagnostics", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "UNKNOWN_DIAGNOSTIC_EVENT" } ], "logPath": "artifacts/quality/check-types.txt", "evidence": ["artifacts/quality/check-types.txt"], @@ -110,7 +110,7 @@ "steps": [ { "script": "test:unit", "expect": "pass" }, { "script": "test:coverage", "expect": "pass" }, - { "script": "check:coverage:fixture", "expect": "fail" } + { "script": "check:coverage:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Risk coverage failed:" } ], "logPath": "artifacts/quality/gates/FE-GATE-005.txt", "evidence": [ @@ -156,7 +156,7 @@ { "script": "test:storybook", "expect": "pass" }, { "script": "test:visual", "expect": "pass" }, { "script": "check:test-evidence", "expect": "pass" }, - { "script": "check:test-evidence:fixture", "expect": "fail" } + { "script": "check:test-evidence:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Test evidence failed:" } ], "logPath": "artifacts/quality/gates/FE-GATE-008.txt", "evidence": [ @@ -197,11 +197,11 @@ "steps": [ { "script": "check:architecture", "expect": "pass" }, { "script": "check:design-system", "expect": "pass" }, - { "script": "check:design-system:fixture", "expect": "fail" }, + { "script": "check:design-system:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Design system contract failed:" }, { "script": "check:i18n", "expect": "pass" }, - { "script": "check:i18n:fixture", "expect": "fail" }, + { "script": "check:i18n:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "I18n contract failed:" }, { "script": "check:diagnostics", "expect": "pass" }, - { "script": "check:diagnostics:fixture", "expect": "fail" }, + { "script": "check:diagnostics:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Diagnostics contract failed:" }, { "script": "check:browser-file-storage-boundaries", "expect": "pass" }, { "script": "check:realtime-boundaries", "expect": "pass" }, { "script": "check:realtime-boundaries:fixture", "expect": "pass" }, @@ -212,9 +212,9 @@ "script": "check:registries:compatibility-fixtures", "expect": "pass" }, - { "script": "check:registries:baseline-fixture", "expect": "fail" }, - { "script": "check:registries:fixture", "expect": "fail" }, - { "script": "check:routes:fixture", "expect": "fail" } + { "script": "check:registries:baseline-fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "registry baseline approval digest mismatch:" }, + { "script": "check:registries:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "duplicates id=duplicate" }, + { "script": "check:routes:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "duplicates routeId=DUPLICATE" } ], "logPath": "artifacts/quality/gates/FE-GATE-010.txt", "evidence": [ @@ -275,7 +275,7 @@ "script": "check:supply-chain:provider-fixtures", "expect": "pass" }, - { "script": "scan:security:fixture", "expect": "fail" }, + { "script": "scan:security:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Security scan found" }, { "script": "check:browser-security", "expect": "pass" } ], "logPath": "artifacts/quality/gates/FE-GATE-013.txt", diff --git a/config/contracts/registry-baseline.approval.json b/config/contracts/registry-baseline.approval.json index a245e1d..bce4eff 100644 --- a/config/contracts/registry-baseline.approval.json +++ b/config/contracts/registry-baseline.approval.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "snapshotDigest": "67f1ad2bf35d04470a7fba1a5ae8f98608b5d70b70887e383692b1e2d6127752", + "snapshotDigest": "53468eada148a7afc821b0885b8a53bf47978bfd17adf26b424054beedcce608", "owner": "frontend-platform", - "reason": "Typed Web Storage codecs and query invalidation topic protocol", - "approvedAt": "2026-07-27T16:41:15.888Z" + "reason": "Make the reference feature route access policy explicitly session-required", + "approvedAt": "2026-08-01T07:18:30.000Z" } diff --git a/config/contracts/registry-baseline.json b/config/contracts/registry-baseline.json index 8316edd..6bce1cd 100644 --- a/config/contracts/registry-baseline.json +++ b/config/contracts/registry-baseline.json @@ -41,8 +41,7 @@ "allowedValues": { "access": [ "public", - "session-required", - "integration-defined" + "session-required" ], "paramsSchema": [ null, @@ -162,7 +161,7 @@ "title": "페이지를 찾을 수 없음" }, "REFERENCE_RESOURCE_DETAIL": { - "access": "integration-defined", + "access": "session-required", "chunkId": "route-reference-resource-detail", "errorSurface": "feature-boundary", "loadingSurface": "reference-resource-detail", @@ -175,7 +174,7 @@ "title": "Reference detail" }, "REFERENCE_RESOURCE_FORM": { - "access": "integration-defined", + "access": "session-required", "chunkId": "route-reference-resource-form", "errorSurface": "feature-boundary", "loadingSurface": "reference-resource-form", @@ -188,7 +187,7 @@ "title": "Reference form" }, "REFERENCE_RESOURCE_LIST": { - "access": "integration-defined", + "access": "session-required", "chunkId": "route-reference-resources", "errorSurface": "feature-boundary", "loadingSurface": "reference-resource-list", @@ -201,7 +200,7 @@ "title": "Reference feature" }, "REFERENCE_RESOURCE_STATUS": { - "access": "integration-defined", + "access": "session-required", "chunkId": "route-reference-resource-status", "errorSurface": "feature-boundary", "loadingSurface": "reference-resource-status", diff --git a/config/contracts/registry-change-evidence.json b/config/contracts/registry-change-evidence.json index ee4bba1..130db93 100644 --- a/config/contracts/registry-change-evidence.json +++ b/config/contracts/registry-change-evidence.json @@ -1,6 +1,14 @@ { "schemaVersion": 1, "changes": [ + { + "changeId": "FE-REG-ENV:API_CONTRACT_VERSION:*:removed", + "versionBump": "Runtime Config V2 (CONFIG_SCHEMA_VERSION 2.0) removes the scalar API contract version.", + "migration": "Release Manifest V2 contractSet replaces the scalar: the compiled external contract package set is canonicalized and digested, and boot compares it against the manifest.", + "compatibilityWindow": "The V1 config and V1 manifest readers stay in place for one release. A V1 document may still carry the scalar; a V2 document is rejected if it does.", + "rollback": "Restore the V1 writer in scripts/generate-build-manifest.ts and the scalar key in public/config.json; the V1 reader is still present.", + "owner": "frontend-platform" + }, { "changeId": "FE-REG-QUERY:$contract:allowedValues:contract-field-changed", "versionBump": "Cross-context invalidation wire protocol starts at version 1.", @@ -41,6 +49,14 @@ "rollback": "Drop invalidationTopic uniqueness after removing the transport consumer.", "owner": "frontend-platform" }, + { + "changeId": "FE-REG-RELEASE:apiContractVersion:compatibilityRole:field-changed", + "versionBump": "The release token registry adds contractSetDigest and demotes apiContractVersion to a legacy V1 scalar.", + "migration": "compareReleaseToRuntime no longer sources the scalar from Runtime Config; when neither side declares it there is nothing to compare and contractSet verification owns contract coherence.", + "compatibilityWindow": "A V1 manifest still supplies apiContractVersion and is still compared against a V1 config that declares one.", + "rollback": "Restore the previous compatibilityRole text and remove the contractSetDigest token together with the V2 manifest writer.", + "owner": "frontend-platform" + }, { "changeId": "FE-REG-STORAGE:$contract:allowedValues:contract-field-changed", "versionBump": "Web Storage value codec contracts start at version 1.", diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index f33db32..29232ed 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -40,7 +40,7 @@ }, "uniqueFields": ["routeId", "path", "chunkId"], "allowedValues": { - "access": ["public", "session-required", "integration-defined"], + "access": ["public", "session-required"], "paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"], "searchSchema": [null, "ReferenceResourceListQuery"], "loadingSurface": [ diff --git a/docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md b/docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md new file mode 100644 index 0000000..cd0c5d7 --- /dev/null +++ b/docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md @@ -0,0 +1,5957 @@ +# 프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계 + +- 작성일: 2026-07-30 +- 상태: 구현 결정 정본 v1 +- 대상 저장소: `clean-architecture-frontend-template` +- 기준 저장소 스냅샷: 제공된 Repomix 전체 저장소 표현 +- 상위 설계: + - `2026-07-30-frontend-production-capability-repository-aligned-deep-design.md` +- 계약 정본: + - 별도 API·이벤트 계약 관리 저장소에서 발행한 immutable TypeScript contract package +- 기준 기술: + - Node.js 24 major + - pnpm 11 + - TypeScript strict / `allowJs: false` + - React 19 + - React Router 7 data mode + - TanStack Query 5 + - Zod 4 + - Vite 8 +- 문서 규칙: + - **MUST**: 구현자가 변경할 수 없는 기본 결정 + - **MUST NOT**: 존재하면 설계 위반 + - **MAY**: 명시적으로 선택된 capability에만 허용 + - 본 문서의 exact literal, 상태, 상한, 순서와 source path는 예시가 아니라 구현 정본이다. + +--- + +## 목차 + +- [0. 문서 권한과 제외 범위](#0-문서-권한과-제외-범위) +- [1. 현재 저장소 기준선](#1-현재-저장소-기준선) +- [2. 구현 결정 폐쇄 표](#2-구현-결정-폐쇄-표) +- [3. 목표 런타임 구조와 lifecycle](#3-목표-런타임-구조와-lifecycle) +- [4. 외부 계약 package 소비 경계](#4-외부-계약-package-소비-경계) +- [5. Contract Set과 release coherence](#5-contract-set과-release-coherence) +- [6. Runtime Config와 boot](#6-runtime-config와-boot) +- [7. Fetch HTTP execution runtime](#7-fetch-http-execution-runtime) +- [8. Retry·cancellation·mutation effect certainty](#8-retrycancellationmutation-effect-certainty) +- [9. Router](#9-router) +- [10. Query lifecycle과 cache](#10-query-lifecycle과-cache) +- [11. Mutation lifecycle](#11-mutation-lifecycle) +- [12. Cross-context invalidation](#12-cross-context-invalidation) +- [13. Realtime product composition](#13-realtime-product-composition) +- [14. Connection·Reconnect·Resume](#14-connectionreconnectresume) +- [15. Event validation과 subscription cleanup](#15-event-validation과-subscription-cleanup) +- [16. Generic Web Worker](#16-generic-web-worker) +- [17. Service Worker lifecycle](#17-service-worker-lifecycle) +- [18. Cache Storage와 fetch policy](#18-cache-storage와-fetch-policy) +- [19. Offline Command와 Background Sync](#19-offline-command와-background-sync) +- [20. 공통 start·stop·dispose](#20-공통-startstopdispose) +- [21. Security와 privacy](#21-security와-privacy) +- [22. Runtime observability](#22-runtime-observability) +- [23. 구현 검증](#23-구현-검증) +- [24. Source 배치와 migration](#24-source-배치와-migration) +- [25. 구현 순서와 stop condition](#25-구현-순서와-stop-condition) +- [26. 완료 기준](#26-완료-기준) +- [27. 위험 등록부](#27-위험-등록부) +- [28. Normative decision ledger](#28-normative-decision-ledger) +- [부록 A. Numeric constants](#부록-a-numeric-constants) +- [부록 B. 금지 구현 목록](#부록-b-금지-구현-목록) +- [부록 C. 플랫폼 참고 기준](#부록-c-플랫폼-참고-기준) + +--- + +## 0. 문서 권한과 제외 범위 + +### 0.1 목적 + +이 문서는 다음 프론트엔드 런타임 capability를 현재 저장소 구조에 맞춰 구현할 때 개발자가 다시 선택하거나 질문할 지점을 제거한다. + +```text +Fetch HTTP Client +Runtime Config +Router +Query / Mutation +Connection / Reconnect / Resume +Event Validation +Subscription Cleanup +Web Worker +Service Worker +Background Sync +``` + +상위 저장소 정합형 설계가 방향과 책임을 고정했다면, 본 문서는 다음을 추가로 고정한다. + +- exact source path; +- exact public type shape; +- exact lifecycle state; +- exact startup/shutdown order; +- exact timeout, queue, payload와 retry ceiling; +- exact optional capability selection과 removal 절차; +- external contract package를 저장소 runtime에 연결하는 단일 경로; +- browser lifecycle과 session/account transition ordering; +- failure union과 mutation uncertainty 처리; +- 구현 중 허용되지 않는 fallback; +- 구현 완료를 판정할 deterministic behavior case. + +### 0.2 본 문서가 대체하는 범위 + +상위 설계의 다음 절은 본 문서가 더 구체적인 정본이다. + +| 상위 범위 | 본 문서 정본 | +| --- | --- | +| Runtime Config와 boot coherence | §5–§6 | +| Fetch HTTP Client | §4, §7–§8 | +| Router | §9 | +| Query와 Mutation | §10–§12 | +| Realtime 공통 경계 | §13–§15 | +| Web Worker | §16 | +| Service Worker | §17–§18 | +| Offline Command와 Background Sync | §19 | +| optional capability 조립 | §3, §17.3, §24 | +| lifecycle·security·observability | §20–§22 | +| source 배치와 구현 순서 | §24–§25 | + +상위 설계와 본 문서가 충돌하면 위 범위에서는 본 문서를 따른다. + +### 0.3 API·이벤트 계약 관련 명시적 제외 + +다음은 별도 API·이벤트 계약 관리 저장소가 소유한다. 본 문서에서 다시 정의하거나 frontend local schema로 복제하지 않는다. + +```text +OpenAPI / AsyncAPI 원문 +operation method/path/status/media semantics +Problem Details type/code/extensions +pagination cursor wire shape +idempotency server semantics와 retention +event type/envelope/payload schema +replay/cursor/snapshot server protocol +provider compatibility와 deprecation/sunset +backend controller/event publisher conformance +contract package 생성·발행·서명·provenance +``` + +본 저장소가 소유하는 것은 다음뿐이다. + +```text +immutable TypeScript contract package pin +package manifest/digest 검증 +frontend-owned normalized descriptor interface +runtime validator 호출 +wire DTO -> application value mapper +HTTP/Query/Realtime runtime behavior +contractSet와 frontend release coherence +unknown/future contract input의 safe failure +``` + +외부 package의 의미가 부족하거나 서로 모순되면 frontend가 임의 의미를 보충하지 않는다. 해당 feature contribution을 composition하지 않고 `CONTRACT_CONTRIBUTION_INVALID`로 종료한다. + +### 0.4 CI 명시적 비범위 + +다음은 별도 CI/CD 플랫폼이 소유하며 본 문서에서 설계하지 않는다. + +- workflow YAML; +- merge/release stage 구성; +- gate ID와 promotion formula; +- artifact retention service; +- package publication identity; +- vulnerability/signing/SBOM pipeline; +- deployment/canary controller; +- CI evidence aggregation. + +§23은 코드가 만족해야 할 **행동 검증 케이스**만 정의한다. 어느 pipeline에서 실행할지는 정의하지 않는다. + +### 0.5 정본 우선순위 + +충돌 시 다음 순서를 사용한다. + +1. 현재 저장소 source와 실행 가능한 test가 증명하는 사실; +2. 별도 계약 저장소에서 발행한 exact immutable package; +3. 본 문서의 MUST/MUST NOT와 decision ledger; +4. 상위 저장소 정합형 설계; +5. 기존 일반 greenfield 설계; +6. 예시 코드와 설명 문구. + +예시가 public type과 다르면 public type이 정본이다. 두 설정이 같은 의미를 서로 다르게 지정하면 precedence를 만들지 않고 startup을 실패시킨다. + +--- + +## 1. 현재 저장소 기준선 + +### 1.1 물리 구조 + +현재 저장소의 canonical source root는 다음과 같다. + +```text +src/ + contracts/ # 저장소 내부 registry와 provider-neutral runtime contract + application/ # input/output port, use case, policy, view model + adapters/ # HTTP, query cache, storage, realtime, web-push 등 concrete adapter + presentation/ # React inbound adapter, Router, Query hook, UI + bootstrap/ # 유일한 composition root와 boot + features/ # 제거 가능한 vertical feature +``` + +다음 신규 parallel root는 만들지 않는다. + +```text +src/platform +src/service-worker +src/workers +src/infrastructure +src/api +src/shared/api +``` + +### 1.2 계층 방향 + +현재 `config/architecture/layers.json`의 방향을 유지한다. + +```text +domain -> domain only +application -> application, domain, contracts +presentation -> presentation, application, domain, contracts +adapters -> adapters, application, domain, contracts +bootstrap -> src 전체 +``` + +추가 불변조건: + +```text +presentation MUST NOT import adapters +application MUST NOT import React, Router, TanStack Query, browser API +adapters MUST NOT import presentation/bootstrap +feature presentation MUST call application input or approved inbound bridge +bootstrap alone selects concrete runtime +``` + +### 1.3 현재 구현을 재사용하는 항목 + +다음 source를 제거하거나 동일 기능으로 다시 만들지 않는다. + +| Capability | 현재 source | 판정 | +| --- | --- | --- | +| Runtime Config schema/load | `src/bootstrap/runtime-config-schema.ts`, `load-runtime-config.ts` | 보강 | +| Release manifest/load | `src/bootstrap/load-release-manifest.ts` | V2 migration | +| composition root | `src/bootstrap/create-runtime-composition.ts`, `composition-root.ts` | 확장 | +| bounded HTTP response | `src/adapters/http/bounded-json.ts` | 공통 bounded body reader로 확장 | +| Fetch client | `src/adapters/http/client.ts` | execution V3 보강 | +| request builder | `src/adapters/http/request-builder.ts` | package descriptor bridge 연결 | +| REST registry/profile | `src/contracts/api-operations.ts`, `rest-profiles.ts` | external contribution normalization으로 migration | +| Query adapter | `src/presentation/adapters/query/application-query.ts` | 유지·보강 | +| Query cache coordinator | `src/adapters/query-cache/*` | scope/invalidation 보강 | +| Router runtime | `src/presentation/routes/*` | 유지 | +| realtime registry/runtime | `src/contracts/realtime-*`, `src/adapters/realtime/*` | 제품 조립만 추가 | +| Web Push worker handler | `src/adapters/web-push/service-worker-runtime.ts` | 단일 SW entry handler로 재사용 | +| IndexedDB reference runtime | `src/adapters/storage/indexeddb/*` | offline dataset backend로 재사용 | +| OPFS Worker | `src/adapters/storage/opfs/*` | generic CPU Worker와 분리 유지 | +| Cache Storage reference | `src/adapters/cache-storage/*` | static asset handler에서 재사용 | + +### 1.4 현재 삭제하지 않는 compatibility surface + +다음 public/runtime surface는 migration 동안 유지한다. + +- existing reference feature route와 local fixture; +- current `ApiOperationV2` reader; +- current runtime config V1 reader; +- current release manifest V1 parser; +- current query key and invalidation contracts; +- current RT-01~RT-04 common runtime; +- current browser file/storage reference runtime. + +새 writer가 V2를 사용하도록 먼저 전환한 뒤 V1 reader를 제거한다. 같은 release에서 source shape, manifest shape와 runtime behavior를 동시에 big-bang 교체하지 않는다. + +### 1.5 현재 상태 taxonomy + +이 문서는 저장소 기존 상태 literal을 유지한다. + +```text +COMPOSED +AVAILABLE_NOT_COMPOSED +DESIGNED_NOT_IMPLEMENTED +NOT_SELECTED +PLATFORM_LIMITED +``` + +현재 주요 판정: + +| Capability | 현재 | 본 문서 목표 | +| --- | --- | --- | +| Runtime Config | `COMPOSED` V1 | V2 bounded boot | +| Release coherence | `COMPOSED` scalar contract version | contractSet V2 | +| HTTP reference path | `COMPOSED` | execution V3 | +| Router | `COMPOSED` | ownership closed | +| Query memory cache | `COMPOSED` | scope/result/invalidation closed | +| Query persistence | `NOT_SELECTED` | 계속 disabled | +| Realtime common runtime | `AVAILABLE_NOT_COMPOSED` | 그대로 재사용 | +| Product realtime | `NOT_SELECTED` | explicit contribution 필요 | +| Generic CPU Worker | `NOT_SELECTED` | opt-in runtime | +| OPFS Worker | `AVAILABLE_NOT_COMPOSED` | 별도 유지 | +| Cache Storage | `AVAILABLE_NOT_COMPOSED` | static asset only | +| PWA Service Worker lifecycle | `DESIGNED_NOT_IMPLEMENTED` | reference runtime | +| Offline command queue | `NOT_SELECTED` | opt-in reference runtime | +| Background Sync guarantee | `PLATFORM_LIMITED` | wake-only enhancement | + +--- + +## 2. 구현 결정 폐쇄 표 + +다음 표의 선택은 구현 중 다시 묻지 않는다. + +| 질문 | 결정 | +| --- | --- | +| capability별 새 `src/platform/*`를 만드는가 | 아니다. 기존 `contracts/application/adapters/presentation/bootstrap/features`에 배치한다. | +| API·이벤트 schema를 frontend에 복사하는가 | 아니다. exact immutable contract package만 소비한다. | +| deployed OpenAPI/AsyncAPI를 runtime에 읽는가 | 아니다. build dependency만 사용한다. | +| `API_CONTRACT_VERSION` scalar를 유지하는가 | 제거한다. Release Manifest V2 `contractSet`으로 교체한다. | +| Runtime Config가 optional feature를 켤 수 있는가 | 아니다. static installed capability를 끌 수만 있다. | +| boot JSON을 `response.json()`으로 읽는가 | 아니다. streaming byte bound 후 UTF-8/JSON parse한다. | +| HTTP retry owner | HTTP transport 하나다. Query/Mutation retry는 false다. | +| redirect | 모든 API/boot request에서 reject한다. | +| mutation timeout/abort를 실패로 단정하는가 | 아니다. send 이후는 `MAYBE_APPLIED`다. | +| Router loader가 server fetch/cache를 소유하는가 | 아니다. URL parse/guard/lazy lifecycle만 소유한다. | +| QueryClient 수 | application runtime당 하나다. | +| Query persistence | 기본 비활성이다. Web Storage와 IndexedDB에 query payload를 넣지 않는다. | +| query result size 추정 | 모든 definition의 required `measureResult`만 사용한다. generic fallback은 없다. | +| cross-tab data 전송 | 금지한다. opaque invalidation topic만 전송한다. | +| realtime transport 자동 fallback | 금지한다. registration이 explicit fallback을 선택한다. | +| connection open과 resume success | 별도 상태다. `OPEN_UNRESUMED`에서 event를 적용하지 않는다. | +| event checkpoint commit | application effect 성공 이후다. | +| component가 raw socket을 소유하는가 | 아니다. ref-counted subscription lease만 소유한다. | +| generic Worker 기본 설치 | 아니다. profiling과 static contribution이 있어야 한다. | +| Worker가 network/auth를 소유하는가 | 아니다. CPU task만 수행한다. | +| PWA와 Web Push worker registration을 나누는가 | 아니다. 동일 scope에 physical worker 하나다. | +| `skipWaiting()` | client drain handshake 성공 후에만 호출한다. | +| `clients.claim()` | baseline에서 사용하지 않는다. | +| navigation offline shell | 제공하지 않는다. navigation은 network-only다. | +| API response Cache Storage | 금지한다. verified static asset만 cache한다. | +| Service Worker 등록 시점 | Runtime Config/release 검증과 첫 committed render 이후다. | +| Background Sync가 command를 전송하는가 | baseline에서는 아니다. foreground drain wake-up만 기록한다. | +| offline sender 수 | origin/account partition당 하나다. | +| `SENDING` 중 crash | `PENDING`으로 되돌리지 않고 `EFFECT_UNKNOWN`이다. | +| 일반 disabled capability | listener/DB/worker/timer를 만들지 않는다. | +| persisted Service Worker disable | 새 register 없이 exact-owned registration만 unregister하고 cache를 보존한다. | +| Service Worker source 제거 | `ACTIVE -> REMOVE_REGISTRATION -> PURGE_OWNED_RESOURCES -> null` 순서다. | +| lifecycle correctness를 unload에 의존하는가 | 아니다. transaction, TTL, recovery로 닫는다. | + +--- + +## 3. 목표 런타임 구조와 lifecycle + +### 3.1 전체 흐름 + +```text +Vite embedded build identity + + installed contract contributions + + installed runtime capabilities + | + v +bounded Runtime Config V2 + | + v +bounded Release Manifest V2 + contractSet check + | + v +base composition + HTTP / auth bridge / application / QueryClient / Router + | + v +React first committed render + | + v +post-mount optional runtime start + offline foreground observer + realtime hosts + lazy Web Worker registry + Service Worker active/cleanup controller +``` + +### 3.2 boot state machine + +```text +UNINITIALIZED + -> LOADING_RUNTIME_CONFIG + -> VALIDATING_RUNTIME_CONFIG + -> LOADING_RELEASE_MANIFEST + -> VALIDATING_RELEASE_COHERENCE + -> COMPOSING_BASE + -> MOUNTING + -> BASE_READY + -> STARTING_OPTIONAL + -> READY +``` + +terminal/degraded state: + +```text +BOOT_FAILED_SAFE +BASE_READY_OPTIONAL_DEGRADED +DISPOSED +``` + +`BOOT_FAILED_SAFE`에서는 product Router와 Query provider를 mount하지 않는다. safe boot shell만 render한다. + +### 3.3 composition result + +```ts +export interface RuntimeComposition { + readonly application: ApplicationApi; + readonly queryClient: QueryClient; + readonly router: Router; + readonly scopeRuntime: ServerStateScopeRuntime; + readonly optional: OptionalRuntimeHost; + dispose(): Promise; +} +``` + +feature page에 다음을 노출하지 않는다. + +- raw Fetch client; +- endpoint URL; +- auth credential attacher; +- browser storage; +- QueryClient; +- WebSocket/EventSource; +- Worker; +- ServiceWorkerRegistration; +- IndexedDB transaction. + +### 3.4 optional runtime host + +```ts +export interface OptionalRuntimeHost { + readonly realtime: RealtimeRuntimeHost | null; + readonly webWorkers: WebWorkerRuntimeHost | null; + readonly serviceWorker: ServiceWorkerRuntimeHost | null; + readonly offlineCommands: OfflineCommandRuntimeHost | null; + startAfterMount(): Promise; + stop(): Promise; +} +``` + +start order: + +```text +1. offline foreground browser lifecycle observer +2. realtime runtime +3. Web Worker prewarm 없음 +4. Service Worker active/cleanup controller +``` + +stop order는 역순이다. + +### 3.5 static capability selection SSOT + +```text +src/features/installed-runtime-capabilities.ts +``` + +```ts +export interface InstalledRuntimeCapabilities { + readonly realtime: readonly InstalledRealtimeContribution[]; + readonly webWorkers: readonly InstalledWebWorkerContribution[]; + readonly serviceWorker: InstalledServiceWorkerSelection | null; + readonly offlineCommands: InstalledOfflineCommandContribution | null; +} + +export type RuntimeCapabilityOverride = "DEFAULT" | "DISABLED"; + +export type InstalledServiceWorkerSelection = + | Readonly<{ + mode: "ACTIVE"; + scriptPath: "service-worker.js"; + handlers: readonly ServiceWorkerHandlerId[]; + }> + | Readonly<{ + mode: "REMOVE_REGISTRATION"; + scriptPath: "service-worker.js"; + }> + | Readonly<{ + mode: "PURGE_OWNED_RESOURCES"; + scriptPath: "service-worker.js"; + }>; + +export type ServiceWorkerHandlerId = + | "WEB_PUSH" + | "PWA_STATIC_ASSETS" + | "OFFLINE_SYNC_WAKEUP"; +``` + +규칙: + +- source contribution이 설치 capability를 결정한다; +- Runtime Config는 `DEFAULT | DISABLED`만 제공한다; +- config가 source에 없는 capability를 켤 수 없다; +- dynamic import path를 config 문자열에서 만들지 않는다; +- contribution array와 object는 composition 시 deep snapshot/freeze한다; +- duplicate ID와 contradictory selection은 mount 전 실패한다. + +### 3.6 zero-side-effect 원칙과 persistent 예외 + +realtime/Web Worker/offline command가 unselected 또는 disabled이면: + +```text +factory call 0 +window listener 0 +timer 0 +network 0 +IndexedDB open 0 +Worker creation 0 +``` + +Service Worker는 browser에 persistent registration이 남을 수 있으므로 다음 예외만 허용한다. + +```text +static ACTIVE + runtime DISABLED + -> new register 0 + -> exact owned registration lookup 1 + -> owned registration unregister at most 1 + -> owned cache delete 0 +``` + +이 예외를 다른 capability의 hidden cleanup side effect 근거로 확대하지 않는다. + +--- + +## 4. 외부 계약 package 소비 경계 + +### 4.1 단일 import 위치 + +service별 generated package import는 다음 파일 한 곳에서만 허용한다. + +```text +src/features//contracts/-contract-contribution.ts +``` + +예: + +```text +src/features/worklog/ + contracts/ + worklog-service-contract-contribution.ts + worklog-mapper.ts + application/ + adapters/ + worklog-http-gateway.ts + presentation/ +``` + +금지 import: + +```text +src/domain/** -> @org-contracts/* +src/application/** -> @org-contracts/* +src/presentation/** -> @org-contracts/* +src/adapters/http/** -> service-specific package +src/bootstrap/** -> service-specific package direct import +``` + +bootstrap은 normalized feature contribution만 수집한다. + +### 4.2 normalized contribution + +```ts +export interface InstalledContractContribution { + readonly featureId: string; + readonly source: ContractContributionSource; + readonly http: readonly InstalledHttpContract[]; + readonly events: readonly InstalledEventContract[]; +} + +export type ContractContributionSource = + | Readonly<{ + kind: "EXTERNAL_PACKAGE"; + package: InstalledContractPackageIdentity; + }> + | Readonly<{ + kind: "TEMPLATE_FIXTURE"; + fixtureId: "REFERENCE_FEATURE_V1"; + revision: 1; + }>; + +export interface InstalledContractPackageIdentity { + readonly packageId: string; + readonly version: string; + readonly digest: `sha256:${string}`; + readonly runtimeProtocolVersion: 1; + readonly sourceRevision: string; +} +``` + +frontend interface는 package의 business model을 소유하지 않는다. descriptor와 runtime validator reference만 보관한다. + +### 4.3 HTTP contribution + +```ts +export interface InstalledHttpContract { + readonly contract: Readonly<{ + operationId: string; + method: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE"; + pathTemplate: string; + inputValidator: RuntimeValidator; + outputValidator: RuntimeValidator; + problemValidator: RuntimeValidator; + acceptedStatuses: readonly number[]; + emptyBodyStatuses: readonly number[]; + retrySemantics: "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER"; + requestBody: "NONE" | "JSON"; + responseBody: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE"; + commandRecovery: CommandRecoveryDescriptor | null; + commandEffect: CommandEffectDescriptor | null; + }>; + readonly frontend: HttpExecutionPolicy; +} + +export interface HttpExecutionPolicy { + readonly policyId: string; + readonly requestByteLimit: number; + readonly responseByteLimit: number; + readonly totalDeadlineMs: number; + readonly retryBudget: 0 | 1 | 2; + readonly authProfileId: string; + readonly diagnosticsOperation: string; +} +``` + +`contract`는 package가 제공한다. frontend가 method/path/status/retry/effect 의미를 다시 적지 않는다. + +`frontend`만 이 저장소가 소유한다. 값은 §7의 hard ceiling 안에 있어야 한다. + +### 4.4 command recovery descriptor + +frontend는 server semantics를 새로 설계하지 않고 package가 제공하는 recovery capability만 소비한다. + +```ts +export type CommandRecoveryDescriptor = Readonly<{ + mode: "IDEMPOTENCY_REPLAY" | "INSPECT_OPERATION"; + operationIdentityField: string; + inspectOperationId?: string; +}>; + +export interface CommandEffectDescriptor { + readonly successEffect: "APPLIED_CONFIRMED"; + classifyProblem(input: Readonly<{ + status: number; + problem: Problem; + }>): "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED"; +} +``` + +규칙: + +- query/read operation은 `commandRecovery=null`, `commandEffect=null`이다; +- command operation은 `commandEffect`가 필수다; +- command operation에 `retrySemantics=KEYED`이면 `commandRecovery`도 필수다; +- descriptor가 없거나 invalid이면 automatic retry와 durable queue가 모두 금지된다; +- frontend는 inspect request/response wire shape나 problem effect meaning을 작성하지 않는다; +- `classifyProblem`은 package가 제공한 pure bounded classifier다; +- classifier가 throw/unknown을 반환하면 effect는 fail-safe `MAYBE_APPLIED`이고 contract runtime failure를 기록한다; +- feature gateway가 package의 inspect operation을 application-safe outcome으로 mapping한다. + +### 4.5 event contribution + +```ts +export interface InstalledEventContract { + readonly eventType: string; + readonly envelopeValidator: RuntimeValidator; + readonly payloadValidator: RuntimeValidator; +} +``` + +package identity는 enclosing `InstalledContractContribution.source`가 단 한 번 소유한다. HTTP와 event가 서로 다른 package identity라면 contribution을 둘로 나눈다. + +connection, queue, dedupe, scope, recovery와 application effect는 frontend realtime contribution이 소유한다. generated package는 socket을 열거나 QueryClient를 참조하지 않는다. + +### 4.6 runtime validator protocol + +```ts +export interface RuntimeValidator { + readonly schemaId: string; + safeParse(value: unknown): + | Readonly<{ success: true; data: T }> + | Readonly<{ + success: false; + issues: readonly RuntimeValidationIssue[]; + }>; +} + +export interface RuntimeValidationIssue { + readonly path: readonly (string | number)[]; + readonly code: string; +} +``` + +규칙: + +- external response/event는 항상 `unknown`에서 시작한다; +- generated interface와 `as T`는 validator를 대체하지 않는다; +- validator throw는 `CONTRACT_RUNTIME_FAILURE`다; +- ordinary mismatch는 `CONTRACT_VALUE_INVALID`다; +- raw value, server detail, validator internals를 diagnostics에 넣지 않는다; +- 성공 값은 mapper 전에 caller-owned mutable reference로 재사용하지 않는다. + +### 4.7 mapper ownership + +```text +wire DTO + -> feature contract mapper + -> domain factory/application value + -> Query cache/UI +``` + +mapper MUST: + +- feature가 소유한다; +- no-throw discriminated result를 반환한다; +- date/number/enum/null을 application canonical value로 변환한다; +- generated DTO를 Query cache에 직접 넣지 않는다; +- package metadata/header를 application value에 포함하지 않는다; +- collection count와 field bound를 mapper 단계에서도 확인한다. + +```ts +export type MappingResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ + ok: false; + error: Readonly<{ kind: "MAPPING_CONTRACT_VIOLATION"; code: string }>; + }>; +``` + +### 4.8 installed contribution composer + +```text +src/features/installed-contract-contributions.ts +``` + +```ts +export const INSTALLED_CONTRACT_CONTRIBUTIONS = Object.freeze([ + REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, +] as const); +``` + +reference fixture 규칙: + +```text +featureId = reference-feature +source.kind = TEMPLATE_FIXTURE +fixtureId = REFERENCE_FEATURE_V1 +revision = 1 +events = [] +``` + +`TEMPLATE_FIXTURE`는 current reference HTTP vertical을 deterministic test data로 유지하기 위한 유일한 예외다. product feature는 반드시 `EXTERNAL_PACKAGE`다. fixture는 `contractSet`에 포함하지 않는다. + +### 4.9 composer exact validation + +composition 전에 다음을 모두 검사한다. + +```text +featureId: + ^[a-z][a-z0-9-]{0,63}$ + +packageId: + ^@[a-z0-9][a-z0-9._-]{0,62}/[a-z0-9][a-z0-9._-]{0,62}$ + +version: + exact SemVer + leading v 금지 + range/tag/whitespace 금지 + +digest: + ^sha256:[0-9a-f]{64}$ + +sourceRevision: + ^[0-9a-f]{7,64}$ + +operationId: + ^[A-Za-z][A-Za-z0-9_.-]{0,127}$ + +eventType: + UTF-8 <= 160 bytes + whitespace/control character 금지 + +pathTemplate: + startsWith / + UTF-8 <= 512 bytes + query/hash 금지 +``` + +추가 검증: + +- external runtime protocol version은 정확히 1; +- package ID별 정확히 한 version/digest; +- duplicate operation ID 없음; +- duplicate event type 없음; +- status는 100..599, 1..32개, sorted unique; +- empty-body status는 accepted status의 subset; +- validator와 non-empty `schemaId` 필수; +- request/response limit은 §7 hard ceiling 이하; +- same operation method/path/status projection drift 없음; +- fixture 외 local product contract 없음. + +package identity 검증은 `EXTERNAL_PACKAGE`에만 적용한다. fixture에 가짜 digest를 부여하지 않는다. + +### 4.10 package dependency 규칙 + +- `package.json`은 exact version만 사용한다; +- `latest`, caret, tilde, tag, Git URL, workspace link 금지; +- lockfile resolved integrity와 package export manifest가 존재해야 한다; +- runtime registry/Git/deployed docs에서 package를 보충하지 않는다; +- package root barrel 전체 import 금지; +- 사용 operation/event subpath만 정적 import한다; +- package import는 top-level network/DOM/listener side effect가 없어야 한다; +- package source와 generated output을 frontend에서 수정하지 않는다. + +### 4.11 reference feature에서 product feature로의 migration + +금지: + +```text +copy reference-schemas.ts +edit URL/status/type +ship as product local contract +``` + +필수: + +```text +external package pin +-> feature contribution adapter +-> feature mapper +-> feature gateway +-> application input +-> query/mutation/realtime bridge +``` + +reference fixture는 template 자체 제거 가능성 검증용이며 서비스 계약 authoring template가 아니다. + +--- + +## 5. Contract Set과 release coherence + +### 5.1 scalar 계약 version 제거 + +현재 다음 scalar는 여러 package를 표현할 수 없다. + +```text +RuntimeConfig.API_CONTRACT_VERSION +ReleaseManifest.apiContractVersion +``` + +V2 전환 결과: + +```text +Runtime Config + = deployment/browser operational setting + +Release Manifest + = build/release/assets/compiled external contract package set +``` + +`RuntimeConfig`에서 API contract version을 제거한다. + +### 5.2 Release Manifest V2 + +```ts +export interface ReleaseManifestV2 { + readonly schemaVersion: 2; + readonly appVersion: string; + readonly buildId: string; + readonly commitSha: string; + readonly configSchemaVersion: string; + readonly assetManifestHash: string; + readonly releaseId: string; + readonly builtAt: string; + readonly routeChunks: Readonly>; + readonly contractSet: Readonly<{ + setAlgorithm: "CA_CONTRACT_SET_V1"; + setDigest: `sha256:${string}`; + packages: readonly ContractSetPackage[]; + }>; +} + +export interface ContractSetPackage { + readonly packageId: string; + readonly version: string; + readonly digest: `sha256:${string}`; + readonly runtimeProtocolVersion: 1; + readonly sourceRevision: string; +} +``` + +fixture contribution은 `packages`에 들어가지 않는다. + +### 5.3 canonicalization + +단일 구현: + +```text +src/contracts/contract-set-canonical.ts +``` + +```ts +export function canonicalizeContractSet( + packages: readonly ContractSetPackage[], +): Uint8Array; +``` + +algorithm: + +```text +header = UTF-8("CA_FRONTEND_CONTRACT_SET_V1\0") +entries = packages sorted by UTF-8 byte lexicographic packageId +for each entry: + append u32be(length(packageId)) + packageId bytes + append u32be(length(version)) + version bytes + append u32be(length(digest)) + digest bytes + append u32be(runtimeProtocolVersion) + append u32be(length(sourceRevision)) + sourceRevision bytes +setDigest = lower-hex SHA-256(canonical bytes) +``` + +중복 package ID는 hash 전에 실패한다. JSON property order나 locale sort에 의존하지 않는다. + +Node build script와 browser runtime은 canonical byte 생성 함수를 공유한다. hash adapter만 Node `crypto`와 Web Crypto로 분리한다. + +### 5.4 build-time expected set + +```text +INSTALLED_CONTRACT_CONTRIBUTIONS + -> EXTERNAL_PACKAGE identities only + -> canonicalize + -> setDigest + -> build manifest embed +``` + +`TEMPLATE_FIXTURE`는 filtering 후 set에 포함하지 않는다. + +### 5.5 runtime check + +boot에서 다음을 비교한다. + +```text +embedded expected package list +manifest package list +embedded expected setDigest +manifest setDigest +recomputed manifest setDigest +runtime protocol version +``` + +모두 일치해야 한다. package range negotiation, deployed server docs와 `latest` lookup을 하지 않는다. + +### 5.6 failure code + +```text +CONTRACT_SET_SCHEMA_INVALID +CONTRACT_SET_ENTRY_INVALID +CONTRACT_SET_DUPLICATE_PACKAGE +CONTRACT_SET_DIGEST_INVALID +CONTRACT_SET_DIGEST_MISMATCH +CONTRACT_SET_PACKAGE_MISSING +CONTRACT_SET_PACKAGE_UNEXPECTED +CONTRACT_SET_VERSION_MISMATCH +CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED +``` + +이 failure는 product route mount 전 safe boot error다. + +### 5.7 independent deployment 의미 + +frontend manifest는 **frontend build에 compile된 package set**을 검증한다. provider runtime version을 추측하거나 provider와 exact equality를 요구하지 않는다. + +provider compatibility와 server support range는 별도 계약 시스템의 release responsibility다. frontend runtime은 build package set과 자신의 artifact coherence만 검증한다. + +### 5.8 V1 migration + +```text +Release 1 + reader: V1 + V2 + writer: V1 + API_CONTRACT_VERSION 유지 + +Release 2 + reader: V1 + V2 + writer: V2 + contractSet 생성 + V1 input parser만 scalar를 읽음 + V2 input에 scalar가 있으면 strict reject + +Release 3 + reader: V2 + writer: V2 + RuntimeConfig scalar reject as unknown field + V1 fixtures 제거 +``` + +V1과 V2 값을 동시에 비교해 precedence를 만들지 않는다. V2 manifest에서 scalar가 추가로 존재하면 strict parser가 거절한다. + +--- +## 6. Runtime Config와 boot + +### 6.1 Runtime Config V2 + +```ts +export const runtimeConfigV2Schema = z + .object({ + APP_ENV: z.enum(["local", "development", "staging", "production"]), + API_BASE_URL: z.url(), + REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000), + MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), + TELEMETRY_ENABLED: z.boolean(), + TELEMETRY_ENDPOINT: z.url().optional(), + AUTH_MODE: z.enum(["external", "demo"]), + CONFIG_SCHEMA_VERSION: z.literal("2.0"), + RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), + RELEASE_ID: z.string().min(1).optional(), + BUILD_ID: z.string().min(1).optional(), + CAPABILITY_OVERRIDES: z + .object({ + REALTIME: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + WEB_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + SERVICE_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + OFFLINE_COMMANDS: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + }) + .strict() + .default({}), + }) + .strict(); +``` + +`API_CONTRACT_VERSION`은 V2에 없다. + +### 6.2 URL validation + +`API_BASE_URL`: + +- absolute URL; +- username/password/hash 없음; +- pathname은 `/`로 끝남; +- local/development 외 `https:`; +- query는 없음; +- canonical `new URL(value).href`를 runtime snapshot에 저장. + +`TELEMETRY_ENDPOINT`: + +- enabled일 때 필수; +- absolute URL; +- username/password/hash 없음; +- local/development 외 `https:`; +- API base와 같을 필요 없음. + +`RELEASE_MANIFEST_URL`: + +- same-origin absolute path; +- `/`로 시작; +- query/hash 없음; +- `//`, backslash, percent-encoded slash, dot segment 거절; +- maximum UTF-8 256 bytes. + +### 6.3 config secret-name scan + +기존 `assertSafeConfigNames`를 유지하고 다음 key fragment를 case-insensitive로 거절한다. + +```text +PASSWORD +SECRET +TOKEN +PRIVATE_KEY +CLIENT_SECRET +ACCESS_KEY +REFRESH_TOKEN +COOKIE +AUTHORIZATION +``` + +`AUTH_MODE`, `TELEMETRY_ENABLED`처럼 fragment가 semantic enum인 known allowlist는 exact key allowlist로만 허용한다. arbitrary nested key에는 allowlist를 적용하지 않는다. + +### 6.4 bounded boot body reader + +공통 source: + +```text +src/bootstrap/read-bounded-boot-json.ts +``` + +```ts +export interface BootJsonPolicy { + readonly operation: "RUNTIME_CONFIG" | "RELEASE_MANIFEST"; + readonly maximumBytes: number; + readonly totalDeadlineMs: 5_000; +} +``` + +numeric policy: + +```text +Runtime Config maximum = 65,536 bytes +Release Manifest maximum = 1,048,576 bytes +total deadline = 5,000 ms each +``` + +request options: + +```ts +{ + method: "GET", + cache: "no-store", + credentials: "same-origin", + redirect: "error", + referrerPolicy: "no-referrer", + headers: { Accept: "application/json" }, + signal, +} +``` + +### 6.5 response admission + +순서: + +```text +fetch settled +-> status 200 only +-> content-type parse +-> application/json or +json only +-> Content-Length precheck when finite +-> ReadableStream bounded read +-> actual bytes <= policy maximum +-> UTF-8 fatal decode +-> JSON.parse +-> object shape check +-> Zod strict parse +-> deep snapshot +``` + +금지: + +- `response.json()`; +- unbounded `response.text()`; +- redirect follow; +- HTML error page를 JSON으로 parse; +- `Content-Length`만 신뢰; +- timeout 후 body read continuation. + +body overflow 시 reader를 cancel하고 `AbortController`를 abort한다. + +### 6.6 boot failure union + +```ts +export type BootLoadFailure = + | "FETCH_FAILED" + | "TIMEOUT" + | "HTTP_STATUS_INVALID" + | "CONTENT_TYPE_INVALID" + | "BODY_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SHAPE_INVALID" + | "SECRET_NAME_REJECTED" + | "SCHEMA_INVALID" + | "BUILD_MISMATCH" + | "RELEASE_MISMATCH" + | "ASSET_MISMATCH" + | "CONTRACT_SET_MISMATCH"; +``` + +safe error는 다음만 포함한다. + +```text +failure kind/code +buildId +releaseId if already validated +config schema version if safe +support reference = buildId + bounded failure code +``` + +URL, response body, validation value, endpoint hostname, stack trace를 포함하지 않는다. + +### 6.7 boot order + +```text +1. read embedded build config +2. load/validate Runtime Config V2 +3. compare optional BUILD_ID +4. load/validate Release Manifest V2 +5. verify build/config/release/assets +6. verify contractSet +7. compile static optional selection + runtime overrides +8. create diagnostics/telemetry no-op or selected sink +9. create auth/session adapter +10. create HTTP runtime +11. create application inputs/gateways +12. create QueryClient/scope/invalidation +13. create Router +14. create optional host objects without start side effect +15. mount React tree +16. committed effect starts optional runtime +``` + +### 6.8 partial composition rollback + +각 step은 cleanup stack에 disposer를 push한다. + +```text +create A -> push dispose A +create B -> push dispose B +create C fails +-> dispose B +-> dispose A +-> render safe boot error +``` + +cleanup은 reverse order이며 하나의 cleanup failure가 다음 cleanup을 막지 않는다. 모든 cleanup failure를 bounded count와 kind로 합산한다. + +### 6.9 boot retry + +safe boot shell의 retry action: + +- 사용자의 explicit action만; +- 동일 page session에서 maximum 1회; +- 이전 attempt의 controller/resource settle 후 실행; +- 1초 minimum interval; +- retry 실패 후 automatic loop 없음; +- retry success 시 full product root를 새로 mount; +- partial composition object 재사용 금지. + +### 6.10 Runtime Config immutability + +validated config는 deep snapshot/freeze한다. + +- runtime 중 config endpoint polling 없음; +- dynamic mutation 없음; +- kill switch 변경은 page reload/new boot에서만 적용; +- config object를 context로 노출하지 않음; +- application은 typed port를 통해 필요한 value만 본다. + +--- + +## 7. Fetch HTTP execution runtime + +### 7.1 source ownership + +유지/수정: + +```text +src/adapters/http/client.ts +src/adapters/http/request-builder.ts +src/adapters/http/bounded-json.ts +src/adapters/http/retry-policy.ts +``` + +신규: + +```text +src/adapters/http/bounded-body-reader.ts +src/adapters/http/http-execution-v3.ts +src/adapters/http/http-effect-certainty.ts +src/adapters/http/http-contract-bridge.ts +``` + +범용 Axios-like API를 만들지 않는다. caller는 `InstalledHttpContract`와 typed input을 전달한다. + +### 7.2 execution interface + +```ts +export interface ContractHttpExecutor { + execute( + operation: InstalledHttpContract, + input: Input, + context: HttpExecutionContext, + ): Promise>; +} + +export interface HttpExecutionContext { + readonly signal?: AbortSignal; + readonly scope: CacheScopeSnapshot; + readonly intent?: MutationIntentContext; +} +``` + +`MutationIntentContext`는 command에만 사용한다. + +```ts +export interface MutationIntentContext { + readonly intentId: string; + readonly idempotencyKey?: string; + readonly startedBy: "USER" | "FOREGROUND_RETRY" | "OUTBOX_REPLAY"; +} +``` + +### 7.3 hard ceilings + +```text +default request body 262,144 bytes +hard request body 1,048,576 bytes +default success response 1,048,576 bytes +hard response body 8,388,608 bytes +problem response maximum 65,536 bytes +path template maximum 512 bytes +encoded query maximum 8,192 bytes +header value examined maximum 8,192 bytes per allowlisted header +total deadline default 10,000 ms +total deadline hard 60,000 ms +retry count hard 2 +``` + +external contribution의 frontend policy는 default보다 작거나 같게 줄일 수 있다. hard ceiling을 초과할 수 없다. + +### 7.4 input validation과 request projection + +순서: + +```text +capture scope +-> verify scope current +-> package input validator +-> feature/application input already canonical +-> request projection +-> encoded path/query/body byte check +-> credential patch +-> final invariant recheck +-> dispatch +``` + +final invariant recheck 항목: + +- method unchanged; +- URL origin/path allowlist; +- redirect mode error; +- credentials mode expected; +- credential patch header allowlist; +- forbidden header 없음; +- body/request byte bound; +- abort/deadline not expired; +- scope still current. + +final invariant failure면 fetch 호출은 0회다. + +### 7.5 URL construction + +- base는 validated `API_BASE_URL`; +- path placeholder는 descriptor의 named input projection만 사용; +- segment는 `encodeURIComponent` semantics로 한 번만 encode; +- raw slash를 path value에 허용하지 않음; +- query key order는 descriptor-generated order; +- array encoding은 package descriptor가 제공한 exact projection; +- `undefined` omit, `null`은 descriptor가 허용한 representation만; +- URL string concatenation 금지; +- absolute path override, origin override 금지; +- final URL maximum 16,384 bytes. + +### 7.6 request body + +`requestBody=NONE`이면 body와 Content-Type 모두 없다. + +`requestBody=JSON`이면: + +```text +package request projection +-> deterministic JSON encoder +-> UTF-8 bytes +-> requestByteLimit check +-> Content-Type application/json +``` + +`JSON.stringify`의 default property enumeration에 semantic digest를 의존하지 않는다. idempotency request digest가 필요하면 package가 canonical request digest projection을 제공한다. + +### 7.7 credential bridge + +```ts +export type CredentialPatchOutcome = + | Readonly<{ + kind: "READY"; + headers: Readonly>; + credentials: RequestCredentials; + }> + | Readonly<{ kind: "UNAUTHENTICATED" }> + | Readonly<{ kind: "UNAVAILABLE" }> + | Readonly<{ kind: "SCOPE_FENCED" }>; +``` + +허용 header: + +```text +Authorization +X-CSRF-Token +X-Tenant-Context +``` + +실제 profile이 쓰는 subset만 등록한다. caller가 arbitrary header를 추가하지 않는다. + +credential unavailable에서 anonymous request로 downgrade하지 않는다. + +### 7.8 physical attempt state + +```ts +export type PhysicalAttemptState = + | "PREPARING" + | "READY_TO_SEND" + | "DISPATCHED" + | "RESPONSE_HEADERS" + | "READING_BODY" + | "VALIDATING" + | "MAPPING_READY" + | "SETTLED"; +``` + +`fetch()` invocation 직전에 `DISPATCHED`로 바꾸지 않는다. invocation expression에 들어가기 직전 local state를 `READY_TO_SEND`, promise가 반환된 직후 `DISPATCHED`로 기록한다. synchronous throw는 `NOT_STARTED`다. + +### 7.9 response admission + +순서: + +```text +redirected=false +-> status package allowlist +-> content-type policy +-> Content-Length precheck +-> bounded stream read +-> empty-body policy +-> UTF-8/JSON parse when required +-> problem or success validator +-> scope current fence +-> typed outcome +``` + +`response.ok`만 사용하지 않는다. + +### 7.10 body policy + +`REQUIRED_JSON`: + +- allowed success status에서 non-empty body 필수; +- JSON media 필수; +- empty/whitespace는 contract violation. + +`OPTIONAL_JSON`: + +- empty-body status 또는 zero body 허용; +- body가 있으면 JSON media/validator 필수; +- body 없는 값을 `undefined`로 명시적으로 mapping. + +`NONE`: + +- body를 application value로 읽지 않음; +- `Content-Length > 0`이면 즉시 violation + body cancel; +- length가 없고 body stream이 있으면 최대 1 byte만 probe한다; +- 첫 chunk가 있으면 violation + reader cancel; +- immediately done이면 empty success; +- descriptor가 unexpected body를 허용하지 않으므로 arbitrary drain하지 않는다. + +### 7.11 typed outcome + +```ts +export type HttpExecutionOutcome = + | Readonly<{ + kind: "SUCCESS"; + value: Value; + metadata: SafeResponseMetadata; + effect: "NOT_APPLICABLE" | "APPLIED_CONFIRMED"; + }> + | Readonly<{ + kind: "PROBLEM"; + problem: Problem; + metadata: SafeResponseMetadata; + effect: "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "UNAUTHENTICATED"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "FORBIDDEN"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "RATE_LIMITED"; + retryAfterMs?: number; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CONTRACT_VIOLATION"; + violation: HttpContractViolation; + }> + | Readonly<{ + kind: "TRANSPORT_FAILURE"; + failure: HttpTransportFailure; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CANCELLED"; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }>; +``` + +### 7.12 safe response metadata + +```ts +export interface SafeResponseMetadata { + readonly status: number; + readonly correlationReference?: string; + readonly retryAfterMs?: number; + readonly validatorToken?: string; +} +``` + +metadata에 raw header map, URL, server, cookie, traceparent, ETag raw value를 노출하지 않는다. validator token은 conditional validator store 내부 opaque binding이며 UI/application에 직접 전달하지 않는다. + +### 7.13 contract violation + +```text +UNEXPECTED_STATUS +UNEXPECTED_EMPTY_BODY +UNEXPECTED_BODY +CONTENT_TYPE_MISMATCH +RESPONSE_TOO_LARGE +UTF8_INVALID +JSON_INVALID +SUCCESS_SCHEMA_INVALID +PROBLEM_SCHEMA_INVALID +VALIDATOR_RUNTIME_FAILURE +MAPPING_CONTRACT_VIOLATION +FINAL_REQUEST_INVARIANT_FAILED +SCOPE_FENCED +``` + +```ts +export type HttpContractViolation = Readonly<{ + kind: + | "UNEXPECTED_STATUS" + | "UNEXPECTED_EMPTY_BODY" + | "UNEXPECTED_BODY" + | "CONTENT_TYPE_MISMATCH" + | "RESPONSE_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SUCCESS_SCHEMA_INVALID" + | "PROBLEM_SCHEMA_INVALID" + | "VALIDATOR_RUNTIME_FAILURE" + | "MAPPING_CONTRACT_VIOLATION" + | "FINAL_REQUEST_INVARIANT_FAILED" + | "SCOPE_FENCED"; + operation: "REQUEST" | "RESPONSE" | "VALIDATION" | "MAPPING"; +}>; +``` + +contract violation은 ordinary network retry 대상이 아니다. + +### 7.14 transport failure + +```text +DNS_OR_CONNECT_FAILURE +TLS_OR_NETWORK_FAILURE +TIMEOUT +RESPONSE_STREAM_FAILURE +ABORTED_BY_SCOPE +ABORTED_BY_CALLER +OVERLOADED_BEFORE_SEND +``` + +```ts +export type HttpTransportFailure = Readonly<{ + kind: + | "DNS_OR_CONNECT_FAILURE" + | "TLS_OR_NETWORK_FAILURE" + | "NETWORK_FAILURE" + | "TIMEOUT" + | "RESPONSE_STREAM_FAILURE" + | "ABORTED_BY_SCOPE" + | "ABORTED_BY_CALLER" + | "OVERLOADED_BEFORE_SEND"; + retryable: boolean; +}>; +``` + +browser가 세부 네트워크 원인을 제공하지 않으면 하나의 `NETWORK_FAILURE` bucket으로 축약한다. native exception message를 boundary 밖으로 내보내지 않는다. + +--- + +## 8. Retry·cancellation·mutation effect certainty + +### 8.1 retry owner + +```text +HTTP transport retry = enabled by exact operation policy +TanStack Query retry = false +TanStack Mutation retry = false +realtime reconnect = realtime coordinator +Background Sync retry = offline drain state machine +``` + +서로 다른 계층이 같은 physical operation을 중복 retry하지 않는다. + +### 8.2 HTTP retry algorithm + +```text +base delay = 250 ms +max local delay = 2,000 ms +algorithm = full jitter +maximum retries = min(operation.retryBudget, RuntimeConfig.MAX_RETRY_ATTEMPTS, 2) +``` + +retry index 0의 ceiling: + +```text +min(2,000, 250 * 2^0) = 250 ms +``` + +실제 delay: + +```text +floor(random[0,1) * ceiling) +``` + +validated `Retry-After`가 있으면 local delay의 lower bound로 사용한다. 5,000ms를 넘거나 remaining total deadline을 넘으면 retry하지 않는다. + +### 8.3 retry classification + +`SAFE`: + +- GET/HEAD; +- network failure after dispatch 허용; +- 408, 425, 429, 502, 503, 504 중 accepted retry policy; +- same frozen request 사용. + +`IDEMPOTENT`: + +- package가 operation 자체 idempotent라고 선언; +- same frozen request; +- SAFE와 같은 failure/status subset. + +`KEYED`: + +- same user intent의 동일 idempotency key 필수; +- known response status가 retry 가능하다고 package가 명시한 경우만; +- dispatch 후 response 없는 network/timeout/abort는 automatic retry 금지; +- inspect/reconciliation path로 이동. + +`NEVER`: + +- automatic retry 0. + +### 8.4 auth retry + +401 response 뒤 hidden refresh-and-replay를 하지 않는다. + +- credential owner는 **send 전** READY/UNAUTHENTICATED/UNAVAILABLE을 결정한다; +- response 401은 terminal `UNAUTHENTICATED`; +- 사용자 re-auth 뒤 새 user intent 또는 explicit retry로 실행; +- command를 새 credential로 자동 replay하지 않는다. + +### 8.5 deadline + +one logical operation deadline은 다음 전체를 포함한다. + +```text +credential resolution +request encode +retry backoff +all physical attempts +response read +runtime validation +mapper preparation +``` + +operation start에서 monotonic deadline을 capture한다. 각 sub-step이 remaining budget을 받는다. physical attempt별로 full timeout을 새로 부여하지 않는다. + +### 8.6 cancellation sources + +```ts +export type CancellationOwner = + | "CALLER" + | "ROUTE_TRANSITION" + | "SCOPE_FENCE" + | "APPLICATION_SHUTDOWN" + | "DEADLINE"; +``` + +linked AbortController는 first reason만 terminal reason으로 저장한다. later reason은 diagnostics count에만 반영한다. + +### 8.7 mutation effect certainty + +```ts +export type MutationEffectCertainty = + | "NOT_STARTED" + | "NOT_APPLIED" + | "MAYBE_APPLIED" + | "APPLIED_CONFIRMED"; +``` + +판정: + +| 관측 | certainty | +| --- | --- | +| validation/config/auth/final invariant에서 종료 | `NOT_STARTED` | +| fetch synchronous throw | `NOT_STARTED` | +| package가 no-effect problem/status로 명시 | `NOT_APPLIED` | +| success/committed receipt | `APPLIED_CONFIRMED` | +| dispatch 후 timeout/network/abort/body loss | `MAYBE_APPLIED` | +| unknown/unclassified problem | `MAYBE_APPLIED` | + +frontend가 HTTP status만 보고 `NOT_APPLIED`를 추측하지 않는다. + +### 8.8 mutation intent identity + +```ts +export interface MutationIntent { + readonly intentId: string; + readonly operationId: string; + readonly canonicalInputIdentity: string; + readonly idempotencyKey?: string; + readonly createdAtMonotonicMs: number; +} +``` + +- `intentId`: `crypto.randomUUID()`; +- 같은 submit의 transport retry/reconcile에는 동일 intent/key; +- form value가 변경되면 새 intent; +- double click은 duplicate coordinator가 처리; +- key/identity를 log/URL/query key에 넣지 않는다. + +### 8.9 uncertain outcome handling + +`MAYBE_APPLIED`면: + +```text +1. automatic resend 금지 +2. optimistic layer를 arbitrary rollback하지 않음 +3. mutation coordinator state = EFFECT_UNKNOWN +4. package recovery descriptor 확인 +5. inspect operation이 있으면 bounded inspect +6. committed -> canonical result 적용 +7. no effect -> explicit user retry 허용 +8. unknown 유지 -> status UI/support path +``` + +inspect도 timeout이면 unknown을 유지한다. + +### 8.10 UI projection + +```text +NOT_STARTED -> ordinary retry 가능 +NOT_APPLIED -> policy상 retry 가능 +MAYBE_APPLIED -> "처리 상태 확인 필요" +APPLIED_CONFIRMED-> success +``` + +사용자 문구는 i18n catalog가 소유한다. native error와 server detail을 표시하지 않는다. + +--- + +## 9. Router + +### 9.1 ownership + +Router owns: + +```text +route registry +path/search codec +navigation +redirect +lazy module lifecycle +route error boundary +focus/scroll/title +dirty navigation guard +session/access hint guard +``` + +Router does not own: + +```text +server data cache +HTTP retry +mutation lifecycle +realtime connection +business authorization +contract package loading +``` + +### 9.2 server-data loader/action 금지 + +baseline에서 loader/action은 server fetch를 수행하지 않는다. + +허용 loader: + +- URL/path/search parse; +- local route registration lookup; +- synchronous redirect; +- session snapshot 기반 access hint; +- route-local static metadata. + +금지: + +- direct fetch; +- gateway call; +- QueryClient prefetch/ensure; +- IndexedDB read; +- mutation action; +- separate loader cache. + +서버 data는 page의 feature query/mutation hook이 application input을 호출해 가져온다. + +### 9.3 route registration + +```ts +export interface InstalledRouteContribution { + readonly routeId: string; + readonly path: string; + readonly ownerFeatureId: string; + readonly load: () => Promise; + readonly inputCodecId: string; + readonly accessPolicyId: string; +} +``` + +- route ID: existing registry format 유지; +- duplicate route ID/path 거절; +- feature page가 app-router를 import하지 않음; +- lazy module은 route runtime module만 export; +- Router가 feature gateway/adapter를 props로 전달하지 않음. + +### 9.4 URL codec + +path/search raw input은 `unknown`이다. + +```text +URLSearchParams +-> registered route codec +-> normalized route input +-> RouteInputProvider +-> page/controller +``` + +- `Object.fromEntries(searchParams)` raw trust 금지; +- duplicate query key policy를 codec이 명시; +- unknown key는 default reject; +- array/count/string byte bound; +- route param은 domain ID factory 전까지 string; +- malformed input은 safe bad-request route surface; +- network/application gateway call 0. + +### 9.5 route transition cleanup + +feature page unmount 순서: + +```text +1. Query observer release +2. feature realtime subscription lease release +3. page browser listener cleanup +4. form/dirty guard cleanup +5. object URL/temporary file lease cleanup +6. page-owned abort controller abort +``` + +다음 global runtime은 유지한다. + +```text +QueryClient +scope runtime +cross-context coordinator +realtime host +Service Worker registration +offline repository +``` + +### 9.6 chunk recovery + +```text +chunk load failure +-> no-store Release Manifest V2 refresh +-> build/release/asset/contractSet compare +-> dirty state inspect +-> one guarded reload +-> second failure terminal safe surface +``` + +reload guard key: + +```text +CHUNK_RELOAD_GUARD = oldBuildId + newBuildId + failedRouteId +``` + +동일 pair/route당 한 번만 reload한다. localStorage record는 10분 TTL과 512-byte hard cap을 갖는다. + +### 9.7 focus/scroll/title + +- successful route commit 후 `main-content` focus; +- navigation마다 scroll top; +- explicit registered hash-anchor route만 anchor focus; +- document title은 typed message catalog; +- background Query refresh는 focus/scroll을 변경하지 않음; +- reduced motion 존중; +- error route도 heading focus를 제공. + +### 9.8 navigation blocker + +blocking 조건: + +```text +dirty form +active non-cancellable local file workflow +mutation EFFECT_UNKNOWN recovery in progress +offline command conflict resolution draft +``` + +ordinary loading/query refresh는 navigation을 막지 않는다. + +browser unload dialog는 browser standard prompt만 사용한다. custom string에 의존하지 않는다. + +--- + +## 10. Query lifecycle과 cache + +### 10.1 QueryClient 수와 기본 정책 + +application runtime당 QueryClient 하나다. + +```ts +export const QUERY_CACHE_DEFAULTS = Object.freeze({ + staleTime: 30_000, + gcTime: 300_000, + refetchOnWindowFocus: true, + retry: false, + mutationRetry: false, + persistence: false, +}); +``` + +route/feature/session마다 새 QueryClient를 만들지 않는다. + +### 10.2 fixed server-state profiles + +```ts +export type ServerStateProfileId = + | "DETAIL_STANDARD" + | "LIST_STANDARD" + | "LOOKUP_STABLE" + | "VOLATILE_STATUS"; + +export interface ServerStateProfile { + readonly profileId: ServerStateProfileId; + readonly staleTimeMs: number; + readonly gcTimeMs: number; + readonly refetchOnWindowFocus: boolean; + readonly maxResultItems: number; + readonly maxEstimatedResultBytes: number; +} +``` + +```text +DETAIL_STANDARD + stale 30s, gc 5m, focus true, items 1, bytes 256KiB + +LIST_STANDARD + stale 15s, gc 5m, focus true, items 200, bytes 1MiB + +LOOKUP_STABLE + stale 5m, gc 30m, focus false, items 500, bytes 2MiB + +VOLATILE_STATUS + stale 0, gc 1m, focus true, items 1, bytes 64KiB +``` + +feature가 임의 숫자를 선언하지 않는다. 네 profile로 표현할 수 없으면 본 문서와 profile registry를 개정한다. + +### 10.3 query definition + +```ts +export interface QueryDefinition { + readonly definitionId: string; + readonly definitionVersion: number; + readonly owner: string; + readonly namespace: string; + readonly namespaceVersion: number; + readonly operationId: string; + readonly profileId: ServerStateProfileId; + measureResult(value: Value): QueryResultMeasure; + execute( + input: Input, + context: Readonly<{ signal: AbortSignal }>, + ): Promise>; +} + +export interface QueryResultMeasure { + readonly itemCount: number; + readonly estimatedBytes: number; +} +``` + +### 10.4 result measurement + +`measureResult`는 모든 definition에 필수다. + +- detail/scalar: item 1; +- empty optional: item 0; +- list/page: actual mapped items; +- bytes: mapped application value의 bounded string UTF-8 bytes + primitive fixed width + collection overhead; +- `JSON.stringify` fallback 금지; +- wire DTO 재직렬화 금지; +- generic recursive walker 금지; +- throw/non-finite/negative/unsafe integer는 `RESULT_MEASUREMENT_FAILED`; +- ceiling 초과는 `RESULT_BUDGET_EXCEEDED`; +- measurement failure/overflow는 cache commit 0, retry 0. + +### 10.5 client scope authority + +현재 `ServerStateScopeRuntime`을 browser client scope authority로 확장한다. + +```ts +export type ClientScopeLifecycleEvent = + | Readonly<{ kind: "FENCED"; previousGeneration: number }> + | Readonly<{ kind: "READY"; snapshot: CacheScopeSnapshot }> + | Readonly<{ kind: "DISPOSED" }>; + +export interface ServerStateScopeRuntime { + getSnapshot(): CacheScopeSnapshot; + subscribe(listener: () => void): () => void; + subscribeLifecycle( + listener: (event: ClientScopeLifecycleEvent) => void, + ): () => void; + dispose(): void; +} +``` + +### 10.6 scope transition exact order + +identity-affecting session change: + +```text +1. generation increment +2. old snapshot isCurrent=false +3. FENCED synchronously publish +4. new HTTP/realtime/offline admission close +5. active Query cancel +6. QueryClient clear +7. conditional validator clear +8. mutation/invalidation pending lease clear +9. realtime stop/checkpoint detach +10. offline drain lease release and old partition hide +11. old identity registry close +12. new fingerprint/identity registry create +13. READY publish +14. React subscriber notify +15. new scope admission open +``` + +`FENCED`와 `READY` 사이 old value를 render하지 않는다. UI는 `scope-transition` state를 사용한다. + +### 10.7 query key + +```text +[ + "query", + 1, + scopeFingerprint, + namespace, + namespaceVersion, + definitionVersion, + runtimeIdentityToken +] +``` + +raw account/resource ID, URL, filter object, document, cursor, message를 key에 넣지 않는다. + +runtime identity registry bounds: + +```text +max depth 12 +max nodes 512 +max string bytes 2,048 +max encoded bytes 16,384 +entries 4,096 +canonical bytes 4 MiB +``` + +active reference가 있는 identity를 evict하지 않는다. capacity 부족은 network 전 local failure다. + +### 10.8 execution commit fence + +```text +capture scope +-> acquire identity lease +-> execute application input with TanStack signal +-> scope current check +-> Result success check +-> measureResult +-> profile budget check +-> immutable value return +-> Query cache commit +-> finally identity release +``` + +scope가 stale면 successful response도 `SCOPE_FENCED`로 폐기한다. + +### 10.9 UI states + +```text +initial-loading +success-fresh +success-refreshing +success-stale-degraded +empty +known-problem +transport-error +contract-error +scope-transition +``` + +background refresh에서 complete data를 full-page skeleton으로 숨기지 않는다. + +### 10.10 persistence + +baseline: + +```text +memory QueryClient = COMPOSED +query persistence = disabled +SSR hydration = NOT_SELECTED +``` + +Web Storage나 existing generic IndexedDB runtime에 Query payload를 자동 저장하지 않는다. persistence 요구는 VD-13 별도 selection을 통과해야 한다. + +### 10.11 conditional validator + +conditional validator store는 Query value와 분리한다. + +- exact operation/representation/scope/release binding; +- max entries 1,024; +- max token bytes 1,024; +- LRU zero-ref eviction; +- session transition clear; +- 304는 current complete cached value가 있을 때만 success; +- cache miss + 304는 contract/recovery failure; +- raw ETag를 Query key/UI/log에 넣지 않음. + +--- + +## 11. Mutation lifecycle + +### 11.1 mutation definition + +```ts +export interface MutationDefinition { + readonly definitionId: string; + readonly operationId: string; + readonly duplicatePolicy: + | "JOIN_IDENTICAL" + | "REJECT_WHILE_ACTIVE" + | "ALLOW_PARALLEL"; + readonly optimisticPolicyId: "NONE" | string; + readonly invalidationTopics: readonly string[]; + canonicalIdentity(input: Input): string; + execute( + input: Input, + context: Readonly<{ + signal: AbortSignal; + intent: MutationIntent; + }>, + ): Promise>; +} + +export type MutationResult = + | Readonly<{ + ok: true; + value: Value; + effect: "APPLIED_CONFIRMED"; + }> + | Readonly<{ + ok: false; + failure: AppFailure; + effect: "NOT_STARTED" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }>; +``` + +### 11.2 duplicate policy defaults + +```text +command default = REJECT_WHILE_ACTIVE +query-like idempotent action = JOIN_IDENTICAL only when exact semantic input identity is stable +ALLOW_PARALLEL = explicit feature decision only +``` + +`JOIN_IDENTICAL`은 같은 scope, definition, canonical input과 same user intent에서만 가능하다. 서로 다른 input을 logical key 하나로 합치지 않는다. + +### 11.3 duplicate coordinator bounds + +```text +active definitions per runtime = 256 +active intents total = 1,024 +canonical identity bytes = 16,384 +waiters per joined intent = 32 +settled retention = 0 ms; immediate removal +``` + +coordinator map을 result cache로 사용하지 않는다. + +### 11.4 optimistic default + +`optimisticPolicyId=NONE`이 기본이다. + +optimistic update 허용 조건: + +- deterministic local projection; +- rollback snapshot bounded; +- server revision/canonical response가 최종 authority; +- list membership/sort/filter effect가 deterministic; +- effect unknown recovery가 정의됨; +- same query key layer order가 관리됨. + +### 11.5 ordered optimistic layer + +```text +layers per query key <= 8 +single layer estimate <= 262,144 bytes +all layers per query key <= 2,097,152 bytes +layer age <= mutation total deadline + 60s +``` + +overflow면 pessimistic execution으로 전환한다. 기존 layer를 silent evict하지 않는다. + +### 11.6 result ordering + +success: + +```text +1. external wire validation +2. feature mapper +3. scope current check +4. mutation effect confirmed +5. exact detail seed if registered +6. optimistic layer commit/remove +7. registered invalidation topics emit +8. local active Query refetch schedule +9. success UI/navigation +``` + +list/aggregate는 default invalidate다. deterministic membership policy가 없으면 direct patch하지 않는다. + +### 11.7 failure ordering + +`NOT_STARTED/NOT_APPLIED`: + +```text +rollback optimistic layer +-> expose retryable failure +``` + +`MAYBE_APPLIED`: + +```text +optimistic layer를 committed/rolled-back으로 단정하지 않음 +-> effect-unknown overlay +-> inspect/reconcile +-> confirmed outcome 후 canonical cache update +``` + +### 11.8 local invalidation failure + +server command가 committed된 뒤 local invalidation이 실패해도 mutation을 failure로 바꾸지 않는다. + +```text +mutation result = success +cache health = degraded +bounded authoritative refetch schedule +``` + +command를 다시 보내지 않는다. + +### 11.9 form submit + +- double-submit coordinator 사용; +- dirty state는 `APPLIED_CONFIRMED` 뒤 clear; +- effect unknown이면 dirty state를 clear하지 않음; +- successful navigation은 invalidation scheduling 뒤; +- route blocker는 active effect-unknown recovery를 고려; +- browser abort가 server rollback 증거가 아님. + +--- + +## 12. Cross-context invalidation + +### 12.1 wire 의미 + +cross-tab wire는 invalidate-only hint다. + +전송 금지: + +```text +query key +query data +input/filter +cursor +validator +optimistic layer +mutation body +account ID +resource ID +``` + +전송: + +```text +protocol version +release epoch +scope epoch +sequence +opaque topic ID +source instance ID digest +``` + +### 12.2 registry + +```ts +export interface InvalidationRegistry { + readonly topics: readonly string[]; + readonly namespaces: readonly string[]; + readonly edges: readonly Readonly<{ + topicId: string; + namespace: string; + }>[]; +} +``` + +bounds: + +```text +topics 256 +namespaces 256 +edges 1,024 +topic fan-out 64 +namespace fan-in 64 +ID bytes 80 +``` + +duplicate edge와 orphan topic/namespace를 startup에서 거절한다. + +### 12.3 primary/fallback transport + +```text +primary = BroadcastChannel +fallback = localStorage pulse + storage event +``` + +channel/key는 registry-issued opaque token을 사용한다. + +fallback pulse: + +```text +maximum 1,024 bytes +TTL 60 seconds +write then remove not used; latest bounded envelope overwrite +storageArea must equal localStorage +same-tab local dispatch is explicit, storage event를 기대하지 않음 +``` + +### 12.4 ordering + +source instance별 monotonic sequence를 사용한다. + +- duplicate sequence: ignore; +- lower sequence: stale duplicate ignore; +- exact next: topic apply; +- gap: 모든 registered namespace stale mark; +- release/scope epoch mismatch: event ignore + local full stale transition; +- event는 authority가 아니라 refetch hint. + +### 12.5 mutation coalescing + +current local mutation lease 동안 같은 topic remote hint를 coalesce한다. + +mutation settlement 후: + +- one invalidation apply; +- active query bounded refetch; +- inactive query는 next mount/focus revalidate; +- remote hint 때문에 optimistic layer를 tab 간 복제하지 않는다. + +### 12.6 lifecycle + +- application runtime start에서 one host; +- session transition 시 scope epoch rotate; +- logout에서 old event ignore; +- pagehide bfcache는 host suspend, restore에서 duplicate listener 없이 resume; +- dispose는 listener/channel/timer 제거; +- hidden page도 invalidation hint 수신은 유지하되 refetch는 visibility policy를 따른다. + +--- +## 13. Realtime product composition + +### 13.1 existing runtime reuse + +다음 existing source를 common runtime으로 유지한다. + +```text +src/contracts/realtime-events.ts +src/contracts/realtime-streams.ts +src/application/ports/realtime/* +src/adapters/realtime/event-codec.ts +src/adapters/realtime/event-consumer.ts +src/adapters/realtime/reconnect-coordinator.ts +src/adapters/realtime/live-poll-handoff-coordinator.ts +src/adapters/realtime/stream-coordinator.ts +src/adapters/realtime/sse/* +src/adapters/realtime/websocket/* +src/adapters/realtime/polling/* +src/adapters/web-push/* +``` + +RT-01~RT-04를 새 protocol로 다시 구현하지 않는다. + +### 13.2 product contribution source + +```text +src/features//contracts/-realtime-contribution.ts +``` + +```ts +export interface InstalledRealtimeContribution { + readonly contributionId: string; + readonly featureId: string; + readonly contractSourcePackageId: string; + readonly stream: RealtimeStreamRegistration; + readonly eventEffects: readonly InstalledRealtimeEventEffect[]; + readonly transport: InstalledRealtimeTransport; +} + +export interface InstalledRealtimeEventEffect { + readonly eventType: string; + readonly mapperId: string; + readonly effect: + | "INVALIDATE_TOPICS" + | "APPLY_AUTHORITATIVE_DELTA" + | "EPHEMERAL_NOTIFICATION"; + readonly invalidationTopics: readonly string[]; +} + +export type InstalledRealtimeTransport = + | Readonly<{ kind: "SSE"; endpointId: string }> + | Readonly<{ kind: "WEBSOCKET"; endpointId: string }> + | Readonly<{ + kind: "POLLING"; + operationId: string; + intervalMs: number; + }>; +``` + +endpoint/event/recovery meaning is imported from the external package/approved frontend contribution. Frontend does not invent a server path or event payload. + +### 13.3 contribution validation + +```text +contributions max 64 +streams max 128 +event types max 512 +effects per event max 8 +invalidation topics per event 32 +``` + +validation: + +- contribution ID unique; +- stream/event registration existing registry rules pass; +- external event type exists in installed contract contribution; +- event validator package belongs to `contractSourcePackageId`; +- mapper exists and input schema matches payload validator; +- invalidation topic exists; +- transport endpoint/operation exists in installed package contribution; +- polling interval 5,000..300,000ms; +- transport exactly one; +- fallback is explicit, not inferred from runtime support. + +### 13.4 no mega port + +SSE, WebSocket, Polling, Web Push는 다음 의미로 분리한다. + +```text +SSE active page one-way stream +WebSocket active page duplex protocol +Polling visible finite HTTP scheduling +Web Push inactive/background user notification hint +``` + +하나의 `RealtimePort.connect(any)`로 합치지 않는다. + +### 13.5 transport selection + +기본 선택 규칙: + +- one-way active stream: SSE; +- duplex application protocol: WebSocket; +- stream provider가 없고 bounded freshness로 충분: Polling; +- inactive user notification: Web Push; +- Web Push를 foreground state synchronization으로 사용 금지; +- Polling을 connection failure 자동 downgrade로 사용 금지; +- primary/fallback pair는 contribution의 `stream.fallback`이 명시한다. + +### 13.6 product composition stop condition + +다음 중 하나라도 없으면 product realtime을 composition하지 않는다. + +```text +exact external package event descriptor +exact endpoint/operation descriptor +feature mapper +feature effect mapping +scope mode +recovery mode +selected transport +kill switch contribution +browser lifecycle owner +``` + +common source가 존재한다는 이유로 production bootstrap에 연결하지 않는다. + +### 13.7 host topology + +application runtime당 `RealtimeRuntimeHost` 하나다. + +```ts +export interface RealtimeRuntimeHost { + acquire(subscription: RealtimeSubscriptionRequest): RealtimeSubscriptionLease; + start(): Promise; + stop(reason: RuntimeStopReason): Promise; + dispose(): Promise; +} +``` + +transport connection은 stream registration 단위로 공유한다. component/route마다 socket을 만들지 않는다. + +### 13.8 single-writer rule + +동일 stream/scope에는 active delivery writer가 하나다. + +```text +SSE or WebSocket writer + XOR +bounded Polling writer +``` + +handoff: + +```text +old writer admission close +-> old in-flight settle/cancel +-> checkpoint snapshot +-> new writer recovery barrier +-> new writer OPEN +``` + +live와 polling이 동시에 same effect를 적용하지 않는다. + +--- + +## 14. Connection·Reconnect·Resume + +### 14.1 state machine + +```ts +export type RealtimeConnectionState = + | "IDLE" + | "CONNECTING" + | "AUTHENTICATING" + | "OPEN_UNRESUMED" + | "RECOVERING" + | "OPEN" + | "BACKING_OFF" + | "PAUSED_OFFLINE" + | "PAUSED_HIDDEN" + | "DEGRADED" + | "FATAL" + | "STOPPED"; +``` + +`OPEN_UNRESUMED`에서 application event effect를 실행하지 않는다. + +### 14.2 connection success + +connection success는 다음만 뜻한다. + +```text +transport handshake completed +selected protocol accepted +connection generation current +``` + +다음을 뜻하지 않는다. + +```text +authenticated subscription active +event gap recovered +server state latest +checkpoint durable +application effect applied +``` + +### 14.3 reconnect policy + +exact default: + +```ts +export const DEFAULT_REALTIME_RECONNECT_POLICY = Object.freeze({ + baseDelayMs: 500, + maxDelayMs: 30_000, + maxAttempts: 10, + maxElapsedMs: 300_000, + stableOpenMs: 60_000, +}); +``` + +- algorithm: full jitter; +- server not-before hint: local delay lower bound; +- hint valid range: 0..60,000ms; +- remaining episode budget 초과 hint: reconnect 종료; +- attempts/exhausted: `DEGRADED`, explicit recovery action; +- automatic new episode cooldown: 300,000ms; +- valid event/heartbeat 또는 60s stable open 후 attempt counter reset; +- offline state에서는 timer 대신 `online` signal을 기다림. + +### 14.4 error classification + +reconnect candidate: + +```text +network close +provider unavailable +retryable server close +idle timeout +validated auth refresh-ready transition +``` + +fatal: + +```text +protocol mismatch +unknown required event version +schema/mapper runtime failure +forbidden +invalid endpoint +repeated recovery invariant failure +``` + +rate limited: + +- validated delay 사용; +- maximum delay/episode budget 안에서만 retry; +- no immediate retry storm. + +### 14.5 visibility policy + +```text +VISIBLE -> normal connection/recovery +HIDDEN -> 30,000ms grace +HIDDEN after grace -> close transport, PAUSED_HIDDEN +VISIBLE restore -> authoritative recovery before OPEN +``` + +hidden 상태에서 Polling fallback을 시작하지 않는다. Web Push는 별도 capability다. + +### 14.6 network hint + +`navigator.onLine`은 hint다. + +- `offline` event: new connection/retry 중지, active transport close; +- `online` event: immediate recovery admission one time; +- online인데 connect failure면 ordinary backoff; +- offline인데 existing request가 성공해도 network authority를 재정의하지 않음. + +### 14.7 resume authority + +resume mode는 existing `RealtimeRecoveryProfile` literal을 유지한다. + +```text +CURSOR +SNAPSHOT_ONLY +SESSION_REBUILD +``` + +frontend owns: + +- checkpoint storage lifecycle; +- connection generation; +- duplicate/gap detection; +- barrier ordering; +- snapshot application ordering. + +external package/provider owns server cursor/replay/snapshot semantics. Frontend는 cursor를 decode/modify하지 않는다. + +### 14.8 recovery barrier + +`CURSOR`: + +```text +open transport +-> send/attach opaque checkpoint through selected adapter +-> replay begins +-> validate/apply events sequentially +-> replay terminal barrier +-> OPEN +``` + +`SNAPSHOT_ONLY`: + +```text +open transport or hold/buffer +-> authoritative snapshot query +-> scope current check +-> apply snapshot +-> release buffered event barrier +-> OPEN +``` + +`SESSION_REBUILD`: + +```text +close old stream +-> rebuild feature input +-> reset feature state +-> fresh subscription +-> OPEN +``` + +barrier가 `NONE`인 snapshot profile도 connection open만으로 latest라고 표시하지 않는다. snapshot operation 성공이 필요하다. + +### 14.9 checkpoint record + +```ts +export interface RealtimeCheckpointRecord { + readonly recordVersion: 1; + readonly streamId: string; + readonly scopeBinding: string; + readonly connectionGeneration: number; + readonly opaqueCheckpoint: string; + readonly lastAppliedEventIdentity: string; + readonly writtenAt: string; + readonly expiresAt: string; + readonly contractPackageDigest: `sha256:${string}`; +} +``` + +bounds: + +```text +checkpoint UTF-8 <= 4,096 bytes +record encoded <= 16,384 bytes +TTL <= 7 days +records per stream/scope = 1 +``` + +credential, endpoint, raw account ID를 넣지 않는다. + +### 14.10 gap handling + +다음은 gap이다. + +- expected sequence보다 큼; +- same sequence different fingerprint; +- cursor expired; +- dedupe window overflow; +- queue overflow; +- checkpoint version/package mismatch; +- connection generation conflict. + +처리: + +```text +new event admission stop +-> queued unapplied delta discard +-> state = RECOVERING +-> authoritative snapshot/session rebuild +-> checkpoint replace after effect +-> OPEN +``` + +gap 이후 delta를 계속 best-effort 적용하지 않는다. + +### 14.11 SSE specifics + +- Fetch streaming adapter reuse; +- response status/media validate; +- maximum line/event bytes는 common event bound; +- UTF-8 streaming decode; +- incomplete terminal event discard; +- heartbeat/comment는 payload event가 아님; +- idle deadline 45s default; +- total connection deadline 없음, lifecycle controls close; +- server `retry` hint is lower bound, max 60s; +- `Last-Event-ID` usage is adapter/profile-owned, opaque only. + +### 14.12 WebSocket specifics + +- exact URL/profile from installed endpoint; +- binary/text mode fixed per registration; +- subprotocol allowlist exact; +- outbound command disabled unless separate registered protocol; +- max event bytes 64KiB baseline; +- bufferedAmount high watermark 256KiB for outbound control messages; +- heartbeat expected 30s, idle timeout 45s when protocol declares heartbeat; +- browser WebSocket API가 backpressure를 보장한다고 가정하지 않음; +- receive queue hard bounds are common runtime authority; +- close code/reason raw text를 UI/log에 노출하지 않음. + +### 14.13 bounded polling specifics + +- existing registered terminal query operation only; +- visible page only; +- default interval 30s; +- minimum 5s, maximum 5m; +- one in-flight request; +- next schedule after previous settlement; +- hidden/offline pause; +- full jitter ±10% schedule spread; +- Query retry disabled, HTTP transport owns attempt; +- polling result enters same mapper/scope/result budget; +- polling is freshness policy, not realtime delivery guarantee. + +--- + +## 15. Event validation과 subscription cleanup + +### 15.1 hard limits + +existing constants are normative. + +```text +max event bytes 64 KiB +max payload depth 16 +max payload nodes 4,096 +max queue events 256 +max queue bytes 4 MiB +max dedupe entries 2,048 +max dedupe bytes 4 MiB +dedupe TTL 10 min +``` + +feature contribution can reduce but not raise them. + +### 15.2 validation pipeline + +```text +raw frame +-> frame byte bound +-> protocol decode +-> common envelope validation +-> stream registration lookup +-> event type membership check +-> package payload validator +-> feature mapper +-> scope binding/generation check +-> duplicate/sequence/gap decision +-> queue admission +-> sequential effect +-> effect success +-> checkpoint transaction commit +-> subscriber notification +``` + +순서를 바꾸지 않는다. + +### 15.3 envelope/payload failure + +```text +MALFORMED_FRAME +EVENT_TOO_LARGE +UNKNOWN_EVENT_TYPE +EVENT_NOT_ALLOWED_FOR_STREAM +ENVELOPE_SCHEMA_INVALID +PAYLOAD_SCHEMA_INVALID +VALIDATOR_RUNTIME_FAILURE +MAPPING_CONTRACT_VIOLATION +SCOPE_PROTOCOL_VIOLATION +``` + +unknown event를 generic object로 application에 전달하지 않는다. + +state-bearing event failure: + +```text +admission stop -> recovery +``` + +ephemeral notification failure: + +```text +drop + bounded observation +``` + +### 15.4 duplicate/conflict + +same event identity + same semantic fingerprint: + +```text +duplicate -> no effect, no checkpoint regression +``` + +same identity + different fingerprint: + +```text +EVENT_CONFLICT -> recovery +``` + +lower sequence but unknown dedupe entry: + +```text +STALE_EVENT -> drop only when registration semantics allow +otherwise recovery +``` + +### 15.5 queue + +- admission count/bytes atomic in host; +- event processing sequential per stream; +- different streams can progress independently; +- queue overflow triggers recovery, not oldest-drop; +- no unbounded `Promise.then` chain retained after settlement; +- queued item owns immutable mapped event and byte estimate; +- shutdown aborts admission and drains up to 2s, then drops unapplied items without checkpoint. + +### 15.6 effect types + +`INVALIDATE_TOPICS`: + +```text +registered opaque topics only +-> local invalidation coordinator +-> active Query bounded refetch +``` + +`APPLY_AUTHORITATIVE_DELTA`: + +- feature-owned reducer; +- current immutable snapshot and revision required; +- deterministic/no throw; +- result size budget; +- failure triggers recovery; +- partial delta not stored as event history. + +`EPHEMERAL_NOTIFICATION`: + +- no server-state authority; +- bounded UI notification; +- missed notification does not require state recovery unless separately marked state-bearing. + +### 15.7 checkpoint commit ordering + +금지: + +```text +checkpoint write +-> application effect +``` + +필수: + +```text +validate/map +-> effect commit +-> scope current recheck +-> checkpoint transaction commit +-> notify +``` + +checkpoint write failure after effect: + +- event effect success를 rollback할 수 없음; +- state `RECOVERING`; +- next connection uses authoritative snapshot; +- same delta replay에 reducer idempotency를 가정하지 않음. + +### 15.8 subscription lease + +```ts +export interface RealtimeSubscriptionLease { + readonly leaseId: string; + readonly streamId: string; + readonly state: "ACTIVE" | "RELEASED"; + release(): void; +} +``` + +key: + +```text +streamId + scope generation + subscription projection identity +``` + +ref-count behavior: + +```text +0 -> 1 : server/transport subscription admission +1 -> N : local subscriber only +N -> 1 : local release +1 -> 0 : 5s grace +0 after grace: unsubscribe/connection policy evaluation +``` + +### 15.9 cleanup triggers + +immediate release: + +- explicit component cleanup; +- route removal; +- feature disable; +- scope fence/logout; +- application shutdown; +- fatal protocol failure. + +grace release: + +- ordinary React remount/navigation; +- StrictMode setup-cleanup-setup; +- short route transition. + +scope fence는 grace를 사용하지 않는다. + +### 15.10 React bridge + +component uses feature hook only. + +```text +Component +-> useFeatureRealtime +-> acquire lease in effect +-> cleanup release +-> read external store via useSyncExternalStore +``` + +component does not: + +- open/close socket; +- store cursor; +- schedule reconnect; +- parse event; +- call QueryClient directly. + +### 15.11 StrictMode + +required behavior: + +```text +setup A acquire +cleanup A release -> grace starts +setup B acquire within grace -> same runtime retained +after final cleanup -> 5s -> actual unsubscribe +``` + +listener/ref count cannot go negative. release is idempotent. + +### 15.12 bfcache + +`pagehide persisted=true`: + +- new event effect admission close; +- transport close; +- checkpoint remains; +- lease registry retained in suspended state. + +`pageshow persisted=true`: + +- release manifest refresh; +- scope current check; +- authoritative resume; +- leases reactivate after OPEN; +- duplicate listeners/connections 없음. + +--- + +## 16. Generic Web Worker + +### 16.1 capability boundary + +Generic CPU Worker is separate from: + +```text +OPFS dedicated worker +Service Worker +Web Push handler +SharedWorker +worklet +``` + +existing OPFS worker source is not moved into generic runtime. + +### 16.2 selection condition + +Generic Worker remains `NOT_SELECTED` until: + +- measured main-thread task exceeds named interaction budget; +- task is CPU-bound; +- input/output/cancellation owner exists; +- safe main-thread fallback is defined or unsupported state is accepted; +- static `InstalledWebWorkerContribution` exists. + +network I/O task, ordinary JSON validation, small mapper work를 worker로 옮기지 않는다. + +### 16.3 source layout + +```text +src/application/ports/web-worker/ + worker-task-port.ts +src/contracts/web-worker.ts +src/adapters/web-worker/ + web-worker-runtime.ts + web-worker-client.ts + web-worker-protocol.ts + web-worker-entry.ts + web-worker-task-registry.ts +``` + +application port is semantic task-specific, not generic arbitrary function execution. + +### 16.4 installed contribution + +```ts +export interface InstalledWebWorkerContribution { + readonly taskGroupId: string; + readonly tasks: readonly InstalledWorkerTask[]; + readonly fallback: "MAIN_THREAD_CHUNKED" | "UNSUPPORTED"; +} + +export interface InstalledWorkerTask { + readonly taskId: string; + readonly taskVersion: 1; + readonly maximumInputBytes: number; + readonly maximumOutputBytes: number; + readonly deadlineMs: number; +} +``` + +bounds: + +```text +task groups max 16 +tasks per group 32 +input default 1 MiB +input hard 8 MiB +output default 1 MiB +output hard 8 MiB +deadline default 5 s +deadline hard 30 s +``` + +### 16.5 worker topology + +- task group당 worker 1개; +- baseline pool size 1; +- same group FIFO; +- groups can run independently; +- max active groups 4; +- no shared global pool; +- first task에서 lazy create; +- idle 60s with no active/queue -> terminate; +- next task creates new generation. + +### 16.6 protocol V1 + +```ts +export type WorkerRequestMessage = Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "EXECUTE"; + taskId: string; + taskVersion: 1; + requestId: string; + workerGeneration: number; + deadlineEpochMs: number; + payload: unknown; +}>; + +export type WorkerCancelMessage = Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "CANCEL"; + requestId: string; + workerGeneration: number; +}>; + +export type WorkerResponseMessage = + | Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "SUCCESS"; + requestId: string; + workerGeneration: number; + payload: unknown; + }> + | Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "FAILURE"; + requestId: string; + workerGeneration: number; + code: WorkerFailureCode; + }>; +``` + +### 16.7 failure code + +```text +TASK_UNKNOWN +VERSION_UNSUPPORTED +INPUT_INVALID +INPUT_TOO_LARGE +OUTPUT_INVALID +OUTPUT_TOO_LARGE +QUEUE_FULL +DEADLINE_EXCEEDED +CANCELLED +CRASHED +TRANSFER_FAILED +STALE_RESULT +RUNTIME_PROTOCOL_FAILURE +``` + +```ts +export type WorkerFailureCode = + | "TASK_UNKNOWN" + | "VERSION_UNSUPPORTED" + | "INPUT_INVALID" + | "INPUT_TOO_LARGE" + | "OUTPUT_INVALID" + | "OUTPUT_TOO_LARGE" + | "QUEUE_FULL" + | "DEADLINE_EXCEEDED" + | "CANCELLED" + | "CRASHED" + | "TRANSFER_FAILED" + | "STALE_RESULT" + | "RUNTIME_PROTOCOL_FAILURE"; +``` + +native error message/stack is not posted to page. + +### 16.8 queue bounds + +```text +queued tasks per group 32 +queued encoded bytes 16 MiB +active tasks per group 1 +waiters per request 1 +``` + +queue full is a local typed failure. It does not create another worker. + +### 16.9 cancellation + +queued: + +- remove immediately; +- settle `CANCELLED`; +- no worker message. + +active: + +```text +send CANCEL +-> cooperative task checks signal +-> 250ms grace +-> not settled: terminate worker +-> all active/queued in same generation settle CRASHED/CANCELLED +-> next task creates new generation subject to restart budget +``` + +### 16.10 crash/restart budget + +```text +maximum restarts = 3 per 5 minutes per task group +fourth crash -> group FATAL +manual retry -> new 5-minute window only after user action/page reload +``` + +crash does not retry completed/unknown task automatically. + +### 16.11 structured clone/transfer + +- message schema validates cloned shape; +- credentials, AbortSignal, class instance, function, DOM node prohibited; +- ArrayBuffer transfer allowed only when task declares ownership transfer; +- sender must treat transferred buffer as detached immediately; +- output transfer is validated after receive; +- SharedArrayBuffer baseline prohibited; +- transferable list count max 16. + +### 16.12 stale result fence + +result is accepted only when all match. + +```text +requestId +workerGeneration +current task lease +caller scope generation +not cancelled +before deadline +``` + +stale result is dropped and observed. Query/UI is not updated. + +### 16.13 worker network prohibition + +worker entry cannot import HTTP adapter or use `fetch`, WebSocket, EventSource, credential/session adapter. + +CPU task requiring remote input: + +```text +page/application HTTP +-> validate/map/bound payload +-> Worker task +-> validate result +-> application/UI +``` + +### 16.14 main-thread fallback + +`MAIN_THREAD_CHUNKED` only: + +- same semantic task interface; +- chunk budget max 8ms; +- yield via scheduler/MessageChannel adapter; +- same total deadline; +- same cancellation/result validator; +- input hard max reduced to 1MiB; +- not presented as equivalent performance guarantee. + +`UNSUPPORTED` returns typed unavailable; it does not execute synchronously. + +--- + +## 17. Service Worker lifecycle + +### 17.1 one physical registration + +same scope has one worker entry. + +```text +PWA lifecycle +verified static asset fetch +Web Push event/click +optional sync wake-up +``` + +separate registration prohibited. + +```text +src/adapters/service-worker/service-worker-entry.ts +``` + +existing Web Push service-worker runtime becomes a handler factory inside this entry. + +### 17.2 source layout + +```text +src/contracts/service-worker.ts +src/adapters/service-worker/ + service-worker-entry.ts + service-worker-protocol.ts + service-worker-page-controller.ts + service-worker-lifecycle.ts + service-worker-static-assets.ts + service-worker-sync-handler.ts + service-worker-removal.ts +src/bootstrap/register-service-worker.ts +``` + +### 17.2.1 TypeScript execution realms + +main DOM, generic Web Worker와 Service Worker는 같은 TypeScript global lib를 공유하지 않는다. + +```text +tsconfig.app.json lib = DOM + ES + +tsconfig.web-worker.json lib = WebWorker + ES + include generic worker entry/runtime only + +tsconfig.service-worker.json lib = WebWorker + ES + include service-worker entry/handlers only +``` + +- worker tsconfig에서 DOM global type을 사용하지 않는다; +- service-worker tsconfig에서 React/presentation/bootstrap page code를 include하지 않는다; +- shared protocol file은 DOM/WebWorker global type 대신 structural types만 사용한다; +- root `tsconfig.json` project references에 두 config를 추가한다; +- `skipLibCheck`로 realm collision을 숨기지 않는다. + +### 17.2.2 deterministic two-pass build + +Service Worker가 exact hashed app asset 목록을 compile-time으로 가져야 하므로 build 순서를 다음으로 고정한다. + +```text +1. clean dist and .generated/frontend-runtime +2. generate contractSet + build-info source +3. Vite app build (emptyOutDir=true) +4. scan app dist and generate static asset source +5. ACTIVE mode일 때 Vite Service Worker build (emptyOutDir=false) +6. generate Release Manifest V2/build manifest +``` + +source: + +```text +scripts/build-frontend.ts +scripts/generate-contract-set.ts +scripts/generate-service-worker-assets.ts +vite.service-worker.config.ts +.generated/frontend-runtime/contract-set.ts +.generated/frontend-runtime/service-worker-build-info.ts +.generated/frontend-runtime/service-worker-assets.ts +``` + +Service Worker build rules: + +- input exactly `src/adapters/service-worker/service-worker-entry.ts`; +- output exactly `dist/service-worker.js` relative to deployed app root; +- ES module format; +- `inlineDynamicImports=true`; +- no additional worker chunk; +- sourcemap off by default; +- minification uses same pinned Vite toolchain; +- `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`, `null` mode does not run active worker build; +- generated asset source excludes `service-worker.js` itself and `index.html`. + +static delivery requirement: + +```text +service-worker.js Cache-Control = no-cache +runtime config/release manifest = no-store +hashed assets = immutable +``` + +runtime does not fetch hosting policy to prove it. Static delivery configuration owns this requirement. + +### 17.3 static selection modes + +#### `ACTIVE` + +handlers contain one or more unique values. + +```text +WEB_PUSH +PWA_STATIC_ASSETS +OFFLINE_SYNC_WAKEUP +``` + +worker entry and register code are included. + +#### `REMOVE_REGISTRATION` + +- worker entry not included; +- cleanup controller included; +- exact registration scope/script ownership check; +- unregister only; +- caches retained. + +#### `PURGE_OWNED_RESOURCES` + +- repeat unregister check; +- exact owned caches/metadata purge; +- outbox/OPFS/file data untouched. + +#### `null` + +- entry and cleanup code absent; +- registration lookup 0; +- Cache Storage access 0. + +transition: + +```text +ACTIVE +-> REMOVE_REGISTRATION for at least one deployed release +-> PURGE_OWNED_RESOURCES after rollback retention +-> null +``` + +### 17.4 exact ownership check + +expected: + +```text +scope = new URL(routerBasePath, location.origin).href +script = new URL("service-worker.js", scope).href +``` + +`getRegistration(routerBasePath)` result is owned only if: + +- registration.scope exact equals expected scope; +- every present installing/waiting/active worker scriptURL is same-origin; +- at least one present worker scriptURL exact equals expected script; +- no present worker has different script URL. + +ownership mismatch means no unregister/purge. + +### 17.5 registration timing + +```text +Runtime Config valid +-> Release Manifest/contractSet valid +-> base composition +-> React first committed effect +-> PostMountRuntimeStarter.start +-> ACTIVE/cleanup mode action +``` + +`ACTIVE` registration call: + +```ts +const expectedScope = new URL(routerBasePath, location.origin); +const scriptUrl = new URL("service-worker.js", expectedScope); + +navigator.serviceWorker.register(scriptUrl, { + scope: expectedScope.pathname, + type: "module", + updateViaCache: "none", +}); +``` + +StrictMode repeat returns same in-flight promise. + +### 17.6 runtime override `DISABLED` + +static `ACTIVE` + config disabled: + +```text +new register 0 +worker message/update timer 0 +sync register 0 +push handler activation 0 +exact-owned registration lookup 1 +owned registration unregister <= 1 +owned cache delete 0 +``` + +failure code `DISABLE_CLEANUP_FAILED`; capability remains inactive. next clean navigation may retry once, no loop. + +### 17.7 protocol identity + +```text +serviceWorkerProtocolVersion = 1 +cacheSchemaVersion = 1 +buildId +releaseId +contractSetDigest +staticAssetSetDigest +``` + +page and worker read the same compile-time virtual build-info module. + +### 17.8 message protocol + +```text +PAGE_HELLO +WORKER_HELLO_ACK +UPDATE_READY +ACTIVATE_REQUEST +ACTIVATE_ACCEPTED +ACTIVATE_REJECTED +CLIENT_DRAIN_REQUEST +CLIENT_DRAINED +ACTIVATED_RELOAD_REQUIRED +CACHE_RESET_REQUEST +CACHE_RESET_RESULT +SYNC_WAKE_OBSERVED +``` + +common fields: + +```text +protocolVersion = 1 +messageId = UUID +sourceBuildId +targetBuildId optional +nonce optional +``` + +origin/source client ID, exact message keys, runtime schema and one-time nonce validation required. + +### 17.9 install + +without static asset handler: + +- Cache Storage open 0; +- lifecycle/push/sync handlers register only. + +with static assets: + +```text +1. embedded manifest validate +2. candidate cache open +3. fetch concurrency 4 +4. status/content-type/bytes/integrity verify +5. candidate marker write +6. all success -> install resolve +7. any failure -> candidate cache delete and install reject +``` + +bounds: + +```text +assets count 256 +single asset 2 MiB +asset set 5 MiB +concurrency 4 +install deadline 60 s +``` + +partial candidate is never used. + +### 17.10 waiting update + +waiting worker sends `UPDATE_READY` to controlled clients. + +it does not call `skipWaiting()` itself. + +page blocks activation when: + +- dirty form; +- active mutation; +- effect unknown recovery; +- offline command `SENDING`; +- unresolved local file write; +- incompatible protocol tuple. + +### 17.11 client drain activation + +```text +page creates one-time nonce +-> ACTIVATE_REQUEST(expected tuple) +-> waiting worker validates +-> CLIENT_DRAIN_REQUEST to all controlled window clients +-> each client closes new mutation/realtime/offline admission +-> active effects settle +-> CLIENT_DRAINED(nonce) +-> all clients within 30s +-> ACTIVATE_ACCEPTED +-> waiting worker skipWaiting +-> activate event +-> no clients.claim +-> ACTIVATED_RELOAD_REQUIRED +-> clean page user/guarded reload once +``` + +one missing client rejects automatic activation. + +### 17.12 no clients.claim + +baseline does not call `clients.claim()`. + +- first install page is not silently controlled; +- update does not mix new worker with old page without reload; +- current page becomes controlled after explicit clean reload; +- future claim adoption requires separate compatibility decision. + +### 17.13 first install + +Service Worker reload guard is distinct from chunk recovery. + +```text +storage key = SERVICE_WORKER_RELOAD_GUARD +value schema = { version: 1, oldBuildId, newBuildId, writtenAt } +maximum bytes = 512 +TTL = 10 minutes +``` + +`CHUNK_RELOAD_GUARD`를 재사용하지 않는다. Storage registry/codec이 exact key와 envelope를 소유한다. + +if active tuple matches current build but `navigator.serviceWorker.controller` is null: + +```text +state = RELOAD_TO_ENABLE +``` + +- no automatic reload; +- user action only when page clean; +- reload guard once per build; +- after reload `PAGE_HELLO` handshake required; +- handshake failure -> `INCOMPATIBLE`, capability inactive. + +### 17.14 update checking + +- no immediate repeated `registration.update()`; +- visible page at most once per 6 hours; +- manual action once independently rate-limited; +- hidden page no scheduled update; +- network failure does not fail product flow; +- controllerchange reload guard `(oldBuild,newBuild)` once. + +### 17.15 activate cleanup + +- candidate marker and tuple validate; +- current revision active; +- previous verified revision retain one; +- older owned cache delete; +- exact prefix + parser only; +- config/release/API cache found -> security violation and delete; +- unregister != cache cleanup. + +### 17.16 page controller interface + +```ts +export type ServiceWorkerStartOutcome = + | Readonly<{ kind: "ACTIVE"; buildId: string }> + | Readonly<{ kind: "RELOAD_TO_ENABLE" }> + | Readonly<{ kind: "UPDATE_WAITING" }> + | Readonly<{ kind: "DISABLED" }> + | Readonly<{ kind: "INCOMPATIBLE" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerActivationOutcome = + | Readonly<{ kind: "ACTIVATED_RELOAD_REQUIRED" }> + | Readonly<{ kind: "BLOCKED_DIRTY_CLIENT" }> + | Readonly<{ kind: "CLIENT_DRAIN_TIMEOUT" }> + | Readonly<{ kind: "NO_WAITING_WORKER" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerResetOutcome = + | Readonly<{ kind: "RESET"; cachesDeleted: number }> + | Readonly<{ kind: "NOT_CONTROLLED" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export interface ServiceWorkerRuntimeHost { + start(): Promise; + requestActivation(): Promise; + resetOwnedCaches(): Promise; + stop(): Promise; +} +``` + +`stop()` removes page listener/update timer. ordinary shutdown does not unregister. + +### 17.17 cleanup mode outcomes + +```ts +export type ServiceWorkerRemovalOutcome = + | Readonly<{ kind: "ABSENT" }> + | Readonly<{ kind: "UNREGISTERED" }> + | Readonly<{ kind: "PURGED"; cachesDeleted: number; metadataDeleted: number }> + | Readonly<{ kind: "OWNERSHIP_MISMATCH" }> + | Readonly<{ kind: "FAILED"; operation: "LOOKUP" | "UNREGISTER" | "PURGE" }>; +``` + +foreign resource count can be observed only as bounded count, no names. + +--- + +## 18. Cache Storage와 fetch policy + +### 18.1 existing runtime reuse + +```text +src/adapters/cache-storage/public-cache-policy.ts +src/adapters/cache-storage/public-response-cache-adapter.ts +``` + +no generic `CacheService`. + +### 18.2 cache name + +```text +ca-static-v1- +``` + +parser requires exact prefix, schema literal and 16 hex chars. + +### 18.3 embedded static asset manifest + +Vite virtual modules: + +```text +virtual:ca-service-worker-assets +virtual:ca-service-worker-build-info +``` + +```ts +export interface StaticAssetManifestV1 { + readonly schemaVersion: 1; + readonly buildId: string; + readonly releaseId: string; + readonly setDigest: `sha256:${string}`; + readonly assets: readonly Readonly<{ + url: string; + sha256: `sha256:${string}`; + bytes: number; + contentType: string; + }>[]; +} +``` + +worker does not fetch a manifest endpoint. + +### 18.4 manifest inclusion + +include only: + +- Vite immutable hashed JS/CSS assets; +- approved font/image assets with immutable hashed URL; +- worker-owned static metadata. + +exclude: + +```text +index.html +runtime config +release manifest +API response +user/account data +telemetry/auth endpoint +source map unless explicitly public +unhashed mutable asset +cross-origin opaque response +``` + +### 18.5 fetch classification order + +```text +non-GET -> network passthrough +navigation request -> network-only +runtime config URL -> network-only +release manifest URL -> network-only +exact static asset manifest hit -> verified cache-first +all other requests -> network passthrough +``` + +API base can be `/`; exact static hit is evaluated before generic network passthrough. + +### 18.6 cache hit validation + +- exact URL; +- method GET; +- only build-generated query if manifest contains it; +- cached marker matches set digest; +- status 200; +- content type exact normalized match; +- no opaque response; +- expected byte count; +- install-time integrity verified. + +invalid hit is deleted and treated as release mismatch/network fetch. It is not returned stale. + +### 18.7 cache miss + +runtime fetch success is returned but not written into active cache. Static cache population is install-time only. + +missing immutable asset: + +```text +network failure/404 +-> release mismatch classification +-> page chunk recovery path +``` + +### 18.8 navigation + +baseline has no offline shell. + +- navigation network-only; +- cached `index.html` fallback prohibited; +- network error uses browser/app boot failure; +- stale HTML and new assets/contractSet never intentionally mix; +- installability/offline document is a separate product capability. + +### 18.9 quota + +- asset set hard max 5MiB; +- `StorageManager.estimate()` is advisory only; +- actual `QuotaExceededError` is authoritative; +- install quota failure deletes candidate; +- existing current/previous cache retained; +- no browser file/OPFS/outbox cleanup to make room; +- user data has higher priority than recomputable static cache. + +### 18.10 reset + +`CACHE_RESET_REQUEST` requires: + +- controlled same-origin client; +- current protocol; +- one-time nonce; +- user/incident action; +- owned prefix parser. + +reset deletes static caches only. registration remains. reload after reset is user/guarded action. + +--- + +## 19. Offline Command와 Background Sync + +### 19.1 capability status + +baseline product selection remains `NOT_SELECTED`. + +reference runtime can be implemented without composing a product operation. It must not create DB/listener when unselected. + +### 19.2 scope + +Offline Command is for durable user command retry after foreground interruption. It is not: + +- Query persistence; +- arbitrary request cache; +- background execution guarantee; +- exactly-once guarantee; +- event queue; +- file upload checkpoint replacement. + +### 19.3 external prerequisite consumption + +an operation is queueable only when external package contribution provides: + +```text +retrySemantics = KEYED +commandRecovery non-null +stable input validator/projection +stable operation identity +runtime validator for terminal result/problem +``` + +frontend does not define server idempotency or inspect protocol. + +### 19.4 source layout + +```text +src/application/ports/offline-command/ + offline-command-port.ts +src/contracts/offline-command.ts +src/adapters/offline-command/ + offline-command-codec.ts + indexeddb-offline-command-repository.ts + offline-command-drain.ts + offline-command-lifecycle.ts + offline-command-summary.ts +``` + +existing generic IndexedDB runtime is reused as dataset backend. + +### 19.5 installed contribution + +```ts +export interface InstalledOfflineCommandContribution { + readonly datasetId: "OFFLINE_COMMANDS_V1"; + readonly operations: readonly InstalledOfflineOperation[]; +} + +export interface InstalledOfflineOperation { + readonly operationId: string; + readonly contractPackageId: string; + readonly maximumRequestBytes: number; + readonly retentionClass: "STANDARD_7D"; +} +``` + +bounds: + +```text +operations 64 +single request 256 KiB +hard single request 1 MiB +records 1,000 +dataset bytes 50 MiB +``` + +### 19.6 record + +```ts +export interface OfflineCommandRecordV1 { + readonly recordVersion: 1; + readonly commandId: string; + readonly operationId: string; + readonly contractPackageId: string; + readonly contractPackageVersion: string; + readonly contractPackageDigest: `sha256:${string}`; + readonly scopePartition: string; + readonly requestDigest: `sha256:${string}`; + readonly requestPayload: Uint8Array; + readonly idempotencyKey: string; + readonly state: OfflineCommandState; + readonly attempt: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly nextAttemptAt?: string; + readonly leaseOwner?: string; + readonly leaseExpiresAt?: string; + readonly terminalCode?: string; +} +``` + +```ts +export type OfflineCommandState = + | "PENDING" + | "LEASED" + | "FOREGROUND_REQUIRED" + | "SENDING" + | "RETRY_WAIT" + | "ACKED" + | "CONFLICT" + | "EFFECT_UNKNOWN" + | "EXPIRED"; +``` + +### 19.7 physical storage + +- opaque DB/dataset name from existing governance; +- account/session scope partition binding; +- transaction-complete is write success; +- record codec V1 strict; +- future schema fail closed/read-only; +- blocked/versionchange typed; +- no credential/token except opaque idempotency key; +- request payload minimized to exact package request projection; +- no raw URL/header. + +### 19.8 enqueue + +```text +scope current +-> operation allowlist +-> package identity current +-> package input validator +-> deterministic request encode +-> request bytes/digest +-> create idempotency key +-> dataset count/byte admission +-> one IDB transaction insert + counters +-> PENDING +``` + +same user intent duplicate uses feature/mutation coordinator before enqueue. Repository does not silently merge by payload digest. + +### 19.9 sender topology + +origin/account partition has one sender lease. + +```text +lease TTL 30s +renew every 10s +batch count 10 +batch window 30s +parallel send 1 +``` + +foreground tabs coordinate through existing cross-context hint and IndexedDB lease CAS. BroadcastChannel is hint, IDB lease is local authority. + +### 19.10 state transitions + +```text +PENDING -> LEASED -> SENDING +SENDING -> ACKED +SENDING -> RETRY_WAIT +SENDING -> CONFLICT +SENDING -> EFFECT_UNKNOWN +PENDING/RETRY_WAIT -> FOREGROUND_REQUIRED +PENDING/RETRY_WAIT/FOREGROUND_REQUIRED -> EXPIRED +``` + +invalid transitions are corruption. + +### 19.11 transaction boundaries + +before network: + +```text +IDB transaction: + verify current state + verify lease owner/live + increment attempt + state=SENDING + write operation attempt identity +commit +``` + +after terminal response: + +```text +validate/map/effect classify +-> IDB transaction terminal/retry state commit +-> lease release +``` + +### 19.12 crash recovery + +expired `LEASED` before send: + +```text +safe -> PENDING +``` + +expired `SENDING`: + +```text +EFFECT_UNKNOWN +``` + +never reset SENDING to PENDING automatically. + +### 19.13 foreground drain triggers + +```text +startup +online +visible +focus +manual +``` + +triggers coalesce into one scheduled drain. hidden page pauses new sends. active send result still settles. + +### 19.14 auth and package fence + +before each send: + +- scope current; +- credential READY; +- installed contract package identity exact matches record; +- operation still installed; +- request payload revalidated; +- idempotency key present; +- total deadline available. + +failure: + +```text +auth unavailable -> FOREGROUND_REQUIRED +package missing/version/digest mismatch -> FOREGROUND_REQUIRED(CONTRACT_PACKAGE_UNAVAILABLE) +scope stale -> old partition hidden, no send +schema invalid -> CONFLICT/terminal corruption review +``` + +### 19.15 retry + +retryable response/failure is package/HTTP runtime decision. + +```text +base delay 1s +max delay 5m +full jitter +attempt cap 10 +``` + +Retry-After is lower bound up to 5m. + +never retry: + +- contract/schema/mapper failure; +- effect maybe applied; +- auth unavailable; +- conflict; +- expired retention. + +attempt cap -> `FOREGROUND_REQUIRED(RETRY_EXHAUSTED)`, not silent drop. + +### 19.16 effect unknown + +```text +automatic send stop +-> package recovery descriptor +-> foreground inspect when auth ready +-> committed: ACKED + canonical refresh +-> no effect: PENDING only after explicit transition +-> still unknown: EFFECT_UNKNOWN +``` + +manual retry button cannot resend EFFECT_UNKNOWN directly. + +### 19.17 retention + +```text +PENDING/RETRY_WAIT/FOREGROUND_REQUIRED max age 7d +ACKED payload delete immediately; safe summary 24h +CONFLICT/EFFECT_UNKNOWN payload max 30d +EXPIRED payload delete; safe summary 7d +``` + +30d 이후 unresolved payload를 자동 send하지 않는다. + +### 19.18 Background Sync baseline + +Background Sync is optional wake-up. + +```text +sync tag = ca-outbox-v1 +``` + +register only when: + +- service worker handler `OFFLINE_SYNC_WAKEUP` selected; +- `registration.sync` exists; +- pending/foreground-required record exists; +- no in-flight same tag; +- last registration >= 1 minute. + +sync handler: + +```text +1. inspect bounded outbox metadata +2. update wakeRequestedAt +3. post SYNC_WAKE_OBSERVED to controlled clients +4. send authenticated command = 0 +5. resolve handler +``` + +### 19.19 foreground fallback + +Background Sync unsupported/fails/never fires is normal. + +foreground triggers remain required. Queue semantics are identical with or without native Sync. + +### 19.20 future worker send explicitly excluded + +Service Worker authenticated send requires separate future design covering: + +- worker-safe credential owner; +- no credential persistence; +- refresh/revocation; +- package validators in worker bundle; +- effect reconciliation; +- logout/version fence; +- platform execution limitations; +- user recovery. + +until then worker send code is prohibited. + +### 19.21 safe summary + +```ts +export interface OfflineCommandSummary { + readonly commandId: string; + readonly operationLabelKey: string; + readonly state: OfflineCommandState; + readonly createdAt: string; + readonly nextAction: + | "WAIT" + | "OPEN_APP" + | "CHECK_STATUS" + | "RESOLVE_CONFLICT" + | "CONTACT_SUPPORT" + | "DISMISS"; +} +``` + +raw payload, idempotency key, package digest, account partition are not exposed to UI. + +### 19.22 removal + +```text +1. new enqueue close +2. drain stop +3. active send settle/lease expire +4. unresolved count inspect +5. product recovery/export decision +6. selected dataset only delete when safe +7. sync registration stop +8. SW sync handler remove +9. composition remove +``` + +unresolved user command is not silently purged with source deletion. + +--- +## 20. 공통 start·stop·dispose + +### 20.1 lifecycle interface + +```ts +export interface RuntimeLifecycle { + start(): void | Promise; + stop(reason: RuntimeStopReason): void | Promise; + dispose(): void | Promise; +} + +export type RuntimeStopReason = + | "APPLICATION_SHUTDOWN" + | "SCOPE_FENCED" + | "FEATURE_DISABLED" + | "HIDDEN_POLICY" + | "INCIDENT_CONTAINMENT"; +``` + +invariants: + +- `start()` idempotent; +- concurrent start returns same promise; +- start failure cleans partial resource and state `FAILED`; +- `stop()` is restartable quiesce unless capability says terminal; +- `dispose()` terminal; +- dispose after dispose no-op; +- start after dispose throws typed lifecycle error; +- concurrent stop/dispose has one settlement; +- cleanup error aggregation does not skip remaining cleanup. + +### 20.2 runtime lifecycle states + +```text +NEW +STARTING +RUNNING +STOPPING +STOPPED +FAILED +DISPOSING +DISPOSED +``` + +allowed transitions: + +```text +NEW -> STARTING -> RUNNING +STARTING -> FAILED +RUNNING -> STOPPING -> STOPPED +STOPPED -> STARTING +NEW/STOPPED/FAILED/RUNNING -> DISPOSING -> DISPOSED +``` + +`FAILED -> STARTING` automatic transition is prohibited. Explicit owner recovery creates a new runtime instance or calls a named reset method. + +### 20.3 application shutdown order + +```text +1. close new navigation/mutation/optional admission +2. stop Service Worker page controller/cleanup action +3. close Web Worker task admission +4. cooperative cancel/terminate Web Workers +5. stop realtime subscriptions/reconnect and drain +6. stop offline foreground drain and release lease +7. dispose client scope runtime +8. clear conditional validator store +9. dispose invalidation coordinator +10. cancel and clear QueryClient +11. close cross-context transport +12. dispose session subscription +13. telemetry flush up to deadline +14. diagnostics dispose +``` + +QueryClient를 realtime보다 먼저 clear하지 않는다. late realtime effect가 cleared cache를 다시 채우는 race를 막는다. + +### 20.4 browser lifecycle owner + +window listener owner: + +```text +src/adapters/platform/browser-lifecycle.ts +``` + +single listener set: + +```text +visibilitychange +online +offline +focus +pagehide +pageshow +beforeunload only while dirty state exists +``` + +capability별 direct window listener를 추가하지 않는다. + +```ts +export interface BrowserLifecycleSnapshot { + readonly visibility: "VISIBLE" | "HIDDEN"; + readonly connectivityHint: "ONLINE" | "OFFLINE"; + readonly pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED"; + readonly generation: number; +} +``` + +### 20.5 pagehide and bfcache + +`pagehide persisted=true`: + +- terminal dispose하지 않음; +- lifecycle state `SUSPENDED` projection; +- realtime transport close; +- offline new send pause; +- active Worker follows task cancellation policy; +- Query memory remains; +- Service Worker registration remains; +- no blocking telemetry flush. + +`pageshow persisted=true`: + +```text +1. browser lifecycle generation increment +2. Release Manifest V2 no-store refresh once +3. scope current check +4. active Query refetch +5. realtime authoritative recovery +6. offline expired lease recovery +7. duplicate listener/runtime check +``` + +manifest incompatible면 product runtime을 resume하지 않고 safe reload/recovery surface로 이동한다. + +### 20.6 unload non-guarantee + +다음을 `beforeunload`, `unload`, `sendBeacon` correctness로 처리하지 않는다. + +- command complete; +- checkpoint write; +- subscription unsubscribe guarantee; +- lease release guarantee; +- Service Worker activation acknowledgement; +- telemetry guaranteed delivery. + +correctness is transaction/TTL/recovery based. + +### 20.7 cleanup deadlines + +```text +realtime drain 2,000ms +worker cooperative stop 250ms per active group +telemetry flush 1,000ms +offline local lease release 2,000ms +Service Worker page stop no network wait +overall application stop 5,000ms +``` + +overall deadline expires: + +- remaining worker terminate; +- transport close; +- listener/timer remove; +- unresolved durable state left for recovery; +- no additional network retry. + +### 20.8 scope fence vs shutdown + +scope fence: + +- application remains alive; +- global Router/QueryClient object remains; +- account-bound data/listener/connection fenced; +- new scope READY after reset. + +shutdown: + +- all admission terminal close; +- no new scope; +- every runtime disposed. + +두 lifecycle을 같은 `clearEverything()`로 합치지 않는다. + +--- + +## 21. Security와 privacy + +### 21.1 trust boundary + +trusted build inputs: + +```text +exact installed contract package bytes +frontend source +embedded contractSet identity +embedded static asset manifest +``` + +untrusted runtime inputs: + +```text +Runtime Config bytes until validated +Release Manifest bytes until validated +HTTP response/problem +realtime frame/event +postMessage +IndexedDB historical record +Cache Storage response +URL/path/search/form input +``` + +trusted package does not make server response trusted. Runtime validation remains mandatory. + +### 21.2 secret prohibition + +must not contain credential/secret: + +```text +Runtime Config +Release Manifest +contractSet +Query key/value +Web Storage +IndexedDB query metadata +Cache Storage +Worker message +Service Worker message +realtime checkpoint diagnostics +URL +telemetry attributes +support reference +``` + +offline outbox may store opaque idempotency key but treats it as sensitive command identity: + +- no log; +- no UI; +- no cross-context message; +- delete with payload retention. + +### 21.3 generated package isolation + +contract package must: + +- have no required postinstall script; +- perform no network/DOM/listener side effect on import; +- expose descriptor/validator/type only; +- have no mutable singleton carrying user state; +- not reference `window`, `document`, `navigator`, `fetch`, WebSocket at runtime; +- not include credential helper; +- not provide user-facing error copy. + +violation blocks contribution composition. + +### 21.4 boot request security + +- same-origin Runtime Config/release manifest; +- redirect error; +- no credentials beyond same-origin browser policy; +- no-store; +- strict content type; +- byte bound; +- secret-name scan; +- safe error only; +- no arbitrary manifest URL from query/local storage. + +### 21.5 HTTP security + +- HTTPS outside local; +- final URL allowlist; +- redirect reject; +- `referrerPolicy=no-referrer`; +- credential patch allowlist; +- raw URL/query/body/header no logs; +- response byte bound; +- duplicate JSON key semantics not relied upon; +- prototype pollution keys rejected/stripped by validator/mapper; +- problem detail rendered through safe text; +- `dangerouslySetInnerHTML` forbidden. + +### 21.6 Router security + +- open redirect forbidden; +- return URL created only by same-origin route builder; +- route param validation before domain ID; +- frontend access guard not authorization proof; +- chunk URL not user-generated; +- search data not interpolated into raw HTML/style/script; +- malformed route causes local failure before network. + +### 21.7 Query/cache security + +- cache hit not authorization; +- old scope value hidden immediately at FENCED; +- raw IDs/URL/document/message absent from key; +- credential/header/trace absent from value; +- query persistence disabled; +- production devtools governed by build profile; +- optimistic layer excludes full command body/token. + +### 21.8 Cross-context security + +- no data/query key; +- opaque topic/source; +- protocol/epoch validation; +- `storageArea` exact check; +- message bytes bound; +- foreign/malformed event ignored; +- channel name not based on account ID; +- event is hint, not authority. + +### 21.9 Realtime security + +- exact endpoint/profile allowlist; +- frame/event bounds; +- envelope/payload validation; +- scope binding; +- unknown event safe recovery; +- cursor/endpoint/push subscription absent from diagnostics; +- no arbitrary WebSocket outbound command; +- open connection not auth success; +- raw close reason not user/log. + +### 21.10 Worker security + +- module worker static URL; +- Blob/data URL worker forbidden; +- `eval`, `Function`, dynamic `importScripts` forbidden; +- no credential/network; +- cloned payload validator; +- input/output bounds; +- generation/request fence; +- SharedArrayBuffer disabled; +- WASM only as separate exact-digest/memory-bound task capability. + +### 21.11 Service Worker security + +- same-origin scope/script; +- source client ID/origin/nonce validation; +- no blind skipWaiting/clients.claim; +- API/private response cache forbidden; +- opaque cache forbidden; +- owned prefix/parser cleanup only; +- global variable not durable authority; +- push payload validator and notification registry; +- notification URL registered same-origin route only; +- foreign registration never unregistered. + +### 21.12 IndexedDB/outbox privacy + +- opaque physical DB name; +- scope binding; +- historical codec validation; +- package digest recheck; +- quota/corrupt/future schema separated; +- logout old partition hidden immediately; +- same-origin encryption not XSS authorization boundary; +- payload minimization; +- retention ceiling; +- unresolved command export/recovery product-owned. + +### 21.13 diagnostics redaction + +prohibited values: + +```text +raw URL/query +request/response body +header/token/cookie +account/resource ID +event payload/cursor +idempotency key +contract package source path +cache key/value +worker payload +file name/path +``` + +allowed dimensions are closed enums and bounded IDs declared in §22. + +--- + +## 22. Runtime observability + +### 22.1 ownership + +existing diagnostics/telemetry adapters are reused. + +```text +src/adapters/diagnostics/bounded-diagnostics.ts +src/adapters/telemetry/best-effort-telemetry.ts +src/contracts/diagnostics.ts +src/contracts/telemetry.ts +``` + +raw SDK is not exposed to feature/application. + +### 22.2 closed event families + +```text +frontend_boot +frontend_http +frontend_query +frontend_mutation +frontend_scope +frontend_realtime +frontend_worker +frontend_service_worker +frontend_offline_command +frontend_drop +``` + +new arbitrary event name is prohibited. Add a typed registry entry first. + +### 22.3 common attributes + +allowlisted: + +```text +operationCategory +outcome +failureKind +appEnv +buildId bucket/release reference +featureId +operationId declared stable ID +profileId +transportKind +browserEngine bucket +queueSize bucket +payloadSize bucket +attempt bucket +lifecycleState +``` + +not allowlisted: + +```text +URL +user/account/resource ID +request ID from server +cursor/checkpoint +package digest full value +idempotency key +message/error string +``` + +contract package digest may be represented only as `MATCH | MISMATCH | MISSING`, not tag value. + +### 22.4 HTTP observation + +one logical operation terminal event plus bounded physical attempt counters. + +```text +logical operation ID +attempt count bucket +terminal outcome +certainty +request/response size bucket +latency bucket +retry reason category +``` + +physical attempts do not each emit high-cardinality traces by default. + +### 22.5 Query observation + +```text +profileId +cache state: MISS | FRESH | STALE | REFRESH +result budget: ADMITTED | REJECTED +scope current: CURRENT | FENCED +``` + +query key/input/value absent. + +### 22.6 Mutation observation + +```text +duplicate policy +duplicate outcome +optimistic used boolean +effect certainty +invalidation result +recovery state +``` + +intent/idempotency identity absent. + +### 22.7 Realtime observation + +```text +streamId stable registry ID +eventType stable registry ID +transport +state transition +reconnect attempt bucket +queue bucket +recovery reason +validation outcome +effect outcome +``` + +cursor/payload/endpoint absent. + +### 22.8 Worker observation + +```text +taskGroupId +taskId +queue bucket +input/output size bucket +latency bucket +cancel/crash/restart outcome +fallback mode +``` + +payload and native stack absent. + +### 22.9 Service Worker observation + +```text +mode +handler set bitmask or closed enum +registration outcome +update state +client drain outcome +cache install/reset outcome +owned removal outcome +``` + +scope/script URL/cache name absent. ownership mismatch is a boolean/outcome only. + +### 22.10 Offline observation + +```text +state transition +attempt bucket +age bucket +queue count/byte bucket +lease outcome +terminal/recovery category +sync wake observed boolean +``` + +operation label uses stable operation ID; no payload/key/partition. + +### 22.11 bounded queue and failure + +telemetry is best effort. + +```text +maximum queued events 500 +maximum estimated bytes 1 MiB +flush batch 50 +shutdown flush 1s +drop oldest non-critical observation when full +never block product flow +never recursively report sink failure +``` + +security/contract fatal events can be mirrored to bounded local diagnostics but do not gain infinite retention. + +### 22.12 health semantics + +frontend does not expose one global `healthy` boolean. + +```ts +export type RuntimeHealth = + | "AVAILABLE" + | "DEGRADED" + | "UNAVAILABLE" + | "INCOMPATIBLE" + | "DISABLED"; +``` + +capability health is independent: + +- HTTP unavailable does not mark Router unavailable; +- realtime fatal can fall back to explicit stale/polling policy; +- Service Worker disabled does not fail product; +- offline outbox conflict does not mean IndexedDB runtime globally corrupt; +- contractSet mismatch is boot incompatible. + +--- + +## 23. 구현 검증 + +### 23.1 범위 + +이 절은 local/unit/component/integration/real-browser behavior를 정의한다. CI stage, workflow, promotion formula는 정의하지 않는다. + +### 23.2 Runtime Config/boot cases + +```text +valid V2 +unknown field reject +API_CONTRACT_VERSION in V2 reject +secret-like key reject +URL credential/hash/query reject +HTTPS rule +config 64KiB exact and +1 +manifest 1MiB exact and +1 +Content-Length lie +chunked overflow +invalid UTF-8 +invalid JSON/media/status +5s timeout +redirect reject +build/release/assets mismatch +contractSet canonical/digest mismatch +fixture excluded from contractSet +partial composition reverse cleanup +retry once/no loop +``` + +### 23.3 contract package consumer cases + +```text +only contribution file imports package +exact version/integrity +packageId/version/digest/source regex +runtime protocol mismatch +package duplicate/mismatch +duplicate operation/event +status sorted unique/subset +path/query/hash bound +validator missing/throw +fixture exact identity/events empty +product local fixture reject +package top-level side-effect fixture reject +``` + +### 23.4 HTTP cases + +```text +request validation before fetch +final invariant after auth patch +request 256KiB/default and hard +1 +response/problem byte bounds +redirect/media/status/body policy +required/optional/none body +success/problem validator +scope fence before/after response +safe/idempotent retry +keyed dispatch-loss no retry +never retry +full jitter deterministic source +Retry-After ceiling/deadline +401 no hidden replay +caller/route/scope/deadline cancellation +NOT_STARTED vs MAYBE_APPLIED +inspect recovery projection +``` + +### 23.5 Router cases + +```text +static route registration +lazy module +path/search codec duplicate/unknown/bounds +no direct fetch/QueryClient/adapter import +loader/action server-data reject +focus/scroll/title +route unmount lease cleanup +chunk release/contractSet recovery +reload guard once +dirty navigation/effect-unknown blocker +``` + +### 23.6 Query/scope cases + +```text +one QueryClient +fixed profile registry only +required measureResult +measure throw/non-finite/negative/overflow +item/byte ceiling +scope FENCED synchronous +old snapshot immediately false +cancel/clear/validator/realtime/offline exact ordering +late success discarded +identity bounds/active lease eviction +retry false +memory-only/no persistence +304 with/without current cache +``` + +### 23.7 Mutation cases + +```text +three duplicate policies +same input different scope not joined +join waiter bound +optimistic default none +layer count/single/total bytes +out-of-order commit/rollback +effect unknown no arbitrary rollback +success seed/invalidation ordering +local invalidation failure does not resend +form dirty state clearing only confirmed +``` + +### 23.8 Cross-context cases + +```text +registry bounds/fanout/fanin +no key/data on wire +BroadcastChannel primary +localStorage fallback/storageArea +same-tab local dispatch +duplicate/lower/gap +release/scope epoch +mutation coalescing +overflow full stale +logout/dispose leak +two-page Chromium/Firefox/WebKit semantics +``` + +### 23.9 Realtime cases + +```text +zero contribution no connection/listener +one host/multiple leases +OPEN_UNRESUMED no effect +recovery barrier +500ms/30s/10/5m/60s reconnect policy +offline wait +hidden 30s/no hidden poll +visible recovery +unknown/malformed/schema/mapper/scope +64KiB event +1 +queue/dedupe bounds +conflict/gap/expired cursor +single-writer live/poll handoff +effect before checkpoint +checkpoint failure after effect -> recovery +5s subscription grace +StrictMode acquire/release +logout/dispose/bfcache +``` + +### 23.10 Generic Worker cases + +```text +unselected no worker +protocol/version/generation +input/output bounds +queue count/bytes/FIFO +queued cancellation +active cooperative cancellation +250ms terminate +stale result fence +transfer ownership/detached buffer +crash 3/5m then fatal +idle 60s +fallback exact +no network/DOM/credential +``` + +### 23.11 Service Worker cases + +```text +null selection no lookup/register/cache +ACTIVE first-render idempotent registration +module/scope/updateViaCache +ACTIVE+DISABLED exact-owned unregister/cache retain +foreign registration ownership mismatch +ACTIVE -> REMOVE -> PURGE -> null +install all-or-nothing +asset count/item/set/integrity/media +waiting no blind skipWaiting +dirty/effect-unknown activation block +multi-client drain/30s missing client +no clients.claim +first install reload-to-enable +update 6h/manual rate +controllerchange guard +network-only navigation/config/manifest/API +exact static hit before API base root +unknown GET no runtime cache +current+previous retention +owned parser cleanup only +Web Push handler composition +future protocol mismatch +``` + +### 23.12 Offline/Sync cases + +```text +unselected no DB/listener +operation not KEYED/recovery missing reject +package identity/digest +request/count/dataset bounds +transaction complete +lease acquire/renew/release/takeover +LEASED expiry -> PENDING +SENDING expiry -> EFFECT_UNKNOWN +single sender/batch 10/30s +retry/backoff/attempt cap +auth/package/scope fence +ACKED payload delete +retention +unsupported Sync foreground works +Sync event sends zero command +wake message only +crash/reload restore +no credential persistence +removal unresolved command no silent purge +``` + +### 23.13 browser result vocabulary + +```text +SUPPORTED_AND_PASSED +SUPPORTED_AND_FAILED +UNSUPPORTED_WITH_FALLBACK_PASSED +UNSUPPORTED_WITHOUT_REQUIRED_CAPABILITY +ENVIRONMENT_UNAVAILABLE +``` + +unsupported is not ordinary pass. Background Sync requires foreground fallback pass. + +### 23.14 leak inspection + +lifecycle suite terminal checks: + +```text +active listeners = 0 except declared singleton +active timers = 0 +open generic workers = 0 +open realtime transports = 0 +subscription refs = 0 +queued worker/realtime task = 0 +active IDB transaction = 0 +owned abort controller settled +unhandled rejection = 0 +``` + +persistent Service Worker registration is inspected according to selection mode, not counted as generic leak. + +--- + +## 24. Source 배치와 migration + +### 24.1 existing files to modify + +```text +src/bootstrap/runtime-config-schema.ts +src/bootstrap/load-runtime-config.ts +src/bootstrap/load-release-manifest.ts +src/bootstrap/create-runtime-composition.ts +src/bootstrap/composition-root.ts +src/bootstrap/runtime-adapters.ts +src/bootstrap/runtime-application.tsx +src/bootstrap/main.tsx +src/contracts/env.ts +src/contracts/release-tokens.ts +src/contracts/storage-keys.ts +src/contracts/api-operations.ts +src/contracts/rest-profiles.ts +src/contracts/server-state.ts +src/contracts/server-state-scope.ts +src/contracts/query-invalidation.ts +src/contracts/realtime-events.ts +src/contracts/realtime-streams.ts +src/adapters/http/bounded-json.ts +src/adapters/http/client.ts +src/adapters/http/request-builder.ts +src/adapters/http/retry-policy.ts +src/adapters/query-cache/server-state-scope-runtime.ts +src/adapters/query-cache/tanstack-cache-coordinator.ts +src/adapters/cross-context-invalidation/browser-cross-context-host.ts +src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts +src/adapters/cross-context-invalidation/index.ts +src/presentation/adapters/query/application-query.ts +src/presentation/adapters/query/optimistic-layer-runtime.ts +src/presentation/routes/app-router.tsx +src/presentation/routes/navigation-policy.ts +src/presentation/routes/platform-route-codecs.ts +src/presentation/routes/route-codecs.ts +src/presentation/routes/route-contract.ts +src/presentation/routes/route-input.tsx +src/presentation/routes/route-runtime.tsx +src/features/installed-feature-contracts.ts +src/features/installed-feature-adapters.ts +src/features/installed-feature-runtimes.tsx +scripts/generate-build-manifest.ts +vite.config.ts +tsconfig.json +tsconfig.app.json +package.json +.gitignore +eslint.config.ts +vitest.config.ts +playwright.capabilities.config.ts +config/hosting/cache-policy.json +public/config.json +public/release-manifest.json +``` + +### 24.2 new common files + +```text +src/contracts/contract-set.ts +src/contracts/contract-set-canonical.ts +src/contracts/external-contract-runtime.ts +src/contracts/runtime-capabilities.ts +src/contracts/service-worker.ts +src/contracts/web-worker.ts +src/contracts/offline-command.ts +src/bootstrap/read-bounded-boot-json.ts +src/bootstrap/register-service-worker.ts +src/features/installed-contract-contributions.ts +src/features/installed-runtime-capabilities.ts +src/adapters/http/bounded-body-reader.ts +src/adapters/http/http-execution-v3.ts +src/adapters/http/http-effect-certainty.ts +src/adapters/http/http-contract-bridge.ts +src/adapters/platform/browser-lifecycle.ts +scripts/build-frontend.ts +scripts/generate-contract-set.ts +scripts/generate-service-worker-assets.ts +vite.service-worker.config.ts +tsconfig.web-worker.json +tsconfig.service-worker.json +.generated/frontend-runtime/contract-set.ts +.generated/frontend-runtime/service-worker-build-info.ts +.generated/frontend-runtime/service-worker-assets.ts +``` + +`.generated/frontend-runtime`은 build-time derived source다. 사람이 수정하지 않고 build 시작 시 전체 삭제 후 재생성한다. Git에 commit하지 않는다. + +### 24.3 optional source files + +Generic Worker selected only: + +```text +src/application/ports/web-worker/* +src/adapters/web-worker/* +``` + +Service Worker active/removal selected: + +```text +src/adapters/service-worker/* +``` + +Offline Commands selected only: + +```text +src/application/ports/offline-command/* +src/adapters/offline-command/* +``` + +### 24.4 feature files + +```text +src/features//contracts/-contract-contribution.ts +src/features//contracts/-mapper.ts +src/features//contracts/-realtime-contribution.ts +src/features//adapters/-http-gateway.ts +src/features//application/-api.ts +src/features//presentation/use-.ts +``` + +path token replacement is deterministic. + +```text + = InstalledContractContribution.featureId exactly + lowercase kebab-case regex already validated + + = external package name의 slash 뒤 segment + lowercase npm segment 그대로 +``` + +팀이 임의 abbreviation/pluralization을 선택하지 않는다. 파일 충돌이 나면 package contribution을 한 feature 안의 named subdirectory로 분리하되 token 자체를 바꾸지 않는다. + +### 24.5 architecture rule amendments + +static checks must reject: + +```text +@org-contracts import outside feature contract contribution +presentation direct fetch +presentation direct adapters/QueryClient +application React/TanStack/browser API +generic Worker fetch/WebSocket/EventSource +separate Service Worker registration +Service Worker API response cache +runtime deployed spec/registry lookup +product TEMPLATE_FIXTURE +Router loader/action server-data call +``` + +### 24.6 Runtime Config/manifest migration + +```text +RC-1 add V2 schema/parser and bounded reader +RC-2 support V1/V2 reader, current writer V1 +RC-3 generate contractSet and writer V2 +RC-4 ignore accepted V1 scalar only in V1 input +RC-5 all fixtures/public files V2 +RC-6 remove V1 parser/scalar after compatibility window +``` + +### 24.7 HTTP migration + +```text +HTTP-1 normalize current local reference fixture into contribution +HTTP-2 introduce external package contribution interface +HTTP-3 bridge current ApiOperationV2 reader +HTTP-4 add execution V3/outcome/certainty +HTTP-5 move feature gateway one operation at a time +HTTP-6 remove duplicate local path/status/type +HTTP-7 retire V2 writer after all installed operations V3 +``` + +### 24.8 Query migration + +```text +Q-1 fixed profile registry +Q-2 required measureResult for current reference definitions +Q-3 scope lifecycle FENCED/READY +Q-4 commit fence/result budget +Q-5 many-to-many invalidation registry +Q-6 mutation duplicate/optimistic exact policies +``` + +### 24.9 Realtime migration + +```text +RT-1 keep common source unchanged where possible +RT-2 external event contract bridge +RT-3 feature effect contribution +RT-4 optional runtime host integration +RT-5 no product selection -> no bootstrap composition +``` + +### 24.10 Service Worker migration/removal + +first adoption: + +```text +SW-1 protocol/build-info virtual module +SW-2 physical entry/handler composition +SW-3 static asset manifest/cache +SW-4 page controller/waiting/drain +SW-5 first-install/update lifecycle +``` + +removal: + +```text +Release N mode ACTIVE +Release N+1 mode REMOVE_REGISTRATION +Release N+2 mode PURGE_OWNED_RESOURCES after rollback window +Release N+3 null +``` + +### 24.11 optional source zero-side-effect + +optional source may exist as `AVAILABLE_NOT_COMPOSED`, but production bootstrap import is absent unless static selection references it. + +Vite module inventory must show: + +- unselected worker entry absent; +- unselected Service Worker active entry absent; +- unselected offline adapter absent from production runtime graph where tree-shaking/static import boundary permits; +- recipe/test source is not production import. + +### 24.12 sample/reference removal + +removing `src/features/reference-feature` must not remove common runtime. + +removal sequence: + +```text +reference feature directory +-> reference template contribution +-> reference route/messages/runtime registration +-> reference tests +-> no remaining import/string in production bundle +``` + +common external contract consumer interfaces remain. + +--- + +## 25. 구현 순서와 stop condition + +### 25.1 principle + +각 phase는 independently reviewable runtime increment다. API/backend contract authoring과 CI workflow는 이 순서에 포함하지 않는다. + +### Phase A — authority cleanup + +1. old generic design을 non-authoritative로 표시; +2. this document reference add; +3. external package ownership boundary document; +4. current source path inventory freeze. + +stop condition: + +- current repository snapshot/path가 문서와 다르면 후속 code 금지; +- contract package format이 library design과 다르면 contribution interface부터 amend. + +### Phase B — contract package consumer foundation + +1. `RuntimeValidator` adapter; +2. normalized contribution types; +3. installed contribution composer; +4. fixture vs external provenance; +5. validation/duplicate checks; +6. reference fixture normalization. + +exit: + +- package import one path; +- product fixture prohibited; +- no runtime network/package lookup. + +### Phase C — contractSet and boot V2 + +1. canonical bytes/digest; +2. Release Manifest V2 schema; +3. bounded boot reader; +4. Runtime Config V2; +5. contractSet boot check; +6. partial cleanup stack; +7. public fixture migration. + +exit: + +- scalar API contract version no longer writer source; +- boot body bounded/deadline; +- product mount gated by contractSet. + +### Phase D — HTTP execution V3 + +1. request/response hard ceilings; +2. package descriptor bridge; +3. final invariant after credential patch; +4. outcome/failure union; +5. total deadline/retry owner; +6. effect certainty; +7. current reference operation migration. + +exit: + +- direct/duplicate path/status schema removed for migrated operation; +- keyed uncertain command never automatic replayed. + +### Phase E — Router/Query/Mutation lifecycle + +1. Router no-server-data rule; +2. fixed Query profiles; +3. required measureResult; +4. scope FENCED/READY exact ordering; +5. commit fence; +6. invalidation many-to-many; +7. duplicate mutation coordinator; +8. bounded optimistic layers. + +exit: + +- old scope late result cannot commit; +- Query/Mutation retry false; +- local invalidation failure cannot resend command. + +### Phase F — Cross-context + +1. registry bounds; +2. protocol/epoch envelope; +3. BroadcastChannel; +4. localStorage fallback; +5. gap/full-stale; +6. scope/logout lifecycle. + +exit: + +- no data/key on wire; +- two-page semantics; +- disposal leak zero. + +### Phase G — Realtime product bridge + +1. external event validator bridge; +2. installed realtime contribution; +3. host lifecycle; +4. subscription lease bridge; +5. recovery/effect/checkpoint ordering; +6. no-selection zero side effect. + +stop condition: + +- no exact endpoint/event/recovery contribution -> remain `NOT_SELECTED`; +- common runtime is not enough to compose. + +### Phase H — Generic Worker optional + +execute only when a selected task exists. + +1. task port/protocol; +2. group worker runtime; +3. queue/cancel/crash; +4. fallback; +5. browser behavior. + +stop condition: + +- no profiling/owner/task contribution -> do not implement production entry. + +### Phase I — Service Worker reference runtime + +1. static selection/removal mode; +2. one physical entry; +3. protocol/registration; +4. static cache; +5. waiting/client drain; +6. first install/update; +7. runtime disabled unregister; +8. removal cleanup. + +exit: + +- no API/navigation cache; +- no blind activation/claim; +- persistent removal path proven. + +### Phase J — Offline Command optional + +execute only when product operation is selected. + +1. operation allowlist/record codec; +2. IDB repository; +3. foreground sender lease; +4. state transitions; +5. effect unknown recovery; +6. retention/summary; +7. optional Sync wake handler; +8. removal. + +stop condition: + +- no KEYED+recovery package descriptor -> remain `NOT_SELECTED`; +- no product conflict/recovery UX -> do not compose. + +### Phase K — final integration review + +1. lifecycle shutdown/bfcache; +2. security/redaction; +3. all behavior cases; +4. source/path/document consistency; +5. old compatibility reader removal only after no caller. + +--- + +## 26. 완료 기준 + +### 26.1 common + +- current repository path/architecture retained; +- no parallel platform layer; +- no backend/API/event source ownership duplication; +- no CI design included; +- optional unselected side effect zero; +- exact lifecycle/disposal; +- bounded input/output/queue/deadline; +- typed failure and uncertainty; +- current scope fence; +- secret/privacy rules; +- source/removal path. + +### 26.2 external contract consumer + +- exact immutable package pin; +- one import location; +- fixture/product provenance separated; +- normalized descriptor/validator; +- package validation bounds; +- local product schema duplication absent; +- runtime registry/deployed docs lookup absent; +- mapper boundary. + +### 26.3 contractSet/boot + +- V2 manifest; +- canonical length-prefixed digest; +- fixture excluded; +- Runtime Config scalar removed; +- 64KiB/1MiB/5s bounded read; +- strict URL/media/JSON/schema; +- reverse partial cleanup; +- safe retry once. + +### 26.4 HTTP + +- package descriptor-driven; +- request/response bounds; +- final invariant after auth; +- redirect reject; +- total deadline; +- transport-only retry; +- body/status/media validation; +- effect certainty; +- keyed uncertainty no automatic resend; +- scope fence. + +### 26.5 Router + +- static route/codec; +- no server-data loader/action; +- no direct fetch/cache; +- lazy/focus/scroll/error; +- chunk contractSet recovery; +- dirty/effect-unknown blocker. + +### 26.6 Query/Mutation + +- one QueryClient; +- fixed profiles; +- required result measurement; +- scope exact reset order; +- late result discard; +- memory-only; +- bounded identity/invalidation; +- duplicate coordinator; +- optimistic default none/bounded layer; +- success/failure ordering. + +### 26.7 Realtime + +- existing common runtime reused; +- product contribution exact; +- open/resume separated; +- reconnect policy exact; +- hidden/offline lifecycle; +- validation pipeline; +- queue/dedupe bounds; +- gap recovery; +- effect-before-checkpoint; +- ref-counted cleanup; +- zero selection side effect. + +### 26.8 Generic Worker + +- explicit selected task; +- semantic port; +- one worker per group; +- protocol validation; +- queue/input/output/deadline; +- cancellation/terminate; +- crash budget; +- stale fence; +- no network/credential; +- fallback exact. + +### 26.9 Service Worker + +- one physical registration; +- post-mount module registration; +- build/protocol handshake; +- all-or-nothing static cache; +- navigation/API/config/manifest network-only; +- waiting/client drain; +- no blind skipWaiting/clients.claim; +- first install reload-to-enable; +- runtime disabled exact unregister; +- ACTIVE→REMOVE→PURGE→null; +- foreign resource protection. + +### 26.10 Offline/Background Sync + +- product operation selected; +- external KEYED/recovery descriptor; +- IDB transaction-complete; +- exact bounds; +- single sender lease; +- SENDING crash -> EFFECT_UNKNOWN; +- no automatic unknown resend; +- foreground fallback; +- Sync wake only; +- retention/recovery/removal. + +--- + +## 27. 위험 등록부 + +| ID | 위험 | 차단 결정 | 잔여 위험 | +| --- | --- | --- | --- | +| FRT-R001 | frontend local API schema가 두 번째 SSOT | external package one import/provenance | package publication quality는 external owner | +| FRT-R002 | scalar contract version으로 multi-service mismatch | contractSet V2 | provider compatibility는 external release system | +| FRT-R003 | boot body memory amplification | streaming byte bound/deadline | browser/network implementation variance | +| FRT-R004 | auth patch가 URL/method/header 변조 | final invariant recheck | external auth adapter defect | +| FRT-R005 | duplicate retry amplification | HTTP-only retry owner | user repeated intent remains product UX | +| FRT-R006 | command timeout duplicate effect | MAYBE_APPLIED/inspect/no resend | server inspect may remain unavailable | +| FRT-R007 | Router/Query dual cache | loader server-data prohibition | future SSR requires separate design | +| FRT-R008 | old account late result leak | synchronous scope FENCED/commit fence | third-party widget outside scope | +| FRT-R009 | result memory blowup | required measure/fixed profiles | feature estimator defect | +| FRT-R010 | cross-tab stale data replication | hint-only protocol | hints can be lost; refetch recovery | +| FRT-R011 | reconnect storm | full jitter/attempt/episode bounds | provider-wide outage | +| FRT-R012 | connection open mistaken as recovered | OPEN_UNRESUMED/barrier | server recovery semantics external | +| FRT-R013 | malformed event corrupts state | validator/mapper/gap recovery | malicious volume before edge controls | +| FRT-R014 | subscription leak under StrictMode | ref-count + 5s grace | browser process crash | +| FRT-R015 | Worker queue/crash heap amplification | exact bounds/restart budget | heavy task still impacts CPU/battery | +| FRT-R016 | multiple Service Worker registrations | one physical entry | existing foreign registration conflict | +| FRT-R017 | stale worker mixed with new page | drain/no claim/reload handshake | user may postpone update | +| FRT-R018 | service worker removed from source but persists | staged removal modes | clients not revisiting app remain registered | +| FRT-R019 | Cache Storage poisoning/stale HTML | static manifest only/no navigation cache | immutable asset host misconfiguration | +| FRT-R020 | offline command duplicate after crash | SENDING->EFFECT_UNKNOWN | external reconciliation may need human | +| FRT-R021 | Background Sync assumed reliable | wake-only/foreground fallback | browser may throttle background work | +| FRT-R022 | logout leaves old durable data visible | scope partition fence | physical cleanup may be delayed | +| FRT-R023 | diagnostics leak | closed allowlist/redaction | vendor SDK internal behavior | +| FRT-R024 | bfcache resumes stale release | manifest refresh/recovery | offline manifest fetch unavailable | +| FRT-R025 | over-design installs optional capability unused | static selection/stop conditions | future teams may bypass process | + +--- + +## 28. Normative decision ledger + +| Decision ID | 결정 | 정본 | +| --- | --- | --- | +| FRT-D001 | 기존 repository layers 유지 | §1.1–§1.2 | +| FRT-D002 | backend/API/event source design 제외 | §0.3 | +| FRT-D003 | CI design 제외 | §0.4 | +| FRT-D004 | immutable external package only | §4 | +| FRT-D005 | package import one feature contribution path | §4.1 | +| FRT-D006 | template fixture HTTP-only and contractSet excluded | §4.8–§4.9 | +| FRT-D007 | scalar API contract version removed | §5.1 | +| FRT-D008 | length-prefixed contractSet canonical digest | §5.3 | +| FRT-D009 | Runtime Config may disable, never enable source-absent capability | §3.5, §6.1 | +| FRT-D010 | boot 64KiB/1MiB/5s | §6.4–§6.5 | +| FRT-D011 | boot reverse cleanup | §6.8 | +| FRT-D012 | descriptor-driven HTTP execution | §7.1–§7.2 | +| FRT-D013 | request/response hard bounds | §7.3 | +| FRT-D014 | transport-only retry | §8.1 | +| FRT-D015 | 250ms/2s/full-jitter/max2 HTTP retry | §8.2 | +| FRT-D016 | 401 no hidden replay | §8.4 | +| FRT-D017 | dispatch loss MAYBE_APPLIED | §8.7–§8.9 | +| FRT-D018 | Router server-data loader/action prohibited | §9.2 | +| FRT-D019 | one QueryClient | §10.1 | +| FRT-D020 | four fixed Query profiles | §10.2 | +| FRT-D021 | required feature-owned measureResult, no fallback | §10.3–§10.4 | +| FRT-D022 | synchronous scope FENCED exact order | §10.5–§10.6 | +| FRT-D023 | Query persistence disabled | §10.10 | +| FRT-D024 | mutation duplicate default reject | §11.2 | +| FRT-D025 | optimistic default none, layer bounds | §11.4–§11.5 | +| FRT-D026 | cross-tab hint-only, many-to-many bounds | §12 | +| FRT-D027 | existing realtime runtime reused | §13.1 | +| FRT-D028 | product realtime explicit contribution | §13.2–§13.6 | +| FRT-D029 | single writer live XOR polling | §13.8 | +| FRT-D030 | OPEN_UNRESUMED separate | §14.1–§14.2 | +| FRT-D031 | realtime reconnect 500ms/30s/10/5m/60s | §14.3 | +| FRT-D032 | hidden 30s then close, no hidden polling | §14.5 | +| FRT-D033 | event hard limits reuse current constants | §15.1 | +| FRT-D034 | effect before checkpoint | §15.7 | +| FRT-D035 | subscription 5s grace, scope immediate | §15.8–§15.9 | +| FRT-D036 | generic Worker default not selected | §16.2 | +| FRT-D037 | one Worker per group, lazy/60s idle | §16.5 | +| FRT-D038 | Worker queue/cancel/crash exact policy | §16.8–§16.10 | +| FRT-D039 | Worker no network/credential | §16.13 | +| FRT-D040 | one physical Service Worker | §17.1 | +| FRT-D041 | Service Worker after first committed render | §17.5 | +| FRT-D042 | ACTIVE/REMOVE/PURGE/null lifecycle | §17.3 | +| FRT-D043 | disabled exact-owned unregister, cache retained | §17.6 | +| FRT-D044 | no blind skipWaiting, no clients.claim | §17.10–§17.12 | +| FRT-D045 | static asset cache only | §18 | +| FRT-D046 | navigation/API/config/manifest network-only | §18.5–§18.8 | +| FRT-D047 | Offline queue selected only with KEYED recovery package | §19.3 | +| FRT-D048 | offline records 1000/50MiB/256KiB | §19.5 | +| FRT-D049 | sender lease 30s/renew10s/batch10/30s | §19.9 | +| FRT-D050 | SENDING crash -> EFFECT_UNKNOWN | §19.12 | +| FRT-D051 | Background Sync wake-only | §19.18 | +| FRT-D052 | lifecycle shutdown exact order | §20.3 | +| FRT-D053 | no unload correctness | §20.6 | +| FRT-D054 | closed diagnostics allowlist | §22 | +| FRT-D055 | behavior verification separate from CI | §23 | +| FRT-D056 | implementation phase stop conditions | §25 | + +--- + +## 부록 A. Numeric constants + +### A.1 Boot + +| Constant | Value | +| --- | ---: | +| Runtime Config bytes | 65,536 | +| Release Manifest bytes | 1,048,576 | +| each boot request deadline | 5,000ms | +| boot retry count | 1 | +| manifest URL bytes | 256 | + +### A.2 HTTP + +| Constant | Value | +| --- | ---: | +| default request | 262,144 bytes | +| hard request | 1,048,576 bytes | +| default response | 1,048,576 bytes | +| hard response | 8,388,608 bytes | +| problem body | 65,536 bytes | +| query string | 8,192 bytes | +| final URL | 16,384 bytes | +| default deadline | 10,000ms | +| hard deadline | 60,000ms | +| retry base | 250ms | +| retry local max | 2,000ms | +| Retry-After max | 5,000ms | +| retries | 2 | + +### A.3 Query + +| Profile | stale | gc | items | bytes | +| --- | ---: | ---: | ---: | ---: | +| DETAIL_STANDARD | 30s | 5m | 1 | 256KiB | +| LIST_STANDARD | 15s | 5m | 200 | 1MiB | +| LOOKUP_STABLE | 5m | 30m | 500 | 2MiB | +| VOLATILE_STATUS | 0 | 1m | 1 | 64KiB | + +identity registry: + +| Constant | Value | +| --- | ---: | +| depth | 12 | +| nodes | 512 | +| string bytes | 2,048 | +| identity bytes | 16,384 | +| entries | 4,096 | +| canonical memory | 4MiB | + +### A.4 Invalidation + +| Constant | Value | +| --- | ---: | +| topics | 256 | +| namespaces | 256 | +| edges | 1,024 | +| topic fan-out | 64 | +| namespace fan-in | 64 | +| pulse bytes | 1,024 | +| pulse TTL | 60s | + +### A.5 Realtime + +| Constant | Value | +| --- | ---: | +| event bytes | 64KiB | +| payload depth | 16 | +| payload nodes | 4,096 | +| queue events | 256 | +| queue bytes | 4MiB | +| dedupe entries | 2,048 | +| dedupe bytes | 4MiB | +| dedupe TTL | 10m | +| reconnect base | 500ms | +| reconnect max | 30s | +| attempts | 10 | +| episode | 5m | +| stable reset | 60s | +| hidden grace | 30s | +| subscription grace | 5s | +| stream drain | 2s | + +### A.6 Web Worker + +| Constant | Value | +| --- | ---: | +| groups | 16 | +| tasks/group | 32 | +| active groups | 4 | +| queued/group | 32 | +| queued bytes/group | 16MiB | +| default input/output | 1MiB | +| hard input/output | 8MiB | +| default deadline | 5s | +| hard deadline | 30s | +| cancel grace | 250ms | +| idle terminate | 60s | +| restarts | 3/5m | +| transferables | 16 | + +### A.7 Service Worker/static cache + +| Constant | Value | +| --- | ---: | +| assets | 256 | +| single asset | 2MiB | +| set | 5MiB | +| fetch concurrency | 4 | +| install deadline | 60s | +| client drain | 30s | +| update check | 6h | +| retained previous cache | 1 | + +### A.8 Offline Commands + +| Constant | Value | +| --- | ---: | +| operations | 64 | +| default request | 256KiB | +| hard request | 1MiB | +| records | 1,000 | +| dataset | 50MiB | +| sender lease | 30s | +| renew | 10s | +| batch | 10 | +| batch window | 30s | +| parallel send | 1 | +| retry base | 1s | +| retry max | 5m | +| attempts | 10 | +| ordinary retention | 7d | +| conflict/unknown | 30d | +| Sync re-register minimum | 1m | + +--- + +## 부록 B. 금지 구현 목록 + +```text +create src/platform/* parallel runtime +copy OpenAPI/AsyncAPI/JSON Schema into product frontend +import backend Java DTO/module +fetch deployed /v3/api-docs during build/runtime +resolve Schema Registry latest at runtime +use mutable package tag/range +let component/hook call raw fetch/socket/Worker/IndexedDB +let Router loader create server cache or fetch +enable TanStack retry while HTTP transport retries +retry keyed command after dispatched response loss +interpret abort as server rollback +store generated wire DTO directly in Query cache +use JSON.stringify as generic Query result size fallback +persist Query payload in Web Storage by default +broadcast Query key/data/input/cursor +open one realtime connection per component +apply event before validation/scope/gap checks +write checkpoint before effect +continue applying deltas after gap/overflow +use hidden polling automatically +create generic Worker for network I/O +allow Worker credential/fetch/WebSocket +create Blob/data URL Worker +register PWA and Web Push workers separately for same scope +call skipWaiting without client drain +call clients.claim in baseline +cache index.html navigation fallback +cache API/private/auth responses in Cache Storage +treat unregister as cache deletion +switch Service Worker ACTIVE directly to null +unregister foreign registration by scope prefix guess +send authenticated command from baseline Background Sync +reset expired SENDING record to PENDING +manual resend EFFECT_UNKNOWN without inspect +store token/URL/header/cursor in diagnostics +use unload/sendBeacon as correctness boundary +claim unsupported browser capability as pass +claim COMPOSED means production/provider-ready +``` + +--- + +## 부록 C. 플랫폼 참고 기준 + +구현 시 pinned browser/library version의 공식 문서를 다시 확인하되 본 문서의 repository decision을 임의 변경하지 않는다. + +- Fetch Standard: +- HTML — Web Workers: +- WebSockets Standard: +- HTML — Server-sent events: +- Service Workers: +- ServiceWorkerContainer.register: +- Background Synchronization: +- SyncManager: +- Cache API: +- IndexedDB: +- TanStack Query v5: +- React Router: +- React `useSyncExternalStore`: + +--- + +## 최종 판정 + +본 문서는 프론트엔드 저장소가 소유하지 않는 backend/API/event 계약 authoring과 CI orchestration을 제거하고, 현재 코드 구조 안에서 contract package 소비부터 HTTP, Router, Query/Mutation, realtime, Worker, Service Worker와 offline lifecycle까지 구현 결정을 닫는다. + +구현자는 다음을 다시 선택하지 않는다. + +```text +source root +contract import boundary +contractSet format/digest +Runtime Config V2 shape +boot byte/deadline +HTTP retry owner/limits +mutation uncertainty +Router data ownership +Query profiles/result measurement +scope reset order +cross-tab wire +realtime reconnect/resume/queue +subscription cleanup +Worker topology/bounds +Service Worker registration/update/removal/cache policy +offline sender/state/retention +Background Sync role +shutdown order +``` + +외부 contract package가 필요한 의미를 제공하지 않으면 frontend가 가정으로 채우지 않는다. 해당 product contribution은 `NOT_SELECTED` 또는 `INCOMPATIBLE` 상태로 남기며, common runtime 구현은 계속 독립적으로 검증 가능해야 한다. diff --git a/docs/architecture/api-contract-schema-mapper-and-server-state.md b/docs/architecture/api-contract-schema-mapper-and-server-state.md index 169fc18..cf32315 100644 --- a/docs/architecture/api-contract-schema-mapper-and-server-state.md +++ b/docs/architecture/api-contract-schema-mapper-and-server-state.md @@ -1,5 +1,13 @@ # API contract, Schema, Mapper와 Server State platform +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + - 상태: capability별 current/target 분리, production design accepted - 기준일: 2026-07-28 - 범위: REST, GraphQL over HTTP, Connect-Web/Connect, gRPC-Web, diff --git a/docs/architecture/backend-api-and-server-state-contract.md b/docs/architecture/backend-api-and-server-state-contract.md index 84974c6..95d45ca 100644 --- a/docs/architecture/backend-api-and-server-state-contract.md +++ b/docs/architecture/backend-api-and-server-state-contract.md @@ -1,5 +1,13 @@ # Backend API와 Server State handoff contract +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + - 상태: frontend handoff design accepted, backend implementation/evidence pending - 기준일: 2026-07-28 - 대상: Web API/BFF, application service, persistence, identity, GraphQL router, diff --git a/docs/architecture/client-cache-and-storage.md b/docs/architecture/client-cache-and-storage.md index 286221f..731cb08 100644 --- a/docs/architecture/client-cache-and-storage.md +++ b/docs/architecture/client-cache-and-storage.md @@ -1,5 +1,13 @@ # Client cache and browser storage platform +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + - 상태: capability별 current/target 상태 분리 - 기준일: 2026-07-28 - 범위: TanStack Query memory cache, Local/Session Storage, IndexedDB query diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md index 06a6fd1..4df124e 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -1,5 +1,13 @@ # 프론트엔드 플랫폼 역량 재검토 +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + ## 1. 문서 목적 이 문서는 도메인 기능과 실제 운영 환경의 배포 증적을 제외하고, 이 저장소가 새 diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md index 542aa19..1049d05 100644 --- a/docs/architecture/frontend-platform-implementation-roadmap.md +++ b/docs/architecture/frontend-platform-implementation-roadmap.md @@ -1,5 +1,13 @@ # 프론트엔드 플랫폼 구현 로드맵 +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + ## 1. 목적과 준비 완료의 의미 이 문서는 `develop`의 `cb195f8`을 계획 기준선으로 삼아, 현재 저장소에 선언된 diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index 2fdc678..0b64fe7 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -1,5 +1,13 @@ # 프론트엔드 포트·어댑터와 경계 설계 +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + ## 1. 문서 목적 이 문서는 이 저장소에서 도메인 기능을 추가할 때 사용할 경계와 확장 diff --git a/docs/architecture/realtime-events-web-push-and-bounded-polling.md b/docs/architecture/realtime-events-web-push-and-bounded-polling.md index 6dfec9a..a1fa0b8 100644 --- a/docs/architecture/realtime-events-web-push-and-bounded-polling.md +++ b/docs/architecture/realtime-events-web-push-and-bounded-polling.md @@ -1,5 +1,13 @@ # Realtime events, Web Push, and bounded polling +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + 이 문서는 SSE, WebSocket, Web Push와 제한된 Polling을 frontend에 도입할 때의 선택 기준, 포트·어댑터 경계, event 계약, 재연결·복구, 인증, 브라우저 lifecycle, 관측성과 promotion 조건을 정의한다. diff --git a/docs/architecture/routing-pages-and-patterns.md b/docs/architecture/routing-pages-and-patterns.md index 074b17f..98d214c 100644 --- a/docs/architecture/routing-pages-and-patterns.md +++ b/docs/architecture/routing-pages-and-patterns.md @@ -1,5 +1,13 @@ # 라우팅, 페이지 템플릿, 재사용 패턴 +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + ## 1. 목적 이 문서는 도메인과 무관하게 다음을 바로 구현할 수 있는 기준을 제공한다. diff --git a/docs/architecture/starter-experience.md b/docs/architecture/starter-experience.md index 2bae499..fb54896 100644 --- a/docs/architecture/starter-experience.md +++ b/docs/architecture/starter-experience.md @@ -32,7 +32,7 @@ remain stable in the shell. | `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens | | `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix | | `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls | -| `REFERENCE_RESOURCE_LIST` | `/examples/reference-resources` | integration-defined | removable vertical slice | +| `REFERENCE_RESOURCE_LIST` | `/examples/reference-resources` | session-required | removable vertical slice | | `NOT_FOUND` | `*` | public | safe navigation recovery | Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not diff --git a/docs/architecture/static-delivery.md b/docs/architecture/static-delivery.md index 927287d..022787c 100644 --- a/docs/architecture/static-delivery.md +++ b/docs/architecture/static-delivery.md @@ -1,5 +1,13 @@ # Static asset and runtime-config delivery +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + This Mermaid view is a repository-local implementation projection. The `PASS_SCOPED` reviewer evidence applies only to the canonical static-delivery draw.io scope recorded in `review-ledger.json`. diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md index abe0229..32fa998 100644 --- a/docs/architecture/typescript-state-and-data-flow.md +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -1,5 +1,13 @@ # TypeScript, 상태 소유권, 데이터 흐름 +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + ## 1. 목적 이 문서는 다음 질문에 대한 저장소 표준을 정의한다. diff --git a/docs/superpowers/plans/2026-07-31-platform-overview-completion.md b/docs/superpowers/plans/2026-07-31-platform-overview-completion.md new file mode 100644 index 0000000..88bd643 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-platform-overview-completion.md @@ -0,0 +1,856 @@ +# 플랫폼 구성 화면 완성 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `/examples/platform` 화면이 정적 선택뿐 아니라 런타임 해석 결과까지 보여주고, 진입 경로와 브라우저 회귀 커버리지를 갖춘다. + +**Architecture:** 능력 상태 파생은 `src/contracts`의 순수 함수가 소유한다. 합성 루트가 런타임 오버라이드를 그 함수에 넣어 경계된 스냅샷을 만들고, 애플리케이션 포트를 통해 표현 계층에 전달한다. 표현 계층은 bootstrap을 알지 못한 채 해석 결과를 읽는다. + +**Tech Stack:** TypeScript 7 (strict), React 19, Zod 4, Vitest 4, Playwright 1.62 + +## Global Constraints + +- `src/presentation`은 `src/adapters`와 `src/bootstrap`을 import 할 수 없다 (`presentation-does-not-know-adapters`). +- `src/application`은 `src/presentation`, `src/adapters`, `src/bootstrap`, `react`, `@tanstack`을 import 할 수 없다. +- 모든 소스는 `.ts` / `.tsx`. `allowJs: false`. +- `src/presentation/{boundaries,components,design-system,forms,layouts,routes,templates}`에는 한글 리터럴을 둘 수 없다. `src/presentation/examples`는 대상이 아니다. +- i18n 카탈로그는 ko-KR / en-US / pseudo 키 집합이 완전히 일치해야 한다. +- 화면은 레지스트리에서 파생한 값만 렌더한다. 샘플 기능 이름을 하드코딩하면 `test:sample-removal`의 잔재 스캔이 실패한다. +- 각 태스크는 `corepack pnpm check:types`, `corepack pnpm lint`, `corepack pnpm check:architecture`를 통과해야 한다. + +## 배경: 왜 이 작업이 남았나 + +`docs/superpowers/specs/2026-07-31-platform-overview-page-design.md` 5절이 런타임 해석 결과를 의도적으로 제외했다. 해석은 `createRuntimeComposition`에서 일어나고 그 결과가 애플리케이션 계층으로 전달되지 않기 때문이다. 그래서 화면은 "정적 선택 기준"이라고만 말할 수 있고, 운영자가 `CAPABILITY_OVERRIDES`로 능력을 껐는지는 화면에서 알 수 없다. + +이 계획은 그 경로를 만든다. 오버라이드는 이미 선택된 능력을 끄는 것만 가능하므로, 스냅샷은 선택 수와 활성 수를 함께 실어 둘의 차이가 곧 오버라이드 효과가 되게 한다. + +## 파일 구조 + +| 파일 | 책임 | +| --- | --- | +| `src/contracts/runtime-capabilities.ts` (수정) | 능력 스냅샷 파생 순수 함수를 추가한다. 기존 `resolveRuntimeCapabilities`와 같은 입력을 받는다. | +| `src/application/ports/runtime-capabilities-port.ts` (생성) | 애플리케이션이 능력 상태를 얻는 출력 포트. | +| `src/application/create-application.ts` (수정) | `runtime.getCapabilitySnapshot()`을 인바운드 API에 노출한다. | +| `src/application/ports/in/application-api.ts` (수정) | 인바운드 계약에 스냅샷 접근자를 선언한다. | +| `src/bootstrap/runtime-adapters.ts` (수정) | 런타임 설정의 오버라이드로 포트를 합성한다. | +| `src/presentation/examples/platform-overview-page.tsx` (수정) | 5번 섹션이 선택과 활성을 함께 표시한다. | +| `src/presentation/pages/home-page.tsx` (수정) | 플랫폼 구성 화면 진입점. | +| `tests/unit/runtime-capability-snapshot.test.ts` (생성) | 순수 함수 검증. | +| `tests/unit/runtime-adapters.test.ts` (수정) | 포트 합성 검증. | +| `tests/component/platform-overview-page.test.tsx` (수정) | 화면이 해석 결과를 표시하는지 검증. | +| `tests/e2e/platform-overview.spec.ts` (생성) | 실제 번들에서 파생 불변식 검증. | +| `tests/visual/platform.visual.spec.ts` (수정) | 시각 회귀 기준선. | + +--- + +### Task 1: 능력 스냅샷 파생 계약 + +**Files:** +- Modify: `src/contracts/runtime-capabilities.ts` +- Test: `tests/unit/runtime-capability-snapshot.test.ts` + +**Interfaces:** +- Consumes: 같은 파일의 `InstalledRuntimeCapabilities`, `CapabilityOverrideMap`, `RuntimeCapabilityOverride` +- Produces: `RuntimeCapabilityId`, `RuntimeCapabilityStatus`, `RuntimeCapabilitySnapshot`, `describeRuntimeCapabilities(installed, overrides)` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/unit/runtime-capability-snapshot.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { + describeRuntimeCapabilities, + type CapabilityOverrideMap, + type InstalledRuntimeCapabilities, +} from "../../src/contracts/runtime-capabilities.ts"; + +const DEFAULTS: CapabilityOverrideMap = Object.freeze({ + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", +}); + +const EMPTY: InstalledRuntimeCapabilities = Object.freeze({ + realtime: Object.freeze([]), + webWorkers: Object.freeze([]), + serviceWorker: null, + offlineCommands: null, +}); + +const SELECTED: InstalledRuntimeCapabilities = Object.freeze({ + realtime: Object.freeze([]), + webWorkers: Object.freeze([]), + serviceWorker: Object.freeze({ mode: "ACTIVE" as const }), + offlineCommands: null, +}); + +describe("runtime capability snapshot", () => { + it("describes every capability id in a fixed order", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS); + + expect(snapshot.map((status) => status.capabilityId)).toEqual([ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", + ]); + }); + + it("reports nothing selected and nothing active for an empty selection", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS); + + for (const status of snapshot) { + expect(status.selected).toBe(0); + expect(status.active).toBe(0); + expect(status.override).toBe("DEFAULT"); + } + }); + + it("keeps a selected capability active while the override is DEFAULT", () => { + const snapshot = describeRuntimeCapabilities(SELECTED, DEFAULTS); + const serviceWorker = snapshot.find( + (status) => status.capabilityId === "SERVICE_WORKER", + ); + + expect(serviceWorker).toMatchObject({ + selected: 1, + active: 1, + override: "DEFAULT", + }); + }); + + it("keeps a disabled capability selected but not active", () => { + const snapshot = describeRuntimeCapabilities(SELECTED, { + ...DEFAULTS, + SERVICE_WORKER: "DISABLED", + }); + const serviceWorker = snapshot.find( + (status) => status.capabilityId === "SERVICE_WORKER", + ); + + expect(serviceWorker).toMatchObject({ + selected: 1, + active: 0, + override: "DISABLED", + }); + }); + + it("cannot activate a capability that was never selected", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, { + ...DEFAULTS, + REALTIME: "DEFAULT", + }); + const realtime = snapshot.find( + (status) => status.capabilityId === "REALTIME", + ); + + expect(realtime).toMatchObject({ selected: 0, active: 0 }); + }); + + it("returns a frozen snapshot and frozen entries", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS); + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(snapshot.every((status) => Object.isFrozen(status))).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 실패 확인** + +실행: `corepack pnpm exec vitest run tests/unit/runtime-capability-snapshot.test.ts` +기대: `describeRuntimeCapabilities` is not exported 로 FAIL + +- [ ] **Step 3: 최소 구현** + +`src/contracts/runtime-capabilities.ts` 끝에 추가한다. `resolveRuntimeCapabilities`를 재사용해 활성 판정 규칙이 한 곳에만 존재하게 한다. + +```ts +export type RuntimeCapabilityId = + | "REALTIME" + | "WEB_WORKER" + | "SERVICE_WORKER" + | "OFFLINE_COMMANDS"; + +/** + * §3.5. A bounded, serialisable view of what a capability is. `selected` is the + * static SSOT count and `active` is what survived the runtime override, so the + * difference between the two is exactly the operator's effect. + */ +export type RuntimeCapabilityStatus = Readonly<{ + capabilityId: RuntimeCapabilityId; + selected: number; + active: number; + override: RuntimeCapabilityOverride; +}>; + +export type RuntimeCapabilitySnapshot = readonly RuntimeCapabilityStatus[]; + +export function describeRuntimeCapabilities( + installed: InstalledRuntimeCapabilities, + overrides: CapabilityOverrideMap, +): RuntimeCapabilitySnapshot { + const resolved = resolveRuntimeCapabilities(installed, overrides); + const counts: Readonly< + Record> + > = { + REALTIME: { + selected: installed.realtime.length, + active: resolved.realtime.length, + }, + WEB_WORKER: { + selected: installed.webWorkers.length, + active: resolved.webWorkers.length, + }, + SERVICE_WORKER: { + selected: installed.serviceWorker === null ? 0 : 1, + active: resolved.serviceWorker === null ? 0 : 1, + }, + OFFLINE_COMMANDS: { + selected: installed.offlineCommands === null ? 0 : 1, + active: resolved.offlineCommands === null ? 0 : 1, + }, + }; + + return Object.freeze( + ( + [ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", + ] as const + ).map((capabilityId) => + Object.freeze({ + capabilityId, + selected: counts[capabilityId].selected, + active: counts[capabilityId].active, + override: overrides[capabilityId], + }), + ), + ); +} +``` + +- [ ] **Step 4: 통과 확인** + +실행: `corepack pnpm exec vitest run tests/unit/runtime-capability-snapshot.test.ts` +기대: 6 passed + +- [ ] **Step 5: 게이트와 커밋** + +```bash +corepack pnpm check:types && corepack pnpm lint && corepack pnpm check:architecture +git add src/contracts/runtime-capabilities.ts tests/unit/runtime-capability-snapshot.test.ts +git commit -m "feat: derive a bounded runtime capability snapshot" +``` + +--- + +### Task 2: 능력 스냅샷을 애플리케이션 포트로 노출 + +**Files:** +- Create: `src/application/ports/runtime-capabilities-port.ts` +- Modify: `src/application/create-application.ts` +- Modify: `src/application/ports/in/application-api.ts` +- Modify: `src/bootstrap/runtime-adapters.ts` +- Modify: `tests/helpers/create-test-application.ts` +- Test: `tests/unit/runtime-adapters.test.ts` + +**Interfaces:** +- Consumes: Task 1의 `describeRuntimeCapabilities`, `RuntimeCapabilitySnapshot` +- Produces: `RuntimeCapabilitiesPort` (`getSnapshot(): RuntimeCapabilitySnapshot`), `ApplicationApi.runtime.getCapabilitySnapshot(): RuntimeCapabilitySnapshot` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/unit/runtime-adapters.test.ts`의 `describe` 블록 안에 추가한다. + +```ts + it("reports the static selection when no capability override disables it", async () => { + const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); + + const snapshot = adapters.outputPorts.runtimeCapabilities.getSnapshot(); + + expect(snapshot.map((status) => status.capabilityId)).toEqual([ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", + ]); + expect(snapshot.every((status) => status.override === "DEFAULT")).toBe(true); + }); + + it("carries a disabling override into the capability snapshot", async () => { + const adapters = await createRuntimeAdapters({ + runtime: { + ...runtime, + config: { + ...runtime.config, + CAPABILITY_OVERRIDES: { + ...runtime.config.CAPABILITY_OVERRIDES, + SERVICE_WORKER: "DISABLED", + }, + }, + }, + release, + host: {}, + }); + + const serviceWorker = adapters.outputPorts.runtimeCapabilities + .getSnapshot() + .find((status) => status.capabilityId === "SERVICE_WORKER"); + + expect(serviceWorker?.override).toBe("DISABLED"); + expect(serviceWorker?.active).toBe(0); + }); +``` + +- [ ] **Step 2: 실패 확인** + +실행: `corepack pnpm exec vitest run tests/unit/runtime-adapters.test.ts` +기대: `runtimeCapabilities` 가 `outputPorts`에 없어 타입/런타임 FAIL + +- [ ] **Step 3: 포트 정의** + +`src/application/ports/runtime-capabilities-port.ts` 생성: + +```ts +import type { RuntimeCapabilitySnapshot } from "../../contracts/runtime-capabilities.ts"; + +export type { RuntimeCapabilitySnapshot }; + +/** + * §3.5. The application reads capability state; it never resolves it. Only the + * composition root knows the runtime overrides. + */ +export type RuntimeCapabilitiesPort = Readonly<{ + getSnapshot(): RuntimeCapabilitySnapshot; +}>; +``` + +- [ ] **Step 4: 출력 포트 등록과 인바운드 노출** + +`src/application/create-application.ts`의 `ApplicationOutputPorts`에 필드를 더하고, `runtime` 그룹에 접근자를 추가한다. + +```ts +import type { RuntimeCapabilitiesPort } from "./ports/runtime-capabilities-port.ts"; +``` + +`ApplicationOutputPorts` 안: + +```ts + runtimeCapabilities: RuntimeCapabilitiesPort; +``` + +`runtime` 그룹 안, `getReleaseSummary` 다음: + +```ts + getCapabilitySnapshot() { + return ports.runtimeCapabilities.getSnapshot(); + }, +``` + +`src/application/ports/in/application-api.ts`의 `runtime` 그룹에 선언을 추가하고 타입을 import 한다. + +```ts +import type { RuntimeCapabilitySnapshot } from "../runtime-capabilities-port.ts"; + +export type { RuntimeCapabilitySnapshot }; +``` + +```ts + runtime: Readonly<{ + getReleaseSummary(): Promise; + getCapabilitySnapshot(): RuntimeCapabilitySnapshot; + }>; +``` + +- [ ] **Step 5: 합성 루트에서 포트 생성** + +`src/bootstrap/runtime-adapters.ts`에 import를 추가한다. + +```ts +import { + describeRuntimeCapabilities, +} from "../contracts/runtime-capabilities.ts"; +import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; +``` + +`releaseInfo` 정의 다음에 포트를 만들고 `outputPorts`에 넣는다. + +```ts + const runtimeCapabilities = Object.freeze({ + getSnapshot() { + return describeRuntimeCapabilities( + INSTALLED_RUNTIME_CAPABILITIES, + config.CAPABILITY_OVERRIDES, + ); + }, + }); +``` + +- [ ] **Step 6: 테스트 헬퍼 기본값** + +`tests/helpers/create-test-application.ts`의 `createApplication` 첫 인자에 추가한다. + +```ts + runtimeCapabilities: + overrides.runtimeCapabilities ?? + { + getSnapshot: () => + Object.freeze([ + Object.freeze({ + capabilityId: "REALTIME" as const, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + Object.freeze({ + capabilityId: "WEB_WORKER" as const, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + Object.freeze({ + capabilityId: "SERVICE_WORKER" as const, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + Object.freeze({ + capabilityId: "OFFLINE_COMMANDS" as const, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + ]), + }, +``` + +- [ ] **Step 7: 통과 확인** + +실행: `corepack pnpm exec vitest run tests/unit/runtime-adapters.test.ts` +기대: 9 passed + +- [ ] **Step 8: 게이트와 커밋** + +```bash +corepack pnpm check:types && corepack pnpm lint && corepack pnpm check:architecture && corepack pnpm test:all +git add src/application src/bootstrap/runtime-adapters.ts tests/helpers/create-test-application.ts tests/unit/runtime-adapters.test.ts +git commit -m "feat: expose the resolved runtime capability snapshot through the application port" +``` + +--- + +### Task 3: 화면이 선택과 활성을 함께 보여준다 + +**Files:** +- Modify: `src/presentation/examples/platform-overview-page.tsx` +- Test: `tests/component/platform-overview-page.test.tsx` + +**Interfaces:** +- Consumes: Task 2의 `useApplication().runtime.getCapabilitySnapshot()` +- Produces: 없음 (화면 종단) + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/component/platform-overview-page.test.tsx`의 기존 능력 테스트를 다음 두 개로 교체한다. + +```ts + it("labels a capability that was never selected as unselected", () => { + renderPage(); + + const region = screen.getByRole("region", { name: "선택적 런타임 능력" }); + + expect(within(region).getAllByText("미선택")).toHaveLength(4); + expect(within(region).queryByText("운영자가 비활성화함")).toBeNull(); + }); + + it("separates an operator disable from a capability that was never selected", () => { + render( + + Object.freeze([ + Object.freeze({ + capabilityId: "REALTIME" as const, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + Object.freeze({ + capabilityId: "WEB_WORKER" as const, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + Object.freeze({ + capabilityId: "SERVICE_WORKER" as const, + selected: 1, + active: 0, + override: "DISABLED" as const, + }), + Object.freeze({ + capabilityId: "OFFLINE_COMMANDS" as const, + selected: 1, + active: 1, + override: "DEFAULT" as const, + }), + ]), + }, + })} + > + + + + , + ); + + const region = screen.getByRole("region", { name: "선택적 런타임 능력" }); + + expect(within(region).getByText("운영자가 비활성화함")).toBeVisible(); + expect(within(region).getByText("활성 (1)")).toBeVisible(); + expect(within(region).getAllByText("미선택")).toHaveLength(2); + }); +``` + +- [ ] **Step 2: 실패 확인** + +실행: `corepack pnpm exec vitest run tests/component/platform-overview-page.test.tsx` +기대: "운영자가 비활성화함" 을 찾지 못해 FAIL + +- [ ] **Step 3: 화면 구현** + +`src/presentation/examples/platform-overview-page.tsx`에서 `INSTALLED_RUNTIME_CAPABILITIES` import를 제거하고 스냅샷 소비로 바꾼다. + +능력 설명은 정적 표이므로 id로 찾는다. + +```ts +const CAPABILITY_COPY: Readonly>> = Object.freeze({ + REALTIME: Object.freeze({ + label: "실시간 수신", + description: + "WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.", + }), + WEB_WORKER: Object.freeze({ + label: "웹 워커", + description: + "워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.", + }), + SERVICE_WORKER: Object.freeze({ + label: "서비스 워커", + description: + "참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.", + }), + OFFLINE_COMMANDS: Object.freeze({ + label: "오프라인 명령", + description: + "명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.", + }), +}); + +function capabilityBadge( + status: RuntimeCapabilityStatus, +): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> { + if (status.selected === 0) { + return { text: "미선택", variant: "neutral" }; + } + if (status.active === 0) { + return { text: "운영자가 비활성화함", variant: "warning" }; + } + return { text: `활성 (${status.active})`, variant: "success" }; +} +``` + +컴포넌트 본문에서: + +```ts + const capabilities = runtime.getCapabilitySnapshot(); + const activeCapabilityCount = capabilities.filter( + (status) => status.active > 0, + ).length; +``` + +카드 렌더링을 바꾼다. + +```tsx + {capabilities.map((status) => { + const copy = CAPABILITY_COPY[status.capabilityId]; + const badge = capabilityBadge(status); + return ( + {badge.text}} + > +

{copy.description}

+
+ ); + })} +``` + +설치 요약의 능력 지표 힌트를 `"런타임 오버라이드 반영"`으로 바꾸고, 5번 섹션 설명 마지막 문장을 다음으로 교체한다. + +``` +여기 표시되는 상태는 정적 선택에 런타임 오버라이드를 적용한 결과입니다. +``` + +- [ ] **Step 4: 통과 확인** + +실행: `corepack pnpm exec vitest run tests/component/platform-overview-page.test.tsx` +기대: 7 passed + +- [ ] **Step 5: 게이트와 커밋** + +```bash +corepack pnpm check:types && corepack pnpm lint && corepack pnpm check:i18n && corepack pnpm check:design-system && corepack pnpm test:all +git add src/presentation/examples/platform-overview-page.tsx tests/component/platform-overview-page.test.tsx +git commit -m "feat: show resolved capability state on the platform overview" +``` + +--- + +### Task 4: 홈에서 플랫폼 구성 화면으로 가는 진입점 + +**Files:** +- Modify: `src/presentation/pages/home-page.tsx` +- Test: `tests/component/router.test.tsx` + +**Interfaces:** +- Consumes: `routePath("EXAMPLES_PLATFORM")` +- Produces: 없음 + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/component/router.test.tsx`에 추가한다. + +```ts + it("reaches the platform overview from the home starter actions", async () => { + const user = userEvent.setup(); + window.history.pushState({}, "", "/"); + renderRouter(); + + await user.click( + await screen.findByRole("link", { name: "플랫폼 구성 보기" }), + ); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }), + ).toHaveFocus(), + ); + }); +``` + +- [ ] **Step 2: 실패 확인** + +실행: `corepack pnpm exec vitest run tests/component/router.test.tsx` +기대: "플랫폼 구성 보기" 링크가 없어 FAIL + +- [ ] **Step 3: 구현** + +`src/presentation/pages/home-page.tsx`의 `button-row`에 첫 번째 링크로 추가한다. + +```tsx + + 플랫폼 구성 보기 + +``` + +기존 "UI 구성요소 보기" 링크의 클래스를 `ui-button ui-button--secondary`로 바꾸어 주 행동이 하나가 되게 한다. + +- [ ] **Step 4: 통과 확인** + +실행: `corepack pnpm exec vitest run tests/component/router.test.tsx` +기대: 4 passed + +`tests/e2e/accessibility.spec.ts`가 홈의 첫 탭 대상을 `"UI 구성요소 보기"`로 단언하므로 함께 바꾼다. + +```ts + const action = page.getByRole("link", { name: "플랫폼 구성 보기" }); +``` + +- [ ] **Step 5: 게이트와 커밋** + +```bash +corepack pnpm check:types && corepack pnpm lint && corepack pnpm test:component +git add src/presentation/pages/home-page.tsx tests/component/router.test.tsx tests/e2e/accessibility.spec.ts +git commit -m "feat: link the platform overview from the home starter actions" +``` + +--- + +### Task 5: 브라우저 회귀 커버리지 + +**Files:** +- Create: `tests/e2e/platform-overview.spec.ts` +- Modify: `tests/visual/platform.visual.spec.ts` +- Create: `tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png` + +**Interfaces:** +- Consumes: 빌드된 번들의 `/examples/platform` +- Produces: 없음 + +**전제:** Playwright 브라우저가 설치되어 있어야 한다. 없으면 `corepack pnpm exec playwright install chromium`을 먼저 실행한다. 설치가 불가능한 환경이면 이 태스크를 건너뛰고 그 사실을 보고한다 — 기준선 없이 스냅샷 테스트를 커밋하면 안 된다. + +- [ ] **Step 1: e2e 스펙 작성** + +`tests/e2e/platform-overview.spec.ts`: + +```ts +import { expect, test } from "../support/browser/strict-browser-test.ts"; +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; +import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts"; + +test("projects the installed route registry into the shipped bundle", async ({ + page, +}) => { + await page.goto("/examples/platform"); + await expect( + page.getByRole("heading", { name: "플랫폼 구성", level: 1 }), + ).toBeVisible(); + + const table = page.getByRole("table", { name: "설치된 라우트 목록" }); + await expect(table.locator("tbody tr")).toHaveCount( + Object.keys(ROUTE_REGISTRY).length, + ); + for (const routeId of Object.keys(ROUTE_REGISTRY)) { + await expect(table.getByText(routeId, { exact: true })).toBeVisible(); + } +}); + +test("shows every fixed server-state profile with its budget", async ({ + page, +}) => { + await page.goto("/examples/platform"); + + const table = page.getByRole("table", { name: "서버 상태 프로파일" }); + await expect(table.locator("tbody tr")).toHaveCount( + Object.keys(SERVER_STATE_PROFILES).length, + ); +}); + +test("states the release contract identity verified at boot", async ({ + page, +}) => { + await page.goto("/examples/platform"); + + const release = page.locator( + "section[aria-labelledby='platform-release-title']", + ); + await expect(release.getByText(/^sha256:/)).toBeVisible(); +}); +``` + +- [ ] **Step 2: e2e 실행** + +실행: `corepack pnpm exec playwright test tests/e2e/platform-overview.spec.ts --project=chromium` +기대: 3 passed + +- [ ] **Step 3: 시각 회귀 케이스 추가** + +`tests/visual/platform.visual.spec.ts` 끝에 추가한다. + +```ts +test("platform composition overview visual contract", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 1100 }); + await page.goto("/examples/platform"); + await expect( + page.locator("section[aria-labelledby='platform-capabilities-title']"), + ).toBeVisible(); + await expect(page.getByRole("main")).toHaveScreenshot( + "platform-overview-light.png", + ); +}); +``` + +- [ ] **Step 4: 기준선 생성과 검증** + +```bash +corepack pnpm test:visual:update +corepack pnpm test:visual +``` + +기대: 5 passed, 새 PNG 기준선이 `tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/`에 생긴다. + +- [ ] **Step 5: 커밋** + +```bash +git add tests/e2e/platform-overview.spec.ts tests/visual +git commit -m "test: cover the platform overview with browser and visual regression" +``` + +--- + +### Task 6: 최종 검증과 문서 갱신 + +**Files:** +- Modify: `docs/superpowers/specs/2026-07-31-platform-overview-page-design.md` + +- [ ] **Step 1: 설계 문서의 제외 항목 갱신** + +5절 "의도적 제외"를 다음으로 교체한다. + +```markdown +## 5. 런타임 해석 결과 + +초판은 런타임 오버라이드 해석 결과를 제외했다. 해석이 bootstrap에서 일어나고 표현 계층이 그 계층을 +import 할 수 없었기 때문이다. + +이후 `RuntimeCapabilitiesPort`가 추가되어 합성 루트가 경계된 스냅샷을 애플리케이션으로 전달한다. +화면은 정적 선택 수와 활성 수를 함께 표시하므로, 운영자가 능력을 껐다는 사실과 애초에 선택되지 +않았다는 사실이 구분된다. +``` + +- [ ] **Step 2: 전체 게이트** + +```bash +corepack pnpm check:types +corepack pnpm lint +corepack pnpm check:architecture +corepack pnpm test:all +corepack pnpm test:coverage +corepack pnpm check:registries +corepack pnpm check:i18n +corepack pnpm check:design-system +corepack pnpm check:diagnostics +corepack pnpm check:ci +corepack pnpm check:test-evidence:source +corepack pnpm verify:compatibility +corepack pnpm build +``` + +기대: 전부 PASS + +- [ ] **Step 3: 제거 하네스 4종** + +```bash +corepack pnpm test:sample-removal +corepack pnpm test:optional-recipe-removal +corepack pnpm test:browser-file-storage-removal +corepack pnpm test:realtime-removal +``` + +기대: 전부 PASS. 특히 `test:sample-removal`은 화면이 여전히 파생 전용인지 확인한다. + +- [ ] **Step 4: 커밋** + +```bash +git add docs/superpowers/specs/2026-07-31-platform-overview-page-design.md +git commit -m "docs: record the resolved capability path in the overview design" +``` + +## Self-Review + +**Spec coverage:** 설계 문서 5절(의도적 제외)이 Task 1–3으로 닫힌다. 6절 변경 파일 목록은 이미 반영됐다. 7절 검증은 Task 6이 수행한다. 진입 경로와 브라우저 커버리지는 초판 범위 밖이었으므로 Task 4–5로 추가했다. + +**Placeholder scan:** TBD 없음. 모든 코드 단계에 실제 코드가 있다. + +**Type consistency:** `describeRuntimeCapabilities`(Task 1) → `RuntimeCapabilitiesPort.getSnapshot`(Task 2) → `runtime.getCapabilitySnapshot()`(Task 3)로 이름과 반환 타입이 일치한다. `RuntimeCapabilityStatus`의 필드명 `capabilityId` / `selected` / `active` / `override`가 세 태스크에서 동일하다. diff --git a/docs/superpowers/plans/2026-08-01-scope-owned-server-state.md b/docs/superpowers/plans/2026-08-01-scope-owned-server-state.md new file mode 100644 index 0000000..f16edf2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-scope-owned-server-state.md @@ -0,0 +1,99 @@ +# Scope-Owned Server State Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent previous-account query and optimistic state from remaining renderable or writable after an authentication scope transition. + +**Architecture:** The scope runtime is a fail-closed lifecycle authority. A bootstrap generation store owns a QueryClient and its concrete invalidation coordinator, swaps them only after the previous generation is closed, and exposes a stable invalidation facade plus a subscribable generation snapshot to React. + +**Tech Stack:** TypeScript 7, React 19, TanStack Query 5, Vitest 4, Testing Library. + +## Global Constraints + +- FENCED is synchronous and no previous-account UI may render after it. +- Mandatory participant or cache cleanup failure never publishes READY. +- Snapshot subscriber defects never prevent reset scheduling. +- Every READY generation owns a different QueryClient identity. +- Late work from an old generation cannot write through the stable coordinator. +- Preserve existing public application ports and dirty-worktree changes. + +--- + +### Task 1: Fail-closed scope lifecycle + +**Files:** +- Modify: `src/contracts/server-state-scope.ts` +- Modify: `src/adapters/query-cache/server-state-scope-runtime.ts` +- Modify: `tests/unit/server-state-scope-runtime.test.ts` + +**Interfaces:** +- Adds phase `FAILED` and lifecycle event `{ kind: "FAILED"; generation: number }`. +- Adds dependency callback `activateNextGeneration(): void | Promise` executed after old local reset and before READY. + +- [ ] Add failing tests for a throwing snapshot listener, participant failure, reset failure, and next-generation activation failure. +- [ ] Run `corepack pnpm exec vitest run tests/unit/server-state-scope-runtime.test.ts` and confirm each new behavior fails for the intended reason. +- [ ] Notify listeners from a stable snapshot with per-listener exception isolation. +- [ ] Continue all cleanup participants, remember the first mandatory failure, and always attempt local reset. +- [ ] Close old identities after cleanup, then publish FAILED if any mandatory step failed. +- [ ] Call `activateNextGeneration` only after successful reset and publish READY only after it succeeds. +- [ ] Re-run the focused unit test and confirm all lifecycle cases pass. + +### Task 2: Render fence surface synchronously + +**Files:** +- Modify: `src/presentation/adapters/query/server-state-scope-provider.tsx` +- Create: `tests/component/server-state-scope-provider.test.tsx` + +**Interfaces:** +- `ServerStateScopeProvider` subscribes to both snapshot and phase through one store notification. +- Props add optional `transitionFallback?: ReactNode`; default is `null`. +- Existing `useServerStateScope()` continues returning `CacheScopeSnapshot` for feature hooks. + +- [ ] Add a failing component test that renders private child data, triggers a deferred reset, and expects the child to disappear in the same `act()` turn. +- [ ] Add a failing test that FAILED never remounts children. +- [ ] Run the new component test and confirm old children remain with the current provider. +- [ ] Subscribe in the provider and render children only for READY; render the supplied fallback for FENCED/FAILED and nothing after DISPOSED. +- [ ] Re-run the test and confirm no old-data frame is observable. + +### Task 3: QueryClient generation store and stable coordinator facade + +**Files:** +- Create: `src/bootstrap/server-state-generation-store.ts` +- Modify: `src/bootstrap/runtime-adapters.ts` +- Modify: `src/bootstrap/runtime-application.tsx` +- Modify: `tests/unit/runtime-adapters.test.ts` +- Modify: `tests/component/runtime-application.test.tsx` + +**Interfaces:** +- `ServerStateGenerationSnapshot = { generation: number; queryClient: QueryClient; coordinator: QueryInvalidationCoordinator; crossContextStatus(): CrossContextInvalidationStatus }`. +- Store methods: `getSnapshot()`, `subscribe(listener)`, `activateNext()`, `resetCurrent()`, `dispose()`. +- A stable `QueryInvalidationCoordinator` facade delegates only to the current snapshot and fences delegates by generation. + +- [ ] Add a failing unit test that captures the initial QueryClient, triggers session transition, completes reset, and expects a different current QueryClient. +- [ ] Add a failing test that an old captured coordinator cannot invalidate after activation. +- [ ] Add a component test that QueryClientProvider receives and renders against the new generation. +- [ ] Implement generation factory ownership in bootstrap; create cross-context transport and concrete coordinator per generation. +- [ ] Pass `resetCurrent` and `activateNext` to the scope runtime in the required order. +- [ ] Make `RuntimeApplication` subscribe to the generation store and key the QueryClient provider by generation. +- [ ] Dispose the current generation and store exactly once during application shutdown. +- [ ] Run runtime-adapter and runtime-application tests until all pass. + +### Task 4: Conditional validator and cleanup ordering + +**Files:** +- Modify: `src/bootstrap/runtime-adapters.ts` +- Modify: `tests/unit/runtime-adapters.test.ts` + +**Interfaces:** +- Conditional validator clearing becomes an ordered mandatory scope participant rather than a general snapshot subscriber. + +- [ ] Add a failing order test proving validator clear occurs after admission fence and before cache disposal. +- [ ] Register the validator closer with an explicit order and remove the generic subscription. +- [ ] Re-run runtime-adapter tests and verify each closer executes once. + +### Task 5: Verification + +- [ ] Run scope runtime, provider, runtime-adapter, runtime-application, and application-query focused suites. +- [ ] Run `corepack pnpm check:types` and `corepack pnpm lint`. +- [ ] Run the complete non-browser suite. +- [ ] Record browser-only follow-up separately; do not claim it passed without Playwright evidence. diff --git a/docs/superpowers/specs/2026-07-31-platform-overview-page-design.md b/docs/superpowers/specs/2026-07-31-platform-overview-page-design.md new file mode 100644 index 0000000..9a43303 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-platform-overview-page-design.md @@ -0,0 +1,82 @@ +# 플랫폼 구성 화면 (`EXAMPLES_PLATFORM`) 설계 + +작성일: 2026-07-31 + +## 1. 문제 + +템플릿이 무엇을 설치해 두었는지 확인할 방법이 없다. 홈 화면은 "실행 계약 / 교체 가능한 연동 / +접근 가능한 화면"이라는 세 문장으로만 요약하고, 실제로 어떤 라우트·계약·런타임 능력이 설치되어 +있는지는 소스를 직접 읽어야만 알 수 있다. + +## 2. 해결 방향 + +설치 상태를 **레지스트리에서 파생해서만** 렌더하는 화면을 하나 추가한다. 수기 서술을 두지 않으므로 +코드가 바뀌면 화면이 따라 바뀌고, 문서가 낡는 문제가 발생하지 않는다. + +이 선택에는 부수 효과가 있다. reference feature를 삭제하면 관련 행이 자동으로 사라지므로 +`test:sample-removal` harness의 잔재 스캔과 충돌하지 않는다. 반대로 수기 목록이었다면 샘플 이름이 +페이지에 박혀 harness가 실패했을 것이다. + +## 3. 배치 + +| 항목 | 값 | 근거 | +| --- | --- | --- | +| `routeId` | `EXAMPLES_PLATFORM` | | +| `path` | `/examples/platform` | 제품 개발 시 통째로 삭제 가능한 `examples/` 옥 | +| `access` | `public` | 세션 연동 없이 확인 가능해야 함 | +| `loadingSurface` | `example-page` | governance `allowedValues`에 이미 존재 — 신규 값 추가 없음 | +| `errorSurface` | `route-boundary` | 동일 | +| `chunkId` | `route-examples-platform` | | +| `navigationOrder` | `15` | 홈(10)과 UI(20) 사이. 기존 값 재번호 불필요 | +| 구현 파일 | `src/presentation/examples/platform-overview-page.tsx` | `examples/`는 `check:i18n` 한글 리터럴 스캔 대상이 아님 | + +## 4. 섹션 구성 + +전부 파생 데이터다. + +| # | 섹션 | 출처 | 표현 대상 | +| --- | --- | --- | --- | +| 1 | 릴리스 신원 | `ApplicationApi.runtime.getReleaseSummary()` | buildId, releaseId, configSchemaVersion, contractSet digest | +| 2 | 설치된 라우트 | `ROUTE_REGISTRY` | path, access, chunkId, params/search 스키마 | +| 3 | 계약과 HTTP 오퍼레이션 | `COMPOSED_CONTRACT_CONTRIBUTIONS`, `EXPECTED_CONTRACT_SET_PACKAGES` | 외부 계약 패키지 수, 오퍼레이션별 재시도 의미·예산·바이트 한도·deadline·효과 확정성 | +| 4 | 서버 상태와 실행 상한 | `SERVER_STATE_PROFILES`, `HTTP_EXECUTION_CEILINGS` | 4개 프로파일의 staleTime·gcTime·결과 예산, 강제되는 실행 상한 | +| 5 | 선택적 런타임 능력 | `INSTALLED_RUNTIME_CAPABILITIES` | realtime / webWorker / serviceWorker / offlineCommands 선택 여부 | + +섹션 3의 "외부 계약 패키지 0개"와 섹션 5의 "4개 능력 전부 미선택"이 "어디까지 제공하는가"에 대한 +답이다. 공통 런타임은 구현·검증되어 있으나 제품 기여물이 없어 선택되지 않은 상태임을 드러낸다. + +## 5. 런타임 해석 결과 + +초판은 `CAPABILITY_OVERRIDES` 해석 결과를 제외했다. 해석이 bootstrap에서 일어나고 +`presentation-does-not-know-adapters` 규칙이 presentation → bootstrap import를 금지했기 때문이다. + +이후 `docs/superpowers/plans/2026-07-31-platform-overview-completion.md`가 그 경로를 만들었다. +`describeRuntimeCapabilities`가 정적 선택과 해석 결과를 경계된 스냅샷으로 축약하고, +`RuntimeCapabilitiesPort`를 통해 합성 루트가 그 스냅샷을 애플리케이션에 전달한다. 표현 계층은 +`runtime.getCapabilitySnapshot()`만 호출하므로 bootstrap을 여전히 알지 못한다. + +스냅샷이 `selected`와 `active`를 함께 실으므로 화면은 세 상태를 구분한다. + +| 상태 | 조건 | 표시 | +| --- | --- | --- | +| 미선택 | `selected === 0` | 애초에 설치되지 않았다 | +| 운영자가 비활성화함 | `selected > 0 && active === 0` | 설치됐으나 런타임 설정이 껐다 | +| 활성 | `active > 0` | 지금 동작한다 | + +## 6. 함께 변경되는 파일 + +1. `src/contracts/routes.ts` — 레지스트리 항목 +2. `src/contracts/route-runtime-contract.ts` — 런타임 계약 항목 +3. `src/presentation/routes/route-runtime.tsx` — lazy import +4. `src/presentation/i18n/catalog.ts` — ko/en `route.EXAMPLES_PLATFORM.{title,navigation}` +5. `public/release-manifest.json` — `routeChunks` 항목 +6. `config/contracts/registry-baseline.json` 및 승인·증거 파일 +7. `src/presentation/styles/` — 요약 그리드 스타일 +8. `tests/component/platform-overview-page.test.tsx` — 컴포넌트 테스트 + +## 7. 검증 + +- 타입, lint, 아키텍처, i18n, 디자인 시스템, 레지스트리 게이트 +- `test:all` +- removal harness 4종. 특히 `test:sample-removal` 이후에도 페이지가 빈 상태로 정상 렌더되어야 한다. +- 실제 브라우저 렌더 확인 diff --git a/eslint.config.ts b/eslint.config.ts index c171e17..2505f4b 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -522,6 +522,9 @@ export default [ `src/adapters/browser-transfer/**/*.${sourceExtensions}`, `src/adapters/cache-storage/**/*.${sourceExtensions}`, `src/adapters/cross-context-invalidation/**/*.${sourceExtensions}`, + `src/adapters/service-worker/**/*.${sourceExtensions}`, + `src/bootstrap/register-service-worker.ts`, + `src/adapters/platform/browser-lifecycle.ts`, `src/adapters/storage/**/*.${sourceExtensions}`, `src/adapters/storage/indexeddb/**/*.${sourceExtensions}`, `src/adapters/storage/opfs/**/*.${sourceExtensions}`, @@ -682,6 +685,9 @@ export default [ `src/adapters/browser-transfer/**/*.${sourceExtensions}`, `src/adapters/cache-storage/**/*.${sourceExtensions}`, `src/adapters/cross-context-invalidation/**/*.${sourceExtensions}`, + `src/adapters/service-worker/**/*.${sourceExtensions}`, + `src/bootstrap/register-service-worker.ts`, + `src/adapters/platform/browser-lifecycle.ts`, `src/adapters/storage/**/*.${sourceExtensions}`, `src/adapters/storage/indexeddb/**/*.${sourceExtensions}`, `src/adapters/storage/opfs/**/*.${sourceExtensions}`, diff --git a/package.json b/package.json index 1fb352f..45e99ee 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "scripts": { "dev": "vite", - "build": "vite build && node scripts/generate-build-manifest.ts", + "build": "node scripts/build-frontend.ts", "build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security", "preview": "vite preview", "lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0", @@ -21,7 +21,7 @@ "check:i18n:fixture": "node scripts/check-i18n.ts --fixture", "check:diagnostics": "node scripts/check-diagnostics.ts", "check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture", - "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes", + "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker", "check:types:app": "tsc --project tsconfig.app.json", "check:types:node": "tsc --project tsconfig.node.json", "check:types:test": "tsc --project tsconfig.test.json", @@ -102,7 +102,11 @@ "drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005", "ci:gate": "node scripts/run-ci-gate.ts", "check:ci": "node scripts/check-ci-contract.ts", - "verify:documentation": "node scripts/verify-documentation-readiness.ts" + "verify:documentation": "node scripts/verify-documentation-readiness.ts", + "build:app-only": "vite build && node scripts/generate-build-manifest.ts", + "generate:contract-set": "node scripts/generate-contract-set.ts", + "check:types:web-worker": "tsc --project tsconfig.web-worker.json", + "check:types:service-worker": "tsc --project tsconfig.service-worker.json" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/public/config.json b/public/config.json index d589523..e2e876d 100644 --- a/public/config.json +++ b/public/config.json @@ -1,13 +1,18 @@ { "APP_ENV": "local", - "API_BASE_URL": "http://localhost:8080", + "API_BASE_URL": "http://localhost:8080/", "REQUEST_TIMEOUT_MS": 10000, "MAX_RETRY_ATTEMPTS": 2, "TELEMETRY_ENABLED": false, "AUTH_MODE": "demo", - "CONFIG_SCHEMA_VERSION": "1", - "API_CONTRACT_VERSION": "1", + "CONFIG_SCHEMA_VERSION": "2.0", "RELEASE_MANIFEST_URL": "/release-manifest.json", "BUILD_ID": "local-build", - "RELEASE_ID": "local-release" + "RELEASE_ID": "local-release", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + } } diff --git a/public/release-manifest.json b/public/release-manifest.json index 056bc95..bd0b8bd 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -1,15 +1,15 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "appVersion": "0.1.0", "buildId": "local-build", "commitSha": "local", - "configSchemaVersion": "1", - "apiContractVersion": "1", + "configSchemaVersion": "2.0", "assetManifestHash": "generated-during-build", "releaseId": "local-release", "builtAt": "1970-01-01T00:00:00.000Z", "routeChunks": { "route-home": "src/presentation/pages/home-page.tsx", + "route-examples-platform": "src/presentation/examples/platform-overview-page.tsx", "route-examples-ui": "src/presentation/examples/ui-gallery-page.tsx", "route-examples-states": "src/presentation/examples/state-gallery-page.tsx", "route-examples-auth": "src/presentation/examples/auth-example-page.tsx", @@ -18,5 +18,10 @@ "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.tsx" + }, + "contractSet": { + "setAlgorithm": "CA_CONTRACT_SET_V1", + "setDigest": "sha256:ad6aab71fea6a9ff87cbd170b984b339965afc90d85bb57f87801c9e0c020da2", + "packages": [] } } diff --git a/schemas/artifacts/build-manifest.schema.json b/schemas/artifacts/build-manifest.schema.json index 7bb681e..a68089d 100644 --- a/schemas/artifacts/build-manifest.schema.json +++ b/schemas/artifacts/build-manifest.schema.json @@ -1,39 +1,116 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "build-manifest.schema.json", "type": "object", - "required": [ - "schemaVersion", - "buildId", - "commitSha", - "generatedAt", - "buildContext", - "outputs" - ], "properties": { - "schemaVersion": { "const": 1 }, - "buildId": { "type": "string", "minLength": 1 }, - "commitSha": { "type": "string", "minLength": 1 }, - "generatedAt": { "type": "string", "format": "date-time" }, + "schemaVersion": { + "type": "number", + "const": 1 + }, + "buildId": { + "type": "string", + "minLength": 1 + }, + "commitSha": { + "type": "string", + "minLength": 1 + }, + "releaseId": { + "type": "string", + "minLength": 1 + }, + "moduleInventoryHash": { + "type": "string", + "minLength": 1 + }, + "generatedAt": { + "type": "string", + "minLength": 1 + }, "buildContext": { "type": "object", - "required": ["nodeVersion", "packageManagerVersion", "runnerImage"], "properties": { - "nodeVersion": { "type": "string" }, - "packageManagerVersion": { "type": "string" }, - "runnerImage": { "type": "string" } + "nodeVersion": { + "type": "string", + "minLength": 1 + }, + "packageManagerVersion": { + "type": "string", + "minLength": 1 + }, + "runnerImage": { + "type": "string", + "minLength": 1 + }, + "sourceDateEpoch": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } }, + "required": [ + "nodeVersion", + "packageManagerVersion", + "runnerImage", + "sourceDateEpoch" + ], "additionalProperties": false }, "outputs": { "type": "object", - "required": ["directory", "viteManifest"], "properties": { - "directory": { "type": "string" }, - "viteManifest": { "type": "string" } + "directory": { + "type": "string", + "minLength": 1 + }, + "viteManifest": { + "type": "string", + "minLength": 1 + }, + "moduleInventory": { + "type": "string", + "minLength": 1 + }, + "routeChunks": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "runtimeConfigSchema": { + "type": "string", + "minLength": 1 + } }, + "required": [ + "directory", + "viteManifest", + "moduleInventory", + "routeChunks", + "runtimeConfigSchema" + ], "additionalProperties": false } }, + "required": [ + "schemaVersion", + "buildId", + "commitSha", + "releaseId", + "moduleInventoryHash", + "generatedAt", + "buildContext", + "outputs" + ], "additionalProperties": false } diff --git a/scripts/build-frontend.ts b/scripts/build-frontend.ts new file mode 100644 index 0000000..26657cc --- /dev/null +++ b/scripts/build-frontend.ts @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { rm } from "node:fs/promises"; +import process from "node:process"; + +import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtime-capabilities.ts"; + +/** + * §17.2.2. Deterministic two-pass build. + * + * The Service Worker needs the exact hashed asset list at compile time, so the + * order is fixed: + * + * 1. clean dist and .generated/frontend-runtime + * 2. generate contractSet and build-info source + * 3. Vite app build (emptyOutDir = true) + * 4. scan app dist and generate the static asset source + * 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false) + * 6. generate Release Manifest V2 and the build manifest + * + * Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES` + * and `null`: those modes never run an active worker build. + */ + +const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker; +const buildsActiveWorker = selection?.mode === "ACTIVE"; + +function run(command: string, args: readonly string[]): void { + const result = spawnSync(command, [...args], { + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + process.stderr.write(`build step failed: ${command} ${args.join(" ")}\n`); + process.exit(result.status ?? 1); + } +} + +// 1. clean +await rm("dist", { recursive: true, force: true }); +await rm(".generated/frontend-runtime", { recursive: true, force: true }); + +// 2. contract set + build info +run("node", ["scripts/generate-contract-set.ts"]); + +// 3. app build +run("npx", ["vite", "build"]); + +if (buildsActiveWorker) { + // 4. hashed asset inventory + run("node", ["scripts/generate-service-worker-assets.ts", "dist"]); + // 5. service worker build + run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]); +} else { + process.stdout.write( + `service worker mode ${selection?.mode ?? "null"}: skipping worker build\n`, + ); +} + +// 6. release + build manifest +run("node", ["scripts/generate-build-manifest.ts"]); diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts index 7f28493..fb3f614 100644 --- a/scripts/check-ci-contract.ts +++ b/scripts/check-ci-contract.ts @@ -71,6 +71,45 @@ for (const [gateId, gate] of Object.entries(document.gates)) { if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) { failures.push(`${gateId} lacks command, evidence, or retention wiring`); } + for (const [index, step] of (gate.steps ?? []).entries()) { + if (!isRecord(step) || (step.expect !== "pass" && step.expect !== "fail")) { + failures.push(`${gateId}[${index}] has an invalid step expectation`); + continue; + } + if (step.expect === "pass") { + if ( + step.expectedExitCode !== undefined || + step.expectedDiagnosticId !== undefined + ) { + failures.push( + `${gateId}[${index}] passing step declares a negative fixture identity`, + ); + } + continue; + } + if ( + typeof step.expectedExitCode !== "number" || + !Number.isSafeInteger(step.expectedExitCode) || + step.expectedExitCode < 1 || + step.expectedExitCode > 255 + ) { + failures.push(`${gateId}[${index}] lacks an exact expected exit code`); + } + const diagnosticId = step.expectedDiagnosticId; + if ( + typeof diagnosticId !== "string" || + diagnosticId.trim().length === 0 || + diagnosticId.length > 256 || + ["\r", "\n", "\0"].some( + (character) => + typeof diagnosticId === "string" && diagnosticId.includes(character), + ) + ) { + failures.push( + `${gateId}[${index}] lacks a bounded expected diagnostic identity`, + ); + } + } } const runbookGateEvidence = Object.freeze({ @@ -107,7 +146,6 @@ if ( const forbiddenWorkflowPatterns = [ /continue-on-error\s*:/, /retention-days\s*:/, - /timeout-minutes\s*:/, /allow_failure\s*:/, ]; for (const pattern of forbiddenWorkflowPatterns) { @@ -115,6 +153,13 @@ for (const pattern of forbiddenWorkflowPatterns) { failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`); } } +const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0; +if (jobTimeoutCount !== 5) { + failures.push("every CI gate job must declare timeout-minutes: 45"); +} +if (/if-no-files-found:\s*warn/.test(workflow)) { + failures.push("CI evidence upload must fail when artifacts are absent"); +} for (const requiredToken of [ "merge_gate:", "release_gate:", @@ -144,6 +189,8 @@ for (const requiredToken of [ "SOURCE_DATE_EPOCH", '"--format=%H%n%ct"', "env: gateEnvironment", + "classifyGateStepResult", + "timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS", ]) { if (!gateRunner.includes(requiredToken)) { failures.push(`CI gate runner missing ${requiredToken}`); diff --git a/scripts/check-optional-recipes.ts b/scripts/check-optional-recipes.ts index 7515bbd..eee0959 100644 --- a/scripts/check-optional-recipes.ts +++ b/scripts/check-optional-recipes.ts @@ -9,6 +9,7 @@ import { scanProductionBundle, validateRecipeCatalog, } from "./lib/optional-recipes.ts"; +import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; type GateViolation = Readonly<{ ruleId: string; @@ -46,7 +47,25 @@ const recipes = Array.isArray(catalog.recipes) ? catalog.recipes : []; const packageDocument = JSON.parse( await readFile("package.json", "utf8"), ) as Record; -const catalogViolations = validateRecipeCatalog(catalog, packageDocument); +const catalogSchemaViolations: string[] = []; +try { + assertMatchesJsonSchema( + JSON.parse( + await readFile( + "schemas/config/frontend-capability-recipes.schema.json", + "utf8", + ), + ), + catalog, + "optional recipe catalog", + ); +} catch { + catalogSchemaViolations.push("CATALOG_JSON_SCHEMA_INVALID"); +} +const catalogViolations = [ + ...catalogSchemaViolations, + ...validateRecipeCatalog(catalog, packageDocument), +]; const runtimeSourceViolations = ( await Promise.all( recipes.flatMap((recipe: unknown) => { diff --git a/scripts/check-registries.ts b/scripts/check-registries.ts index 5ff49c3..fbbc32f 100644 --- a/scripts/check-registries.ts +++ b/scripts/check-registries.ts @@ -15,6 +15,7 @@ import { validateBreakingEvidence, verifyRegistryBaselineApproval, } from "./lib/registry-compatibility.ts"; +import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; type RegistryRow = Record; type RegistryRows = Record; @@ -436,6 +437,22 @@ const report = { failures, registries: snapshots, }; +if (usesRepositoryBaseline && failures.length === 0) { + try { + assertMatchesJsonSchema( + JSON.parse( + await readFile( + "schemas/artifacts/registry-snapshot.schema.json", + "utf8", + ), + ), + report, + "registry snapshot", + ); + } catch { + failures.push("registry snapshot JSON Schema mismatch"); + } +} await mkdir(path.dirname(artifactPath), { recursive: true }); await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`); diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts new file mode 100644 index 0000000..01893ca --- /dev/null +++ b/scripts/contracts/release-artifacts.ts @@ -0,0 +1 @@ +export * from "../../src/contracts/release-artifacts.ts"; diff --git a/scripts/generate-build-manifest.ts b/scripts/generate-build-manifest.ts index 42756a4..b2afb72 100644 --- a/scripts/generate-build-manifest.ts +++ b/scripts/generate-build-manifest.ts @@ -7,7 +7,12 @@ import { ROUTE_REGISTRY, ROUTE_RUNTIME_CONTRACT, } from "../src/features/installed-feature-contracts.ts"; -import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.ts"; +import { + buildManifestArtifactSchema, + releaseManifestV2ArtifactSchema, + runtimeConfigV2ArtifactSchema, +} from "../src/contracts/release-artifacts.ts"; +import { buildContractSet } from "./generate-contract-set.ts"; import { assertCiBuildEnvironment, buildDate, @@ -43,7 +48,7 @@ const assetManifestHash = createHash("sha256") const moduleInventoryHash = createHash("sha256") .update(moduleInventory) .digest("hex"); -const runtimeConfig = runtimeConfigSchema.parse( +const runtimeConfig = runtimeConfigV2ArtifactSchema.parse( JSON.parse(await readFile("dist/config.json", "utf8")), ); const routeChunks: Record = {}; @@ -59,12 +64,12 @@ for (const definition of Object.values(ROUTE_REGISTRY)) { } routeChunks[definition.chunkId] = asset.file; } -const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema); +const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigV2ArtifactSchema); runtimeConfig.BUILD_ID = buildId; runtimeConfig.RELEASE_ID = releaseId; -const manifest = { +const manifest = buildManifestArtifactSchema.parse({ schemaVersion: 1, buildId, commitSha, @@ -84,20 +89,22 @@ const manifest = { routeChunks, runtimeConfigSchema: "dist/runtime-config.schema.json", }, -}; +}); -const releaseManifest = { - schemaVersion: 1, +// §5.2 / §24.6 RC-3. The writer emits V2 only; the scalar API contract +// version has no writer source left. +const releaseManifest = releaseManifestV2ArtifactSchema.parse({ + schemaVersion: 2, appVersion: packageJson.version, buildId, commitSha, configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, - apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, assetManifestHash, releaseId, builtAt, routeChunks, -}; + contractSet: buildContractSet(), +}); await mkdir("artifacts/release", { recursive: true }); await mkdir("artifacts/quality", { recursive: true }); diff --git a/scripts/generate-contract-set.ts b/scripts/generate-contract-set.ts new file mode 100644 index 0000000..8aa37f6 --- /dev/null +++ b/scripts/generate-contract-set.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { + CONTRACT_SET_ALGORITHM, + computeContractSetDigestWith, + type ContractSetPackage, +} from "../src/contracts/contract-set-canonical.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; + +/** + * §5.4 / §17.2.2 step 2. Derives the build-time expected contract set from the + * installed contributions and emits it as generated source for the manifest + * writer. `TEMPLATE_FIXTURE` provenance never reaches this list, so a fixture + * cannot influence the release digest. + */ + +const OUTPUT = ".generated/frontend-runtime/contract-set.ts"; + +function sha256(bytes: Uint8Array): Uint8Array { + return new Uint8Array(createHash("sha256").update(bytes).digest()); +} + +export function buildContractSet( + packages: readonly ContractSetPackage[] = EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[], +) { + return Object.freeze({ + setAlgorithm: CONTRACT_SET_ALGORITHM, + setDigest: computeContractSetDigestWith(packages, sha256), + packages: Object.freeze( + [...packages] + .map((entry) => Object.freeze({ ...entry })) + .sort((left, right) => (left.packageId < right.packageId ? -1 : 1)), + ), + }); +} + +async function main(): Promise { + const contractSet = buildContractSet(); + const source = [ + "// Generated by scripts/generate-contract-set.ts. Do not edit.", + "// Regenerated from scratch on every build; never committed.", + "", + "import type { ContractSet } from \"../../src/contracts/contract-set.ts\";", + "", + `export const BUILD_CONTRACT_SET: ContractSet = ${JSON.stringify( + contractSet, + null, + 2, + )} as const;`, + "", + ].join("\n"); + + await mkdir(dirname(OUTPUT), { recursive: true }); + await writeFile(OUTPUT, source, "utf8"); + process.stdout.write( + `contract set: ${contractSet.packages.length} package(s) ${contractSet.setDigest}\n`, + ); +} + +if (process.argv[1]?.endsWith("generate-contract-set.ts")) { + await main(); +} diff --git a/scripts/generate-service-worker-assets.ts b/scripts/generate-service-worker-assets.ts new file mode 100644 index 0000000..543a7b0 --- /dev/null +++ b/scripts/generate-service-worker-assets.ts @@ -0,0 +1,144 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + SERVICE_WORKER_BOUNDS, + SERVICE_WORKER_SCRIPT_PATH, + type StaticAssetManifestV1, +} from "../src/contracts/service-worker.ts"; + +/** + * §17.2.2 step 4. Scans the completed app `dist` and emits the exact hashed + * asset list the Service Worker will verify at install time. + * + * `service-worker.js` itself and `index.html` are excluded (§17.2.2), as are + * the runtime config and release manifest, which are network-only (§18.4). + */ + +const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts"; + +const CACHEABLE_EXTENSIONS: Readonly> = Object.freeze({ + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".woff2": "font/woff2", + ".svg": "image/svg+xml", + ".png": "image/png", + ".webp": "image/webp", +}); + +const EXCLUDED_FILES: ReadonlySet = new Set([ + "index.html", + SERVICE_WORKER_SCRIPT_PATH, + "config.json", + "release-manifest.json", + "runtime-config.schema.json", +]); + +/** Vite emits content-hashed names; only those may be treated as immutable. */ +const HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/; + +export async function collectStaticAssets( + distDirectory: string, + buildId: string, + releaseId: string, +): Promise { + const files = await walk(distDirectory, distDirectory); + const assets: StaticAssetManifestV1["assets"][number][] = []; + + for (const relative of files.sort()) { + const base = path.basename(relative); + if (EXCLUDED_FILES.has(base) || relative.startsWith(".vite/")) continue; + const contentType = CACHEABLE_EXTENSIONS[path.extname(base).toLowerCase()]; + if (!contentType || !HASHED_NAME.test(base)) continue; + + const absolute = path.join(distDirectory, relative); + const bytes = await readFile(absolute); + if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) { + throw new Error(`Static asset exceeds its byte bound: ${relative}`); + } + assets.push({ + url: `/${relative.split(path.sep).join("/")}`, + sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + bytes: bytes.byteLength, + contentType, + }); + } + + if (assets.length > SERVICE_WORKER_BOUNDS.assets) { + throw new Error("Static asset count exceeds its bound."); + } + const totalBytes = assets.reduce((sum, asset) => sum + asset.bytes, 0); + if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) { + throw new Error("Static asset set exceeds its byte bound."); + } + + // The set digest is a length-prefixed hash over the sorted asset identities, + // so a reordered directory listing cannot change it. + const hash = createHash("sha256"); + hash.update("CA_STATIC_ASSET_SET_V1\0"); + for (const asset of assets) { + hash.update(lengthPrefixed(asset.url)); + hash.update(lengthPrefixed(asset.sha256)); + hash.update(lengthPrefixed(String(asset.bytes))); + hash.update(lengthPrefixed(asset.contentType)); + } + + return { + schemaVersion: 1, + buildId, + releaseId, + setDigest: `sha256:${hash.digest("hex")}`, + assets, + }; +} + +function lengthPrefixed(value: string): Buffer { + const bytes = Buffer.from(value, "utf8"); + const prefix = Buffer.alloc(4); + prefix.writeUInt32BE(bytes.byteLength, 0); + return Buffer.concat([prefix, bytes]); +} + +async function walk(root: string, current: string): Promise { + const entries = await readdir(current, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + files.push(...(await walk(root, absolute))); + } else if ((await stat(absolute)).isFile()) { + files.push(path.relative(root, absolute)); + } + } + return files; +} + +async function main(): Promise { + const distDirectory = process.argv[2] ?? "dist"; + const buildId = process.env.VITE_BUILD_ID ?? "local-build"; + const releaseId = process.env.RELEASE_ID ?? "local-release"; + const manifest = await collectStaticAssets(distDirectory, buildId, releaseId); + const source = [ + "// Generated by scripts/generate-service-worker-assets.ts. Do not edit.", + "", + 'import type { StaticAssetManifestV1 } from "../../src/contracts/service-worker.ts";', + "", + `export const SERVICE_WORKER_ASSETS: StaticAssetManifestV1 = ${JSON.stringify( + manifest, + null, + 2, + )} as const;`, + "", + ].join("\n"); + await mkdir(path.dirname(OUTPUT), { recursive: true }); + await writeFile(OUTPUT, source, "utf8"); + process.stdout.write( + `service worker assets: ${manifest.assets.length} file(s) ${manifest.setDigest}\n`, + ); +} + +if (process.argv[1]?.endsWith("generate-service-worker-assets.ts")) { + await main(); +} diff --git a/scripts/lib/ci-step-result.ts b/scripts/lib/ci-step-result.ts new file mode 100644 index 0000000..12cc2d9 --- /dev/null +++ b/scripts/lib/ci-step-result.ts @@ -0,0 +1,82 @@ +export type GateStepExpectation = + | Readonly<{ kind: "pass" }> + | Readonly<{ + kind: "fail"; + expectedExitCode: number; + expectedDiagnosticId: string; + }>; + +export type GateProcessResult = Readonly<{ + status: number | null; + signal: string | null; + stdout: string; + stderr: string; + error?: Readonly<{ code?: string }>; +}>; + +export type GateStepClassification = + | Readonly<{ + kind: "EXPECTED_PASS" | "EXPECTED_FAILURE"; + expectationMet: true; + }> + | Readonly<{ + kind: "UNEXPECTED_EXIT"; + expectationMet: false; + }> + | Readonly<{ + kind: "UNEXPECTED_DIAGNOSTIC"; + expectationMet: false; + }> + | Readonly<{ + kind: "INFRASTRUCTURE_FAILURE"; + expectationMet: false; + detail: string; + }>; + +/** A negative fixture passes only with its registered exit and diagnostic. */ +export function classifyGateStepResult( + expectation: GateStepExpectation, + result: GateProcessResult, +): GateStepClassification { + const errorCode = result.error?.code; + const spawnFailed = result.error !== undefined; + if (spawnFailed || result.signal || result.status === null) { + return Object.freeze({ + kind: "INFRASTRUCTURE_FAILURE" as const, + expectationMet: false as const, + detail: + errorCode ?? + result.signal ?? + (spawnFailed ? "SPAWN_ERROR" : "NO_EXIT_STATUS"), + }); + } + if (expectation.kind === "pass" && result.status === 0) { + return Object.freeze({ + kind: "EXPECTED_PASS" as const, + expectationMet: true as const, + }); + } + if (expectation.kind === "fail") { + if (result.status !== expectation.expectedExitCode) { + return Object.freeze({ + kind: "UNEXPECTED_EXIT" as const, + expectationMet: false as const, + }); + } + const diagnosticOutput = `${result.stdout}\n${result.stderr}`; + if (!diagnosticOutput.includes(expectation.expectedDiagnosticId)) { + return Object.freeze({ + kind: "UNEXPECTED_DIAGNOSTIC" as const, + expectationMet: false as const, + }); + } + return Object.freeze({ + kind: "EXPECTED_FAILURE" as const, + expectationMet: true as const, + }); + } + return Object.freeze({ + kind: "UNEXPECTED_EXIT" as const, + expectationMet: false as const, + }); +} diff --git a/scripts/lib/json-schema.ts b/scripts/lib/json-schema.ts new file mode 100644 index 0000000..9f01914 --- /dev/null +++ b/scripts/lib/json-schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * Compile a checked-in JSON Schema and assert a concrete artifact against it. + * Conversion failures are fatal too: an unsupported or malformed schema must + * not silently turn a release schema into documentation-only metadata. + */ +export function assertMatchesJsonSchema( + schemaDocument: unknown, + value: unknown, + label: string, +): void { + try { + const schema = z.fromJSONSchema(schemaDocument as never); + const result = schema.safeParse(value); + if (!result.success) { + throw new TypeError(z.prettifyError(result.error)); + } + } catch (error) { + throw new TypeError(`${label} does not satisfy its checked-in JSON Schema.`, { + cause: error, + }); + } +} diff --git a/scripts/lib/service-worker-build-input.ts b/scripts/lib/service-worker-build-input.ts new file mode 100644 index 0000000..62e2cc9 --- /dev/null +++ b/scripts/lib/service-worker-build-input.ts @@ -0,0 +1,101 @@ +import type { + InstalledServiceWorkerSelection, + ServiceWorkerHandlerId, + StaticAssetManifestV1, +} from "../../src/contracts/service-worker.ts"; + +const DIGEST = /^sha256:[0-9a-f]{64}$/u; + +export type ServiceWorkerBuildInput = Readonly<{ + assets: StaticAssetManifestV1; + handlers: readonly ServiceWorkerHandlerId[]; + contractSetDigest: string; + releaseManifestUrl: string; +}>; + +/** + * ACTIVE worker compilation is a release-integrity boundary. Missing generated + * modules, stale identities and placeholder digests are fatal build defects; + * they must never be converted into a worker that merely degrades at runtime. + */ +export function resolveServiceWorkerBuildInput(input: Readonly<{ + selection: InstalledServiceWorkerSelection | null; + assets: unknown; + contractSet: unknown; + runtimeConfig: unknown; + buildId: string; + releaseId: string; +}>): ServiceWorkerBuildInput { + if (input.selection?.mode !== "ACTIVE") { + throw new TypeError( + "Service Worker build requires an ACTIVE static selection.", + ); + } + if (!Array.isArray(input.selection.handlers)) { + throw new TypeError("Service Worker handlers must be an array."); + } + const handlers = new Set(); + for (const handler of input.selection.handlers) { + if ( + handler !== "PWA_STATIC_ASSETS" && + handler !== "OFFLINE_SYNC_WAKEUP" && + handler !== "WEB_PUSH" + ) { + throw new TypeError(`Unknown Service Worker handler: ${String(handler)}.`); + } + if (handlers.has(handler)) { + throw new TypeError(`Duplicate Service Worker handler: ${handler}.`); + } + handlers.add(handler); + } + if (handlers.has("WEB_PUSH")) { + throw new TypeError( + "WEB_PUSH requires an installed product-owned worker contribution.", + ); + } + const assets = parseAssets(input.assets); + if (assets.buildId !== input.buildId || assets.releaseId !== input.releaseId) { + throw new TypeError("Generated Service Worker asset identity is stale."); + } + const contractSet = record(input.contractSet); + const contractSetDigest = contractSet?.setDigest; + if (typeof contractSetDigest !== "string" || !DIGEST.test(contractSetDigest)) { + throw new TypeError("Generated contract set digest is invalid."); + } + const runtimeConfig = record(input.runtimeConfig); + const releaseManifestUrl = runtimeConfig?.RELEASE_MANIFEST_URL; + if ( + typeof releaseManifestUrl !== "string" || + releaseManifestUrl.length === 0 || + releaseManifestUrl.length > 2_048 + ) { + throw new TypeError("Runtime release manifest URL is invalid."); + } + return Object.freeze({ + assets, + handlers: Object.freeze([...handlers]), + contractSetDigest, + releaseManifestUrl, + }); +} + +function parseAssets(value: unknown): StaticAssetManifestV1 { + const candidate = record(value); + if ( + candidate?.schemaVersion !== 1 || + typeof candidate.buildId !== "string" || + typeof candidate.releaseId !== "string" || + typeof candidate.setDigest !== "string" || + !DIGEST.test(candidate.setDigest) || + !Array.isArray(candidate.assets) + ) { + throw new TypeError("Generated Service Worker asset manifest is invalid."); + } + return candidate as unknown as StaticAssetManifestV1; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/scripts/run-ci-gate.ts b/scripts/run-ci-gate.ts index d3d078f..2bfe8b0 100644 --- a/scripts/run-ci-gate.ts +++ b/scripts/run-ci-gate.ts @@ -8,12 +8,21 @@ import { isValidCommitSha, isValidSourceDateEpoch, } from "./lib/build-environment.ts"; +import { classifyGateStepResult } from "./lib/ci-step-result.ts"; -type GateStep = Readonly<{ +type GateStepBase = Readonly<{ script: string; args?: readonly string[]; - expect: "pass" | "fail"; + timeoutMs?: number; }>; +type GateStep = + | (GateStepBase & Readonly<{ expect: "pass" }>) + | (GateStepBase & + Readonly<{ + expect: "fail"; + expectedExitCode: number; + expectedDiagnosticId: string; + }>); type GateDefinition = Readonly<{ name: string; steps: readonly GateStep[]; @@ -40,6 +49,8 @@ if (!gateId || !gate) { const output: string[] = []; let passed = true; +const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000; +const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024; const gateEnvironment = { ...process.env }; if (gateEnvironment.CI === "true") { @@ -92,19 +103,49 @@ if (passed) { const result = spawnSync( "corepack", ["pnpm", step.script, ...(step.args ?? [])], - { encoding: "utf8", env: gateEnvironment }, + { + encoding: "utf8", + env: gateEnvironment, + timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS, + maxBuffer: MAX_STEP_OUTPUT_BYTES, + }, ); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; output.push( `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(), - result.stdout, - result.stderr, + stdout, + stderr, ); - const exitedSuccessfully = result.status === 0; - const expectationMet = - step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully; - if (!expectationMet) { + const expectation = + step.expect === "pass" + ? ({ kind: "pass" } as const) + : ({ + kind: "fail", + expectedExitCode: step.expectedExitCode, + expectedDiagnosticId: step.expectedDiagnosticId, + } as const); + const classification = classifyGateStepResult(expectation, { + status: result.status, + signal: result.signal, + stdout, + stderr, + ...(result.error + ? { error: { code: (result.error as NodeJS.ErrnoException).code } } + : {}), + }); + output.push(`classification: ${classification.kind}`); + if (!classification.expectationMet) { output.push( - `expectation failed: expected ${step.expect}, exit=${result.status}`, + `expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`, + ...(step.expect === "fail" + ? [ + `expected negative fixture identity: exit=${step.expectedExitCode}, diagnostic=${JSON.stringify(step.expectedDiagnosticId)}`, + ] + : []), + ...(classification.kind === "INFRASTRUCTURE_FAILURE" + ? [`infrastructure failure: ${classification.detail}`] + : []), ); passed = false; break; @@ -187,11 +228,61 @@ function parseGateSteps(value: unknown, gateId: string): GateStep[] { const args = candidate.args === undefined ? undefined - : parseStringArray(candidate.args, `${gateId}[${index}].args`); - return { + : parseStringArray(candidate.args, `${gateId}[${index}].args`); + const timeoutMs = candidate.timeoutMs; + if ( + timeoutMs !== undefined && + (typeof timeoutMs !== "number" || + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1_000 || + timeoutMs > 3_600_000) + ) { + throw new TypeError(`Invalid CI gate step timeout: ${gateId}[${index}]`); + } + const base = { script: candidate.script, - expect: candidate.expect, ...(args ? { args } : {}), + ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), + }; + if (candidate.expect === "pass") { + if ( + candidate.expectedExitCode !== undefined || + candidate.expectedDiagnosticId !== undefined + ) { + throw new TypeError( + `Passing CI gate step cannot declare failure identity: ${gateId}[${index}]`, + ); + } + return { ...base, expect: "pass" as const }; + } + if ( + typeof candidate.expectedExitCode !== "number" || + !Number.isSafeInteger(candidate.expectedExitCode) || + candidate.expectedExitCode < 1 || + candidate.expectedExitCode > 255 + ) { + throw new TypeError( + `Invalid expected failure exit code: ${gateId}[${index}]`, + ); + } + const expectedDiagnosticId = candidate.expectedDiagnosticId; + if ( + typeof expectedDiagnosticId !== "string" || + expectedDiagnosticId.trim().length === 0 || + expectedDiagnosticId.length > 256 || + ["\r", "\n", "\0"].some((character) => + expectedDiagnosticId.includes(character), + ) + ) { + throw new TypeError( + `Invalid expected failure diagnostic: ${gateId}[${index}]`, + ); + } + return { + ...base, + expect: "fail" as const, + expectedExitCode: candidate.expectedExitCode, + expectedDiagnosticId, }; }); } diff --git a/scripts/test-browser-file-storage-runtime-removal.ts b/scripts/test-browser-file-storage-runtime-removal.ts index f7afb68..7dc9f3b 100644 --- a/scripts/test-browser-file-storage-runtime-removal.ts +++ b/scripts/test-browser-file-storage-runtime-removal.ts @@ -47,6 +47,9 @@ const copyTargets = [ "tsconfig.node.json", "tsconfig.test.json", "tsconfig.recipes.json", + "tsconfig.web-worker.json", + "tsconfig.service-worker.json", + "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", diff --git a/scripts/test-optional-recipe-removal.ts b/scripts/test-optional-recipe-removal.ts index 30e03c3..dce00c1 100644 --- a/scripts/test-optional-recipe-removal.ts +++ b/scripts/test-optional-recipe-removal.ts @@ -28,6 +28,9 @@ const copyTargets = [ "tsconfig.node.json", "tsconfig.test.json", "tsconfig.recipes.json", + "tsconfig.web-worker.json", + "tsconfig.service-worker.json", + "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts index 815e46b..68c4c3f 100644 --- a/scripts/test-performance.ts +++ b/scripts/test-performance.ts @@ -82,13 +82,7 @@ try { }).observe({ type: "layout-shift", buffered: true }); }); await page.goto(baseUrl, { waitUntil: "networkidle" }); - const performanceRoutes: ReadonlyArray<{ - access: string; - navigationLabel: string | null; - }> = Object.values(ROUTE_REGISTRY); - const targetLabel = performanceRoutes.find( - (definition) => definition.access === "integration-defined", - )?.navigationLabel; + const targetLabel = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST.navigationLabel; if (!targetLabel) { throw new Error("Performance route must be present in navigation."); } diff --git a/scripts/test-realtime-runtime-removal.ts b/scripts/test-realtime-runtime-removal.ts index 3f7794d..cf87c80 100644 --- a/scripts/test-realtime-runtime-removal.ts +++ b/scripts/test-realtime-runtime-removal.ts @@ -49,6 +49,9 @@ const copyTargets = [ "tsconfig.node.json", "tsconfig.test.json", "tsconfig.recipes.json", + "tsconfig.web-worker.json", + "tsconfig.service-worker.json", + "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 1d8dffd..904dcc3 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -39,6 +39,9 @@ const copyTargets = [ "tsconfig.node.json", "tsconfig.test.json", "tsconfig.recipes.json", + "tsconfig.web-worker.json", + "tsconfig.service-worker.json", + "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", @@ -94,6 +97,23 @@ export function createInstalledFeatureInputs(_context: FeatureContext) { } `; +const emptyContractContributions = `import { + composeContractContributions, + type InstalledContractContribution, + type InstalledContractPackageIdentity, +} from "../contracts/external-contract-runtime.ts"; + +export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = + Object.freeze([]); + +export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( + INSTALLED_CONTRACT_CONTRIBUTIONS, +); + +export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageIdentity[] = + COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages; +`; + const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ "ko-KR": Object.freeze({}), "en-US": Object.freeze({}), @@ -170,6 +190,10 @@ await writeFile( path.join(fixtureRoot, "src/features/installed-feature-messages.ts"), emptyMessages, ); +await writeFile( + path.join(fixtureRoot, "src/features/installed-contract-contributions.ts"), + emptyContractContributions, +); const vitestConfigFile = path.join(fixtureRoot, "vitest.config.ts"); const vitestConfig = await readFile(vitestConfigFile, "utf8"); diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index 96ba7ad..a5b78cd 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -7,12 +7,23 @@ import { } from "../src/application/policies/compatibility.ts"; import { compareReleaseToRuntime, - RELEASE_TOKEN_REGISTRY, } from "../src/contracts/release-tokens.ts"; +import { + parseBuildManifestArtifact, + parseReleaseArtifact, + parseRuntimeConfigArtifact, + projectReleaseTokens, + type BuildManifestArtifact, + type ReleaseArtifact, + type RuntimeConfigArtifact, +} from "../src/contracts/release-artifacts.ts"; +import { verifyContractSet } from "../src/contracts/contract-set.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; import { ROUTE_REGISTRY, ROUTE_RUNTIME_CONTRACT, } from "../src/features/installed-feature-contracts.ts"; +import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; type CoherenceFixture = Readonly<{ name: string; @@ -20,23 +31,8 @@ type CoherenceFixture = Readonly<{ frontend: CompatibilityTuple; runtime: CompatibilityTuple; }>; -type ReleaseDocument = CompatibilityTuple & - Record & - Readonly<{ releaseId: string; routeChunks: Readonly> }>; -type RuntimeConfigDocument = Readonly<{ - BUILD_ID: string; - CONFIG_SCHEMA_VERSION: string; - API_CONTRACT_VERSION: string; - RELEASE_ID: string; -}>; -type BuildManifestDocument = Readonly< - Record & { - outputs?: Readonly<{ - runtimeConfigSchema?: unknown; - routeChunks?: Readonly>; - }>; - } ->; +type RuntimeConfigDocument = RuntimeConfigArtifact & + Readonly<{ BUILD_ID: string; RELEASE_ID: string }>; type ViteManifestEntry = Readonly<{ file: string; name?: string; @@ -54,9 +50,17 @@ const release = parseReleaseDocument( const runtimeConfig = parseRuntimeConfigDocument( JSON.parse(await readFile("dist/config.json", "utf8")), ); -const buildManifest = parseBuildManifestDocument( - JSON.parse(await readFile("artifacts/release/build-manifest.json", "utf8")), +const buildManifestDocument: unknown = JSON.parse( + await readFile("artifacts/release/build-manifest.json", "utf8"), ); +assertMatchesJsonSchema( + JSON.parse( + await readFile("schemas/artifacts/build-manifest.schema.json", "utf8"), + ), + buildManifestDocument, + "build manifest", +); +const buildManifest = parseBuildManifestDocument(buildManifestDocument); const runtimeConfigJsonSchema = requireRecord( JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")), "runtime config JSON schema", @@ -69,11 +73,20 @@ const actualAssetManifestHash = createHash("sha256") const artifactComparison = compareReleaseToRuntime(release, runtimeConfig); const artifactMismatches: string[] = [...artifactComparison.mismatches]; -for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) { - if (typeof release[token] !== "string" || release[token].length === 0) { +for (const [token, value] of Object.entries(projectReleaseTokens(release))) { + if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) { artifactMismatches.push(`releaseToken:${token}`); } } +if (release.schemaVersion === 2) { + const contractSetVerification = await verifyContractSet({ + expected: EXPECTED_CONTRACT_SET_PACKAGES, + manifest: release.contractSet, + }); + if (!contractSetVerification.ok) { + artifactMismatches.push(contractSetVerification.code); + } +} if ( typeof release.builtAt !== "string" || !Number.isFinite(Date.parse(release.builtAt)) @@ -91,19 +104,19 @@ if ( artifactMismatches.push("runtimeConfigSchema"); } if ( - buildManifest.outputs?.runtimeConfigSchema !== + buildManifest.outputs.runtimeConfigSchema !== "dist/runtime-config.schema.json" ) { artifactMismatches.push("buildManifest:runtimeConfigSchema"); } -for (const [buildToken, releaseToken] of [ - ["buildId", "buildId"], - ["commitSha", "commitSha"], - ["releaseId", "releaseId"], - ["generatedAt", "builtAt"], -]) { - if (buildManifest[buildToken] !== release[releaseToken]) { - artifactMismatches.push(`buildManifest:${buildToken}`); +for (const [token, buildValue, releaseValue] of [ + ["buildId", buildManifest.buildId, release.buildId], + ["commitSha", buildManifest.commitSha, release.commitSha], + ["releaseId", buildManifest.releaseId, release.releaseId], + ["generatedAt", buildManifest.generatedAt, release.builtAt], +] as const) { + if (buildValue !== releaseValue) { + artifactMismatches.push(`buildManifest:${token}`); } } @@ -134,7 +147,7 @@ for (const definition of Object.values(ROUTE_REGISTRY)) { continue; } if ( - buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset + buildManifest.outputs.routeChunks[definition.chunkId] !== routeAsset ) { artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`); } @@ -221,45 +234,21 @@ function parseFixturesDocument(value: unknown): Readonly<{ }; } -function parseReleaseDocument(value: unknown): ReleaseDocument { - const document = requireRecord(value, "release manifest"); - const tuple = parseCompatibilityTuple(document, "release manifest"); - const routeChunks = isRecord(document.routeChunks) - ? document.routeChunks - : {}; - return { ...document, ...tuple, routeChunks }; +function parseReleaseDocument(value: unknown): ReleaseArtifact { + return parseReleaseArtifact(value); } function parseRuntimeConfigDocument(value: unknown): RuntimeConfigDocument { - const document = requireRecord(value, "runtime config"); + const document = parseRuntimeConfigArtifact(value); return { + ...document, BUILD_ID: requireString(document.BUILD_ID, "runtime config BUILD_ID"), - CONFIG_SCHEMA_VERSION: requireString( - document.CONFIG_SCHEMA_VERSION, - "runtime config CONFIG_SCHEMA_VERSION", - ), - API_CONTRACT_VERSION: requireString( - document.API_CONTRACT_VERSION, - "runtime config API_CONTRACT_VERSION", - ), - RELEASE_ID: requireString( - document.RELEASE_ID, - "runtime config RELEASE_ID", - ), + RELEASE_ID: requireString(document.RELEASE_ID, "runtime config RELEASE_ID"), }; } -function parseBuildManifestDocument(value: unknown): BuildManifestDocument { - const document = requireRecord(value, "build manifest"); - const outputs = isRecord(document.outputs) - ? { - runtimeConfigSchema: document.outputs.runtimeConfigSchema, - routeChunks: isRecord(document.outputs.routeChunks) - ? document.outputs.routeChunks - : undefined, - } - : undefined; - return { ...document, ...(outputs ? { outputs } : {}) }; +function parseBuildManifestDocument(value: unknown): BuildManifestArtifact { + return parseBuildManifestArtifact(value); } function parseCompatibilityTuple(value: unknown, label: string): CompatibilityTuple { diff --git a/scripts/verify-supply-chain-artifacts.ts b/scripts/verify-supply-chain-artifacts.ts index 4f24fb6..a8ab245 100644 --- a/scripts/verify-supply-chain-artifacts.ts +++ b/scripts/verify-supply-chain-artifacts.ts @@ -8,6 +8,7 @@ import { supplyChainDigest, verifySupplyChainCoherence, } from "./lib/supply-chain.ts"; +import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; type Document = Record; @@ -48,6 +49,25 @@ const provenance = await readDocument("artifacts/release/provenance.json"); const verification = await readDocument( "artifacts/security/supply-chain-verification.json", ); +const artifactSchemaFailures: string[] = []; +for (const [schemaPath, artifact, label] of [ + [ + "schemas/artifacts/dependency-inventory.schema.json", + inventory, + "dependency inventory", + ], + [ + "schemas/artifacts/supply-chain-verification.schema.json", + verification, + "supply-chain verification", + ], +] as const) { + try { + assertMatchesJsonSchema(await readDocument(schemaPath), artifact, label); + } catch { + artifactSchemaFailures.push(`${label} JSON Schema mismatch`); + } +} const lockfileText = await readFile("pnpm-lock.yaml", "utf8"); const lockfileSha256 = createHash("sha256") .update(lockfileText) @@ -69,7 +89,7 @@ const coherence = verifySupplyChainCoherence( provenance, distDigest, ); -const failures: string[] = [...coherence.failures]; +const failures: string[] = [...artifactSchemaFailures, ...coherence.failures]; if ( inventory.lockfileSha256 !== lockfileSha256 || verification.lockfileSha256 !== lockfileSha256 diff --git a/src/adapters/cross-context-invalidation/browser-cross-context-host.ts b/src/adapters/cross-context-invalidation/browser-cross-context-host.ts index 68a8580..e1c2ca0 100644 --- a/src/adapters/cross-context-invalidation/browser-cross-context-host.ts +++ b/src/adapters/cross-context-invalidation/browser-cross-context-host.ts @@ -41,6 +41,9 @@ const STORAGE_PULSE_KEY = export function createBrowserCrossContextInvalidationFromHost( dependencies: BrowserCrossContextHostDependencies, ): BrowserCrossContextInvalidation | undefined { + // A zero-feature build owns no cross-context invalidation runtime. Preserve + // that property strictly: do not even probe browser capability getters. + if (dependencies.topics.length === 0) return undefined; const host = dependencies.host ?? (globalThis as unknown as Record); diff --git a/src/adapters/http/bounded-body-reader.ts b/src/adapters/http/bounded-body-reader.ts new file mode 100644 index 0000000..90e5680 --- /dev/null +++ b/src/adapters/http/bounded-body-reader.ts @@ -0,0 +1,160 @@ +/** + * §7.9–§7.10. Common bounded body reader. + * + * `bounded-json.ts` stays the V2 reader for already-migrated callers; this + * module adds the byte-level primitives execution V3 needs: a raw bounded read, + * a strict media-type check and the `NONE` one-byte probe. + */ + +export type BoundedBytesOutcome = + | Readonly<{ ok: true; bytes: Uint8Array }> + | Readonly<{ ok: false; code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE" }>; + +export type BodyProbeOutcome = + | Readonly<{ ok: true; present: boolean }> + | Readonly<{ ok: false; code: "RESPONSE_STREAM_FAILURE" }>; + +/** Essence match: `application/json` or any `*+json` subtype. */ +export function isJsonMediaType(headerValue: string | null): boolean { + if (!headerValue) return false; + const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return essence === "application/json" || essence.endsWith("+json"); +} + +export function declaredContentLength(response: Response): number | null { + const raw = response.headers.get("content-length"); + if (raw === null) return null; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? value : null; +} + +export async function readBoundedBytes( + response: Response, + maximumBytes: number, +): Promise { + const declared = declaredContentLength(response); + if (declared !== null && declared > maximumBytes) { + await cancelBody(response); + return failure("RESPONSE_TOO_LARGE"); + } + if (!response.body) { + return Object.freeze({ ok: true as const, bytes: new Uint8Array(0) }); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + if (!next.value) continue; + total += next.value.byteLength; + if (total > maximumBytes) { + await reader.cancel().catch(() => {}); + return failure("RESPONSE_TOO_LARGE"); + } + chunks.push(next.value); + } + } catch { + await reader.cancel().catch(() => {}); + return failure("RESPONSE_STREAM_FAILURE"); + } finally { + try { + reader.releaseLock(); + } catch { + // A cancelled reader has already released its lock. + } + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return Object.freeze({ ok: true as const, bytes }); +} + +/** + * §7.10 `NONE`. A declared positive length is an immediate violation. Otherwise + * at most one byte is probed: the descriptor does not permit an unexpected + * body, so the runtime never drains an arbitrary amount to find out. + */ +export async function probeForbiddenBody( + response: Response, +): Promise { + const declared = declaredContentLength(response); + if (declared !== null && declared > 0) { + await cancelBody(response); + return Object.freeze({ ok: true as const, present: true }); + } + if (!response.body) { + return Object.freeze({ ok: true as const, present: false }); + } + + const reader = response.body.getReader(); + try { + const next = await reader.read(); + if (next.done || !next.value || next.value.byteLength === 0) { + return Object.freeze({ ok: true as const, present: false }); + } + await reader.cancel().catch(() => {}); + return Object.freeze({ ok: true as const, present: true }); + } catch { + await reader.cancel().catch(() => {}); + return Object.freeze({ + ok: false as const, + code: "RESPONSE_STREAM_FAILURE" as const, + }); + } finally { + try { + reader.releaseLock(); + } catch { + // Already released by cancel(). + } + } +} + +export type DecodedJson = + | Readonly<{ ok: true; value: unknown }> + | Readonly<{ ok: false; code: "UTF8_INVALID" | "JSON_INVALID" }>; + +export function decodeJsonBytes(bytes: Uint8Array): DecodedJson { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return Object.freeze({ ok: false as const, code: "UTF8_INVALID" as const }); + } + try { + return Object.freeze({ ok: true as const, value: JSON.parse(text) }); + } catch { + return Object.freeze({ ok: false as const, code: "JSON_INVALID" as const }); + } +} + +export function isEffectivelyEmpty(bytes: Uint8Array): boolean { + if (bytes.byteLength === 0) return true; + for (const byte of bytes) { + // Space, tab, LF, CR are the only permitted "empty" filler. + if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) { + return false; + } + } + return true; +} + +async function cancelBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Cancelling an already-settled body is not itself a failure. + } +} + +function failure( + code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE", +): BoundedBytesOutcome { + return Object.freeze({ ok: false as const, code }); +} diff --git a/src/adapters/http/http-contract-bridge.ts b/src/adapters/http/http-contract-bridge.ts new file mode 100644 index 0000000..eadede6 --- /dev/null +++ b/src/adapters/http/http-contract-bridge.ts @@ -0,0 +1,288 @@ +import { + HTTP_EXECUTION_CEILINGS, + type InstalledHttpContract, +} from "../../contracts/external-contract-runtime.ts"; + +/** + * §7.4–§7.7. Descriptor-driven request projection. + * + * Nothing here re-derives operation semantics. The package descriptor supplies + * path values, query entry order and the body value; this module only encodes, + * bounds and re-verifies them. + */ + +export type CredentialPatchOutcome = + | Readonly<{ + kind: "READY"; + headers: Readonly>; + credentials: RequestCredentials; + }> + | Readonly<{ kind: "UNAUTHENTICATED" }> + | Readonly<{ kind: "UNAVAILABLE" }> + | Readonly<{ kind: "SCOPE_FENCED" }>; + +/** §7.7. The complete set of headers a credential bridge may contribute. */ +export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet = new Set([ + "authorization", + "x-csrf-token", + "x-tenant-context", +]); + +const FORBIDDEN_REQUEST_HEADERS: ReadonlySet = new Set([ + "host", + "origin", + "referer", + "cookie", + "content-length", + "connection", + "transfer-encoding", + "upgrade", +]); + +export type RequestProjectionFailure = + | "PROJECTION_RUNTIME_FAILURE" + | "PROJECTION_INVALID" + | "PATH_PLACEHOLDER_MISSING" + | "PATH_VALUE_INVALID" + | "QUERY_TOO_LARGE" + | "URL_TOO_LARGE" + | "URL_ORIGIN_ESCAPED" + | "REQUEST_BODY_TOO_LARGE" + | "REQUEST_BODY_UNENCODABLE" + | "REQUEST_BODY_UNEXPECTED"; + +export type ProjectedRequest = Readonly<{ + url: string; + method: string; + bodyBytes: Uint8Array | null; +}>; + +export type RequestProjectionOutcome = + | Readonly<{ ok: true; request: ProjectedRequest }> + | Readonly<{ ok: false; failure: RequestProjectionFailure }>; + +const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9_]*)\}|:([A-Za-z][A-Za-z0-9_]*)/g; + +const encoder = new TextEncoder(); + +/** + * §7.5. Built from the validated base URL and the descriptor's named values. + * Segments are encoded exactly once; a raw slash inside a value is rejected + * rather than silently creating a new path segment. + */ +export function projectRequest( + installed: InstalledHttpContract, + input: Input, + baseUrl: string, +): RequestProjectionOutcome { + try { + return projectRequestChecked(installed, input, baseUrl); + } catch { + return failure("PROJECTION_RUNTIME_FAILURE"); + } +} + +function projectRequestChecked( + installed: InstalledHttpContract, + input: Input, + baseUrl: string, +): RequestProjectionOutcome { + const contract = installed.contract; + const projection = contract.projectRequest(input); + if (!isValidProjection(projection)) return failure("PROJECTION_INVALID"); + + let missing = false; + let invalid = false; + const path = contract.pathTemplate.replace( + PLACEHOLDER, + (_match, braced?: string, colon?: string) => { + const name = braced ?? colon ?? ""; + const value = projection.pathValues[name]; + if (typeof value !== "string" || value.length === 0) { + missing = true; + return ""; + } + if (value.includes("/") || value.includes("\\")) { + invalid = true; + return ""; + } + return encodeURIComponent(value); + }, + ); + if (missing) return failure("PATH_PLACEHOLDER_MISSING"); + if (invalid) return failure("PATH_VALUE_INVALID"); + + const base = new URL(baseUrl); + const url = new URL(path.replace(/^\/+/, ""), base); + if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) { + return failure("URL_ORIGIN_ESCAPED"); + } + + const search = new URLSearchParams(); + for (const [key, value] of projection.queryEntries) { + search.append(key, value); + } + const encodedQuery = search.toString(); + if ( + encoder.encode(encodedQuery).byteLength > + HTTP_EXECUTION_CEILINGS.encodedQueryBytes + ) { + return failure("QUERY_TOO_LARGE"); + } + url.search = encodedQuery; + + if (encoder.encode(url.href).byteLength > HTTP_EXECUTION_CEILINGS.finalUrlBytes) { + return failure("URL_TOO_LARGE"); + } + + let bodyBytes: Uint8Array | null = null; + if (contract.requestBody === "JSON") { + let encoded: string; + try { + encoded = JSON.stringify(projection.body); + } catch { + return failure("REQUEST_BODY_UNENCODABLE"); + } + if (typeof encoded !== "string") { + return failure("REQUEST_BODY_UNENCODABLE"); + } + bodyBytes = encoder.encode(encoded); + if (bodyBytes.byteLength > installed.frontend.requestByteLimit) { + return failure("REQUEST_BODY_TOO_LARGE"); + } + } else if (projection.body !== null && projection.body !== undefined) { + return failure("REQUEST_BODY_UNEXPECTED"); + } + + return Object.freeze({ + ok: true as const, + request: Object.freeze({ + url: url.href, + method: contract.method, + bodyBytes, + }), + }); +} + +function isValidProjection( + value: unknown, +): value is ReturnType< + InstalledHttpContract["contract"]["projectRequest"] +> { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + const keys = Object.keys(candidate).sort(); + if (keys.join("|") !== "body|pathValues|queryEntries") return false; + + const pathValues = candidate.pathValues; + if (!pathValues || typeof pathValues !== "object" || Array.isArray(pathValues)) { + return false; + } + const pathPrototype = Object.getPrototypeOf(pathValues); + if (pathPrototype !== Object.prototype && pathPrototype !== null) return false; + const pathEntries = Object.entries(pathValues as Record); + if (pathEntries.length > 32) return false; + for (const [key, pathValue] of pathEntries) { + if ( + !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) || + typeof pathValue !== "string" || + pathValue.length === 0 || + encoder.encode(pathValue).byteLength > HTTP_EXECUTION_CEILINGS.pathTemplateBytes + ) { + return false; + } + } + + const queryEntries = candidate.queryEntries; + if (!Array.isArray(queryEntries) || queryEntries.length > 256) return false; + for (const entry of queryEntries) { + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== "string" || + typeof entry[1] !== "string" + ) { + return false; + } + } + return true; +} + +export type FinalInvariantInput = Readonly<{ + request: ProjectedRequest; + expectedMethod: string; + baseUrl: string; + init: RequestInit; + headers: Readonly>; + requestByteLimit: number; + deadlineRemainingMs: number; + scopeIsCurrent: boolean; +}>; + +export type FinalInvariantFailure = + | "METHOD_CHANGED" + | "URL_NOT_ALLOWED" + | "REDIRECT_MODE_INVALID" + | "CREDENTIALS_MODE_INVALID" + | "HEADER_NOT_ALLOWED" + | "FORBIDDEN_HEADER" + | "REQUEST_BODY_TOO_LARGE" + | "DEADLINE_EXPIRED" + | "SCOPE_FENCED"; + +/** + * §7.4. Runs after the credential patch and immediately before dispatch. A + * failure here means `fetch()` is called zero times, so a defective auth + * adapter can never alter the method, target or transport policy. + */ +export function checkFinalInvariants( + input: FinalInvariantInput, +): FinalInvariantFailure | null { + if (input.request.method !== input.expectedMethod) return "METHOD_CHANGED"; + + const base = new URL(input.baseUrl); + let target: URL; + try { + target = new URL(input.request.url); + } catch { + return "URL_NOT_ALLOWED"; + } + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname)) { + return "URL_NOT_ALLOWED"; + } + if (input.init.redirect !== "error") return "REDIRECT_MODE_INVALID"; + if ( + input.init.credentials !== "omit" && + input.init.credentials !== "same-origin" && + input.init.credentials !== "include" + ) { + return "CREDENTIALS_MODE_INVALID"; + } + + for (const name of Object.keys(input.headers)) { + const lower = name.toLowerCase(); + if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER"; + if ( + lower !== "accept" && + lower !== "content-type" && + lower !== "idempotency-key" && + !ALLOWED_CREDENTIAL_HEADERS.has(lower) + ) { + return "HEADER_NOT_ALLOWED"; + } + } + + if ( + input.request.bodyBytes && + input.request.bodyBytes.byteLength > input.requestByteLimit + ) { + return "REQUEST_BODY_TOO_LARGE"; + } + if (input.deadlineRemainingMs <= 0) return "DEADLINE_EXPIRED"; + if (!input.scopeIsCurrent) return "SCOPE_FENCED"; + return null; +} + +function failure(failureKind: RequestProjectionFailure): RequestProjectionOutcome { + return Object.freeze({ ok: false as const, failure: failureKind }); +} diff --git a/src/adapters/http/http-effect-certainty.ts b/src/adapters/http/http-effect-certainty.ts new file mode 100644 index 0000000..af8d282 --- /dev/null +++ b/src/adapters/http/http-effect-certainty.ts @@ -0,0 +1,151 @@ +import type { + CommandEffectDescriptor, + CommandEffectClassification, +} from "../../contracts/external-contract-runtime.ts"; + +/** + * §8.7–§8.9. Mutation effect certainty. + * + * The frontend never infers "not applied" from an HTTP status alone. Anything + * observed after the request was dispatched but before a classified terminal + * response is `MAYBE_APPLIED`, which forbids automatic resend. + */ + +export type MutationEffectCertainty = + | "NOT_STARTED" + | "NOT_APPLIED" + | "MAYBE_APPLIED" + | "APPLIED_CONFIRMED"; + +export type PhysicalAttemptState = + | "NOT_STARTED" + | "PREPARING" + | "READY_TO_SEND" + | "DISPATCHED" + | "RESPONSE_HEADERS" + | "READING_BODY" + | "VALIDATING" + | "MAPPING_READY" + | "SETTLED"; + +/** + * `READY_TO_SEND` is recorded immediately before entering the `fetch()` + * invocation expression and `DISPATCHED` immediately after the promise is + * returned. A synchronous throw therefore leaves the attempt `NOT_STARTED`. + */ +export function certaintyForAbandonedAttempt( + state: PhysicalAttemptState, + isCommand: boolean, +): MutationEffectCertainty { + if (!isCommand) return "NOT_STARTED"; + switch (state) { + case "NOT_STARTED": + case "PREPARING": + case "READY_TO_SEND": + return "NOT_STARTED"; + default: + return "MAYBE_APPLIED"; + } +} + +export type ProblemEffectInput = Readonly<{ + status: number; + problem: Problem; + descriptor: CommandEffectDescriptor | null; +}>; + +export type ProblemEffectOutcome = Readonly<{ + effect: MutationEffectCertainty; + contractRuntimeFailure: boolean; +}>; + +const CLASSIFICATIONS: ReadonlySet = new Set([ + "NOT_APPLIED", + "APPLIED_CONFIRMED", + "MAYBE_APPLIED", +]); + +/** + * §4.4. The classifier is package-owned and pure. A throw or an unrecognised + * return value fails safe to `MAYBE_APPLIED` and is recorded as a contract + * runtime failure rather than being silently treated as "not applied". + */ +export function classifyProblemEffect( + input: ProblemEffectInput, +): ProblemEffectOutcome { + if (!input.descriptor) { + // A read operation carries no command effect; there is nothing to apply. + return Object.freeze({ + effect: "NOT_STARTED" as const, + contractRuntimeFailure: false, + }); + } + let classification: CommandEffectClassification; + try { + classification = input.descriptor.classifyProblem({ + status: input.status, + problem: input.problem, + }); + } catch { + return Object.freeze({ + effect: "MAYBE_APPLIED" as const, + contractRuntimeFailure: true, + }); + } + if (!CLASSIFICATIONS.has(classification)) { + return Object.freeze({ + effect: "MAYBE_APPLIED" as const, + contractRuntimeFailure: true, + }); + } + return Object.freeze({ + effect: classification, + contractRuntimeFailure: false, + }); +} + +export type MutationIntent = Readonly<{ + intentId: string; + operationId: string; + canonicalInputIdentity: string; + idempotencyKey?: string; + createdAtMonotonicMs: number; +}>; + +export type MutationIntentContext = Readonly<{ + intentId: string; + idempotencyKey?: string; + startedBy: "USER" | "FOREGROUND_RETRY" | "OUTBOX_REPLAY"; +}>; + +export function createMutationIntent( + input: Readonly<{ + operationId: string; + canonicalInputIdentity: string; + idempotencyKey?: string; + monotonicNow?: () => number; + }>, +): MutationIntent { + const now = input.monotonicNow ?? (() => performance.now()); + return Object.freeze({ + intentId: crypto.randomUUID(), + operationId: input.operationId, + canonicalInputIdentity: input.canonicalInputIdentity, + ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}), + createdAtMonotonicMs: now(), + }); +} + +/** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */ +export function projectCertaintyToUi( + certainty: MutationEffectCertainty, +): "RETRYABLE" | "CHECK_STATUS" | "SUCCESS" { + switch (certainty) { + case "APPLIED_CONFIRMED": + return "SUCCESS"; + case "MAYBE_APPLIED": + return "CHECK_STATUS"; + default: + return "RETRYABLE"; + } +} diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts new file mode 100644 index 0000000..227c237 --- /dev/null +++ b/src/adapters/http/http-execution-v3.ts @@ -0,0 +1,1095 @@ +import { + HTTP_EXECUTION_CEILINGS, + invokeValidator, + type InstalledHttpContract, +} from "../../contracts/external-contract-runtime.ts"; +import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts"; +import { + decodeJsonBytes, + isEffectivelyEmpty, + isJsonMediaType, + probeForbiddenBody, + readBoundedBytes, +} from "./bounded-body-reader.ts"; +import { + checkFinalInvariants, + projectRequest, + type CredentialPatchOutcome, +} from "./http-contract-bridge.ts"; +import { + certaintyForAbandonedAttempt, + classifyProblemEffect, + type MutationIntentContext, + type PhysicalAttemptState, +} from "./http-effect-certainty.ts"; +import { parseRetryAfter } from "./retry-policy.ts"; + +/** + * §7–§8. Descriptor-driven HTTP execution. + * + * This is not a general purpose HTTP client. A caller passes an installed + * contract and typed input; the runtime owns bounds, the total deadline, the + * single retry authority and the effect-certainty verdict. + */ + +export type SafeResponseMetadata = Readonly<{ + status: number; + correlationReference?: string; + retryAfterMs?: number; + validatorToken?: string; +}>; + +export type HttpContractViolationKind = + | "UNEXPECTED_STATUS" + | "UNEXPECTED_EMPTY_BODY" + | "UNEXPECTED_BODY" + | "CONTENT_TYPE_MISMATCH" + | "RESPONSE_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SUCCESS_SCHEMA_INVALID" + | "PROBLEM_SCHEMA_INVALID" + | "VALIDATOR_RUNTIME_FAILURE" + | "MAPPING_CONTRACT_VIOLATION" + | "FINAL_REQUEST_INVARIANT_FAILED" + | "SCOPE_FENCED"; + +export type HttpContractViolation = Readonly<{ + kind: HttpContractViolationKind; + operation: "REQUEST" | "RESPONSE" | "VALIDATION" | "MAPPING"; +}>; + +export type HttpEffectCertainty = + | "NOT_APPLICABLE" + | "NOT_STARTED" + | "NOT_APPLIED" + | "APPLIED_CONFIRMED" + | "MAYBE_APPLIED"; + +export type HttpTransportFailure = Readonly<{ + kind: + | "DNS_OR_CONNECT_FAILURE" + | "TLS_OR_NETWORK_FAILURE" + | "NETWORK_FAILURE" + | "TIMEOUT" + | "RESPONSE_STREAM_FAILURE" + | "ABORTED_BY_SCOPE" + | "ABORTED_BY_CALLER" + | "OVERLOADED_BEFORE_SEND"; + retryable: boolean; +}>; + +export type HttpExecutionOutcome = + | Readonly<{ + kind: "SUCCESS"; + value: Value; + metadata: SafeResponseMetadata; + effect: "NOT_APPLICABLE" | "APPLIED_CONFIRMED"; + }> + | Readonly<{ + kind: "PROBLEM"; + problem: Problem; + metadata: SafeResponseMetadata; + effect: "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "UNAUTHENTICATED"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "FORBIDDEN"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "RATE_LIMITED"; + retryAfterMs?: number; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CONTRACT_VIOLATION"; + violation: HttpContractViolation; + effect: HttpEffectCertainty; + }> + | Readonly<{ + kind: "TRANSPORT_FAILURE"; + failure: HttpTransportFailure; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CANCELLED"; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }>; + +export type CancellationOwner = + | "CALLER" + | "ROUTE_TRANSITION" + | "SCOPE_FENCE" + | "APPLICATION_SHUTDOWN" + | "DEADLINE"; + +export interface HttpExecutionContext { + readonly signal?: AbortSignal; + readonly scope: CacheScopeSnapshot; + readonly intent?: MutationIntentContext; +} + +export interface ContractHttpExecutor { + execute( + operation: InstalledHttpContract, + input: Input, + context: HttpExecutionContext, + ): Promise>; +} + +export type HttpExecutionObservation = Readonly<{ + diagnosticsOperation: string; + outcome: string; + attempts: number; + certainty: string; +}>; + +export type ContractHttpExecutorDependencies = Readonly<{ + baseUrl: string; + /** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */ + maxRetryAttempts: number; + attachCredentials( + operation: Readonly<{ + operationId: string; + authProfileId: string; + method: string; + }>, + ): Promise | CredentialPatchOutcome; + fetcher?: typeof fetch; + monotonicNow?: () => number; + sleep?: (ms: number, signal: AbortSignal) => Promise; + random?: () => number; + observe?: (observation: HttpExecutionObservation) => void; +}>; + +const RETRYABLE_STATUSES: ReadonlySet = new Set([ + 408, 425, 429, 502, 503, 504, +]); + +const RETRY_BASE_DELAY_MS = 250; +const RETRY_MAX_LOCAL_DELAY_MS = 2_000; +const RETRY_AFTER_CEILING_MS = 5_000; + +export function createContractHttpExecutor( + dependencies: ContractHttpExecutorDependencies, +): ContractHttpExecutor { + const fetcher = dependencies.fetcher ?? fetch; + const now = dependencies.monotonicNow ?? (() => performance.now()); + const random = dependencies.random ?? Math.random; + const sleep = + dependencies.sleep ?? + ((ms: number, signal: AbortSignal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + })); + + async function execute( + operation: InstalledHttpContract, + input: Input, + context: HttpExecutionContext, + ): Promise> { + const contract = operation.contract; + const policy = operation.frontend; + const isCommand = contract.commandEffect !== null; + + // §8.5. One monotonic deadline covers credential resolution, encoding, + // backoff, every physical attempt, body read and validation. + const deadlineAt = now() + policy.totalDeadlineMs; + const remaining = () => deadlineAt - now(); + + let attemptState: PhysicalAttemptState = "PREPARING"; + let attempts = 0; + let terminalCancellation: CancellationOwner | null = null; + const lifetimeController = new AbortController(); + const forwardCallerToLifetime = () => { + terminalCancellation ??= "CALLER"; + lifetimeController.abort(); + }; + const forwardScopeToLifetime = () => { + terminalCancellation ??= "SCOPE_FENCE"; + lifetimeController.abort(); + }; + const callerSignal = + context.signal === context.scope.signal ? undefined : context.signal; + if (callerSignal?.aborted) forwardCallerToLifetime(); + else { + callerSignal?.addEventListener("abort", forwardCallerToLifetime, { + once: true, + }); + } + if (context.scope.signal.aborted) forwardScopeToLifetime(); + else { + context.scope.signal.addEventListener("abort", forwardScopeToLifetime, { + once: true, + }); + } + const lifetimeDeadlineTimer = setTimeout(() => { + terminalCancellation ??= "DEADLINE"; + lifetimeController.abort(); + }, policy.totalDeadlineMs); + let lifetimeDisposed = false; + const disposeLifetime = () => { + if (lifetimeDisposed) return; + lifetimeDisposed = true; + clearTimeout(lifetimeDeadlineTimer); + callerSignal?.removeEventListener("abort", forwardCallerToLifetime); + context.scope.signal.removeEventListener("abort", forwardScopeToLifetime); + lifetimeController.abort(); + }; + + const finish = ( + outcome: HttpExecutionOutcome, + certainty: string, + ): HttpExecutionOutcome => { + disposeLifetime(); + try { + dependencies.observe?.({ + diagnosticsOperation: policy.diagnosticsOperation, + outcome: outcome.kind, + attempts, + certainty, + }); + } catch { + // Observation is outside the execution authority. + } + return outcome; + }; + + try { + + // §7.4 step 1-2: capture the scope and verify it is still current. + if (!context.scope.isCurrent()) { + return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED"); + } + + const validated = invokeValidator(contract.inputValidator, input); + if (validated.outcome === "THROWN") { + return finish( + violation( + "VALIDATOR_RUNTIME_FAILURE", + "VALIDATION", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + if (validated.outcome === "INVALID") { + return finish( + violation( + "FINAL_REQUEST_INVARIANT_FAILED", + "REQUEST", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + + const projected = projectRequest( + operation, + validated.value, + dependencies.baseUrl, + ); + if (!projected.ok) { + return finish( + violation( + "FINAL_REQUEST_INVARIANT_FAILED", + "REQUEST", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + + // §7.7 / §8.4. Credentials are resolved before send. A response 401 is + // terminal; there is no hidden refresh-and-replay. + let patchOperation: Promise; + try { + patchOperation = Promise.resolve( + dependencies.attachCredentials({ + operationId: contract.operationId, + authProfileId: policy.authProfileId, + method: contract.method, + }), + ); + } catch { + return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED"); + } + const patchResult = await awaitWithAbort( + patchOperation, + lifetimeController.signal, + ); + if (patchResult === ABORTED) { + if (terminalCancellation === "SCOPE_FENCE") { + return finish( + transportFailure( + "ABORTED_BY_SCOPE", + false, + attemptState, + isCommand, + ), + "SCOPE_FENCED", + ); + } + return terminalCancellation === "CALLER" + ? finish(cancelled("NOT_STARTED"), "CANCELLED") + : finish( + transportFailure( + "TIMEOUT", + false, + attemptState, + isCommand, + ), + "TIMEOUT", + ); + } + const patch = patchResult; + if (patch.kind === "SCOPE_FENCED") { + return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED"); + } + if (patch.kind !== "READY") { + // A missing credential never downgrades into an anonymous request. + return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED"); + } + + const headers: Record = { + Accept: "application/json", + ...patch.headers, + }; + if (contract.requestBody === "JSON") { + headers["Content-Type"] = "application/json"; + } + if (context.intent?.idempotencyKey) { + headers["Idempotency-Key"] = context.intent.idempotencyKey; + } + + const retryCeiling = Math.min( + policy.retryBudget, + dependencies.maxRetryAttempts, + HTTP_EXECUTION_CEILINGS.hardRetryCount, + ); + + for (let retryIndex = 0; ; retryIndex += 1) { + if (callerSignal?.aborted) { + terminalCancellation ??= "CALLER"; + return finish( + cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)), + "CANCELLED", + ); + } + if (!context.scope.isCurrent()) { + terminalCancellation ??= "SCOPE_FENCE"; + return finish( + scopeFenced(contractViolationEffect(attemptState, isCommand)), + "SCOPE_FENCED", + ); + } + + const controller = new AbortController(); + const budget = remaining(); + if (budget <= 0) { + return finish( + transportFailure("TIMEOUT", false, attemptState, isCommand), + "TIMEOUT", + ); + } + const deadlineTimer = setTimeout(() => { + terminalCancellation ??= "DEADLINE"; + controller.abort(); + }, budget); + const forwardLifetime = () => { + controller.abort(); + }; + if (lifetimeController.signal.aborted) forwardLifetime(); + else { + lifetimeController.signal.addEventListener("abort", forwardLifetime, { + once: true, + }); + } + + const init: RequestInit = { + method: contract.method, + headers, + redirect: "error", + referrerPolicy: "no-referrer", + credentials: patch.credentials, + cache: "no-store", + signal: controller.signal, + ...(projected.request.bodyBytes + ? { body: projected.request.bodyBytes.slice() } + : {}), + }; + + const invariantFailure = checkFinalInvariants({ + request: projected.request, + expectedMethod: contract.method, + baseUrl: dependencies.baseUrl, + init, + headers, + requestByteLimit: policy.requestByteLimit, + deadlineRemainingMs: remaining(), + scopeIsCurrent: context.scope.isCurrent(), + }); + if (invariantFailure) { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener("abort", forwardLifetime); + return finish( + invariantFailure === "SCOPE_FENCED" + ? scopeFenced(preDispatchEffect(isCommand)) + : violation( + "FINAL_REQUEST_INVARIANT_FAILED", + "REQUEST", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + + let response: Response; + attemptState = "READY_TO_SEND"; + try { + attempts += 1; + const pending = fetcher(projected.request.url, init); + attemptState = "DISPATCHED"; + response = await pending; + attemptState = "RESPONSE_HEADERS"; + } catch { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener("abort", forwardLifetime); + const owner = terminalCancellation; + if (owner === "CALLER") { + return finish( + cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)), + "CANCELLED", + ); + } + if (owner === "SCOPE_FENCE") { + return finish( + transportFailure( + "ABORTED_BY_SCOPE", + false, + attemptState, + isCommand, + ), + "SCOPE_FENCED", + ); + } + const kind = owner === "DEADLINE" ? "TIMEOUT" : "NETWORK_FAILURE"; + if ( + canRetryTransport(contract.retrySemantics, attemptState) && + retryIndex < retryCeiling && + remaining() > 0 + ) { + const delay = jitteredDelay(retryIndex, random); + if (delay < remaining()) { + const slept = await awaitWithAbort( + sleep(delay, lifetimeController.signal), + lifetimeController.signal, + ); + if (slept === ABORTED) { + return terminalCancellation === "CALLER" + ? finish( + cancelled( + certaintyForAbandonedAttempt(attemptState, isCommand), + ), + "CANCELLED", + ) + : finish( + transportFailure( + "TIMEOUT", + false, + attemptState, + isCommand, + ), + "TIMEOUT", + ); + } + continue; + } + } + return finish( + transportFailure(kind, false, attemptState, isCommand), + kind, + ); + } + + try { + const outcome = await admitResponse( + operation, + response, + context, + attemptState, + ); + attemptState = "SETTLED"; + if ( + outcome.retryHint && + retryIndex < retryCeiling && + isRetryableSemantics(contract.retrySemantics) + ) { + const delay = retryDelayFor(outcome.retryAfterMs, retryIndex, random); + if (delay !== null && delay < remaining()) { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener( + "abort", + forwardLifetime, + ); + const slept = await awaitWithAbort( + sleep(delay, lifetimeController.signal), + lifetimeController.signal, + ); + if (slept === ABORTED) { + return terminalCancellation === "CALLER" + ? finish( + cancelled( + certaintyForAbandonedAttempt(attemptState, isCommand), + ), + "CANCELLED", + ) + : finish( + transportFailure( + "TIMEOUT", + false, + attemptState, + isCommand, + ), + "TIMEOUT", + ); + } + continue; + } + } + return finish(outcome.result, outcome.certainty); + } finally { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener("abort", forwardLifetime); + } + } + } catch { + return finish( + transportFailure("NETWORK_FAILURE", false, attemptState, isCommand), + "RUNTIME_FAILURE", + ); + } finally { + disposeLifetime(); + } + } + + return Object.freeze({ execute }); +} + +type AdmissionOutcome = Readonly<{ + result: HttpExecutionOutcome; + certainty: string; + retryHint: boolean; + retryAfterMs: number | null; +}>; + +/** + * §7.9. Admission order is fixed: redirect, status allowlist, media type, + * declared length, bounded stream, body policy, decode, validate, scope fence. + * `response.ok` is never used on its own. + */ +async function admitResponse( + operation: InstalledHttpContract, + response: Response, + context: HttpExecutionContext, + attemptState: PhysicalAttemptState, +): Promise> { + const contract = operation.contract; + const policy = operation.frontend; + const isCommand = contract.commandEffect !== null; + const status = response.status; + const retryAfterMs = parseRetryAfter(response.headers.get("retry-after")); + + if (response.redirected || response.type === "opaqueredirect") { + await response.body?.cancel().catch(() => {}); + return settled( + violation( + "UNEXPECTED_STATUS", + "RESPONSE", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + + const metadata = safeMetadata(response, retryAfterMs); + + if (!contract.acceptedStatuses.includes(status)) { + if (status === 401) { + await response.body?.cancel().catch(() => {}); + return settled( + unauthenticated( + certaintyForAbandonedAttempt(attemptState, isCommand), + isCommand, + ), + "UNAUTHENTICATED", + ); + } + if (status === 403) { + await response.body?.cancel().catch(() => {}); + return settled( + forbidden(certaintyForAbandonedAttempt(attemptState, isCommand), isCommand), + "FORBIDDEN", + ); + } + if (status === 429) { + await response.body?.cancel().catch(() => {}); + return Object.freeze({ + result: Object.freeze({ + kind: "RATE_LIMITED" as const, + ...(retryAfterMs === null ? {} : { retryAfterMs }), + effect: certaintyForAbandonedAttempt( + attemptState, + isCommand, + ) as "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED", + }), + certainty: "RATE_LIMITED", + retryHint: true, + retryAfterMs, + }); + } + return admitProblem(operation, response, status, metadata, attemptState); + } + + // Success status: body policy first. + if (contract.responseBody === "NONE") { + const probe = await probeForbiddenBody(response); + if (!probe.ok) { + return settled( + transportFailure( + "RESPONSE_STREAM_FAILURE", + false, + attemptState, + isCommand, + ), + probe.code, + ); + } + if (probe.present) { + return settled( + violation("UNEXPECTED_BODY", "RESPONSE", postDispatchEffect(isCommand)), + "CONTRACT_VIOLATION", + ); + } + if (!context.scope.isCurrent()) { + return settled( + scopeFenced(postDispatchEffect(isCommand)), + "SCOPE_FENCED", + ); + } + return settled( + Object.freeze({ + kind: "SUCCESS" as const, + value: undefined as WireOutput, + metadata, + effect: (isCommand + ? "APPLIED_CONFIRMED" + : "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED", + }), + isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE", + ); + } + + const emptyAllowed = contract.emptyBodyStatuses.includes(status); + const mediaOk = isJsonMediaType(response.headers.get("content-type")); + const bytes = await readBoundedBytes(response, policy.responseByteLimit); + if (!bytes.ok) { + return settled( + bytes.code === "RESPONSE_TOO_LARGE" + ? violation( + "RESPONSE_TOO_LARGE", + "RESPONSE", + postDispatchEffect(isCommand), + ) + : transportFailure( + "RESPONSE_STREAM_FAILURE", + false, + attemptState, + isCommand, + ), + bytes.code, + ); + } + + const empty = isEffectivelyEmpty(bytes.bytes); + if (empty) { + if (contract.responseBody === "REQUIRED_JSON" || !emptyAllowed) { + return settled( + violation( + "UNEXPECTED_EMPTY_BODY", + "RESPONSE", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + if (!context.scope.isCurrent()) { + return settled( + scopeFenced(postDispatchEffect(isCommand)), + "SCOPE_FENCED", + ); + } + return settled( + Object.freeze({ + kind: "SUCCESS" as const, + value: undefined as WireOutput, + metadata, + effect: (isCommand + ? "APPLIED_CONFIRMED" + : "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED", + }), + isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE", + ); + } + + if (!mediaOk) { + return settled( + violation( + "CONTENT_TYPE_MISMATCH", + "RESPONSE", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + + const decoded = decodeJsonBytes(bytes.bytes); + if (!decoded.ok) { + return settled( + violation(decoded.code, "RESPONSE", postDispatchEffect(isCommand)), + "CONTRACT_VIOLATION", + ); + } + + const validated = invokeValidator(contract.outputValidator, decoded.value); + if (validated.outcome === "THROWN") { + return settled( + violation( + "VALIDATOR_RUNTIME_FAILURE", + "VALIDATION", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + if (validated.outcome === "INVALID") { + return settled( + violation( + "SUCCESS_SCHEMA_INVALID", + "VALIDATION", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + + // §10.8. A successful response for a stale scope is discarded, not committed. + if (!context.scope.isCurrent()) { + return settled( + scopeFenced(postDispatchEffect(isCommand)), + "SCOPE_FENCED", + ); + } + + return settled( + Object.freeze({ + kind: "SUCCESS" as const, + value: validated.value, + metadata, + effect: (isCommand + ? "APPLIED_CONFIRMED" + : "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED", + }), + isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE", + ); +} + +const ABORTED = Symbol("http-operation-aborted"); + +async function awaitWithAbort( + operation: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return ABORTED; + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(ABORTED); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function admitProblem( + operation: InstalledHttpContract, + response: Response, + status: number, + metadata: SafeResponseMetadata, + attemptState: PhysicalAttemptState, +): Promise> { + const contract = operation.contract; + const isCommand = contract.commandEffect !== null; + const retryable = RETRYABLE_STATUSES.has(status); + + const bytes = await readBoundedBytes( + response, + HTTP_EXECUTION_CEILINGS.problemResponseBytes, + ); + if (!bytes.ok || isEffectivelyEmpty(bytes.bytes)) { + // An unclassifiable failure stays uncertain for a command. + return Object.freeze({ + result: violation( + "PROBLEM_SCHEMA_INVALID", + "VALIDATION", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + if (!isJsonMediaType(response.headers.get("content-type"))) { + return Object.freeze({ + result: violation( + "CONTENT_TYPE_MISMATCH", + "RESPONSE", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + + const decoded = decodeJsonBytes(bytes.bytes); + if (!decoded.ok) { + return Object.freeze({ + result: violation( + decoded.code, + "RESPONSE", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + + const validated = invokeValidator(contract.problemValidator, decoded.value); + if (validated.outcome !== "VALID") { + return Object.freeze({ + result: violation( + validated.outcome === "THROWN" + ? "VALIDATOR_RUNTIME_FAILURE" + : "PROBLEM_SCHEMA_INVALID", + "VALIDATION", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + + const classification = classifyProblemEffect({ + status, + problem: validated.value, + descriptor: contract.commandEffect, + }); + const effect = isCommand + ? classification.effect + : certaintyForAbandonedAttempt(attemptState, false); + + return Object.freeze({ + result: Object.freeze({ + kind: "PROBLEM" as const, + problem: validated.value, + metadata, + effect: (effect === "NOT_STARTED" + ? "NOT_APPLIED" + : effect) as "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED", + }), + certainty: effect, + retryHint: retryable && effect !== "MAYBE_APPLIED", + retryAfterMs: metadata.retryAfterMs ?? null, + }); +} + +/** + * §8.3. `SAFE` and `IDEMPOTENT` may replay the same frozen request. `KEYED` + * must not automatically retry once an attempt was dispatched and its response + * was lost; that path goes to inspect/reconciliation instead. `NEVER` is zero. + */ +function canRetryTransport( + semantics: InstalledHttpContract< + unknown, + unknown, + unknown + >["contract"]["retrySemantics"], + attemptState: PhysicalAttemptState, +): boolean { + if (semantics === "NEVER") return false; + if (semantics === "KEYED") { + return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"; + } + return true; +} + +function isRetryableSemantics( + semantics: InstalledHttpContract< + unknown, + unknown, + unknown + >["contract"]["retrySemantics"], +): boolean { + return semantics === "SAFE" || semantics === "IDEMPOTENT"; +} + +/** §8.2. Full jitter over `min(2000, 250 * 2^index)`. */ +function jitteredDelay(retryIndex: number, random: () => number): number { + const ceiling = Math.min( + RETRY_MAX_LOCAL_DELAY_MS, + RETRY_BASE_DELAY_MS * 2 ** retryIndex, + ); + return Math.floor(random() * ceiling); +} + +function retryDelayFor( + retryAfterMs: number | null, + retryIndex: number, + random: () => number, +): number | null { + const local = jitteredDelay(retryIndex, random); + if (retryAfterMs === null) return local; + if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null; + return Math.max(local, retryAfterMs); +} + +/** §7.12. No raw header map, URL, cookie, traceparent or ETag value escapes. */ +function safeMetadata( + response: Response, + retryAfterMs: number | null, +): SafeResponseMetadata { + const correlation = response.headers.get("x-correlation-id"); + return Object.freeze({ + status: response.status, + ...(correlation && /^[A-Za-z0-9._:-]{1,128}$/.test(correlation) + ? { correlationReference: correlation } + : {}), + ...(retryAfterMs === null ? {} : { retryAfterMs }), + }); +} + +function settled( + result: HttpExecutionOutcome, + certainty: string, +): AdmissionOutcome { + return Object.freeze({ + result, + certainty, + retryHint: false, + retryAfterMs: null, + }); +} + +function violation( + kind: HttpContractViolationKind, + operation: HttpContractViolation["operation"], + effect: HttpEffectCertainty, +): HttpExecutionOutcome { + return Object.freeze({ + kind: "CONTRACT_VIOLATION" as const, + violation: Object.freeze({ kind, operation }), + effect, + }); +} + +function scopeFenced( + effect: HttpEffectCertainty, +): HttpExecutionOutcome { + return violation("SCOPE_FENCED", "RESPONSE", effect); +} + +function preDispatchEffect(isCommand: boolean): HttpEffectCertainty { + return isCommand ? "NOT_STARTED" : "NOT_APPLICABLE"; +} + +function postDispatchEffect(isCommand: boolean): HttpEffectCertainty { + return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE"; +} + +function contractViolationEffect( + attemptState: PhysicalAttemptState, + isCommand: boolean, +): HttpEffectCertainty { + if (!isCommand) return "NOT_APPLICABLE"; + return attemptState === "PREPARING" || attemptState === "READY_TO_SEND" + ? "NOT_STARTED" + : "MAYBE_APPLIED"; +} + +function unauthenticated( + effect: string, + isCommand: boolean, +): HttpExecutionOutcome { + return Object.freeze({ + kind: "UNAUTHENTICATED" as const, + effect: normalizeOptionalEffect(effect, isCommand), + }); +} + +function forbidden( + effect: string, + isCommand: boolean, +): HttpExecutionOutcome { + return Object.freeze({ + kind: "FORBIDDEN" as const, + effect: normalizeOptionalEffect(effect, isCommand), + }); +} + +function cancelled( + effect: "NOT_STARTED" | "NOT_APPLIED" | "MAYBE_APPLIED" | "APPLIED_CONFIRMED", +): HttpExecutionOutcome { + return Object.freeze({ + kind: "CANCELLED" as const, + effect: effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_STARTED", + }); +} + +function transportFailure( + kind: HttpTransportFailure["kind"], + retryable: boolean, + attemptState: PhysicalAttemptState, + isCommand: boolean, +): HttpExecutionOutcome { + const effect = certaintyForAbandonedAttempt(attemptState, isCommand); + return Object.freeze({ + kind: "TRANSPORT_FAILURE" as const, + failure: Object.freeze({ kind, retryable }), + effect: effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_STARTED", + }); +} + +function normalizeOptionalEffect( + effect: string, + isCommand: boolean, +): "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED" { + if (!isCommand) return "NOT_APPLICABLE"; + return effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_APPLIED"; +} diff --git a/src/adapters/platform/browser-lifecycle.ts b/src/adapters/platform/browser-lifecycle.ts new file mode 100644 index 0000000..3e82e22 --- /dev/null +++ b/src/adapters/platform/browser-lifecycle.ts @@ -0,0 +1,186 @@ +/** + * §20.4. The single owner of window lifecycle listeners. + * + * No capability adds its own `visibilitychange`, `online`, `offline`, `focus`, + * `pagehide` or `pageshow` listener. They subscribe here instead, so the + * listener count stays constant and leak inspection (§23.14) is meaningful. + * + * §20.6: nothing in this module is a correctness boundary. `beforeunload` is a + * user prompt, never a place to complete a command, write a checkpoint or + * guarantee a lease release. + */ + +export type BrowserLifecycleSnapshot = Readonly<{ + visibility: "VISIBLE" | "HIDDEN"; + connectivityHint: "ONLINE" | "OFFLINE"; + pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED"; + generation: number; +}>; + +export type BrowserLifecycleEvent = + | Readonly<{ kind: "VISIBILITY_CHANGED"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ kind: "ONLINE"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ kind: "OFFLINE"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ kind: "FOCUS"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ + kind: "PAGEHIDE"; + persisted: boolean; + snapshot: BrowserLifecycleSnapshot; + }> + | Readonly<{ + kind: "PAGESHOW"; + persisted: boolean; + snapshot: BrowserLifecycleSnapshot; + }>; + +export type BrowserLifecycleRuntime = Readonly<{ + getSnapshot(): BrowserLifecycleSnapshot; + subscribe(listener: (event: BrowserLifecycleEvent) => void): () => void; + /** + * Registers a dirty-state source. `beforeunload` is attached only while at + * least one source reports dirty, and it uses the browser's standard prompt. + */ + registerDirtySource(isDirty: () => boolean): () => void; + dispose(): void; +}>; + +type LifecycleHost = Readonly<{ + addEventListener: Window["addEventListener"]; + removeEventListener: Window["removeEventListener"]; + document?: Pick & { + addEventListener: Document["addEventListener"]; + removeEventListener: Document["removeEventListener"]; + }; + navigator?: Pick; +}>; + +export function createBrowserLifecycleRuntime( + host: LifecycleHost = globalThis as unknown as LifecycleHost, +): BrowserLifecycleRuntime { + const listeners = new Set<(event: BrowserLifecycleEvent) => void>(); + const dirtySources = new Set<() => boolean>(); + const document = host.document; + + let generation = 1; + let visibility: BrowserLifecycleSnapshot["visibility"] = + document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE"; + let connectivityHint: BrowserLifecycleSnapshot["connectivityHint"] = + host.navigator?.onLine === false ? "OFFLINE" : "ONLINE"; + let pageState: BrowserLifecycleSnapshot["pageState"] = "ACTIVE"; + let disposed = false; + let beforeUnloadAttached = false; + + function snapshot(): BrowserLifecycleSnapshot { + return Object.freeze({ + visibility, + connectivityHint, + pageState, + generation, + }); + } + + function publish(event: BrowserLifecycleEvent): void { + for (const listener of listeners) { + try { + listener(event); + } catch { + // One subscriber defect cannot suppress the signal for the others. + } + } + } + + const onVisibility = () => { + visibility = document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE"; + publish({ kind: "VISIBILITY_CHANGED", snapshot: snapshot() }); + }; + const onOnline = () => { + connectivityHint = "ONLINE"; + publish({ kind: "ONLINE", snapshot: snapshot() }); + }; + const onOffline = () => { + connectivityHint = "OFFLINE"; + publish({ kind: "OFFLINE", snapshot: snapshot() }); + }; + const onFocus = () => { + publish({ kind: "FOCUS", snapshot: snapshot() }); + }; + const onPageHide = (event: Event) => { + const persisted = (event as PageTransitionEvent).persisted === true; + pageState = "PAGEHIDE"; + publish({ kind: "PAGEHIDE", persisted, snapshot: snapshot() }); + }; + const onPageShow = (event: Event) => { + const persisted = (event as PageTransitionEvent).persisted === true; + // §20.5. A bfcache restore is a new lifecycle generation, not a fresh boot. + if (persisted) generation += 1; + pageState = persisted ? "BFCACHE_RESTORED" : "ACTIVE"; + publish({ kind: "PAGESHOW", persisted, snapshot: snapshot() }); + }; + const onBeforeUnload = (event: Event) => { + if (!hasDirtyState()) return; + event.preventDefault(); + }; + + function hasDirtyState(): boolean { + for (const isDirty of dirtySources) { + try { + if (isDirty()) return true; + } catch { + // A defective reporter is treated as clean rather than trapping the user. + } + } + return false; + } + + function syncBeforeUnload(): void { + const shouldAttach = dirtySources.size > 0; + if (shouldAttach && !beforeUnloadAttached) { + host.addEventListener("beforeunload", onBeforeUnload); + beforeUnloadAttached = true; + } else if (!shouldAttach && beforeUnloadAttached) { + host.removeEventListener("beforeunload", onBeforeUnload); + beforeUnloadAttached = false; + } + } + + document?.addEventListener("visibilitychange", onVisibility); + host.addEventListener("online", onOnline); + host.addEventListener("offline", onOffline); + host.addEventListener("focus", onFocus); + host.addEventListener("pagehide", onPageHide); + host.addEventListener("pageshow", onPageShow); + + return Object.freeze({ + getSnapshot: snapshot, + subscribe(listener) { + if (disposed) return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + registerDirtySource(isDirty) { + if (disposed) return () => {}; + dirtySources.add(isDirty); + syncBeforeUnload(); + return () => { + dirtySources.delete(isDirty); + syncBeforeUnload(); + }; + }, + dispose() { + if (disposed) return; + disposed = true; + document?.removeEventListener("visibilitychange", onVisibility); + host.removeEventListener("online", onOnline); + host.removeEventListener("offline", onOffline); + host.removeEventListener("focus", onFocus); + host.removeEventListener("pagehide", onPageHide); + host.removeEventListener("pageshow", onPageShow); + if (beforeUnloadAttached) { + host.removeEventListener("beforeunload", onBeforeUnload); + beforeUnloadAttached = false; + } + listeners.clear(); + dirtySources.clear(); + }, + }); +} diff --git a/src/adapters/query-cache/server-state-scope-runtime.ts b/src/adapters/query-cache/server-state-scope-runtime.ts index e440b7b..8aea9da 100644 --- a/src/adapters/query-cache/server-state-scope-runtime.ts +++ b/src/adapters/query-cache/server-state-scope-runtime.ts @@ -6,18 +6,47 @@ import { } from "../../contracts/query-keys.ts"; import type { CacheScopeSnapshot, + ClientScopeLifecycleEvent, + ClientScopePhase, ServerStateScopeRuntime, } from "../../contracts/server-state-scope.ts"; -export function createServerStateScopeRuntime(dependencies: Readonly<{ +/** + * Steps 4-11 of §10.6 that this runtime does not own directly. Each optional + * capability registers its own closer so the ordering lives in one place rather + * than being re-derived by every subsystem. + */ +export type ScopeResetParticipant = Readonly<{ + /** Lower runs earlier; the §10.6 step number is used as the rank. */ + order: number; + label: string; + close(): void | Promise; +}>; + +export type ServerStateScopeDependencies = Readonly<{ session: Pick; - queryInvalidation: QueryInvalidationCoordinator; + queryInvalidation: Pick; tokenFactory?: () => string; -}>): ServerStateScopeRuntime { + participants?: readonly ScopeResetParticipant[]; + activateNextGeneration?: () => void | Promise; +}>; + +export function createServerStateScopeRuntime( + dependencies: ServerStateScopeDependencies, +): ServerStateScopeRuntime { const listeners = new Set<() => void>(); + const lifecycleListeners = new Set< + (event: ClientScopeLifecycleEvent) => void + >(); + const participants = [...(dependencies.participants ?? [])].sort( + (left, right) => left.order - right.order, + ); + let generation = 1; let identities = newIdentityRegistry(dependencies.tokenFactory); let fingerprint = scopeFingerprint(dependencies.tokenFactory); + let generationLifetime = new AbortController(); + let phase: ClientScopePhase = "READY"; let disposed = false; let resetChain = Promise.resolve(); @@ -28,42 +57,129 @@ export function createServerStateScopeRuntime(dependencies: Readonly<{ generation: capturedGeneration, fingerprint, identities: capturedIdentities, + signal: generationLifetime.signal, isCurrent: () => !disposed && + phase === "READY" && generation === capturedGeneration && identities === capturedIdentities, }); } let currentSnapshot = createSnapshot(); + function publishLifecycle(event: ClientScopeLifecycleEvent): void { + for (const listener of [...lifecycleListeners]) { + try { + listener(event); + } catch { + // One subscriber defect cannot stop the fence from propagating. + } + } + } + + function publishSnapshot(): void { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + // Subscriber defects are isolated from the mandatory reset sequence. + } + } + } + const unsubscribe = dependencies.session.subscribe(() => { if (disposed) return; const previousIdentities = identities; + const previousGeneration = generation; + + // §10.6 steps 1-3 are synchronous: increment the generation, invalidate the + // old snapshot, publish FENCED. Nothing between here and READY may render a + // value that belonged to the previous identity. + generationLifetime.abort(); const targetGeneration = ++generation; + phase = "FENCED"; + currentSnapshot = createSnapshot(); + publishLifecycle( + Object.freeze({ kind: "FENCED" as const, previousGeneration }), + ); + publishSnapshot(); + resetChain = resetChain - .then(() => dependencies.queryInvalidation.resetLocal()) .catch(() => {}) - .finally(() => { + .then(async () => { + let failed = false; + // Steps 4-11: close admission, cancel and clear, release leases. + for (const participant of participants) { + try { + await participant.close(); + } catch { + failed = true; + } + } + try { + await dependencies.queryInvalidation.resetLocal(); + } catch { + failed = true; + } + previousIdentities.close(); if (disposed || generation !== targetGeneration) return; + + if (!failed) { + try { + await dependencies.activateNextGeneration?.(); + } catch { + failed = true; + } + } + if (disposed || generation !== targetGeneration) return; + + if (failed) { + phase = "FAILED"; + currentSnapshot = createSnapshot(); + publishLifecycle( + Object.freeze({ + kind: "FAILED" as const, + generation: targetGeneration, + }), + ); + publishSnapshot(); + return; + } + + // Steps 12-15: new identity registry, READY, notify, reopen admission. identities = newIdentityRegistry(dependencies.tokenFactory); fingerprint = scopeFingerprint(dependencies.tokenFactory); + generationLifetime = new AbortController(); + phase = "READY"; currentSnapshot = createSnapshot(); - for (const listener of listeners) listener(); + publishLifecycle( + Object.freeze({ kind: "READY" as const, snapshot: currentSnapshot }), + ); + publishSnapshot(); }); }); return Object.freeze({ getSnapshot: () => currentSnapshot, - subscribe(listener) { + getPhase: () => phase, + subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, + subscribeLifecycle(listener: (event: ClientScopeLifecycleEvent) => void) { + lifecycleListeners.add(listener); + return () => lifecycleListeners.delete(listener); + }, dispose() { if (disposed) return; disposed = true; + phase = "DISPOSED"; + generationLifetime.abort(); unsubscribe(); + publishLifecycle(Object.freeze({ kind: "DISPOSED" as const })); listeners.clear(); + lifecycleListeners.clear(); identities.close(); }, }); diff --git a/src/adapters/query-cache/tanstack-cache-coordinator.ts b/src/adapters/query-cache/tanstack-cache-coordinator.ts index 311149a..2c00dd8 100644 --- a/src/adapters/query-cache/tanstack-cache-coordinator.ts +++ b/src/adapters/query-cache/tanstack-cache-coordinator.ts @@ -237,12 +237,17 @@ export function createTanStackCacheCoordinator( } catch { report("reset-flush"); } + let cancellationFailed = false; try { await dependencies.queryClient.cancelQueries(); } catch { report("reset-cancel"); + cancellationFailed = true; } dependencies.queryClient.clear(); + if (cancellationFailed) { + throw new TypeError("mandatory query cancellation failed"); + } })().finally(() => { resetting = false; resetPromise = null; @@ -299,11 +304,9 @@ function buildDefinitions( }), ); } - if (definitions.size === 0) { - throw new TypeError( - "Query invalidation registry requires at least one topic.", - ); - } + // An empty registry is a legitimate state: a template with no installed + // feature has no invalidation topic. Every per-entry rule above still + // applies, and an unregistered topic still fails at the call site. return definitions; } diff --git a/src/adapters/service-worker/service-worker-entry.ts b/src/adapters/service-worker/service-worker-entry.ts new file mode 100644 index 0000000..2db48b8 --- /dev/null +++ b/src/adapters/service-worker/service-worker-entry.ts @@ -0,0 +1,170 @@ +/// +import { OFFLINE_SYNC_TAG } from "../../contracts/offline-command.ts"; +import type { + ServiceWorkerHandlerId, + ServiceWorkerProtocolIdentity, + StaticAssetManifestV1, +} from "../../contracts/service-worker.ts"; +import { + createServiceWorkerRuntime, + type WorkerScopeLike, +} from "./service-worker-lifecycle.ts"; +import { parseServiceWorkerMessage } from "./service-worker-protocol.ts"; + +/** + * §17.1. The one physical worker entry for this scope. + * + * PWA lifecycle, verified static asset fetch, Web Push and the optional sync + * wake-up are all handler factories inside this single entry. A second + * registration for any of them is prohibited. + * + * This module is compiled only by `vite.service-worker.config.ts` when the + * static selection is `ACTIVE`; it is never part of the page bundle. + */ + +declare const self: ServiceWorkerGlobalScope; + +// Build-time virtual modules (§18.3). They resolve through the Service Worker +// Vite config only, so the page bundle can never import a worker asset list. +declare const __CA_SERVICE_WORKER_BUILD_INFO__: ServiceWorkerProtocolIdentity; +declare const __CA_SERVICE_WORKER_ASSETS__: StaticAssetManifestV1 | null; +declare const __CA_SERVICE_WORKER_HANDLERS__: readonly ServiceWorkerHandlerId[]; +declare const __CA_RUNTIME_CONFIG_URL__: string; +declare const __CA_RELEASE_MANIFEST_URL__: string; + +const identity = __CA_SERVICE_WORKER_BUILD_INFO__; +const handlers = __CA_SERVICE_WORKER_HANDLERS__; + +const scope: WorkerScopeLike = { + caches: { + open: (name) => caches.open(name), + keys: () => caches.keys(), + delete: (name) => caches.delete(name), + match: (request) => caches.match(request), + }, + clients: { + matchAll: (options) => + self.clients.matchAll( + options as { type?: "window"; includeUncontrolled?: boolean }, + ) as Promise< + readonly { + id: string; + url: string; + postMessage(m: unknown): void; + }[] + >, + }, + registrationScope: self.registration.scope, + skipWaiting: () => self.skipWaiting(), + fetcher: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init), + async digest(bytes) { + const buffer = await crypto.subtle.digest( + "SHA-256", + bytes.slice().buffer as ArrayBuffer, + ); + let hex = ""; + for (const byte of new Uint8Array(buffer)) { + hex += byte.toString(16).padStart(2, "0"); + } + return `sha256:${hex}`; + }, +}; + +const runtime = createServiceWorkerRuntime(scope, { + identity, + handlers, + manifest: __CA_SERVICE_WORKER_ASSETS__, + runtimeConfigUrl: __CA_RUNTIME_CONFIG_URL__, + releaseManifestUrl: __CA_RELEASE_MANIFEST_URL__, +}); + +self.addEventListener("install", (event) => { + // §17.10. Install never calls skipWaiting(); activation is a page handshake. + event.waitUntil(runtime.onInstall()); +}); + +self.addEventListener("activate", (event) => { + // §17.12. No clients.claim() in the baseline. + event.waitUntil(runtime.onActivate()); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + event.respondWith( + runtime + .onFetch({ + method: request.method, + url: request.url, + mode: request.mode, + }) + .then((cached) => cached ?? fetch(request)), + ); +}); + +self.addEventListener("message", (event) => { + const parsed = parseServiceWorkerMessage(event.data); + if (!parsed.ok) return; + if (parsed.message.kind === "ACTIVATE_REQUEST") { + event.waitUntil(runtime.onActivateRequest(event.data)); + return; + } + if ( + parsed.message.kind === "CLIENT_DRAINED" || + parsed.message.kind === "ACTIVATE_REJECTED" + ) { + const source = event.source; + if (source && "id" in source && typeof source.id === "string") { + runtime.onClientMessage(event.data, source.id); + } + return; + } + if (parsed.message.kind === "CACHE_RESET_REQUEST") { + const source = event.source; + if ( + source && + "id" in source && + typeof source.id === "string" && + "postMessage" in source && + typeof source.postMessage === "function" + ) { + event.waitUntil(runtime.onCacheResetRequest(event.data, source)); + } + } +}); + +// §17.1 WEB_PUSH composition point. +// +// The Web Push runtime is a handler factory inside this one entry, never a +// second registration. It is not wired here because the template cannot supply +// the two product-owned inputs it needs: a PushAssociationFenceStore over the +// product push control repository, and a WebPushNotificationRegistry of exact +// notification types with the same-origin routes their clicks may open +// (§21.11). Selecting WEB_PUSH means adding, inside a +// `handlers.includes("WEB_PUSH")` guard: import +// createWebPushServiceWorkerRuntime and createServiceWorkerScopeHost from the +// sibling web-push adapter, then call the runtime with the scope host built +// from `self` plus the product fence store and notification registry. +// +// Keeping the import out of the baseline entry is also what lets the realtime +// and Web Push runtime be removed as a pure file deletion (§24.12). + +if (handlers.includes("OFFLINE_SYNC_WAKEUP")) { + // §19.18. Wake-up only: the handler records that a sync fired and notifies + // controlled clients. It never sends an authenticated command (§19.20). + self.addEventListener("sync", (rawEvent: Event) => { + const event = rawEvent as ExtendableEvent & { tag?: string }; + if (event.tag !== OFFLINE_SYNC_TAG) return; + event.waitUntil( + self.clients.matchAll({ type: "window" }).then((clients) => { + for (const client of clients) { + client.postMessage({ + protocolVersion: 1, + kind: "SYNC_WAKE_OBSERVED", + messageId: crypto.randomUUID(), + sourceBuildId: identity.buildId, + }); + } + }), + ); + }); +} diff --git a/src/adapters/service-worker/service-worker-lifecycle.ts b/src/adapters/service-worker/service-worker-lifecycle.ts new file mode 100644 index 0000000..e93239c --- /dev/null +++ b/src/adapters/service-worker/service-worker-lifecycle.ts @@ -0,0 +1,442 @@ +import { + SERVICE_WORKER_BOUNDS, + isOwnedStaticCacheName, + staticCacheName, + type ServiceWorkerHandlerId, + type ServiceWorkerProtocolIdentity, + type StaticAssetManifestV1, +} from "../../contracts/service-worker.ts"; +import { + createServiceWorkerMessage, + parseServiceWorkerMessage, +} from "./service-worker-protocol.ts"; +import { + classifyFetch, + installStaticAssets, + selectCachesToDelete, +} from "./service-worker-static-assets.ts"; + +/** + * §17.9–§17.15. Worker-side lifecycle, expressed against structural types so it + * can be unit-tested outside a real Service Worker global and compiled under + * `tsconfig.service-worker.json` without pulling in DOM globals. + */ + +export type WorkerClientLike = Readonly<{ + id: string; + url: string; + postMessage(message: unknown): void; +}>; + +export type WorkerScopeLike = Readonly<{ + caches: Readonly<{ + open(cacheName: string): Promise; + keys(): Promise; + delete(cacheName: string): Promise; + match(request: string): Promise; + }>; + clients: Readonly<{ + matchAll( + options?: Readonly<{ + type?: "window"; + includeUncontrolled?: boolean; + }>, + ): Promise< + readonly WorkerClientLike[] + >; + }>; + registrationScope: string; + skipWaiting(): Promise; + fetcher: typeof fetch; + digest(bytes: Uint8Array): Promise; +}>; + +export type WorkerRuntimeConfig = Readonly<{ + identity: ServiceWorkerProtocolIdentity; + handlers: readonly ServiceWorkerHandlerId[]; + manifest: StaticAssetManifestV1 | null; + runtimeConfigUrl: string; + releaseManifestUrl: string; +}>; + +const ACTIVATION_MARKER_URL = + "https://clean-architecture.invalid/__service-worker-activation-v1__"; +const ACTIVATION_MARKER_MAX_BYTES = 256; + +type ActivationMarker = Readonly<{ + cacheName: string; + activationSequence: number; +}>; + +export function createServiceWorkerRuntime( + scope: WorkerScopeLike, + config: WorkerRuntimeConfig, +) { + const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS"); + const manifestUrls = new Set( + staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [], + ); + const consumedNonces = new Set(); + type PendingActivation = Readonly<{ + requesterBuildId: string; + expectedClientIds: ReadonlySet; + acknowledgedClientIds: Set; + resolve(drained: boolean): void; + timer: ReturnType; + }>; + const pendingActivations = new Map(); + + /** + * §17.9. Without the static asset handler the install step opens zero caches; + * it only registers lifecycle, push and sync handlers. + */ + async function onInstall(): Promise { + if (!staticEnabled || !config.manifest) return; + const outcome = await installStaticAssets(config.manifest, { + caches: scope.caches, + fetcher: scope.fetcher, + digest: scope.digest, + }); + if (outcome.kind === "REJECTED") { + throw new Error(`STATIC_INSTALL_REJECTED:${outcome.code}`); + } + } + + /** + * §17.15. Delete only owned caches outside the current and one previous + * revision. `clients.claim()` is never called (§17.12). + */ + async function onActivate(): Promise { + if (!staticEnabled || !config.manifest) return 0; + const current = staticCacheName(config.manifest.setDigest); + const names = await scope.caches.keys(); + const owned = names.filter(isOwnedStaticCacheName); + const currentCache = await scope.caches.open(current); + const oldCaches = owned.filter((name) => name !== current); + const markers: ActivationMarker[] = []; + for (const name of oldCaches) { + const marker = await readActivationMarker(await scope.caches.open(name), name); + if (marker) markers.push(marker); + } + const currentMarker = await readActivationMarker(currentCache, current); + const highestOld = markers.reduce( + (highest, marker) => + !highest || marker.activationSequence >= highest.activationSequence + ? marker + : highest, + null, + ); + const previous = highestOld?.cacheName ?? oldCaches.at(-1) ?? null; + if ( + !currentMarker || + currentMarker.activationSequence < (highestOld?.activationSequence ?? 0) + ) { + const nextSequence = (highestOld?.activationSequence ?? 0) + 1; + if (!Number.isSafeInteger(nextSequence)) { + throw new Error("SERVICE_WORKER_ACTIVATION_SEQUENCE_EXHAUSTED"); + } + await currentCache.put( + ACTIVATION_MARKER_URL, + new Response( + JSON.stringify({ + schemaVersion: 1, + cacheName: current, + activationSequence: nextSequence, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + } + const stale = selectCachesToDelete(names, current, previous); + let deleted = 0; + for (const name of stale) { + if (await scope.caches.delete(name)) deleted += 1; + } + void SERVICE_WORKER_BOUNDS.retainedPreviousCaches; + return deleted; + } + + /** + * §18.5–§18.7. A verified cache hit is returned; anything else goes to the + * network and is never written back into the active cache at runtime. + */ + async function onFetch( + request: Readonly<{ method: string; url: string; mode?: string }>, + ): Promise { + const classification = classifyFetch({ + method: request.method, + requestUrl: request.url, + isNavigation: request.mode === "navigate", + runtimeConfigUrl: config.runtimeConfigUrl, + releaseManifestUrl: config.releaseManifestUrl, + manifestUrls, + }); + if (classification !== "VERIFIED_CACHE_FIRST") return null; + + const cached = await scope.caches.match(request.url); + if (!cached) return null; + if (cached.status !== 200 || cached.type === "opaque") { + // §18.6. An invalid hit is deleted and treated as a release mismatch. + const current = config.manifest + ? staticCacheName(config.manifest.setDigest) + : null; + if (current) { + const cache = await scope.caches.open(current); + await cache.delete(request.url).catch(() => false); + } + return null; + } + return cached; + } + + /** + * §17.11. The waiting worker validates the request, drains every controlled + * client, and only then calls `skipWaiting()`. + */ + async function onActivateRequest( + data: unknown, + ): Promise<"ACCEPTED" | "REJECTED" | "IGNORED"> { + const parsed = parseServiceWorkerMessage(data); + if (!parsed.ok || parsed.message.kind !== "ACTIVATE_REQUEST") return "IGNORED"; + const nonce = parsed.message.nonce; + if (!nonce || consumedNonces.has(nonce)) return "REJECTED"; + if ( + parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== config.identity.buildId + ) { + return "REJECTED"; + } + consumedNonces.add(nonce); + if (consumedNonces.size > 64) { + const oldest = consumedNonces.values().next().value; + if (oldest !== undefined) consumedNonces.delete(oldest); + } + + const candidates = await scope.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + const clients = candidates.filter((client) => + isClientWithinRegistrationScope(client.url, scope.registrationScope), + ); + const drained = await drainClients( + clients, + nonce, + parsed.message.sourceBuildId, + ); + if (!drained) { + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATE_REJECTED", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + } + return "REJECTED"; + } + + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATE_ACCEPTED", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + } + await scope.skipWaiting(); + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATED_RELOAD_REQUIRED", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + } + return "ACCEPTED"; + } + + async function drainClients( + clients: readonly WorkerClientLike[], + nonce: string, + requesterBuildId: string, + ): Promise { + if (clients.length === 0) return false; + const drained = new Promise((resolve) => { + const timer = setTimeout(() => { + pendingActivations.delete(nonce); + resolve(false); + }, SERVICE_WORKER_BOUNDS.clientDrainMs); + pendingActivations.set( + nonce, + Object.freeze({ + requesterBuildId, + expectedClientIds: new Set(clients.map((client) => client.id)), + acknowledgedClientIds: new Set(), + resolve, + timer, + }), + ); + }); + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "CLIENT_DRAIN_REQUEST", + sourceBuildId: config.identity.buildId, + targetBuildId: requesterBuildId, + nonce, + }), + ); + } + return drained; + } + + function onClientMessage(data: unknown, sourceClientId: string): void { + const parsed = parseServiceWorkerMessage(data); + if (!parsed.ok || !parsed.message.nonce) return; + if ( + parsed.message.targetBuildId !== config.identity.buildId || + (parsed.message.kind !== "CLIENT_DRAINED" && + parsed.message.kind !== "ACTIVATE_REJECTED") + ) { + return; + } + const pending = pendingActivations.get(parsed.message.nonce); + if ( + !pending || + parsed.message.sourceBuildId !== pending.requesterBuildId || + !pending.expectedClientIds.has(sourceClientId) + ) { + return; + } + if (parsed.message.kind === "ACTIVATE_REJECTED") { + settlePendingActivation(parsed.message.nonce, pending, false); + return; + } + pending.acknowledgedClientIds.add(sourceClientId); + if ( + pending.acknowledgedClientIds.size === pending.expectedClientIds.size + ) { + settlePendingActivation(parsed.message.nonce, pending, true); + } + } + + async function onCacheResetRequest( + data: unknown, + source: WorkerClientLike, + ): Promise { + const parsed = parseServiceWorkerMessage(data); + if ( + !parsed.ok || + parsed.message.kind !== "CACHE_RESET_REQUEST" || + !parsed.message.nonce || + (parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== config.identity.buildId) + ) { + return; + } + let cachesDeleted = 0; + const names = await scope.caches.keys(); + for (const name of names) { + if (!name.startsWith("ca-static-v1-")) continue; + try { + if (await scope.caches.delete(name)) cachesDeleted += 1; + } catch { + return; + } + } + source.postMessage( + createServiceWorkerMessage({ + kind: "CACHE_RESET_RESULT", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce: parsed.message.nonce, + cachesDeleted, + }), + ); + } + + return Object.freeze({ + onInstall, + onActivate, + onFetch, + onActivateRequest, + onClientMessage, + onCacheResetRequest, + manifestUrls: manifestUrls as ReadonlySet, + }); + + function settlePendingActivation( + nonce: string, + pending: PendingActivation, + drained: boolean, + ): void { + clearTimeout(pending.timer); + pendingActivations.delete(nonce); + pending.resolve(drained); + } +} + +function isClientWithinRegistrationScope( + clientUrl: string, + registrationScope: string, +): boolean { + try { + const client = new URL(clientUrl); + const scope = new URL(registrationScope); + return client.origin === scope.origin && client.href.startsWith(scope.href); + } catch { + return false; + } +} + +async function readActivationMarker( + cache: Cache, + expectedCacheName: string, +): Promise { + try { + const response = await cache.match(ACTIVATION_MARKER_URL); + if (!response || response.status !== 200) return null; + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + (!/^\d+$/u.test(declaredLength) || + Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES) + ) { + return null; + } + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) { + return null; + } + const value: unknown = JSON.parse(text); + if ( + value === null || + typeof value !== "object" || + (value as { schemaVersion?: unknown }).schemaVersion !== 1 || + (value as { cacheName?: unknown }).cacheName !== expectedCacheName || + !Number.isSafeInteger( + (value as { activationSequence?: unknown }).activationSequence, + ) || + ((value as { activationSequence: number }).activationSequence ?? 0) < 1 + ) { + return null; + } + return Object.freeze({ + cacheName: expectedCacheName, + activationSequence: (value as { activationSequence: number }) + .activationSequence, + }); + } catch { + return null; + } +} diff --git a/src/adapters/service-worker/service-worker-page-controller.ts b/src/adapters/service-worker/service-worker-page-controller.ts new file mode 100644 index 0000000..346ce93 --- /dev/null +++ b/src/adapters/service-worker/service-worker-page-controller.ts @@ -0,0 +1,399 @@ +import { + SERVICE_WORKER_BOUNDS, + type InstalledServiceWorkerSelection, + type ServiceWorkerActivationOutcome, + type ServiceWorkerResetOutcome, + type ServiceWorkerRuntimeHost, + type ServiceWorkerStartOutcome, +} from "../../contracts/service-worker.ts"; +import { + createNonceRegistry, + createServiceWorkerMessage, + parseServiceWorkerMessage, +} from "./service-worker-protocol.ts"; +import { + expectedServiceWorkerUrls, + isOwnedRegistration, + purgeOwnedResources, + removeOwnedRegistration, +} from "./service-worker-removal.ts"; + +/** + * §17.5–§17.16. The page-side controller. + * + * Registration happens after Runtime Config, release and contract set have all + * validated and the first React effect has committed. The controller never + * calls `skipWaiting()` blindly and never calls `clients.claim()`. + */ + +export type ActivationBlocker = () => boolean; + +export type PageControllerDependencies = Readonly<{ + selection: InstalledServiceWorkerSelection | null; + /** True when static selection is ACTIVE but Runtime Config disabled it. */ + disabledCleanup: boolean; + routerBasePath: string; + origin: string; + buildId: string; + container?: ServiceWorkerContainer; + caches?: CacheStorage; + /** §17.10. Any blocker returning true rejects automatic activation. */ + blockers?: readonly ActivationBlocker[]; + now?: () => number; + observe?: (observation: Readonly<{ event: string; outcome: string }>) => void; +}>; + +export function createServiceWorkerPageController( + dependencies: PageControllerDependencies, +): ServiceWorkerRuntimeHost { + const nonces = createNonceRegistry(); + const now = dependencies.now ?? (() => Date.now()); + const urls = expectedServiceWorkerUrls( + dependencies.routerBasePath, + dependencies.origin, + ); + + let registrationPromise: Promise | null = null; + let registration: ServiceWorkerRegistration | null = null; + let messageListener: ((event: MessageEvent) => void) | null = null; + let updateTimer: ReturnType | null = null; + let stopped = false; + const pendingStops = new Set<() => void>(); + + const observe = (event: string, outcome: string) => + dependencies.observe?.({ event, outcome }); + + function isBlocked(): boolean { + for (const blocker of dependencies.blockers ?? []) { + try { + if (blocker()) return true; + } catch { + // A defective blocker is treated as blocking: never activate on doubt. + return true; + } + } + return false; + } + + async function start(): Promise { + if (stopped) return failed("STOPPED"); + const container = dependencies.container; + + // §3.6 / §17.6. Static ACTIVE plus runtime DISABLED performs exactly one + // owned-registration lookup and at most one unregister. No new register, no + // cache deletion, no message or update timer. + if (dependencies.disabledCleanup) { + if (!container) return Object.freeze({ kind: "DISABLED" as const }); + const outcome = await removeOwnedRegistration({ + container, + routerBasePath: dependencies.routerBasePath, + origin: dependencies.origin, + }); + observe("disable_cleanup", outcome.kind); + if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED"); + return Object.freeze({ kind: "DISABLED" as const }); + } + + const selection = dependencies.selection; + // §3.6 / §17.3 `null`: zero registration lookups and zero Cache Storage + // access. The controller must not even probe. + if (!selection) return Object.freeze({ kind: "DISABLED" as const }); + + if (!container) return Object.freeze({ kind: "INCOMPATIBLE" as const }); + + if (selection.mode === "REMOVE_REGISTRATION") { + const outcome = await removeOwnedRegistration({ + container, + routerBasePath: dependencies.routerBasePath, + origin: dependencies.origin, + }); + observe("remove_registration", outcome.kind); + return Object.freeze({ kind: "DISABLED" as const }); + } + if (selection.mode === "PURGE_OWNED_RESOURCES") { + const outcome = await purgeOwnedResources({ + container, + ...(dependencies.caches ? { caches: dependencies.caches } : {}), + routerBasePath: dependencies.routerBasePath, + origin: dependencies.origin, + }); + observe("purge_owned_resources", outcome.kind); + return Object.freeze({ kind: "DISABLED" as const }); + } + + // §17.5. StrictMode's repeated effect returns the same in-flight promise + // instead of issuing a second registration. + registrationPromise ??= container.register(urls.scriptHref, { + scope: urls.scopePath, + type: "module", + updateViaCache: "none", + }); + + let installedRegistration: ServiceWorkerRegistration; + try { + installedRegistration = await registrationPromise; + } catch { + registrationPromise = null; + observe("register", "FAILED"); + return failed("REGISTRATION_FAILED"); + } + if (stopped) return failed("STOPPED"); + registration = installedRegistration; + + if ( + !isOwnedRegistration({ + registration, + expectedScopeHref: urls.scopeHref, + expectedScriptHref: urls.scriptHref, + }) + ) { + observe("register", "OWNERSHIP_MISMATCH"); + return Object.freeze({ kind: "INCOMPATIBLE" as const }); + } + + attachMessageListener(container); + scheduleUpdateChecks(); + + if (registration.waiting) { + observe("register", "UPDATE_WAITING"); + return Object.freeze({ kind: "UPDATE_WAITING" as const }); + } + // §17.13. Without `clients.claim()` the first install leaves this page + // uncontrolled. That is reported, never silently reloaded. + if (registration.active && !container.controller) { + observe("register", "RELOAD_TO_ENABLE"); + return Object.freeze({ kind: "RELOAD_TO_ENABLE" as const }); + } + observe("register", "ACTIVE"); + return Object.freeze({ + kind: "ACTIVE" as const, + buildId: dependencies.buildId, + }); + } + + function attachMessageListener(container: ServiceWorkerContainer): void { + if (messageListener) return; + messageListener = (event: MessageEvent) => { + if (event.origin && event.origin !== dependencies.origin) return; + const parsed = parseServiceWorkerMessage(event.data); + if (!parsed.ok) { + observe("message", parsed.code); + return; + } + if ( + parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== dependencies.buildId + ) { + observe("message", "TARGET_BUILD_MISMATCH"); + return; + } + if (parsed.message.kind === "CLIENT_DRAIN_REQUEST") { + const nonce = parsed.message.nonce; + const source = event.source; + if (!nonce || !canPostMessage(source)) { + observe("client_drain", "MALFORMED"); + return; + } + const rejected = isBlocked(); + source.postMessage( + createServiceWorkerMessage({ + kind: rejected ? "ACTIVATE_REJECTED" : "CLIENT_DRAINED", + sourceBuildId: dependencies.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + observe("client_drain", rejected ? "BLOCKED" : "DRAINED"); + return; + } + observe("message", parsed.message.kind); + }; + container.addEventListener("message", messageListener); + } + + function scheduleUpdateChecks(): void { + // §17.14. At most one check per 6 hours, and none while the page is hidden. + if (updateTimer) return; + updateTimer = setInterval(() => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + void registration?.update().catch(() => { + // A failed update check never fails a product flow. + }); + }, SERVICE_WORKER_BOUNDS.updateCheckIntervalMs); + } + + /** + * §17.11. Activation is a handshake: every controlled client must close new + * admission and acknowledge within 30s. One missing client rejects it. + */ + async function requestActivation(): Promise { + const waiting = registration?.waiting; + if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const }); + if (isBlocked()) { + observe("activation", "BLOCKED_DIRTY_CLIENT"); + return Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const }); + } + + const nonce = nonces.issue(); + const deadline = now() + SERVICE_WORKER_BOUNDS.clientDrainMs; + const accepted = await new Promise( + (resolve) => { + const container = dependencies.container; + if (!container) { + resolve(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const })); + return; + } + let settled = false; + const finish = (outcome: ServiceWorkerActivationOutcome) => { + if (settled) return; + settled = true; + nonces.consume(nonce); + clearTimeout(timer); + container.removeEventListener("message", onMessage); + pendingStops.delete(onStop); + resolve(outcome); + }; + const onMessage = (event: MessageEvent) => { + const parsed = parseServiceWorkerMessage(event.data); + if (!parsed.ok) return; + if ( + parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== dependencies.buildId + ) { + return; + } + if ( + parsed.message.kind === "ACTIVATE_REJECTED" && + parsed.message.nonce === nonce + ) { + finish(Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const })); + return; + } + if ( + parsed.message.kind === "ACTIVATED_RELOAD_REQUIRED" && + parsed.message.nonce === nonce + ) { + finish( + Object.freeze({ kind: "ACTIVATED_RELOAD_REQUIRED" as const }), + ); + } + }; + const onStop = () => + finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" })); + const timer = setTimeout( + () => finish(Object.freeze({ kind: "CLIENT_DRAIN_TIMEOUT" as const })), + Math.max(0, deadline - now()), + ); + pendingStops.add(onStop); + container.addEventListener("message", onMessage); + try { + waiting.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATE_REQUEST", + sourceBuildId: dependencies.buildId, + nonce, + }), + ); + } catch { + finish(Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" })); + } + }, + ); + observe("activation", accepted.kind); + return accepted; + } + + /** §18.10. Static caches only; the registration itself is left in place. */ + async function resetOwnedCaches(): Promise { + const container = dependencies.container; + if (!container?.controller) { + return Object.freeze({ kind: "NOT_CONTROLLED" as const }); + } + const nonce = nonces.issue(); + return new Promise((resolve) => { + let settled = false; + const finish = (outcome: ServiceWorkerResetOutcome) => { + if (settled) return; + settled = true; + nonces.consume(nonce); + clearTimeout(timer); + container.removeEventListener("message", onMessage); + pendingStops.delete(onStop); + resolve(outcome); + }; + const onMessage = (event: MessageEvent) => { + if (event.origin && event.origin !== dependencies.origin) return; + const parsed = parseServiceWorkerMessage(event.data); + if ( + !parsed.ok || + parsed.message.kind !== "CACHE_RESET_RESULT" || + parsed.message.targetBuildId !== dependencies.buildId || + parsed.message.nonce !== nonce + ) { + return; + } + finish( + Object.freeze({ + kind: "RESET" as const, + cachesDeleted: parsed.message.cachesDeleted ?? 0, + }), + ); + }; + const onStop = () => + finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" })); + const timer = setTimeout( + () => + finish( + Object.freeze({ kind: "FAILED" as const, code: "RESET_TIMEOUT" }), + ), + SERVICE_WORKER_BOUNDS.clientDrainMs, + ); + pendingStops.add(onStop); + container.addEventListener("message", onMessage); + try { + container.controller?.postMessage( + createServiceWorkerMessage({ + kind: "CACHE_RESET_REQUEST", + sourceBuildId: dependencies.buildId, + nonce, + }), + ); + observe("cache_reset", "REQUESTED"); + } catch { + finish( + Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }), + ); + } + }); + } + + /** §17.16. Ordinary shutdown removes listeners and timers; it never unregisters. */ + async function stop(): Promise { + stopped = true; + for (const stopPending of [...pendingStops]) stopPending(); + pendingStops.clear(); + if (updateTimer) { + clearInterval(updateTimer); + updateTimer = null; + } + if (messageListener && dependencies.container) { + dependencies.container.removeEventListener("message", messageListener); + messageListener = null; + } + nonces.clear(); + } + + return Object.freeze({ start, requestActivation, resetOwnedCaches, stop }); +} + +function canPostMessage( + source: MessageEventSource | null, +): source is MessageEventSource & { postMessage(message: unknown): void } { + return !!source && typeof source.postMessage === "function"; +} + +function failed(code: string): ServiceWorkerStartOutcome { + return Object.freeze({ kind: "FAILED" as const, code }); +} diff --git a/src/adapters/service-worker/service-worker-protocol.ts b/src/adapters/service-worker/service-worker-protocol.ts new file mode 100644 index 0000000..74d86d0 --- /dev/null +++ b/src/adapters/service-worker/service-worker-protocol.ts @@ -0,0 +1,178 @@ +import { + SERVICE_WORKER_PROTOCOL_VERSION, + type ServiceWorkerMessage, + type ServiceWorkerMessageKind, +} from "../../contracts/service-worker.ts"; + +/** + * §17.8. Message protocol shared by the page controller and the worker entry. + * + * Only structural types are used here: this module is compiled into both the + * DOM realm and the WebWorker realm, so it must not reference a global from + * either one. + */ + +const MESSAGE_KINDS: ReadonlySet = new Set([ + "PAGE_HELLO", + "WORKER_HELLO_ACK", + "UPDATE_READY", + "ACTIVATE_REQUEST", + "ACTIVATE_ACCEPTED", + "ACTIVATE_REJECTED", + "CLIENT_DRAIN_REQUEST", + "CLIENT_DRAINED", + "ACTIVATED_RELOAD_REQUIRED", + "CACHE_RESET_REQUEST", + "CACHE_RESET_RESULT", + "SYNC_WAKE_OBSERVED", +]); + +const ID = /^[A-Za-z0-9._:-]{1,128}$/; + +export type ParsedMessage = + | Readonly<{ ok: true; message: ServiceWorkerMessage }> + | Readonly<{ + ok: false; + code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND"; + }>; + +/** + * Exact key set, exact protocol version, bounded identifiers. Anything else is + * rejected rather than partially interpreted: a postMessage payload is an + * untrusted runtime input (§21.1). + */ +export function parseServiceWorkerMessage(value: unknown): ParsedMessage { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return reject("MALFORMED"); + } + const candidate = value as Record; + const allowed = new Set([ + "protocolVersion", + "kind", + "messageId", + "sourceBuildId", + "targetBuildId", + "nonce", + "cachesDeleted", + ]); + for (const key of Object.keys(candidate)) { + if (!allowed.has(key)) return reject("MALFORMED"); + } + if (candidate.protocolVersion !== SERVICE_WORKER_PROTOCOL_VERSION) { + return reject("PROTOCOL_MISMATCH"); + } + if (typeof candidate.kind !== "string" || !MESSAGE_KINDS.has(candidate.kind)) { + return reject("UNKNOWN_KIND"); + } + if ( + typeof candidate.messageId !== "string" || + !ID.test(candidate.messageId) || + typeof candidate.sourceBuildId !== "string" || + !ID.test(candidate.sourceBuildId) + ) { + return reject("MALFORMED"); + } + if ( + candidate.cachesDeleted !== undefined && + (!Number.isSafeInteger(candidate.cachesDeleted) || + (candidate.cachesDeleted as number) < 0 || + (candidate.cachesDeleted as number) > 1_024) + ) { + return reject("MALFORMED"); + } + if ( + (candidate.kind === "CACHE_RESET_RESULT") !== + (candidate.cachesDeleted !== undefined) + ) { + return reject("MALFORMED"); + } + if ( + candidate.targetBuildId !== undefined && + (typeof candidate.targetBuildId !== "string" || + !ID.test(candidate.targetBuildId)) + ) { + return reject("MALFORMED"); + } + if ( + candidate.nonce !== undefined && + (typeof candidate.nonce !== "string" || !ID.test(candidate.nonce)) + ) { + return reject("MALFORMED"); + } + + return Object.freeze({ + ok: true as const, + message: Object.freeze({ + protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION, + kind: candidate.kind as ServiceWorkerMessageKind, + messageId: candidate.messageId, + sourceBuildId: candidate.sourceBuildId, + ...(candidate.targetBuildId === undefined + ? {} + : { targetBuildId: candidate.targetBuildId }), + ...(candidate.nonce === undefined ? {} : { nonce: candidate.nonce }), + ...(candidate.cachesDeleted === undefined + ? {} + : { cachesDeleted: candidate.cachesDeleted as number }), + }), + }); +} + +export function createServiceWorkerMessage( + input: Readonly<{ + kind: ServiceWorkerMessageKind; + sourceBuildId: string; + targetBuildId?: string; + nonce?: string; + cachesDeleted?: number; + messageId?: string; + }>, +): ServiceWorkerMessage { + return Object.freeze({ + protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION, + kind: input.kind, + messageId: input.messageId ?? randomId(), + sourceBuildId: input.sourceBuildId, + ...(input.targetBuildId === undefined + ? {} + : { targetBuildId: input.targetBuildId }), + ...(input.nonce === undefined ? {} : { nonce: input.nonce }), + ...(input.cachesDeleted === undefined + ? {} + : { cachesDeleted: input.cachesDeleted }), + }); +} + +/** One-time nonce store. A nonce is consumed on first match and never reused. */ +export function createNonceRegistry(maximumEntries = 32) { + const nonces = new Set(); + return Object.freeze({ + issue(): string { + if (nonces.size >= maximumEntries) { + const oldest = nonces.values().next().value; + if (oldest !== undefined) nonces.delete(oldest); + } + const nonce = randomId(); + nonces.add(nonce); + return nonce; + }, + consume(nonce: string | undefined): boolean { + if (!nonce || !nonces.has(nonce)) return false; + nonces.delete(nonce); + return true; + }, + clear(): void { + nonces.clear(); + }, + }); +} + +function randomId(): string { + return crypto.randomUUID(); +} + +function reject( + code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND", +): ParsedMessage { + return Object.freeze({ ok: false as const, code }); +} diff --git a/src/adapters/service-worker/service-worker-removal.ts b/src/adapters/service-worker/service-worker-removal.ts new file mode 100644 index 0000000..e7ca8ee --- /dev/null +++ b/src/adapters/service-worker/service-worker-removal.ts @@ -0,0 +1,169 @@ +import { + isOwnedStaticCacheName, + SERVICE_WORKER_SCRIPT_PATH, + type ServiceWorkerRemovalOutcome, +} from "../../contracts/service-worker.ts"; + +/** + * §17.4 / §17.17. Exact ownership check and staged removal. + * + * A registration is only ours when the scope matches exactly and every present + * worker's script URL is same-origin, with at least one matching the expected + * script and none pointing anywhere else. A scope-prefix guess is never enough: + * a foreign registration must never be unregistered. + */ + +export type ServiceWorkerContainerLike = Readonly<{ + getRegistration( + clientUrl?: string, + ): Promise; +}>; + +export type CacheStorageLike = Readonly<{ + keys(): Promise; + delete(cacheName: string): Promise; +}>; + +export type OwnershipInput = Readonly<{ + registration: ServiceWorkerRegistration; + expectedScopeHref: string; + expectedScriptHref: string; +}>; + +export function isOwnedRegistration(input: OwnershipInput): boolean { + const { registration, expectedScopeHref, expectedScriptHref } = input; + if (registration.scope !== expectedScopeHref) return false; + + const expectedOrigin = new URL(expectedScriptHref).origin; + const present = [ + registration.installing, + registration.waiting, + registration.active, + ].filter((worker): worker is ServiceWorker => worker !== null); + if (present.length === 0) return false; + + let matched = false; + for (const worker of present) { + let scriptOrigin: string; + try { + scriptOrigin = new URL(worker.scriptURL).origin; + } catch { + return false; + } + if (scriptOrigin !== expectedOrigin) return false; + if (worker.scriptURL === expectedScriptHref) { + matched = true; + } else { + // A present worker running a different script means this registration is + // not exclusively ours. + return false; + } + } + return matched; +} + +export function expectedServiceWorkerUrls( + routerBasePath: string, + origin: string, +): Readonly<{ scopeHref: string; scriptHref: string; scopePath: string }> { + const scope = new URL(routerBasePath, origin); + const script = new URL(SERVICE_WORKER_SCRIPT_PATH, scope); + return Object.freeze({ + scopeHref: scope.href, + scriptHref: script.href, + scopePath: scope.pathname, + }); +} + +export type RemovalDependencies = Readonly<{ + container: ServiceWorkerContainerLike; + caches?: CacheStorageLike; + routerBasePath: string; + origin: string; +}>; + +/** + * `REMOVE_REGISTRATION`: unregister only, caches retained so a rollback within + * the retention window still finds its verified assets. + */ +export async function removeOwnedRegistration( + dependencies: RemovalDependencies, +): Promise { + const urls = expectedServiceWorkerUrls( + dependencies.routerBasePath, + dependencies.origin, + ); + let registration: ServiceWorkerRegistration | undefined; + try { + registration = await dependencies.container.getRegistration(urls.scopePath); + } catch { + return Object.freeze({ kind: "FAILED" as const, operation: "LOOKUP" as const }); + } + if (!registration) return Object.freeze({ kind: "ABSENT" as const }); + if ( + !isOwnedRegistration({ + registration, + expectedScopeHref: urls.scopeHref, + expectedScriptHref: urls.scriptHref, + }) + ) { + return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const }); + } + try { + await registration.unregister(); + } catch { + return Object.freeze({ + kind: "FAILED" as const, + operation: "UNREGISTER" as const, + }); + } + return Object.freeze({ kind: "UNREGISTERED" as const }); +} + +/** + * `PURGE_OWNED_RESOURCES`: repeat the unregister check, then delete only caches + * whose name parses as ours. Outbox, OPFS and user file data are untouched, and + * unregistering is never confused with cache deletion. + */ +export async function purgeOwnedResources( + dependencies: RemovalDependencies, +): Promise { + const removal = await removeOwnedRegistration(dependencies); + if (removal.kind === "OWNERSHIP_MISMATCH" || removal.kind === "FAILED") { + return removal; + } + + const cacheStorage = dependencies.caches; + if (!cacheStorage) { + return Object.freeze({ + kind: "PURGED" as const, + cachesDeleted: 0, + metadataDeleted: 0, + }); + } + + let names: readonly string[]; + try { + names = await cacheStorage.keys(); + } catch { + return Object.freeze({ kind: "FAILED" as const, operation: "PURGE" as const }); + } + + let cachesDeleted = 0; + for (const name of names) { + if (!isOwnedStaticCacheName(name)) continue; + try { + if (await cacheStorage.delete(name)) cachesDeleted += 1; + } catch { + return Object.freeze({ + kind: "FAILED" as const, + operation: "PURGE" as const, + }); + } + } + return Object.freeze({ + kind: "PURGED" as const, + cachesDeleted, + metadataDeleted: 0, + }); +} diff --git a/src/adapters/service-worker/service-worker-static-assets.ts b/src/adapters/service-worker/service-worker-static-assets.ts new file mode 100644 index 0000000..a028924 --- /dev/null +++ b/src/adapters/service-worker/service-worker-static-assets.ts @@ -0,0 +1,359 @@ +import { + isOwnedStaticCacheName, + SERVICE_WORKER_BOUNDS, + staticCacheName, + type StaticAssetManifestV1, +} from "../../contracts/service-worker.ts"; + +/** + * §17.9 / §18. Static asset install and fetch classification. + * + * Only immutable hashed build assets are cached, all-or-nothing, verified at + * install time. Navigation, runtime config, the release manifest and every API + * response are network-only, and no runtime response is ever written into the + * active cache. + */ + +export type FetchClassification = + | "NETWORK_PASSTHROUGH" + | "NETWORK_ONLY" + | "VERIFIED_CACHE_FIRST"; + +export type ClassificationInput = Readonly<{ + method: string; + requestUrl: string; + isNavigation: boolean; + runtimeConfigUrl: string; + releaseManifestUrl: string; + manifestUrls: ReadonlySet; +}>; + +/** + * §18.5. Order matters: the exact static hit is evaluated before the generic + * network passthrough, because an API base may legitimately be `/`. + */ +export function classifyFetch(input: ClassificationInput): FetchClassification { + if (input.method !== "GET") return "NETWORK_PASSTHROUGH"; + if (input.isNavigation) return "NETWORK_ONLY"; + if ( + sameResource(input.requestUrl, input.runtimeConfigUrl) || + sameResource(input.requestUrl, input.releaseManifestUrl) + ) { + return "NETWORK_ONLY"; + } + if (input.manifestUrls.has(input.requestUrl)) return "VERIFIED_CACHE_FIRST"; + return "NETWORK_PASSTHROUGH"; +} + +function sameResource(left: string, right: string): boolean { + try { + const a = new URL(left); + const b = new URL(right, left); + return a.origin === b.origin && a.pathname === b.pathname; + } catch { + return false; + } +} + +export type InstallOutcome = + | Readonly<{ kind: "INSTALLED"; cacheName: string; assets: number }> + | Readonly<{ + kind: "REJECTED"; + code: + | "MANIFEST_INVALID" + | "ASSET_COUNT_EXCEEDED" + | "ASSET_TOO_LARGE" + | "ASSET_SET_TOO_LARGE" + | "INSTALL_DEADLINE_EXCEEDED" + | "FETCH_FAILED" + | "STATUS_INVALID" + | "CONTENT_TYPE_INVALID" + | "BYTES_MISMATCH" + | "INTEGRITY_MISMATCH" + | "QUOTA_EXCEEDED"; + }>; + +export type InstallDependencies = Readonly<{ + caches: Readonly<{ + open(cacheName: string): Promise; + delete(cacheName: string): Promise; + }>; + fetcher: typeof fetch; + digest(bytes: Uint8Array): Promise; +}>; + +export function validateStaticAssetManifest( + manifest: StaticAssetManifestV1, +): InstallOutcome | null { + const bounds = SERVICE_WORKER_BOUNDS; + if ( + manifest.schemaVersion !== 1 || + !/^sha256:[0-9a-f]{64}$/.test(manifest.setDigest) + ) { + return rejected("MANIFEST_INVALID"); + } + if (manifest.assets.length > bounds.assets) { + return rejected("ASSET_COUNT_EXCEEDED"); + } + let total = 0; + for (const asset of manifest.assets) { + if ( + !asset.url || + !/^sha256:[0-9a-f]{64}$/.test(asset.sha256) || + !Number.isSafeInteger(asset.bytes) || + asset.bytes < 0 + ) { + return rejected("MANIFEST_INVALID"); + } + if (asset.bytes > bounds.singleAssetBytes) return rejected("ASSET_TOO_LARGE"); + total += asset.bytes; + } + if (total > bounds.assetSetBytes) return rejected("ASSET_SET_TOO_LARGE"); + return null; +} + +/** + * §17.9. A partial candidate is never used: any failure deletes the candidate + * cache and rejects install, leaving the previous verified revision in place. + */ +export async function installStaticAssets( + manifest: StaticAssetManifestV1, + dependencies: InstallDependencies, +): Promise { + const invalid = validateStaticAssetManifest(manifest); + if (invalid) return invalid; + + const cacheName = staticCacheName(manifest.setDigest); + const abortController = new AbortController(); + let deadlineExceeded = false; + let deadlineTimer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => { + deadlineExceeded = true; + abortController.abort(); + resolve(rejected("INSTALL_DEADLINE_EXCEEDED")); + }, SERVICE_WORKER_BOUNDS.installDeadlineMs); + }); + + const installation = installCandidate( + manifest, + cacheName, + dependencies, + abortController, + ); + const raced = await Promise.race([installation, deadline]); + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + const outcome = deadlineExceeded + ? rejected("INSTALL_DEADLINE_EXCEEDED") + : raced; + + if (outcome.kind === "REJECTED") { + await dependencies.caches.delete(cacheName).catch(() => false); + } + return outcome; +} + +async function installCandidate( + manifest: StaticAssetManifestV1, + cacheName: string, + dependencies: InstallDependencies, + abortController: AbortController, +): Promise { + const signal = abortController.signal; + let cache: Cache; + try { + cache = await dependencies.caches.open(cacheName); + } catch { + return rejected("QUOTA_EXCEEDED"); + } + + const queue = [...manifest.assets]; + let failure: InstallOutcome | null = null; + + const worker = async (): Promise => { + for (;;) { + if (failure) return; + const asset = queue.shift(); + if (!asset) return; + const outcome = await storeAsset(asset, cache, dependencies, signal); + if (outcome) { + failure ??= outcome; + abortController.abort(); + return; + } + } + }; + + await Promise.all( + Array.from({ length: SERVICE_WORKER_BOUNDS.fetchConcurrency }, worker), + ); + + if (failure) return failure; + return Object.freeze({ + kind: "INSTALLED" as const, + cacheName, + assets: manifest.assets.length, + }); +} + +async function storeAsset( + asset: StaticAssetManifestV1["assets"][number], + cache: Cache, + dependencies: InstallDependencies, + signal: AbortSignal, +): Promise { + if (signal.aborted) return rejected("FETCH_FAILED"); + let response: Response; + try { + const fetched = await abortable( + dependencies.fetcher(asset.url, { + cache: "no-store", + credentials: "omit", + redirect: "error", + signal, + }), + signal, + ); + if (fetched === ABORTED) return rejected("FETCH_FAILED"); + response = fetched; + } catch { + return rejected("FETCH_FAILED"); + } + if (response.status !== 200 || response.type === "opaque") { + return rejected("STATUS_INVALID"); + } + const contentType = response.headers.get("content-type") ?? ""; + if ( + contentType.split(";", 1)[0]?.trim().toLowerCase() !== + asset.contentType.toLowerCase() + ) { + return rejected("CONTENT_TYPE_INVALID"); + } + + const body = await readBoundedBody(response, asset.bytes, signal); + if (!body.ok) return rejected(body.code); + const bytes = body.bytes; + + const digest = await abortable(dependencies.digest(bytes), signal); + if (digest === ABORTED) return rejected("FETCH_FAILED"); + if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH"); + + try { + if (signal.aborted) return rejected("FETCH_FAILED"); + await cache.put( + asset.url, + new Response(bytes.slice(), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + ); + } catch { + return rejected("QUOTA_EXCEEDED"); + } + return null; +} + +const ABORTED = Symbol("service-worker-install-aborted"); + +async function abortable( + operation: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return ABORTED; + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(ABORTED); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function readBoundedBody( + response: Response, + expectedBytes: number, + signal: AbortSignal, +): Promise< + | Readonly<{ ok: true; bytes: Uint8Array }> + | Readonly<{ ok: false; code: "BYTES_MISMATCH" | "FETCH_FAILED" }> +> { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + /^\d+$/u.test(declaredLength) && + Number(declaredLength) !== expectedBytes + ) { + await response.body?.cancel().catch(() => {}); + return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); + } + if (!response.body) { + return expectedBytes === 0 + ? Object.freeze({ ok: true as const, bytes: new Uint8Array() }) + : Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const result = await abortable(reader.read(), signal); + if (result === ABORTED) { + await reader.cancel().catch(() => {}); + return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const }); + } + if (result.done) break; + total += result.value.byteLength; + if (total > expectedBytes) { + await reader.cancel().catch(() => {}); + return Object.freeze({ + ok: false as const, + code: "BYTES_MISMATCH" as const, + }); + } + chunks.push(result.value); + } + } catch { + return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const }); + } finally { + reader.releaseLock(); + } + if (total !== expectedBytes) { + return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return Object.freeze({ ok: true as const, bytes }); +} + +/** + * §17.15. Keep the current revision plus exactly one previous verified cache. + * A cache found outside the owned prefix is left alone; a cache holding config, + * manifest or API data is a security violation and is deleted. + */ +export function selectCachesToDelete( + names: readonly string[], + currentCacheName: string, + previousCacheName: string | null, +): readonly string[] { + return Object.freeze( + names.filter( + (name) => + isOwnedStaticCacheName(name) && + name !== currentCacheName && + name !== previousCacheName, + ), + ); +} + +function rejected(code: Extract["code"]) { + return Object.freeze({ kind: "REJECTED" as const, code }); +} diff --git a/src/adapters/storage/indexeddb/indexeddb-runtime.ts b/src/adapters/storage/indexeddb/indexeddb-runtime.ts index 9896762..3d29f99 100644 --- a/src/adapters/storage/indexeddb/indexeddb-runtime.ts +++ b/src/adapters/storage/indexeddb/indexeddb-runtime.ts @@ -591,6 +591,8 @@ export function createIndexedDbRuntime( }); let connection: IDBDatabase | null = null; let openingRequest: IDBOpenDBRequest | null = null; + let openingPromise: Promise> | null = null; + let activeOpeningGeneration: object | null = null; let cancelPendingOpen: (() => void) | null = null; let disposed = false; @@ -654,56 +656,61 @@ export function createIndexedDbRuntime( updateStatus({ kind: "CLOSED", reason: "FORCED" }); } - async function open( - signal?: AbortSignal, + function waitForOpeningAttempt( + attempt: Promise>, + signal: AbortSignal | undefined, ): Promise> { const operation = "INDEXEDDB_OPEN" as const; - if (disposed) return observeResult(operation, unavailable(operation)); - if (connection) { - return observeResult(operation, browserDataSuccess(undefined)); - } const cancelled = abortedResult(signal, operation); - if (cancelled) return observeResult(operation, cancelled); - if (!factory) { - return observeResult( - operation, - browserDataFailure("UNSUPPORTED", operation, { - recovery: "ONLINE_ONLY", - }), - ); - } - if (openingRequest) { - const result = - status.kind === "BLOCKED" - ? browserDataFailure("BLOCKED", operation, { - retryable: true, - recovery: "RELOAD_OTHER_CONTEXTS", - }) - : unavailable(operation); - return observeResult(operation, result); - } + if (cancelled) return Promise.resolve(cancelled); - updateStatus({ - kind: "OPENING", - targetVersion: dependencies.schemaVersion, + return new Promise>((resolve) => { + let callerSettled = false; + const finishCaller = (result: BrowserDataResult) => { + if (callerSettled) return; + callerSettled = true; + signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + function onAbort(): void { + finishCaller(browserDataFailure("ABORTED", operation)); + } + + signal?.addEventListener("abort", onAbort, { once: true }); + void attempt.then(finishCaller, () => finishCaller(unavailable(operation))); }); + } - return await new Promise>((resolve) => { + function startOpeningAttempt( + availableFactory: IDBFactory, + generation: object, + ): Promise> { + const operation = "INDEXEDDB_OPEN" as const; + + return new Promise>((resolve) => { + const settleNativeRequest = () => { + if (activeOpeningGeneration !== generation) return; + activeOpeningGeneration = null; + openingRequest = null; + openingPromise = null; + cancelPendingOpen = null; + }; let request: IDBOpenDBRequest; try { - request = factory.open( + request = availableFactory.open( databaseName, dependencies.schemaVersion, ); } catch (error) { const result = mapIndexedDbException(error, operation); updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); - resolve(observeResult(operation, result)); + resolve(result); + queueMicrotask(settleNativeRequest); return; } openingRequest = request; - let callerSettled = false; + let requestSettled = false; let migrationFailed = false; let policyBindingRejected = false; let appliedMigrations = 0; @@ -715,17 +722,14 @@ export function createIndexedDbRuntime( blockedTimer = undefined; } }; - const detachAbort = () => signal?.removeEventListener("abort", onAbort); - const finishCaller = (result: BrowserDataResult) => { - if (callerSettled) return; - callerSettled = true; + const finishOpeningAttempt = (result: BrowserDataResult) => { + if (requestSettled) return; + requestSettled = true; clearBlockedTimer(); - detachAbort(); - resolve(observeResult(operation, result)); + resolve(result); }; const settleLateRequest = () => { - openingRequest = null; - cancelPendingOpen = null; + settleNativeRequest(); }; const abortUpgrade = () => { try { @@ -734,15 +738,10 @@ export function createIndexedDbRuntime( // An open request cannot otherwise be cancelled. } }; - function onAbort(): void { - abortUpgrade(); - finishCaller(browserDataFailure("ABORTED", operation)); - } cancelPendingOpen = () => { abortUpgrade(); - finishCaller(unavailable(operation)); + finishOpeningAttempt(unavailable(operation)); }; - signal?.addEventListener("abort", onAbort, { once: true }); request.onupgradeneeded = (event) => { const transaction = request.transaction; @@ -789,7 +788,7 @@ export function createIndexedDbRuntime( }); if (blockedTimer === undefined) { blockedTimer = scheduler.setTimeout(() => { - finishCaller( + finishOpeningAttempt( browserDataFailure("BLOCKED", operation, { retryable: true, recovery: "RELOAD_OTHER_CONTEXTS", @@ -816,24 +815,24 @@ export function createIndexedDbRuntime( { recovery: "READ_ONLY" }, ); observeResult("INDEXEDDB_MIGRATE", result); - finishCaller(result); + finishOpeningAttempt(result); return; } if (policyBindingRejected) { - finishCaller( + finishOpeningAttempt( browserDataFailure("POLICY_REJECTED", operation, { recovery: storagePolicySnapshot.unavailableFallback, }), ); return; } - finishCaller(mapIndexedDbException(request.error, operation)); + finishOpeningAttempt(mapIndexedDbException(request.error, operation)); }; request.onsuccess = () => { const opened = request.result; void (async () => { - if (callerSettled || disposed) { + if (requestSettled || disposed) { settleLateRequest(); opened.close(); if (!disposed) { @@ -861,14 +860,14 @@ export function createIndexedDbRuntime( "INDEXEDDB_MIGRATE", ); observeResult("INDEXEDDB_MIGRATE", result); - finishCaller(result); + finishOpeningAttempt(result); return; } const binding = await verifyIndexedDbDatasetBinding( opened, dependencies.governanceStore, expectedBinding, - signal, + undefined, ); settleLateRequest(); if (!binding.ok) { @@ -876,8 +875,8 @@ export function createIndexedDbRuntime( if (!disposed) { updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); } - if (!callerSettled) { - finishCaller( + if (!requestSettled) { + finishOpeningAttempt( binding.reason === "ABORTED" ? browserDataFailure("ABORTED", operation) : binding.reason === "NATIVE_ERROR" @@ -894,7 +893,7 @@ export function createIndexedDbRuntime( } return; } - if (callerSettled || disposed) { + if (requestSettled || disposed) { opened.close(); if (!disposed) { updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); @@ -915,12 +914,46 @@ export function createIndexedDbRuntime( appliedMigrations, ); } - finishCaller(browserDataSuccess(undefined)); + finishOpeningAttempt(browserDataSuccess(undefined)); })(); }; }); } + async function open( + signal?: AbortSignal, + ): Promise> { + const operation = "INDEXEDDB_OPEN" as const; + if (disposed) return observeResult(operation, unavailable(operation)); + if (connection) { + return observeResult(operation, browserDataSuccess(undefined)); + } + const cancelled = abortedResult(signal, operation); + if (cancelled) return observeResult(operation, cancelled); + if (!factory) { + return observeResult( + operation, + browserDataFailure("UNSUPPORTED", operation, { + recovery: "ONLINE_ONLY", + }), + ); + } + + if (!openingPromise) { + updateStatus({ + kind: "OPENING", + targetVersion: dependencies.schemaVersion, + }); + const generation = {}; + activeOpeningGeneration = generation; + const attempt = startOpeningAttempt(factory, generation); + openingPromise = attempt; + } + + const result = await waitForOpeningAttempt(openingPromise, signal); + return observeResult(operation, result); + } + function createTransaction( db: IDBDatabase, stores: readonly string[], diff --git a/src/adapters/storage/opfs/opfs-worker-client.ts b/src/adapters/storage/opfs/opfs-worker-client.ts index 2d43932..8e1e2bf 100644 --- a/src/adapters/storage/opfs/opfs-worker-client.ts +++ b/src/adapters/storage/opfs/opfs-worker-client.ts @@ -33,6 +33,8 @@ export interface OpfsWorkerLike { type: "message", listener: (event: MessageEvent) => void, ): void; + addFailureEventListener?(listener: (event: Event) => void): void; + removeFailureEventListener?(listener: (event: Event) => void): void; } export type OpfsWorkerClientDependencies = Readonly<{ @@ -72,6 +74,15 @@ export function createOpfsWorkerGateway( const pending = new Map(); let disposed = false; + const rejectAllPending = (): void => { + for (const request of pending.values()) { + clearTimeout(request.timeout); + request.removeAbortListener(); + request.reject(new OpfsRpcError("UNAVAILABLE")); + } + pending.clear(); + }; + const onMessage = (event: MessageEvent): void => { if (disposed) return; if (!isWorkerResponse(event.data)) return; @@ -82,7 +93,15 @@ export function createOpfsWorkerGateway( request.removeAbortListener(); request.resolve(event.data); }; + const onWorkerFailure = (): void => { + if (disposed) return; + disposed = true; + dependencies.worker.removeEventListener("message", onMessage); + dependencies.worker.removeFailureEventListener?.(onWorkerFailure); + rejectAllPending(); + }; dependencies.worker.addEventListener("message", onMessage); + dependencies.worker.addFailureEventListener?.(onWorkerFailure); async function rpc( request: OpfsWorkerRequestBody, @@ -92,6 +111,14 @@ export function createOpfsWorkerGateway( if (disposed) throw new OpfsRpcError("UNAVAILABLE"); if (signal?.aborted) throw new OpfsRpcError("ABORTED"); const requestId = createRequestId(); + if ( + typeof requestId !== "string" || + requestId.length === 0 || + requestId.length > 128 || + pending.has(requestId) + ) { + throw new OpfsRpcError("UNAVAILABLE"); + } const message = { ...request, requestId } as OpfsWorkerRequest; return await new Promise((resolve, reject) => { @@ -423,12 +450,8 @@ export function createOpfsWorkerGateway( if (disposed) return; disposed = true; dependencies.worker.removeEventListener("message", onMessage); - for (const request of pending.values()) { - clearTimeout(request.timeout); - request.removeAbortListener(); - request.reject(new OpfsRpcError("UNAVAILABLE")); - } - pending.clear(); + dependencies.worker.removeFailureEventListener?.(onWorkerFailure); + rejectAllPending(); }, }); } @@ -445,8 +468,24 @@ export function createOwnedOpfsWorkerClient( type: "module", name: dependencies.workerName ?? "ca-opfs-byte-store", }); + const workerPort: OpfsWorkerLike = { + postMessage: (message, transfer) => + worker.postMessage(message, transfer ? [...transfer] : []), + addEventListener: (_type, listener) => + worker.addEventListener("message", listener), + removeEventListener: (_type, listener) => + worker.removeEventListener("message", listener), + addFailureEventListener: (listener) => { + worker.addEventListener("error", listener); + worker.addEventListener("messageerror", listener); + }, + removeFailureEventListener: (listener) => { + worker.removeEventListener("error", listener); + worker.removeEventListener("messageerror", listener); + }, + }; const gateway = createOpfsWorkerGateway({ - worker, + worker: workerPort, policy: dependencies.policy, createRequestId: dependencies.createRequestId, }); diff --git a/src/adapters/web-push/push-association-fence-store.ts b/src/adapters/web-push/push-association-fence-store.ts index e46f5e3..c19af32 100644 --- a/src/adapters/web-push/push-association-fence-store.ts +++ b/src/adapters/web-push/push-association-fence-store.ts @@ -1,11 +1,3 @@ -import type { - IndexedDbRepositoryPort, - IndexedDbWriteReceipt, -} from "../../application/ports/browser-file-storage/indexeddb-port.ts"; -import type { - BrowserDataFailure, - BrowserDataResult, -} from "../../application/ports/browser-file-storage/shared.ts"; import { WEB_PUSH_LIMITS, WEB_PUSH_PROTOCOLS, @@ -33,15 +25,66 @@ export type PushControlReceipt = Readonly<{ revision: number; }>; +export type PushControlWriteReceipt = Readonly<{ + key: string; + revision: number; + replayed: boolean; +}>; + /** - * The generic repository owns IndexedDB connection, migration, transaction, - * timeout, codec and version-change policy. This adapter adds only the - * Web Push authority transition rules on top of its revisioned CAS. + * The failure surface this adapter consumes. `code` is intentionally a plain + * string: the storage taxonomy is owned by whichever runtime backs the store, + * and an unrecognised code maps to the safe default in + * {@link mapRepositoryFailure} rather than failing to compile. */ -export type PushControlRepository = Pick< - IndexedDbRepositoryPort, - "open" | "read" | "compareAndSwap" | "remove" | "close" ->; +export type PushControlStoreFailure = Readonly<{ + code: string; + retryable: boolean; +}>; + +export type PushControlStoreResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: PushControlStoreFailure }>; + +/** + * The narrow durable store this capability requires: revisioned + * compare-and-swap over a single key. + * + * It is declared here, structurally, rather than imported from the browser + * file/storage port so the two capabilities stay independently removable. The + * generic IndexedDB repository satisfies it as-is; the composition root is + * where the two are joined, and it owns connection, migration, transaction, + * timeout, codec and version-change policy. + */ +export type PushControlRepository = Readonly<{ + open(signal?: AbortSignal): Promise>; + read( + key: string, + signal?: AbortSignal, + ): Promise< + PushControlStoreResult< + Readonly<{ value: PushControlV1; revision: number }> | null + > + >; + compareAndSwap( + input: Readonly<{ + key: string; + value: PushControlV1; + expectedRevision: number | null; + idempotencyKey: string; + signal?: AbortSignal; + }>, + ): Promise>; + remove( + input: Readonly<{ + key: string; + expectedRevision: number | null; + idempotencyKey: string; + signal?: AbortSignal; + }>, + ): Promise>; + close(): void; +}>; export interface PushAssociationFenceStore { read(input?: Readonly<{ @@ -505,7 +548,7 @@ export function createPushAssociationFenceStore( } async function callRepository( - call: () => Promise>, + call: () => Promise>, operation: WebPushOperation, ): Promise> { try { @@ -519,7 +562,7 @@ async function callRepository( } function mapRepositoryFailure( - failure: BrowserDataFailure, + failure: PushControlStoreFailure, operation: WebPushOperation, ): WebPushResult { switch (failure.code) { @@ -651,8 +694,8 @@ function validRepository( } function validWriteReceipt( - value: IndexedDbWriteReceipt, -): value is IndexedDbWriteReceipt { + value: PushControlWriteReceipt, +): value is PushControlWriteReceipt { return ( Boolean(value) && value.key === CONTROL_KEY && diff --git a/src/adapters/web-push/service-worker-scope-host.ts b/src/adapters/web-push/service-worker-scope-host.ts new file mode 100644 index 0000000..dda28aa --- /dev/null +++ b/src/adapters/web-push/service-worker-scope-host.ts @@ -0,0 +1,57 @@ +import type { ServiceWorkerEventHost } from "./service-worker-runtime.ts"; + +type NotificationOptions = Parameters< + ServiceWorkerEventHost["registration"]["showNotification"] +>[1]; +type MatchAllOptions = Parameters< + ServiceWorkerEventHost["clients"]["matchAll"] +>[0]; + +/** + * Adapts a native Service Worker global scope onto the structural + * {@link ServiceWorkerEventHost} facade. + * + * The native `showNotification` call lives here because `src/adapters/web-push` + * is the owner of the notification API. The single physical worker entry + * (§17.1) composes the runtime but never touches the native API itself. + */ + +export type NativeWorkerScope = Readonly<{ + location: Readonly<{ origin: string }>; + registration: Readonly<{ + showNotification(title: string, options: unknown): Promise; + }>; + clients: Readonly<{ + matchAll(options: unknown): Promise; + openWindow(url: string): Promise; + }>; + // Deliberately loose: a native scope declares a richly overloaded listener + // signature, and this facade only needs to forward the registration. + addEventListener(type: string, listener: never, options?: never): void; + removeEventListener(type: string, listener: never, options?: never): void; +}>; + +export function createServiceWorkerScopeHost( + scope: NativeWorkerScope, +): ServiceWorkerEventHost { + return Object.freeze({ + origin: scope.location.origin, + registration: Object.freeze({ + showNotification: (title: string, options: NotificationOptions) => + scope.registration.showNotification(title, { + body: options.body, + data: options.data, + requireInteraction: options.requireInteraction, + tag: options.tag, + }), + }), + clients: Object.freeze({ + matchAll: (options: MatchAllOptions) => scope.clients.matchAll(options), + openWindow: (url: string) => scope.clients.openWindow(url), + }), + addEventListener: (type, listener) => + scope.addEventListener(type, listener as never), + removeEventListener: (type, listener) => + scope.removeEventListener(type, listener as never), + }); +} diff --git a/src/application/create-application.ts b/src/application/create-application.ts index 2d9b7e1..81f3ad3 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -91,9 +91,17 @@ export function createApplication( buildId: release.buildId, releaseId: release.releaseId, configSchemaVersion: release.configSchemaVersion, - apiContractVersion: release.apiContractVersion, + ...(release.apiContractVersion === undefined + ? {} + : { apiContractVersion: release.apiContractVersion }), + ...(release.contractSetDigest === undefined + ? {} + : { contractSetDigest: release.contractSetDigest }), }); }, + getCapabilitySnapshot() { + return outputPorts.runtimeCapabilities.getSnapshot(); + }, }); const recovery = Object.freeze({ diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts index 19d87bc..602093c 100644 --- a/src/application/ports/in/application-api.ts +++ b/src/application/ports/in/application-api.ts @@ -1,7 +1,9 @@ import type { SessionState } from "../auth-session-port.ts"; +import type { RuntimeCapabilitySnapshot } from "../runtime-capabilities-port.ts"; import type { StoragePort } from "../storage-port.ts"; export type { SessionState } from "../auth-session-port.ts"; +export type { RuntimeCapabilitySnapshot }; /** * Features add their driving API through module augmentation. The application @@ -31,7 +33,10 @@ export type ReleaseSummary = Readonly<{ buildId: string; releaseId: string; configSchemaVersion: string; - apiContractVersion: string; + /** Legacy V1 scalar; absent once the release manifest is V2. */ + apiContractVersion?: string; + /** §5.2 contract set identity for a V2 release manifest. */ + contractSetDigest?: string; }>; export type ApplicationApi = Readonly<{ @@ -54,6 +59,11 @@ export type ApplicationApi = Readonly<{ }>; runtime: Readonly<{ getReleaseSummary(): Promise; + /** + * §3.5. The static selection reduced by the runtime overrides. Presentation + * reads capability state here instead of importing the composition root. + */ + getCapabilitySnapshot(): RuntimeCapabilitySnapshot; }>; recovery: Readonly<{ recoverChunk(input: Readonly<{ diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts index 1737d78..352a971 100644 --- a/src/application/ports/out/application-output-ports.ts +++ b/src/application/ports/out/application-output-ports.ts @@ -1,5 +1,6 @@ import type { AuthSessionPort } from "../auth-session-port.ts"; import type { ReleaseInfoPort } from "../release-info-port.ts"; +import type { RuntimeCapabilitiesPort } from "../runtime-capabilities-port.ts"; import type { StoragePort } from "../storage-port.ts"; import type { TelemetryPort } from "../telemetry-port.ts"; import type { DiagnosticsPort } from "../diagnostics-port.ts"; @@ -17,5 +18,6 @@ export type ApplicationOutputPorts = Readonly<{ diagnostics: DiagnosticsPort; telemetry: TelemetryPort; releaseInfo: ReleaseInfoPort; + runtimeCapabilities: RuntimeCapabilitiesPort; navigation: Readonly<{ reload(): void }>; }>; diff --git a/src/application/ports/release-info-port.ts b/src/application/ports/release-info-port.ts index 6593bdd..b569861 100644 --- a/src/application/ports/release-info-port.ts +++ b/src/application/ports/release-info-port.ts @@ -4,7 +4,13 @@ export type ReleaseInfo = Readonly<{ buildId: string; commitSha?: string; configSchemaVersion: string; - apiContractVersion: string; + /** + * §5.1. Legacy scalar, present only while a V1 release manifest is still + * accepted. A V2 manifest expresses contract identity through + * {@link ReleaseInfo.contractSetDigest}. + */ + apiContractVersion?: string; + contractSetDigest?: string; assetManifestHash: string; releaseId: string; builtAt?: string; @@ -17,6 +23,7 @@ export type ActiveReleaseInfo = Readonly< | "buildId" | "configSchemaVersion" | "apiContractVersion" + | "contractSetDigest" | "assetManifestHash" | "releaseId" | "routeChunks" diff --git a/src/application/ports/runtime-capabilities-port.ts b/src/application/ports/runtime-capabilities-port.ts new file mode 100644 index 0000000..4b282a0 --- /dev/null +++ b/src/application/ports/runtime-capabilities-port.ts @@ -0,0 +1,12 @@ +import type { RuntimeCapabilitySnapshot } from "../../contracts/runtime-capabilities.ts"; + +export type { RuntimeCapabilitySnapshot }; + +/** + * §3.5. The application reads capability state; it never resolves it. Only the + * composition root knows the runtime overrides, so the snapshot arrives here + * already reduced to counts and cannot be used to reach a runtime object. + */ +export type RuntimeCapabilitiesPort = Readonly<{ + getSnapshot(): RuntimeCapabilitySnapshot; +}>; diff --git a/src/bootstrap/create-runtime-composition.ts b/src/bootstrap/create-runtime-composition.ts index 3056f45..8d1b712 100644 --- a/src/bootstrap/create-runtime-composition.ts +++ b/src/bootstrap/create-runtime-composition.ts @@ -1,6 +1,12 @@ +import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts"; +import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; import { createCompositionRoot } from "./composition-root.ts"; import { loadReleaseManifest } from "./load-release-manifest.ts"; import { loadRuntimeConfig } from "./load-runtime-config.ts"; +import { + createOptionalRuntimeHost, + type OptionalRuntimeHost, +} from "./optional-runtime-host.ts"; import { createRuntimeAdapters } from "./runtime-adapters.ts"; export type RuntimeCompositionDependencies = Readonly<{ @@ -8,10 +14,16 @@ export type RuntimeCompositionDependencies = Readonly<{ host?: Record; }>; -export function createRuntimeComposition( +/** + * §6.7 steps 7 and 14. The static capability selection is compiled against the + * runtime overrides, and the optional host objects are created without any + * start side effect. Nothing observable happens until the first committed React + * effect calls `optional.startAfterMount()`. + */ +export async function createRuntimeComposition( dependencies: RuntimeCompositionDependencies = {}, ) { - return createCompositionRoot({ + const root = await createCompositionRoot({ loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }), loadRelease: (runtime) => loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }), @@ -23,6 +35,36 @@ export function createRuntimeComposition( host: dependencies.host, }), }); + + const capabilities = resolveRuntimeCapabilities( + INSTALLED_RUNTIME_CAPABILITIES, + root.config.config.CAPABILITY_OVERRIDES, + ); + const optional: OptionalRuntimeHost = createOptionalRuntimeHost({ + capabilities, + routerBasePath: root.config.build.routerBasePath, + buildId: root.release.buildId, + ...(dependencies.host + ? { + host: dependencies.host as Parameters< + typeof createOptionalRuntimeHost + >[0]["host"], + } + : {}), + }); + + return Object.freeze({ + ...root, + capabilities, + optional, + async dispose(): Promise { + // §20.3. Optional runtime first, then the base infrastructure: the query + // cache is cleared only after realtime and worker admission has closed, + // so a late effect cannot repopulate a cleared cache. + await optional.stop("APPLICATION_SHUTDOWN"); + root.infrastructure.dispose(); + }, + }); } export type RuntimeComposition = Awaited< diff --git a/src/bootstrap/load-release-manifest.ts b/src/bootstrap/load-release-manifest.ts index 0f0a50a..79f1661 100644 --- a/src/bootstrap/load-release-manifest.ts +++ b/src/bootstrap/load-release-manifest.ts @@ -1,41 +1,77 @@ -import { z } from "zod"; - +import { + verifyContractSet, + type ContractSet, + type ContractSetFailureCode, +} from "../contracts/contract-set.ts"; +import type { ContractSetPackage } from "../contracts/contract-set-canonical.ts"; +import { + releaseManifestV1ArtifactSchema, + releaseManifestV2ArtifactSchema, + type ReleaseManifestV1Artifact, + type ReleaseManifestV2Artifact, +} from "../contracts/release-artifacts.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../features/installed-contract-contributions.ts"; +import { + BOOT_JSON_POLICIES, + readBoundedBootJson, + type BootLoadFailure, +} from "./read-bounded-boot-json.ts"; import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts"; -const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/); -export const releaseManifestSchema = z - .object({ - schemaVersion: z.literal(1), - appVersion: z.string().min(1), - buildId: z.string().min(1), - commitSha: z.string().min(1), - configSchemaVersion: version, - apiContractVersion: version, - assetManifestHash: z.string().min(1), - releaseId: z.string().min(1), - builtAt: z.string().min(1), - routeChunks: z.record(z.string().min(1), z.string().min(1)), - }) - .strict(); +/** §5.8. Retained for one compatibility window; carries the removed scalar. */ +export const releaseManifestV1Schema = releaseManifestV1ArtifactSchema; + +/** §5.2. The frontend build's compiled external contract package set. */ +export const releaseManifestV2Schema = releaseManifestV2ArtifactSchema; + +export type ReleaseManifestV1 = ReleaseManifestV1Artifact; +export type ReleaseManifestV2 = ReleaseManifestV2Artifact; + +/** + * The composition-facing manifest. A V1 document is normalized onto it with a + * null contract set so downstream runtime never branches on schema version. + */ +export type ReleaseManifest = Readonly<{ + schemaVersion: 1 | 2; + appVersion: string; + buildId: string; + commitSha: string; + configSchemaVersion: string; + assetManifestHash: string; + releaseId: string; + builtAt: string; + routeChunks: Readonly>; + contractSet: ContractSet | null; + legacyApiContractVersion?: string; +}>; -export type ReleaseManifest = z.output; export type ReleaseManifestErrorCode = | "MANIFEST_BUILD_MISMATCH" + | "MANIFEST_PROTOCOL_PAIR_MISMATCH" | "MANIFEST_CONFIG_SCHEMA_MISMATCH" | "MANIFEST_API_CONTRACT_MISMATCH" | "MANIFEST_RELEASE_MISMATCH" | "MANIFEST_ASSET_MISMATCH" | "MANIFEST_FETCH_FAILED" + | "MANIFEST_TIMEOUT" | "MANIFEST_HTTP_FAILED" + | "MANIFEST_CONTENT_TYPE_INVALID" + | "MANIFEST_BODY_TOO_LARGE" + | "MANIFEST_UTF8_INVALID" | "MANIFEST_JSON_INVALID" - | "MANIFEST_SCHEMA_INVALID"; + | "MANIFEST_SCHEMA_INVALID" + | ContractSetFailureCode; + export type ReleaseManifestFailureKind = | "BUILD_MISMATCH" + | "PROTOCOL_PAIR_MISMATCH" | "CONFIG_MISMATCH" | "API_CONTRACT_MISMATCH" | "RELEASE_MISMATCH" | "ASSET_MISMATCH" + | "CONTRACT_SET_MISMATCH" | "RELEASE_MANIFEST_FAILURE"; + export type ReleaseManifestSafe = Readonly<{ kind: ReleaseManifestFailureKind; code: ReleaseManifestErrorCode; @@ -55,6 +91,8 @@ function failureKindFor( switch (code) { case "MANIFEST_BUILD_MISMATCH": return "BUILD_MISMATCH"; + case "MANIFEST_PROTOCOL_PAIR_MISMATCH": + return "PROTOCOL_PAIR_MISMATCH"; case "MANIFEST_CONFIG_SCHEMA_MISMATCH": return "CONFIG_MISMATCH"; case "MANIFEST_API_CONTRACT_MISMATCH": @@ -64,7 +102,9 @@ function failureKindFor( case "MANIFEST_ASSET_MISMATCH": return "ASSET_MISMATCH"; default: - return "RELEASE_MANIFEST_FAILURE"; + return code.startsWith("CONTRACT_") + ? "CONTRACT_SET_MISMATCH" + : "RELEASE_MANIFEST_FAILURE"; } } @@ -88,63 +128,111 @@ export class ReleaseManifestError extends Error { } } +const READ_FAILURE_CODE: Readonly< + Record +> = Object.freeze({ + FETCH_FAILED: "MANIFEST_FETCH_FAILED", + TIMEOUT: "MANIFEST_TIMEOUT", + HTTP_STATUS_INVALID: "MANIFEST_HTTP_FAILED", + CONTENT_TYPE_INVALID: "MANIFEST_CONTENT_TYPE_INVALID", + BODY_TOO_LARGE: "MANIFEST_BODY_TOO_LARGE", + UTF8_INVALID: "MANIFEST_UTF8_INVALID", + JSON_INVALID: "MANIFEST_JSON_INVALID", + SHAPE_INVALID: "MANIFEST_SCHEMA_INVALID", + SECRET_NAME_REJECTED: "MANIFEST_SCHEMA_INVALID", + SCHEMA_INVALID: "MANIFEST_SCHEMA_INVALID", + BUILD_MISMATCH: "MANIFEST_BUILD_MISMATCH", + RELEASE_MISMATCH: "MANIFEST_RELEASE_MISMATCH", + ASSET_MISMATCH: "MANIFEST_ASSET_MISMATCH", + CONTRACT_SET_MISMATCH: "CONTRACT_SET_DIGEST_MISMATCH", +}); + export type FetchReleaseManifestOptions = Readonly<{ fetcher?: typeof fetch; buildId: string; releaseId?: string; + signal?: AbortSignal; }>; /** - * Fetches and validates the active manifest without imposing the current - * build tuple. Chunk recovery uses this no-store view to detect a new release. + * Fetches and validates the active manifest without imposing the current build + * tuple. Chunk recovery uses this no-store view to detect a new release. */ export async function fetchReleaseManifest( url: string, options: FetchReleaseManifestOptions, -): Promise> { - const fetcher = options.fetcher ?? fetch; - let response: Response; - try { - response = await fetcher(url, { - cache: "no-store", - headers: { Accept: "application/json" }, - }); - } catch { - throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", options); +): Promise { + const outcome = await readBoundedBootJson( + url, + BOOT_JSON_POLICIES.RELEASE_MANIFEST, + { + ...(options.fetcher ? { fetcher: options.fetcher } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }, + ); + if (!outcome.ok) { + throw new ReleaseManifestError(READ_FAILURE_CODE[outcome.failure], options); } - if (!response.ok) { - throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options); + + if (outcome.value.schemaVersion === 2) { + const parsed = releaseManifestV2Schema.safeParse(outcome.value); + if (!parsed.success) { + throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options); + } + return Object.freeze(structuredClone(parsed.data)); } - let raw: unknown; - try { - raw = await response.json(); - } catch { - throw new ReleaseManifestError("MANIFEST_JSON_INVALID", options); - } - const parsed = releaseManifestSchema.safeParse(raw); + + const parsed = releaseManifestV1Schema.safeParse(outcome.value); if (!parsed.success) { throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options); } - return Object.freeze(structuredClone(parsed.data)); + const { apiContractVersion, ...rest } = structuredClone(parsed.data); + return Object.freeze({ + ...rest, + contractSet: null, + legacyApiContractVersion: apiContractVersion, + }); } export type LoadReleaseManifestOptions = Readonly<{ fetcher?: typeof fetch; expectedAssetManifestHash?: string; + expectedContractSetPackages?: readonly ContractSetPackage[]; + signal?: AbortSignal; }>; export async function loadReleaseManifest( runtime: RuntimeConfigLoadResult, options: LoadReleaseManifestOptions = {}, -): Promise> { +): Promise { + const identity: ReleaseManifestSafeInput = { + buildId: runtime.build.buildId, + ...(runtime.config.RELEASE_ID + ? { releaseId: runtime.config.RELEASE_ID } + : {}), + }; + const manifest = await fetchReleaseManifest( runtime.config.RELEASE_MANIFEST_URL, { - fetcher: options.fetcher, + ...(options.fetcher ? { fetcher: options.fetcher } : {}), + ...(options.signal ? { signal: options.signal } : {}), buildId: runtime.build.buildId, - releaseId: runtime.config.RELEASE_ID, + ...(runtime.config.RELEASE_ID + ? { releaseId: runtime.config.RELEASE_ID } + : {}), }, ); + + const expectedManifestVersion = runtime.configSchema === "V1" ? 1 : 2; + if (manifest.schemaVersion !== expectedManifestVersion) { + throw new ReleaseManifestError( + "MANIFEST_PROTOCOL_PAIR_MISMATCH", + identity, + ); + } + + // §6.7 steps 5-6, in order: build, config, release, assets, then contractSet. let mismatchCode: ReleaseManifestErrorCode | null = null; if (manifest.buildId !== runtime.build.buildId) { mismatchCode = "MANIFEST_BUILD_MISMATCH"; @@ -164,7 +252,11 @@ export async function loadReleaseManifest( } if ( !mismatchCode && - manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION + runtime.configSchema === "V1" && + (manifest.legacyApiContractVersion === undefined || + runtime.config.LEGACY_API_CONTRACT_VERSION === undefined || + manifest.legacyApiContractVersion !== + runtime.config.LEGACY_API_CONTRACT_VERSION) ) { mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH"; } @@ -183,10 +275,20 @@ export async function loadReleaseManifest( mismatchCode = "MANIFEST_ASSET_MISMATCH"; } if (mismatchCode) { - throw new ReleaseManifestError(mismatchCode, { - buildId: runtime.build.buildId, - releaseId: runtime.config.RELEASE_ID, - }); + throw new ReleaseManifestError(mismatchCode, identity); } + + if (manifest.schemaVersion === 2 && manifest.contractSet) { + const verification = await verifyContractSet({ + expected: + options.expectedContractSetPackages ?? + (EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[]), + manifest: manifest.contractSet, + }); + if (!verification.ok) { + throw new ReleaseManifestError(verification.code, identity); + } + } + return manifest; } diff --git a/src/bootstrap/load-runtime-config.ts b/src/bootstrap/load-runtime-config.ts index 8d31e58..af0f815 100644 --- a/src/bootstrap/load-runtime-config.ts +++ b/src/bootstrap/load-runtime-config.ts @@ -1,9 +1,19 @@ import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.ts"; +import { + BOOT_JSON_POLICIES, + readBoundedBootJson, + type BootLoadFailure, +} from "./read-bounded-boot-json.ts"; import { validateRuntimeConfig, type RuntimeConfig, } from "./runtime-config-schema.ts"; +/** + * §6.6. A safe boot error carries the failure kind, the build identity and a + * support reference. It never carries a URL, a response body, a validation + * value, an endpoint hostname or a stack trace. + */ export type BootConfigSafe = Readonly<{ kind: "BOOT_CONFIG_FAILURE"; code: string; @@ -43,58 +53,57 @@ export type RuntimeConfigLoadOptions = Readonly<{ fetcher?: typeof fetch; buildConfig?: ReturnType; now?: () => number; + signal?: AbortSignal; }>; export type RuntimeConfigLoadResult = Readonly<{ config: RuntimeConfig; + configSchema: "V1" | "V2"; build: ReturnType; validationDurationMs: number; }>; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const READ_FAILURE_CODE: Readonly> = + Object.freeze({ + FETCH_FAILED: "CONFIG_FETCH_FAILED", + TIMEOUT: "CONFIG_TIMEOUT", + HTTP_STATUS_INVALID: "CONFIG_HTTP_FAILED", + CONTENT_TYPE_INVALID: "CONFIG_CONTENT_TYPE_INVALID", + BODY_TOO_LARGE: "CONFIG_BODY_TOO_LARGE", + UTF8_INVALID: "CONFIG_UTF8_INVALID", + JSON_INVALID: "CONFIG_JSON_INVALID", + SHAPE_INVALID: "CONFIG_SHAPE_INVALID", + SECRET_NAME_REJECTED: "CONFIG_SECRET_NAME_REJECTED", + SCHEMA_INVALID: "CONFIG_SCHEMA_INVALID", + BUILD_MISMATCH: "CONFIG_BUILD_MISMATCH", + RELEASE_MISMATCH: "CONFIG_RELEASE_MISMATCH", + ASSET_MISMATCH: "CONFIG_ASSET_MISMATCH", + CONTRACT_SET_MISMATCH: "CONFIG_CONTRACT_SET_MISMATCH", + }); export async function loadRuntimeConfig( options: RuntimeConfigLoadOptions = {}, ): Promise { - const fetcher = options.fetcher ?? fetch; const buildConfig = options.buildConfig ?? getBuildConfig(); const now = options.now ?? performance.now.bind(performance); + + const outcome = await readBoundedBootJson( + buildConfig.runtimeConfigUrl, + BOOT_JSON_POLICIES.RUNTIME_CONFIG, + { + ...(options.fetcher ? { fetcher: options.fetcher } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }, + ); + + if (!outcome.ok) { + throw new BootConfigError(READ_FAILURE_CODE[outcome.failure], { + buildId: buildConfig.buildId, + }); + } + const startedAt = now(); - - let response: Response; - try { - response = await fetcher(buildConfig.runtimeConfigUrl, { - cache: "no-store", - headers: { Accept: "application/json" }, - }); - } catch { - throw new BootConfigError("CONFIG_FETCH_FAILED", { - buildId: buildConfig.buildId, - }); - } - - if (!response.ok) { - throw new BootConfigError("CONFIG_HTTP_FAILED", { - buildId: buildConfig.buildId, - }); - } - - let rawConfig: unknown; - try { - rawConfig = await response.json(); - } catch { - throw new BootConfigError("CONFIG_JSON_INVALID", { - buildId: buildConfig.buildId, - }); - } - - if (!isRecord(rawConfig)) { - throw new BootConfigError("CONFIG_SHAPE_INVALID", { - buildId: buildConfig.buildId, - }); - } + const rawConfig = outcome.value; try { assertSafeConfigNames(rawConfig); @@ -119,7 +128,10 @@ export async function loadRuntimeConfig( }); } - if (validated.data.BUILD_ID && validated.data.BUILD_ID !== buildConfig.buildId) { + if ( + validated.data.BUILD_ID && + validated.data.BUILD_ID !== buildConfig.buildId + ) { throw new BootConfigError("CONFIG_BUILD_MISMATCH", { buildId: buildConfig.buildId, configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION, @@ -127,8 +139,11 @@ export async function loadRuntimeConfig( }); } + // §6.10: the validated snapshot is frozen. Nothing re-reads or mutates it; + // a kill-switch change only takes effect on a new boot. return Object.freeze({ config: validated.data, + configSchema: validated.schema, build: buildConfig, validationDurationMs: now() - startedAt, }); diff --git a/src/bootstrap/main.tsx b/src/bootstrap/main.tsx index 5dcf68a..bc5509e 100644 --- a/src/bootstrap/main.tsx +++ b/src/bootstrap/main.tsx @@ -25,7 +25,7 @@ async function boot(): Promise { initializeColorScheme(composition.application.preferences); root.render(); import.meta.hot?.dispose(() => { - composition.infrastructure.dispose(); + void composition.dispose(); }); } catch (error: unknown) { const safe = diff --git a/src/bootstrap/optional-runtime-host.ts b/src/bootstrap/optional-runtime-host.ts new file mode 100644 index 0000000..d6c465d --- /dev/null +++ b/src/bootstrap/optional-runtime-host.ts @@ -0,0 +1,152 @@ +import { + createBrowserLifecycleRuntime, + type BrowserLifecycleRuntime, +} from "../adapters/platform/browser-lifecycle.ts"; +import type { + ResolvedRuntimeCapabilities, + RuntimeHealth, + RuntimeStopReason, +} from "../contracts/runtime-capabilities.ts"; +import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts"; +import { createServiceWorkerRuntimeHost } from "./register-service-worker.ts"; + +/** + * §3.4 / §20.3. Optional runtime host. + * + * Creating this object has no side effect: no listener, timer, network call, + * IndexedDB open or worker is created until `startAfterMount()` runs from the + * first committed React effect. `stop()` unwinds in reverse order. + */ + +export type OptionalRuntimeHost = Readonly<{ + readonly realtime: null; + readonly webWorkers: null; + readonly serviceWorker: ServiceWorkerRuntimeHost | null; + readonly offlineCommands: null; + browserLifecycle(): BrowserLifecycleRuntime | null; + health(): Readonly>; + startAfterMount(): Promise; + stop(reason?: RuntimeStopReason): Promise; +}>; + +export type OptionalRuntimeHostInput = Readonly<{ + capabilities: ResolvedRuntimeCapabilities; + routerBasePath: string; + buildId: string; + /** Explicit host seam for deterministic lifecycle tests and platform shells. */ + serviceWorkerHost?: ServiceWorkerRuntimeHost | null; + browserLifecycleHost?: Parameters[0]; + host?: Parameters[0]["host"]; + blockers?: readonly (() => boolean)[]; + observe?: (observation: Readonly<{ event: string; outcome: string }>) => void; +}>; + +export function createOptionalRuntimeHost( + input: OptionalRuntimeHostInput, +): OptionalRuntimeHost { + const { capabilities } = input; + + const serviceWorker = Object.hasOwn(input, "serviceWorkerHost") + ? (input.serviceWorkerHost ?? null) + : createServiceWorkerRuntimeHost({ + capabilities, + routerBasePath: input.routerBasePath, + buildId: input.buildId, + ...(input.host ? { host: input.host } : {}), + ...(input.blockers ? { blockers: input.blockers } : {}), + ...(input.observe ? { observe: input.observe } : {}), + }); + + let lifecycle: BrowserLifecycleRuntime | null = null; + let started = false; + let startPromise: Promise | null = null; + let stopPromise: Promise | null = null; + let stopRequested = false; + const health: Record = { + realtime: capabilities.realtime.length === 0 ? "DISABLED" : "UNAVAILABLE", + webWorkers: capabilities.webWorkers.length === 0 ? "DISABLED" : "UNAVAILABLE", + serviceWorker: serviceWorker ? "UNAVAILABLE" : "DISABLED", + offlineCommands: capabilities.offlineCommands ? "UNAVAILABLE" : "DISABLED", + }; + + /** + * §3.4 start order: + * 1. offline foreground browser lifecycle observer + * 2. realtime runtime + * 3. no Web Worker prewarm + * 4. Service Worker active/cleanup controller + */ + async function startAfterMount(): Promise { + // A stopped host is terminal. Its children (notably the Service Worker page + // controller) own one-shot listeners and timers and cannot be resurrected. + if (stopPromise) return stopPromise; + if (startPromise) return startPromise; + startPromise = (async () => { + // 1. The lifecycle observer is the single window listener owner. It is + // only created when something downstream can actually consume it. + if (serviceWorker || capabilities.realtime.length > 0) { + lifecycle = createBrowserLifecycleRuntime(input.browserLifecycleHost); + } + + // 2. Realtime stays NOT_SELECTED until a product contribution exists + // (§13.6), so there is nothing to start and nothing to observe. + + // 3. Web Workers are created lazily on first task; there is no prewarm. + + // 4. Service Worker registration or the exact-owned cleanup action. + if (serviceWorker) { + const outcome = await serviceWorker.start(); + // `stop()` may have fenced this generation while start was awaiting a + // browser operation. A late result must never reactivate health. + if (!stopRequested) { + health.serviceWorker = + outcome.kind === "ACTIVE" + ? "AVAILABLE" + : outcome.kind === "DISABLED" + ? "DISABLED" + : outcome.kind === "INCOMPATIBLE" + ? "INCOMPATIBLE" + : "DEGRADED"; + } + } + if (!stopRequested) started = true; + })(); + return startPromise; + } + + async function stop( + reason: RuntimeStopReason = "APPLICATION_SHUTDOWN", + ): Promise { + void reason; + stopRequested = true; + if (stopPromise) return stopPromise; + stopPromise = (async () => { + // Serialize teardown behind any in-flight browser registration. Cleanup + // then observes the final acquired resources and unwinds them exactly once. + await startPromise?.catch(() => {}); + // Reverse of the start order. + if (serviceWorker && (started || startPromise)) { + await serviceWorker.stop().catch(() => {}); + } + if (serviceWorker) { + health.serviceWorker = "DISABLED"; + } + lifecycle?.dispose(); + lifecycle = null; + started = false; + startPromise = null; + })(); + return stopPromise; + } + + return Object.freeze({ + realtime: null, + webWorkers: null, + serviceWorker, + offlineCommands: null, + browserLifecycle: () => lifecycle, + health: () => Object.freeze({ ...health }), + startAfterMount, + stop, + }); +} diff --git a/src/bootstrap/read-bounded-boot-json.ts b/src/bootstrap/read-bounded-boot-json.ts new file mode 100644 index 0000000..161964d --- /dev/null +++ b/src/bootstrap/read-bounded-boot-json.ts @@ -0,0 +1,189 @@ +/** + * §6.4–§6.5. The only boot-time JSON reader. + * + * `response.json()` and unbounded `response.text()` are prohibited: a hostile or + * misconfigured origin must not be able to amplify boot memory, and an HTML + * error page must not reach `JSON.parse` as if it were configuration. + */ + +export type BootJsonOperation = "RUNTIME_CONFIG" | "RELEASE_MANIFEST"; + +export interface BootJsonPolicy { + readonly operation: BootJsonOperation; + readonly maximumBytes: number; + readonly totalDeadlineMs: 5_000; +} + +export const BOOT_JSON_POLICIES = Object.freeze({ + RUNTIME_CONFIG: Object.freeze({ + operation: "RUNTIME_CONFIG" as const, + maximumBytes: 65_536, + totalDeadlineMs: 5_000 as const, + }), + RELEASE_MANIFEST: Object.freeze({ + operation: "RELEASE_MANIFEST" as const, + maximumBytes: 1_048_576, + totalDeadlineMs: 5_000 as const, + }), +} satisfies Readonly>); + +export type BootLoadFailure = + | "FETCH_FAILED" + | "TIMEOUT" + | "HTTP_STATUS_INVALID" + | "CONTENT_TYPE_INVALID" + | "BODY_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SHAPE_INVALID" + | "SECRET_NAME_REJECTED" + | "SCHEMA_INVALID" + | "BUILD_MISMATCH" + | "RELEASE_MISMATCH" + | "ASSET_MISMATCH" + | "CONTRACT_SET_MISMATCH"; + +export type BootJsonOutcome = + | Readonly<{ ok: true; value: Readonly> }> + | Readonly<{ ok: false; failure: BootLoadFailure }>; + +export type ReadBoundedBootJsonOptions = Readonly<{ + fetcher?: typeof fetch; + signal?: AbortSignal; +}>; + +function isJsonMediaType(headerValue: string | null): boolean { + if (!headerValue) return false; + const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return essence === "application/json" || essence.endsWith("+json"); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export async function readBoundedBootJson( + url: string, + policy: BootJsonPolicy, + options: ReadBoundedBootJsonOptions = {}, +): Promise { + if (options.signal?.aborted) return fail("FETCH_FAILED"); + + const fetcher = options.fetcher ?? fetch; + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, policy.totalDeadlineMs); + const forwardAbort = () => controller.abort(); + options.signal?.addEventListener("abort", forwardAbort, { once: true }); + + try { + let response: Response; + try { + response = await fetcher(url, { + method: "GET", + cache: "no-store", + credentials: "same-origin", + redirect: "error", + referrerPolicy: "no-referrer", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + } catch { + return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED"); + } + + if (response.status !== 200 || response.redirected) { + await discard(response); + return fail("HTTP_STATUS_INVALID"); + } + if (!isJsonMediaType(response.headers.get("content-type"))) { + await discard(response); + return fail("CONTENT_TYPE_INVALID"); + } + + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > policy.maximumBytes) { + await discard(response); + return fail("BODY_TOO_LARGE"); + } + + const bytes = await readBoundedBytes(response, policy.maximumBytes, controller); + if (bytes === "TOO_LARGE") return fail("BODY_TOO_LARGE"); + if (bytes === "STREAM_FAILED") return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED"); + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return fail("UTF8_INVALID"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return fail("JSON_INVALID"); + } + if (!isRecord(parsed)) return fail("SHAPE_INVALID"); + + return Object.freeze({ ok: true as const, value: parsed }); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", forwardAbort); + } +} + +async function readBoundedBytes( + response: Response, + maximumBytes: number, + controller: AbortController, +): Promise { + const body = response.body; + if (!body) { + // A body-less 200 cannot satisfy any boot document. + return new Uint8Array(0); + } + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maximumBytes) { + await reader.cancel().catch(() => {}); + controller.abort(); + return "TOO_LARGE"; + } + chunks.push(value); + } + } catch { + await reader.cancel().catch(() => {}); + return "STREAM_FAILED"; + } + + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +async function discard(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Cancelling an already-settled body is not a boot failure. + } +} + +function fail(failure: BootLoadFailure): BootJsonOutcome { + return Object.freeze({ ok: false as const, failure }); +} diff --git a/src/bootstrap/register-service-worker.ts b/src/bootstrap/register-service-worker.ts new file mode 100644 index 0000000..ff87d96 --- /dev/null +++ b/src/bootstrap/register-service-worker.ts @@ -0,0 +1,57 @@ +import type { + ResolvedRuntimeCapabilities, +} from "../contracts/runtime-capabilities.ts"; +import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts"; +import { createServiceWorkerPageController } from "../adapters/service-worker/service-worker-page-controller.ts"; + +/** + * §17.5. Composition-side factory. The host object is created without any + * side effect; `start()` is only called from the post-mount runtime starter, + * after Runtime Config, the release manifest and the contract set have all + * validated and React has committed its first render. + */ + +export type ServiceWorkerHostInput = Readonly<{ + capabilities: ResolvedRuntimeCapabilities; + routerBasePath: string; + buildId: string; + host?: Readonly<{ + location?: Pick; + navigator?: Pick; + caches?: CacheStorage; + document?: Pick; + }>; + blockers?: readonly (() => boolean)[]; + observe?: (observation: Readonly<{ event: string; outcome: string }>) => void; +}>; + +export function createServiceWorkerRuntimeHost( + input: ServiceWorkerHostInput, +): ServiceWorkerRuntimeHost | null { + const { capabilities } = input; + + // §3.6. `null` selection with no disable-cleanup obligation means zero + // registration lookups and zero Cache Storage access: no host at all. + if (!capabilities.serviceWorker && !capabilities.serviceWorkerDisabledCleanup) { + return null; + } + + const host = + input.host ?? + (globalThis as unknown as NonNullable); + const container = host?.navigator?.serviceWorker; + const origin = host?.location?.origin; + if (!origin) return null; + + return createServiceWorkerPageController({ + selection: capabilities.serviceWorker, + disabledCleanup: capabilities.serviceWorkerDisabledCleanup, + routerBasePath: input.routerBasePath, + origin, + buildId: input.buildId, + ...(container ? { container } : {}), + ...(host?.caches ? { caches: host.caches } : {}), + ...(input.blockers ? { blockers: input.blockers } : {}), + ...(input.observe ? { observe: input.observe } : {}), + }); +} diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index d9c5a5d..373069a 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -6,23 +6,32 @@ import { } from "../adapters/auth/external-session-adapter.ts"; import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts"; import { createHttpClient } from "../adapters/http/client.ts"; +import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts"; import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts"; -import { createTanStackCacheCoordinator } from "../adapters/query-cache/tanstack-cache-coordinator.ts"; +import { + createTanStackCacheCoordinator, + type InstalledQueryInvalidationDefinition, +} from "../adapters/query-cache/tanstack-cache-coordinator.ts"; import { createQueryClient } from "../adapters/query-cache/tanstack-query-cache.ts"; import { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts"; import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts"; import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts"; import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts"; import type { AuthSessionPort } from "../application/ports/auth-session-port.ts"; +import type { ReleaseInfo } from "../application/ports/release-info-port.ts"; import { createRestProviderProfile } from "../contracts/rest-profiles.ts"; import type { ClockPort } from "../application/ports/clock-port.ts"; import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts"; import { QUERY_REGISTRY } from "../features/installed-feature-contracts.ts"; +import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; +import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts"; import { fetchReleaseManifest, type ReleaseManifest, } from "./load-release-manifest.ts"; import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts"; +import { createServerStateGenerationStore } from "./server-state-generation-store.ts"; +import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../features/installed-contract-contributions.ts"; type HttpClientDependencies = Parameters[0]; export type RuntimeHttpContract = Pick< @@ -47,6 +56,26 @@ export type RuntimeAdaptersContext = Readonly<{ fetcher?: typeof fetch; }>; +/** + * §5.2. The composition root is where a manifest becomes release info. A V2 + * manifest states contract identity as a verified contract set and a V1 + * manifest as the legacy scalar; the application layer sees one shape and never + * branches on the schema version to find the identity. + */ +function toReleaseInfo(manifest: Readonly): ReleaseInfo { + const { contractSet, legacyApiContractVersion, ...rest } = + structuredClone(manifest); + return Object.freeze({ + ...rest, + ...(legacyApiContractVersion === undefined + ? {} + : { apiContractVersion: legacyApiContractVersion }), + ...(contractSet === null + ? {} + : { contractSetDigest: contractSet.setDigest }), + }); +} + const EXTERNAL_OWNER_METHODS = Object.freeze([ "readState", "subscribe", @@ -155,48 +184,69 @@ export async function createRuntimeAdapters( }); }, }); - const queryClient = createQueryClient({ diagnostics }); - const crossContextInvalidation = - createBrowserCrossContextInvalidationFromHost({ - ...(context.host === undefined ? {} : { host: context.host }), - cacheEpoch: `release.${context.release.releaseId}`, - topics: Object.values(QUERY_REGISTRY).map((definition) => - Object.freeze({ - topic: definition.invalidationTopic, - topicVersion: definition.version, - }), - ), - observe(observation) { - if ( - observation.outcome !== "FAILED" && - observation.outcome !== "DEGRADED" - ) { - return; - } - diagnostics.record({ - level: "warn", - eventId: "cache.operation.failed", - context: { - operation: observation.operation, - outcome: observation.outcome, - reason: observation.reason, - }, - }); - }, + // The composition root states the registry shape it consumes rather than + // inferring it from whichever features happen to be installed, so a build + // with zero installed features still type-checks. + const queryRegistry: Readonly< + Record + > = QUERY_REGISTRY; + const conditionalValidators = createConditionalValidatorStore(); + const serverStateGeneration = createServerStateGenerationStore(() => { + const queryClient = createQueryClient({ diagnostics }); + const crossContextInvalidation = + createBrowserCrossContextInvalidationFromHost({ + ...(context.host === undefined ? {} : { host: context.host }), + cacheEpoch: `release.${context.release.releaseId}`, + topics: Object.values(queryRegistry).map((definition) => + Object.freeze({ + topic: definition.invalidationTopic, + topicVersion: definition.version, + }), + ), + observe(observation) { + if ( + observation.outcome !== "FAILED" && + observation.outcome !== "DEGRADED" + ) { + return; + } + diagnostics.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation: observation.operation, + outcome: observation.outcome, + reason: observation.reason, + }, + }); + }, + }); + const queryInvalidation = createTanStackCacheCoordinator({ + queryClient, + queryRegistry, + crossContext: crossContextInvalidation, + diagnostics, + }); + return Object.freeze({ + queryClient, + queryInvalidation, + crossContextStatus: () => + crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY", }); - const queryInvalidation = createTanStackCacheCoordinator({ - queryClient, - queryRegistry: QUERY_REGISTRY, - crossContext: crossContextInvalidation, - diagnostics, }); const serverStateScope = createServerStateScopeRuntime({ session: authSession, - queryInvalidation, - }); - const conditionalValidators = createConditionalValidatorStore(); - const unsubscribeConditionalScope = serverStateScope.subscribe(() => { - conditionalValidators.clear(); + queryInvalidation: { + resetLocal: () => serverStateGeneration.resetCurrent(), + }, + participants: [ + { + order: 4, + label: "conditional-validators", + close: () => conditionalValidators.clear(), + }, + ], + activateNextGeneration: () => serverStateGeneration.activateNext(), }); const storage = createBrowserStorageAdapter({ localStorage: storageOrUndefined(hostValue(host, "localStorage")), @@ -207,14 +257,24 @@ export async function createRuntimeAdapters( }); const releaseInfo = Object.freeze({ async getCurrent() { - return structuredClone(context.release); + return toReleaseInfo(context.release); }, async refresh() { - return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, { - fetcher: context.fetcher, - buildId: context.release.buildId, - releaseId: context.release.releaseId, - }); + return toReleaseInfo( + await fetchReleaseManifest(config.RELEASE_MANIFEST_URL, { + fetcher: context.fetcher, + buildId: context.release.buildId, + releaseId: context.release.releaseId, + }), + ); + }, + }); + const runtimeCapabilities = Object.freeze({ + getSnapshot() { + return describeRuntimeCapabilities( + INSTALLED_RUNTIME_CAPABILITIES, + config.CAPABILITY_OVERRIDES, + ); }, }); const navigation = Object.freeze({ @@ -226,18 +286,105 @@ export async function createRuntimeAdapters( location.reload(); }, }); + const contractHttp = createContractHttpExecutor({ + baseUrl: config.API_BASE_URL, + maxRetryAttempts: config.MAX_RETRY_ATTEMPTS, + fetcher: context.fetcher, + async attachCredentials(operation) { + if (serverStateScope.getPhase() !== "READY") { + return Object.freeze({ kind: "SCOPE_FENCED" as const }); + } + const state = authSession.getState(); + if (state === "integration-failed") { + return Object.freeze({ kind: "UNAVAILABLE" as const }); + } + if (state !== "authenticated") { + return Object.freeze({ kind: "UNAUTHENTICATED" as const }); + } + try { + const patch = await authSession.credentialPatch({ + origin: new URL(config.API_BASE_URL).origin, + method: operation.method, + operationId: operation.operationId, + }); + if (serverStateScope.getPhase() !== "READY") { + return Object.freeze({ kind: "SCOPE_FENCED" as const }); + } + return Object.freeze({ + kind: "READY" as const, + headers: patch.headers, + credentials: "omit" as const, + }); + } catch { + return Object.freeze({ kind: "UNAVAILABLE" as const }); + } + }, + observe(observation) { + try { + diagnostics.record({ + level: + observation.outcome === "SUCCESS" ? "info" : "warn", + eventId: "http.request.completed", + context: { + operation_id: observation.diagnosticsOperation, + outcome: observation.outcome, + attempts: observation.attempts, + certainty: observation.certainty, + }, + }); + } catch { + // Diagnostics cannot change a contract execution outcome. + } + }, + }); + let contractExecutionSequence = 0; + const contractOperations = Object.freeze({ + async execute( + operationId: string, + input: unknown, + executionContext: Readonly<{ signal?: AbortSignal }> = {}, + ) { + const operation = + COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId); + if (!operation) { + return Object.freeze({ + kind: "CONTRACT_VIOLATION" as const, + effect: "NOT_STARTED" as const, + violation: Object.freeze({ + kind: "FINAL_REQUEST_INVARIANT_FAILED" as const, + operation: "REQUEST" as const, + }), + }); + } + contractExecutionSequence += 1; + const intentId = `http-intent-${contractExecutionSequence}`; + const isCommand = operation.contract.commandEffect !== null; + const requiresKey = operation.contract.retrySemantics === "KEYED"; + const outcome = await contractHttp.execute(operation, input, { + scope: serverStateScope.getSnapshot(), + ...(executionContext.signal === undefined + ? {} + : { signal: executionContext.signal }), + ...(isCommand + ? { + intent: Object.freeze({ + intentId, + startedBy: "USER" as const, + ...(requiresKey + ? { idempotencyKey: `http-key-${contractExecutionSequence}` } + : {}), + }), + } + : {}), + }); + if (outcome.kind === "UNAUTHENTICATED") { + authSession.onUnauthenticated(); + } + return outcome; + }, + }); const featureInputs = createInstalledFeatureInputs({ - createHttpClient: (contract) => - createRuntimeHttpClient( - { - runtime: context.runtime, - authSession, - fetcher: context.fetcher, - diagnostics, - telemetry, - }, - contract, - ), + contractOperations, }); return Object.freeze({ @@ -247,20 +394,25 @@ export async function createRuntimeAdapters( diagnostics, telemetry, releaseInfo, + runtimeCapabilities, navigation, }), infrastructure: Object.freeze({ - queryClient, - queryInvalidation, + get queryClient() { + return serverStateGeneration.getSnapshot().queryClient; + }, + get queryInvalidation() { + return serverStateGeneration.getSnapshot().queryInvalidation; + }, + serverStateGeneration, serverStateScope, conditionalValidators, crossContextInvalidationStatus: () => - crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY", + serverStateGeneration.getSnapshot().crossContextStatus(), dispose() { - unsubscribeConditionalScope(); conditionalValidators.clear(); serverStateScope.dispose(); - queryInvalidation.dispose(); + serverStateGeneration.dispose(); }, }), featureInputs, diff --git a/src/bootstrap/runtime-application.tsx b/src/bootstrap/runtime-application.tsx index 452d53b..480e281 100644 --- a/src/bootstrap/runtime-application.tsx +++ b/src/bootstrap/runtime-application.tsx @@ -1,9 +1,7 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { StrictMode } from "react"; +import { StrictMode, useEffect } from "react"; import { ApplicationProvider } from "../presentation/providers/application-provider.tsx"; -import { QueryInvalidationProvider } from "../presentation/adapters/query/query-invalidation-provider.tsx"; -import { ServerStateScopeProvider } from "../presentation/adapters/query/server-state-scope-provider.tsx"; +import { ServerStateGenerationProvider } from "../presentation/adapters/query/server-state-generation-provider.tsx"; import { AppRouter } from "../presentation/routes/app-router.tsx"; import type { RuntimeComposition } from "./create-runtime-composition.ts"; @@ -11,27 +9,44 @@ import type { RuntimeComposition } from "./create-runtime-composition.ts"; * Production provider tree. Tests import this component so the validated * composition is proven against the same provider order used by main. */ +/** + * §6.7 step 16 / §17.5. Optional runtime starts from the first committed + * effect, never during render and never during composition. StrictMode double + * invocation is safe: `startAfterMount` returns the same in-flight promise. + */ +function PostMountRuntimeStarter({ + composition, +}: Readonly<{ composition: RuntimeComposition }>) { + useEffect(() => { + void composition.optional.startAfterMount(); + }, [composition]); + return null; +} + export function RuntimeApplication({ composition, }: Readonly<{ composition: RuntimeComposition }>) { return ( - - - - - - - - - + + } + > + + + + + ); } diff --git a/src/bootstrap/runtime-config-schema.ts b/src/bootstrap/runtime-config-schema.ts index 921c497..a7b1d68 100644 --- a/src/bootstrap/runtime-config-schema.ts +++ b/src/bootstrap/runtime-config-schema.ts @@ -1,79 +1,138 @@ -import { z } from "zod"; +import { + runtimeConfigV1ArtifactSchema, + runtimeConfigV2ArtifactSchema, + type CapabilityOverrideArtifact, + type RuntimeConfigV1Artifact, + type RuntimeConfigV2Artifact, +} from "../contracts/release-artifacts.ts"; -const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/); +export { isValidReleaseManifestUrl } from "../contracts/release-artifacts.ts"; -export const runtimeConfigSchema = z - .object({ - APP_ENV: z.enum(["local", "development", "staging", "production"]), - API_BASE_URL: z.url(), - REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000), - MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), - TELEMETRY_ENABLED: z.boolean(), - TELEMETRY_ENDPOINT: z.url().optional(), - AUTH_MODE: z.enum(["external", "demo"]), - CONFIG_SCHEMA_VERSION: version, - API_CONTRACT_VERSION: version, - RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), - RELEASE_ID: z.string().min(1).optional(), - BUILD_ID: z.string().min(1).optional(), - }) - .strict() - .superRefine((config, context) => { - if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) { - context.addIssue({ - code: "custom", - path: ["TELEMETRY_ENDPOINT"], - message: "required when telemetry is enabled", - }); - } +export type CapabilityOverrides = CapabilityOverrideArtifact; - const local = config.APP_ENV === "local" || config.APP_ENV === "development"; - if (!local && config.AUTH_MODE === "demo") { - context.addIssue({ - code: "custom", - path: ["AUTH_MODE"], - message: "demo authentication is limited to local environments", - }); - } - const endpointEntries = [ - ["API_BASE_URL", config.API_BASE_URL], - ["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT], - ] as const; +/** + * §6.1. Runtime Config V2 is deployment and browser operational setting only. + * `API_CONTRACT_VERSION` is gone: a scalar cannot describe a multi-package + * contract set, and Release Manifest V2 `contractSet` owns that meaning. + */ +export const runtimeConfigV2Schema = runtimeConfigV2ArtifactSchema; - for (const [key, value] of endpointEntries) { - if (value && !local && new URL(value).protocol !== "https:") { - context.addIssue({ - code: "custom", - path: [key], - message: "HTTPS is required outside local environments", - }); - } - } - }); +/** + * §5.8 / §24.6 RC-2. The V1 reader is retained for one compatibility window so + * a release never swaps source shape, manifest shape and runtime behaviour at + * the same time. Only a V1 document may carry the scalar contract version. + */ +export const runtimeConfigV1Schema = runtimeConfigV1ArtifactSchema; + +export type RuntimeConfigV2 = RuntimeConfigV2Artifact; +export type RuntimeConfigV1 = RuntimeConfigV1Artifact; + +/** + * The composition-facing shape. V1 documents are normalized onto it so the rest + * of the runtime never branches on config schema version. + */ +export type RuntimeConfig = Readonly<{ + APP_ENV: RuntimeConfigV2["APP_ENV"]; + API_BASE_URL: string; + REQUEST_TIMEOUT_MS: number; + MAX_RETRY_ATTEMPTS: number; + TELEMETRY_ENABLED: boolean; + TELEMETRY_ENDPOINT?: string; + AUTH_MODE: RuntimeConfigV2["AUTH_MODE"]; + CONFIG_SCHEMA_VERSION: string; + RELEASE_MANIFEST_URL: string; + RELEASE_ID?: string; + BUILD_ID?: string; + CAPABILITY_OVERRIDES: CapabilityOverrides; + /** Present only while a V1 document is still accepted. */ + LEGACY_API_CONTRACT_VERSION?: string; +}>; -export type RuntimeConfig = z.output; export type RuntimeConfigValidation = - | Readonly<{ success: true; data: RuntimeConfig }> + | Readonly<{ success: true; data: RuntimeConfig; schema: "V1" | "V2" }> | Readonly<{ success: false; issues: readonly Readonly<{ path: string; code: string }>[]; }>; -export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation { - const result = runtimeConfigSchema.safeParse(value); +const DEFAULT_OVERRIDES: CapabilityOverrides = Object.freeze({ + REALTIME: "DEFAULT" as const, + WEB_WORKER: "DEFAULT" as const, + SERVICE_WORKER: "DEFAULT" as const, + OFFLINE_COMMANDS: "DEFAULT" as const, +}); - if (!result.success) { - return { - success: false, - issues: result.error.issues.map((issue) => ({ - path: issue.path.join("."), - code: issue.code, - })), - }; +function canonicalUrl(value: string): string { + return new URL(value).href; +} + +export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation { + const declared = + value && typeof value === "object" + ? (value as Record).CONFIG_SCHEMA_VERSION + : undefined; + + // §5.8: no precedence between V1 and V2. The declared version selects exactly + // one parser, and a V2 document carrying the removed scalar is rejected. + if (declared !== "1" && declared !== "2.0") { + return Object.freeze({ + success: false as const, + issues: Object.freeze([ + Object.freeze({ + path: "CONFIG_SCHEMA_VERSION", + code: "unsupported_value", + }), + ]), + }); } - return { - success: true, - data: structuredClone(result.data), - }; + const isV2 = declared === "2.0"; + const result = isV2 + ? runtimeConfigV2Schema.safeParse(value) + : runtimeConfigV1Schema.safeParse(value); + + if (!result.success) { + return Object.freeze({ + success: false as const, + issues: Object.freeze( + result.error.issues.map((issue) => + Object.freeze({ path: issue.path.join("."), code: issue.code }), + ), + ), + }); + } + + const parsed = result.data; + const normalized: RuntimeConfig = Object.freeze({ + APP_ENV: parsed.APP_ENV, + API_BASE_URL: canonicalUrl(parsed.API_BASE_URL), + REQUEST_TIMEOUT_MS: parsed.REQUEST_TIMEOUT_MS, + MAX_RETRY_ATTEMPTS: parsed.MAX_RETRY_ATTEMPTS, + TELEMETRY_ENABLED: parsed.TELEMETRY_ENABLED, + ...(parsed.TELEMETRY_ENDPOINT + ? { TELEMETRY_ENDPOINT: canonicalUrl(parsed.TELEMETRY_ENDPOINT) } + : {}), + AUTH_MODE: parsed.AUTH_MODE, + CONFIG_SCHEMA_VERSION: parsed.CONFIG_SCHEMA_VERSION, + RELEASE_MANIFEST_URL: parsed.RELEASE_MANIFEST_URL, + ...(parsed.RELEASE_ID ? { RELEASE_ID: parsed.RELEASE_ID } : {}), + ...(parsed.BUILD_ID ? { BUILD_ID: parsed.BUILD_ID } : {}), + CAPABILITY_OVERRIDES: Object.freeze({ + ...(isV2 + ? (parsed as RuntimeConfigV2).CAPABILITY_OVERRIDES + : DEFAULT_OVERRIDES), + }), + ...(isV2 + ? {} + : { + LEGACY_API_CONTRACT_VERSION: (parsed as RuntimeConfigV1) + .API_CONTRACT_VERSION, + }), + }); + + return Object.freeze({ + success: true as const, + data: normalized, + schema: isV2 ? ("V2" as const) : ("V1" as const), + }); } diff --git a/src/bootstrap/server-state-generation-store.ts b/src/bootstrap/server-state-generation-store.ts new file mode 100644 index 0000000..450290e --- /dev/null +++ b/src/bootstrap/server-state-generation-store.ts @@ -0,0 +1,74 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { CrossContextInvalidationStatus } from "../adapters/cross-context-invalidation/index.ts"; +import type { QueryInvalidationCoordinator } from "../contracts/query-invalidation.ts"; + +export type ServerStateGenerationResources = Readonly<{ + queryClient: QueryClient; + queryInvalidation: QueryInvalidationCoordinator; + crossContextStatus(): CrossContextInvalidationStatus; +}>; + +export type ServerStateGenerationSnapshot = + ServerStateGenerationResources & Readonly<{ generation: number }>; + +export type ServerStateGenerationStore = Readonly<{ + getSnapshot(): ServerStateGenerationSnapshot; + subscribe(listener: () => void): () => void; + resetCurrent(): Promise; + activateNext(): void; + dispose(): void; +}>; + +export function createServerStateGenerationStore( + createResources: (generation: number) => ServerStateGenerationResources, +): ServerStateGenerationStore { + const listeners = new Set<() => void>(); + let disposed = false; + let current = snapshot(1, createResources(1)); + + function publish(): void { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + // Provider defects cannot change generation ownership. + } + } + } + + return Object.freeze({ + getSnapshot: () => current, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async resetCurrent() { + if (disposed) throw new TypeError("Server-state generations are disposed."); + await current.queryInvalidation.resetLocal(); + }, + activateNext() { + if (disposed) throw new TypeError("Server-state generations are disposed."); + const previous = current; + previous.queryInvalidation.dispose(); + previous.queryClient.clear(); + const generation = previous.generation + 1; + current = snapshot(generation, createResources(generation)); + publish(); + }, + dispose() { + if (disposed) return; + disposed = true; + listeners.clear(); + current.queryInvalidation.dispose(); + current.queryClient.clear(); + }, + }); +} + +function snapshot( + generation: number, + resources: ServerStateGenerationResources, +): ServerStateGenerationSnapshot { + return Object.freeze({ generation, ...resources }); +} diff --git a/src/contracts/contract-set-canonical.ts b/src/contracts/contract-set-canonical.ts new file mode 100644 index 0000000..2eb23dd --- /dev/null +++ b/src/contracts/contract-set-canonical.ts @@ -0,0 +1,111 @@ +/** + * §5.3. The single canonical byte producer for a contract set. + * + * Node build scripts and the browser runtime share this function. Only the hash + * adapter differs (Node `crypto` vs Web Crypto), so a digest can never diverge + * because of JSON property order or a locale-sensitive sort. + */ + +export type ContractSetPackage = Readonly<{ + packageId: string; + version: string; + digest: `sha256:${string}`; + runtimeProtocolVersion: 1; + sourceRevision: string; +}>; + +export const CONTRACT_SET_ALGORITHM = "CA_CONTRACT_SET_V1" as const; + +const HEADER = "CA_FRONTEND_CONTRACT_SET_V1\u0000"; + +const encoder = new TextEncoder(); + +function compareUtf8(left: string, right: string): number { + const a = encoder.encode(left); + const b = encoder.encode(right); + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index += 1) { + const difference = (a[index] as number) - (b[index] as number); + if (difference !== 0) return difference; + } + return a.length - b.length; +} + +export function canonicalizeContractSet( + packages: readonly ContractSetPackage[], +): Uint8Array { + const seen = new Set(); + for (const entry of packages) { + if (seen.has(entry.packageId)) { + throw new TypeError("Duplicate contract set package identity."); + } + seen.add(entry.packageId); + } + + const sorted = [...packages].sort((left, right) => + compareUtf8(left.packageId, right.packageId), + ); + + const chunks: Uint8Array[] = [encoder.encode(HEADER)]; + for (const entry of sorted) { + appendString(chunks, entry.packageId); + appendString(chunks, entry.version); + appendString(chunks, entry.digest); + chunks.push(u32be(entry.runtimeProtocolVersion)); + appendString(chunks, entry.sourceRevision); + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + return output; +} + +function appendString(chunks: Uint8Array[], value: string): void { + const bytes = encoder.encode(value); + chunks.push(u32be(bytes.length)); + chunks.push(bytes); +} + +function u32be(value: number): Uint8Array { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new TypeError("Contract set length prefix is out of range."); + } + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, false); + return bytes; +} + +export function toLowerHex(digest: ArrayBuffer | Uint8Array): string { + const bytes = + digest instanceof Uint8Array ? digest : new Uint8Array(digest); + let output = ""; + for (const byte of bytes) output += byte.toString(16).padStart(2, "0"); + return output; +} + +/** + * Browser-side digest. Node callers pass their own `crypto.createHash` adapter + * through {@link computeContractSetDigestWith}. + */ +export async function computeContractSetDigest( + packages: readonly ContractSetPackage[], +): Promise<`sha256:${string}`> { + const bytes = canonicalizeContractSet(packages); + const buffer = await crypto.subtle.digest( + "SHA-256", + bytes.slice().buffer as ArrayBuffer, + ); + return `sha256:${toLowerHex(buffer)}`; +} + +export function computeContractSetDigestWith( + packages: readonly ContractSetPackage[], + sha256: (bytes: Uint8Array) => Uint8Array, +): `sha256:${string}` { + return `sha256:${toLowerHex(sha256(canonicalizeContractSet(packages)))}`; +} diff --git a/src/contracts/contract-set.ts b/src/contracts/contract-set.ts new file mode 100644 index 0000000..caf3956 --- /dev/null +++ b/src/contracts/contract-set.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +import { + CONTRACT_SET_ALGORITHM, + computeContractSetDigest, + type ContractSetPackage, +} from "./contract-set-canonical.ts"; + +/** + * §5. Contract set and release coherence. + * + * The frontend verifies that the packages compiled into this build match the + * packages the release manifest declares. It never negotiates ranges, resolves + * `latest`, or infers a provider runtime version. + */ + +export type ContractSetFailureCode = + | "CONTRACT_SET_SCHEMA_INVALID" + | "CONTRACT_SET_ENTRY_INVALID" + | "CONTRACT_SET_DUPLICATE_PACKAGE" + | "CONTRACT_SET_DIGEST_INVALID" + | "CONTRACT_SET_DIGEST_MISMATCH" + | "CONTRACT_SET_PACKAGE_MISSING" + | "CONTRACT_SET_PACKAGE_UNEXPECTED" + | "CONTRACT_SET_VERSION_MISMATCH" + | "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED"; + +const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/); + +export const contractSetPackageSchema = z + .object({ + packageId: z + .string() + .regex(/^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/), + version: z + .string() + .regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/, + ), + digest: digestSchema, + runtimeProtocolVersion: z.literal(1), + sourceRevision: z.string().regex(/^[0-9a-f]{7,64}$/), + }) + .strict(); + +export const contractSetSchema = z + .object({ + setAlgorithm: z.literal(CONTRACT_SET_ALGORITHM), + setDigest: digestSchema, + packages: z.array(contractSetPackageSchema).max(256), + }) + .strict(); + +export type ContractSet = z.output; + +export type ContractSetVerification = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; code: ContractSetFailureCode }>; + +/** + * §5.5. Every comparison below must hold. There is no precedence rule between + * the embedded expectation and the manifest: a disagreement fails the boot. + */ +export async function verifyContractSet( + input: Readonly<{ + expected: readonly ContractSetPackage[]; + manifest: ContractSet; + expectedSetDigest?: `sha256:${string}`; + }>, +): Promise { + const manifestIds = new Set(); + for (const entry of input.manifest.packages) { + if (manifestIds.has(entry.packageId)) { + return failure("CONTRACT_SET_DUPLICATE_PACKAGE"); + } + manifestIds.add(entry.packageId); + if (entry.runtimeProtocolVersion !== 1) { + return failure("CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED"); + } + } + + const expectedById = new Map( + input.expected.map((entry) => [entry.packageId, entry] as const), + ); + for (const entry of input.manifest.packages) { + if (!expectedById.has(entry.packageId)) { + return failure("CONTRACT_SET_PACKAGE_UNEXPECTED"); + } + } + for (const entry of input.expected) { + const found = input.manifest.packages.find( + (candidate) => candidate.packageId === entry.packageId, + ); + if (!found) return failure("CONTRACT_SET_PACKAGE_MISSING"); + if ( + found.version !== entry.version || + found.sourceRevision !== entry.sourceRevision + ) { + return failure("CONTRACT_SET_VERSION_MISMATCH"); + } + if (found.digest !== entry.digest) { + return failure("CONTRACT_SET_DIGEST_MISMATCH"); + } + } + + let recomputed: `sha256:${string}`; + try { + recomputed = await computeContractSetDigest( + input.manifest.packages as readonly ContractSetPackage[], + ); + } catch { + return failure("CONTRACT_SET_DIGEST_INVALID"); + } + if (recomputed !== input.manifest.setDigest) { + return failure("CONTRACT_SET_DIGEST_MISMATCH"); + } + + const expectedDigest = + input.expectedSetDigest ?? (await computeContractSetDigest(input.expected)); + if (expectedDigest !== input.manifest.setDigest) { + return failure("CONTRACT_SET_DIGEST_MISMATCH"); + } + + return Object.freeze({ ok: true as const }); +} + +function failure(code: ContractSetFailureCode): ContractSetVerification { + return Object.freeze({ ok: false as const, code }); +} diff --git a/src/contracts/env.ts b/src/contracts/env.ts index 84d28a9..95679f4 100644 --- a/src/contracts/env.ts +++ b/src/contracts/env.ts @@ -1,4 +1,27 @@ -const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i; +/** + * §6.3. Case-insensitive key fragments that can never appear in a client + * configuration document. + */ +const FORBIDDEN_CONFIG_NAME_FRAGMENTS = Object.freeze([ + "PASSWORD", + "SECRET", + "TOKEN", + "PRIVATE_KEY", + "CLIENT_SECRET", + "ACCESS_KEY", + "REFRESH_TOKEN", + "COOKIE", + "AUTHORIZATION", +]); + +/** + * Exact top-level keys whose fragment match is a semantic enum name, not a + * credential. The allowlist is exact-key only; it is never applied to arbitrary + * nested keys. + */ +const SEMANTIC_KEY_ALLOWLIST = Object.freeze( + new Set(["AUTH_MODE", "TELEMETRY_ENABLED"]), +); export type EnvironmentPhase = "build" | "runtime"; export type EnvironmentDefinition = Readonly<{ @@ -21,8 +44,9 @@ export const ENV_REGISTRY = Object.freeze({ TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null), AUTH_MODE: runtime("public", true, "external"), CONFIG_SCHEMA_VERSION: runtime("public", true, null), - API_CONTRACT_VERSION: runtime("public", true, null), RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"), + // §3.5: overrides may only disable an installed capability, never enable one. + CAPABILITY_OVERRIDES: runtime("public", false, null), }); function build( @@ -43,14 +67,29 @@ function runtime( export function assertSafeConfigNames( config: Readonly>, + depth = 0, ): void { - for (const name of Object.keys(config)) { - if (forbiddenConfigName.test(name)) { + if (depth > 4) { + throw new Error("Client configuration nesting exceeds its bound"); + } + for (const [name, value] of Object.entries(config)) { + const allowlisted = depth === 0 && SEMANTIC_KEY_ALLOWLIST.has(name); + if (!allowlisted && isForbiddenConfigName(name)) { throw new Error(`Forbidden client configuration key: ${name}`); } + if (value && typeof value === "object" && !Array.isArray(value)) { + assertSafeConfigNames(value as Record, depth + 1); + } } } +function isForbiddenConfigName(name: string): boolean { + const upper = name.toUpperCase(); + return FORBIDDEN_CONFIG_NAME_FRAGMENTS.some((fragment) => + upper.includes(fragment), + ); +} + export type BuildEnvironment = Readonly<{ VITE_BUILD_ID?: string; VITE_COMMIT_SHA?: string; diff --git a/src/contracts/errors.ts b/src/contracts/errors.ts index c8b374d..c4ff9e4 100644 --- a/src/contracts/errors.ts +++ b/src/contracts/errors.ts @@ -228,6 +228,13 @@ export type FailureKind = keyof typeof ERROR_REGISTRY; export type ValidationIssue = Readonly<{ path: string; code: string }>; +export type FailureEffectCertainty = + | "NOT_APPLICABLE" + | "NOT_STARTED" + | "NOT_APPLIED" + | "APPLIED_CONFIRMED" + | "MAYBE_APPLIED"; + export type AppFailure = Readonly<{ kind: FailureKind; code: string; @@ -238,6 +245,7 @@ export type AppFailure = Readonly<{ requestId?: string; traceId?: string; retryAfterMs?: number; + effect?: FailureEffectCertainty; validationIssues?: readonly ValidationIssue[]; userMessageKey: string; action: ErrorAction; @@ -257,6 +265,7 @@ export type FailureDetails = Readonly<{ requestId?: string; traceId?: string; retryAfterMs?: number; + effect?: FailureEffectCertainty; validationIssues?: readonly ValidationIssue[]; causeClass?: string; }>; @@ -271,7 +280,10 @@ export function createFailure( return Object.freeze({ kind: definition.kind, code: typeof details.code === "string" ? details.code : definition.kind, - retryable: definition.defaultRetryable, + retryable: + details.effect === "MAYBE_APPLIED" + ? false + : definition.defaultRetryable, operationId, attemptCount: Math.max(1, attempt + 1), ...(Number.isInteger(details.httpStatus) @@ -282,6 +294,7 @@ export function createFailure( ...(typeof details.retryAfterMs === "number" ? { retryAfterMs: details.retryAfterMs } : {}), + ...(details.effect === undefined ? {} : { effect: details.effect }), ...(Array.isArray(details.validationIssues) ? { validationIssues: Object.freeze( @@ -304,7 +317,10 @@ export function createFailure( ? { causeClass: details.causeClass } : {}), userMessageKey: definition.userMessageKey, - action: definition.action, + action: + details.effect === "MAYBE_APPLIED" + ? "contact-support" + : definition.action, }); } diff --git a/src/contracts/external-contract-runtime.ts b/src/contracts/external-contract-runtime.ts new file mode 100644 index 0000000..53fa937 --- /dev/null +++ b/src/contracts/external-contract-runtime.ts @@ -0,0 +1,582 @@ +/** + * External contract package consumer boundary (§4). + * + * This repository does not own OpenAPI/AsyncAPI source, operation semantics, + * Problem Details meaning or event payload schemas. It owns only the normalized + * descriptor interface, the runtime validator protocol, and the bounds it + * applies before a contribution may be composed. + */ + +/** §7.3 hard ceilings. A contribution may lower these, never raise them. */ +export const HTTP_EXECUTION_CEILINGS = Object.freeze({ + defaultRequestBytes: 262_144, + hardRequestBytes: 1_048_576, + defaultResponseBytes: 1_048_576, + hardResponseBytes: 8_388_608, + problemResponseBytes: 65_536, + pathTemplateBytes: 512, + encodedQueryBytes: 8_192, + examinedHeaderValueBytes: 8_192, + defaultTotalDeadlineMs: 10_000, + hardTotalDeadlineMs: 60_000, + hardRetryCount: 2, + finalUrlBytes: 16_384, +}); + +export type RuntimeValidationIssue = Readonly<{ + path: readonly (string | number)[]; + code: string; +}>; + +export type RuntimeValidationResult = + | Readonly<{ success: true; data: T }> + | Readonly<{ success: false; issues: readonly RuntimeValidationIssue[] }>; + +export interface RuntimeValidator { + readonly schemaId: string; + safeParse(value: unknown): RuntimeValidationResult; +} + +/** + * A validator invocation never escapes as a native throw. `THROWN` is the + * `CONTRACT_RUNTIME_FAILURE` signal; `false` success is the ordinary + * `CONTRACT_VALUE_INVALID` signal. + */ +export type ValidatorInvocation = + | Readonly<{ outcome: "VALID"; value: T }> + | Readonly<{ outcome: "INVALID"; issues: readonly RuntimeValidationIssue[] }> + | Readonly<{ outcome: "THROWN" }>; + +export function invokeValidator( + validator: RuntimeValidator, + value: unknown, +): ValidatorInvocation { + let result: RuntimeValidationResult; + try { + result = validator.safeParse(value); + } catch { + return Object.freeze({ outcome: "THROWN" as const }); + } + if (!result || typeof result !== "object" || !("success" in result)) { + return Object.freeze({ outcome: "THROWN" as const }); + } + if (result.success) { + return Object.freeze({ outcome: "VALID" as const, value: result.data }); + } + return Object.freeze({ + outcome: "INVALID" as const, + issues: Object.freeze([...(result.issues ?? [])]), + }); +} + +export type MappingResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ + ok: false; + error: Readonly<{ kind: "MAPPING_CONTRACT_VIOLATION"; code: string }>; + }>; + +export function mappingViolation(code: string): MappingResult { + return Object.freeze({ + ok: false as const, + error: Object.freeze({ + kind: "MAPPING_CONTRACT_VIOLATION" as const, + code, + }), + }); +} + +export type CommandRecoveryDescriptor = Readonly<{ + mode: "IDEMPOTENCY_REPLAY" | "INSPECT_OPERATION"; + operationIdentityField: string; + inspectOperationId?: string; +}>; + +export type CommandEffectClassification = + | "NOT_APPLIED" + | "APPLIED_CONFIRMED" + | "MAYBE_APPLIED"; + +export interface CommandEffectDescriptor { + readonly successEffect: "APPLIED_CONFIRMED"; + classifyProblem( + input: Readonly<{ status: number; problem: Problem }>, + ): CommandEffectClassification; +} + +export type HttpMethod = + | "GET" + | "HEAD" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; + +export type RetrySemantics = "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER"; + +export interface HttpExecutionPolicy { + readonly policyId: string; + readonly requestByteLimit: number; + readonly responseByteLimit: number; + readonly totalDeadlineMs: number; + readonly retryBudget: 0 | 1 | 2; + readonly authProfileId: string; + readonly diagnosticsOperation: string; +} + +export interface InstalledHttpContract { + readonly contract: Readonly<{ + operationId: string; + method: HttpMethod; + pathTemplate: string; + inputValidator: RuntimeValidator; + outputValidator: RuntimeValidator; + problemValidator: RuntimeValidator; + acceptedStatuses: readonly number[]; + emptyBodyStatuses: readonly number[]; + retrySemantics: RetrySemantics; + requestBody: "NONE" | "JSON"; + responseBody: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE"; + commandRecovery: CommandRecoveryDescriptor | null; + commandEffect: CommandEffectDescriptor | null; + /** + * Descriptor-owned projection from canonical application input to the wire + * request. The frontend never re-derives method, path or query semantics. + */ + projectRequest(input: Input): HttpRequestProjection; + }>; + readonly frontend: HttpExecutionPolicy; +} + +export type HttpRequestProjection = Readonly<{ + /** Ordered path placeholder values keyed by descriptor placeholder name. */ + pathValues: Readonly>; + /** Descriptor-generated query entry order; array encoding is descriptor-owned. */ + queryEntries: readonly (readonly [string, string])[]; + /** Canonical JSON body value, or `null` when `requestBody` is `NONE`. */ + body: unknown; +}>; + +export interface InstalledEventContract { + readonly eventType: string; + readonly envelopeValidator: RuntimeValidator; + readonly payloadValidator: RuntimeValidator; +} + +export interface InstalledContractPackageIdentity { + readonly packageId: string; + readonly version: string; + readonly digest: `sha256:${string}`; + readonly runtimeProtocolVersion: 1; + readonly sourceRevision: string; +} + +export type ContractContributionSource = + | Readonly<{ + kind: "EXTERNAL_PACKAGE"; + package: InstalledContractPackageIdentity; + }> + | Readonly<{ + kind: "TEMPLATE_FIXTURE"; + fixtureId: "REFERENCE_FEATURE_V1"; + revision: 1; + }>; + +export interface InstalledContractContribution { + readonly contributionId: string; + readonly featureId: string; + readonly source: ContractContributionSource; + readonly http: readonly InstalledHttpContract[]; + readonly events: readonly InstalledEventContract[]; +} + +export type ContractCompositionFailureCode = + | "CONTRACT_CONTRIBUTION_INVALID" + | "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED"; + +export class ContractContributionError extends Error { + readonly code: ContractCompositionFailureCode; + readonly reason: string; + + constructor(reason: string, code: ContractCompositionFailureCode = "CONTRACT_CONTRIBUTION_INVALID") { + super("Installed contract contribution is not composable"); + this.name = "ContractContributionError"; + this.code = code; + this.reason = reason; + } +} + +// §4.9 exact validation vocabulary. +const FEATURE_ID = /^[a-z][a-z0-9-]{0,63}$/; +const CONTRIBUTION_ID = /^[a-z][a-z0-9._-]{0,127}$/; +const PACKAGE_ID = + /^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/; +const SEM_VER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +const DIGEST = /^sha256:[0-9a-f]{64}$/; +const SOURCE_REVISION = /^[0-9a-f]{7,64}$/; +const OPERATION_ID = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/; +/** Whitespace and C0/C1 control characters are rejected in an event type. */ +function hasControlOrSpace(value: string): boolean { + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code <= 0x20 || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +} + +const encoder = new TextEncoder(); + +function utf8Bytes(value: string): number { + return encoder.encode(value).byteLength; +} + +function fail(reason: string): never { + throw new ContractContributionError(reason); +} + +function assertPackageIdentity( + identity: InstalledContractPackageIdentity, + featureId: string, +): void { + if (!identity || typeof identity !== "object") { + fail(`${featureId}: package identity`); + } + if (identity.runtimeProtocolVersion !== 1) { + throw new ContractContributionError( + `${featureId}: runtime protocol version must be exactly 1`, + "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED", + ); + } + if (!PACKAGE_ID.test(identity.packageId)) fail(`${featureId}: packageId`); + if ( + typeof identity.version !== "string" || + identity.version !== identity.version.trim() || + identity.version.startsWith("v") || + !SEM_VER.test(identity.version) + ) { + fail(`${featureId}: package version must be exact SemVer`); + } + if (!DIGEST.test(identity.digest)) fail(`${featureId}: package digest`); + if (!SOURCE_REVISION.test(identity.sourceRevision)) { + fail(`${featureId}: package sourceRevision`); + } +} + +function assertValidator( + validator: RuntimeValidator, + label: string, +): void { + if ( + !validator || + typeof validator.safeParse !== "function" || + typeof validator.schemaId !== "string" || + validator.schemaId.length === 0 + ) { + fail(`${label}: runtime validator with non-empty schemaId is required`); + } +} + +function assertStatuses( + statuses: readonly number[], + label: string, +): void { + if (!Array.isArray(statuses)) fail(`${label}: status array required`); + if (statuses.length < 1 || statuses.length > 32) { + fail(`${label}: 1..32 statuses required`); + } + for (let index = 0; index < statuses.length; index += 1) { + const status = statuses[index] as number; + if (!Number.isInteger(status) || status < 100 || status > 599) { + fail(`${label}: status out of range`); + } + const previous = statuses[index - 1]; + if (index > 0 && previous !== undefined && status <= previous) { + fail(`${label}: statuses must be sorted and unique`); + } + } +} + +function assertExecutionPolicy( + policy: HttpExecutionPolicy, + label: string, +): void { + const ceilings = HTTP_EXECUTION_CEILINGS; + if ( + !policy || + typeof policy.policyId !== "string" || + policy.policyId.length === 0 || + typeof policy.authProfileId !== "string" || + policy.authProfileId.length === 0 || + typeof policy.diagnosticsOperation !== "string" || + policy.diagnosticsOperation.length === 0 + ) { + fail(`${label}: frontend execution policy identity`); + } + if ( + !Number.isSafeInteger(policy.requestByteLimit) || + policy.requestByteLimit < 0 || + policy.requestByteLimit > ceilings.hardRequestBytes + ) { + fail(`${label}: requestByteLimit exceeds the hard ceiling`); + } + if ( + !Number.isSafeInteger(policy.responseByteLimit) || + policy.responseByteLimit < 1 || + policy.responseByteLimit > ceilings.hardResponseBytes + ) { + fail(`${label}: responseByteLimit exceeds the hard ceiling`); + } + if ( + !Number.isSafeInteger(policy.totalDeadlineMs) || + policy.totalDeadlineMs < 1 || + policy.totalDeadlineMs > ceilings.hardTotalDeadlineMs + ) { + fail(`${label}: totalDeadlineMs exceeds the hard ceiling`); + } + if ( + policy.retryBudget !== 0 && + policy.retryBudget !== 1 && + policy.retryBudget !== 2 + ) { + fail(`${label}: retryBudget must be 0, 1 or 2`); + } +} + +function assertHttpContract( + installed: InstalledHttpContract, + featureId: string, +): void { + const contract = installed?.contract; + if (!contract || typeof contract !== "object") { + fail(`${featureId}: http contract descriptor missing`); + } + const label = `${featureId}/${String(contract.operationId)}`; + + if ( + typeof contract.operationId !== "string" || + !OPERATION_ID.test(contract.operationId) + ) { + fail(`${label}: operationId`); + } + if ( + contract.method !== "GET" && + contract.method !== "HEAD" && + contract.method !== "POST" && + contract.method !== "PUT" && + contract.method !== "PATCH" && + contract.method !== "DELETE" + ) { + fail(`${label}: method`); + } + if ( + typeof contract.pathTemplate !== "string" || + !contract.pathTemplate.startsWith("/") || + utf8Bytes(contract.pathTemplate) > HTTP_EXECUTION_CEILINGS.pathTemplateBytes || + contract.pathTemplate.includes("?") || + contract.pathTemplate.includes("#") + ) { + fail(`${label}: pathTemplate`); + } + assertValidator(contract.inputValidator, `${label}.input`); + assertValidator(contract.outputValidator, `${label}.output`); + assertValidator(contract.problemValidator, `${label}.problem`); + if (typeof contract.projectRequest !== "function") { + fail(`${label}: descriptor request projection is required`); + } + if ( + contract.retrySemantics !== "SAFE" && + contract.retrySemantics !== "IDEMPOTENT" && + contract.retrySemantics !== "KEYED" && + contract.retrySemantics !== "NEVER" + ) { + fail(`${label}: retrySemantics`); + } + if (contract.requestBody !== "NONE" && contract.requestBody !== "JSON") { + fail(`${label}: requestBody`); + } + if ( + contract.responseBody !== "REQUIRED_JSON" && + contract.responseBody !== "OPTIONAL_JSON" && + contract.responseBody !== "NONE" + ) { + fail(`${label}: responseBody`); + } + assertStatuses(contract.acceptedStatuses, `${label}.acceptedStatuses`); + if (contract.emptyBodyStatuses.length > 0) { + assertStatuses(contract.emptyBodyStatuses, `${label}.emptyBodyStatuses`); + const accepted = new Set(contract.acceptedStatuses); + for (const status of contract.emptyBodyStatuses) { + if (!accepted.has(status)) { + fail(`${label}: empty-body status must be an accepted status`); + } + } + } + const isRead = contract.method === "GET" || contract.method === "HEAD"; + if (isRead) { + if (contract.commandEffect !== null || contract.commandRecovery !== null) { + fail(`${label}: read operations carry no command descriptors`); + } + if (contract.requestBody !== "NONE") { + fail(`${label}: read operations carry no request body`); + } + } else { + if (!contract.commandEffect) { + fail(`${label}: command operations require a command effect descriptor`); + } + if (contract.commandEffect.successEffect !== "APPLIED_CONFIRMED") { + fail(`${label}: command success effect`); + } + if (typeof contract.commandEffect.classifyProblem !== "function") { + fail(`${label}: command effect classifier is required`); + } + if (contract.retrySemantics === "KEYED" && !contract.commandRecovery) { + fail(`${label}: KEYED commands require a recovery descriptor`); + } + } + if (contract.commandRecovery) { + const recovery = contract.commandRecovery; + if ( + (recovery.mode !== "IDEMPOTENCY_REPLAY" && + recovery.mode !== "INSPECT_OPERATION") || + !recovery.operationIdentityField || + (recovery.mode === "INSPECT_OPERATION" && !recovery.inspectOperationId) + ) { + fail(`${label}: command recovery descriptor`); + } + } + assertExecutionPolicy(installed.frontend, label); + if ( + installed.frontend.retryBudget > 0 && + contract.retrySemantics === "NEVER" + ) { + fail(`${label}: retry budget contradicts NEVER retry semantics`); + } +} + +function assertEventContract( + event: InstalledEventContract, + featureId: string, +): void { + const label = `${featureId}/${String(event?.eventType)}`; + if ( + typeof event?.eventType !== "string" || + event.eventType.length === 0 || + utf8Bytes(event.eventType) > 160 || + hasControlOrSpace(event.eventType) + ) { + fail(`${label}: eventType`); + } + assertValidator(event.envelopeValidator, `${label}.envelope`); + assertValidator(event.payloadValidator, `${label}.payload`); +} + +export type ComposedContractContributions = Readonly<{ + contributions: readonly InstalledContractContribution[]; + httpByOperationId: ReadonlyMap< + string, + InstalledHttpContract + >; + eventByType: ReadonlyMap>; + externalPackages: readonly InstalledContractPackageIdentity[]; +}>; + +/** + * §4.8–§4.9. The only place installed contributions become a runtime registry. + * Every bound is checked before composition; a violation stops the boot rather + * than degrading into an assumed meaning. + */ +export function composeContractContributions( + contributions: readonly InstalledContractContribution[], +): ComposedContractContributions { + if (!Array.isArray(contributions)) fail("contributions: array required"); + const httpByOperationId = new Map< + string, + InstalledHttpContract + >(); + const eventByType = new Map< + string, + InstalledEventContract + >(); + const packagesById = new Map(); + const contributionIds = new Set(); + + for (const contribution of contributions) { + if (!contribution || typeof contribution !== "object") { + fail("contribution: object required"); + } + const contributionId = contribution.contributionId; + if ( + typeof contributionId !== "string" || + !CONTRIBUTION_ID.test(contributionId) + ) { + fail(`contributionId: ${String(contributionId)}`); + } + if (contributionIds.has(contributionId)) { + fail(`duplicate contributionId: ${contributionId}`); + } + contributionIds.add(contributionId); + const featureId = contribution?.featureId; + if (typeof featureId !== "string" || !FEATURE_ID.test(featureId)) { + fail(`featureId: ${String(featureId)}`); + } + const source = contribution.source; + if (!source || typeof source !== "object" || !("kind" in source)) { + fail(`${featureId}: source`); + } + if (!Array.isArray(contribution.http) || !Array.isArray(contribution.events)) { + fail(`${featureId}: contribution arrays`); + } + if (source.kind === "EXTERNAL_PACKAGE") { + assertPackageIdentity(source.package, featureId); + const existing = packagesById.get(source.package.packageId); + if ( + existing && + (existing.version !== source.package.version || + existing.digest !== source.package.digest || + existing.sourceRevision !== source.package.sourceRevision) + ) { + fail( + `${featureId}: package ${source.package.packageId} has conflicting identities`, + ); + } + packagesById.set(source.package.packageId, source.package); + } else if (source.kind === "TEMPLATE_FIXTURE") { + if (source.fixtureId !== "REFERENCE_FEATURE_V1" || source.revision !== 1) { + fail(`${featureId}: template fixture identity`); + } + if (contribution.events.length !== 0) { + fail(`${featureId}: template fixture must not contribute events`); + } + } else { + fail(`${featureId}: unknown contribution source kind`); + } + + for (const installed of contribution.http) { + assertHttpContract(installed, featureId); + const operationId = installed.contract.operationId; + const previous = httpByOperationId.get(operationId); + if (previous) fail(`duplicate operation: ${operationId}`); + httpByOperationId.set(operationId, installed); + } + + for (const event of contribution.events) { + assertEventContract(event, featureId); + if (eventByType.has(event.eventType)) { + fail(`duplicate event type: ${event.eventType}`); + } + eventByType.set(event.eventType, event); + } + } + + const externalPackages = [...packagesById.values()].map((identity) => + Object.freeze({ ...identity }), + ); + + return Object.freeze({ + contributions: Object.freeze([...contributions]), + httpByOperationId, + eventByType, + externalPackages: Object.freeze(externalPackages), + }); +} diff --git a/src/contracts/offline-command.ts b/src/contracts/offline-command.ts new file mode 100644 index 0000000..1c57aa9 --- /dev/null +++ b/src/contracts/offline-command.ts @@ -0,0 +1,154 @@ +/** + * §19. Offline Command and Background Sync contract. + * + * The product capability is `NOT_SELECTED` (§19.1). An operation only becomes + * queueable when the external package contribution provides `KEYED` retry + * semantics plus a non-null recovery descriptor (§19.3); the frontend never + * defines server idempotency or an inspect protocol of its own. + */ + +export const OFFLINE_COMMAND_BOUNDS = Object.freeze({ + operations: 64, + defaultRequestBytes: 262_144, + hardRequestBytes: 1_048_576, + records: 1_000, + datasetBytes: 50 * 1024 * 1024, + senderLeaseMs: 30_000, + leaseRenewMs: 10_000, + batchCount: 10, + batchWindowMs: 30_000, + parallelSend: 1, + retryBaseMs: 1_000, + retryMaxMs: 300_000, + attemptCap: 10, + ordinaryRetentionMs: 7 * 24 * 60 * 60 * 1_000, + conflictRetentionMs: 30 * 24 * 60 * 60 * 1_000, + ackedSummaryRetentionMs: 24 * 60 * 60 * 1_000, + syncReregisterMinimumMs: 60_000, +}); + +/** §19.18. Background Sync is a wake-up hint only; it never sends a command. */ +export const OFFLINE_SYNC_TAG = "ca-outbox-v1" as const; + +export interface InstalledOfflineOperation { + readonly operationId: string; + readonly contractPackageId: string; + readonly maximumRequestBytes: number; + readonly retentionClass: "STANDARD_7D"; +} + +export interface InstalledOfflineCommandContribution { + readonly datasetId: "OFFLINE_COMMANDS_V1"; + readonly operations: readonly InstalledOfflineOperation[]; +} + +export type OfflineCommandState = + | "PENDING" + | "LEASED" + | "FOREGROUND_REQUIRED" + | "SENDING" + | "RETRY_WAIT" + | "ACKED" + | "CONFLICT" + | "EFFECT_UNKNOWN" + | "EXPIRED"; + +export interface OfflineCommandRecordV1 { + readonly recordVersion: 1; + readonly commandId: string; + readonly operationId: string; + readonly contractPackageId: string; + readonly contractPackageVersion: string; + readonly contractPackageDigest: `sha256:${string}`; + readonly scopePartition: string; + readonly requestDigest: `sha256:${string}`; + readonly requestPayload: Uint8Array; + readonly idempotencyKey: string; + readonly state: OfflineCommandState; + readonly attempt: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly nextAttemptAt?: string; + readonly leaseOwner?: string; + readonly leaseExpiresAt?: string; + readonly terminalCode?: string; +} + +/** + * §19.10. Anything not listed is corruption. In particular an expired + * `SENDING` record is never reset to `PENDING`: it becomes `EFFECT_UNKNOWN`. + */ +const ALLOWED_TRANSITIONS = Object.freeze({ + PENDING: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]), + LEASED: Object.freeze(["SENDING", "PENDING"]), + SENDING: Object.freeze([ + "ACKED", + "RETRY_WAIT", + "CONFLICT", + "EFFECT_UNKNOWN", + ]), + RETRY_WAIT: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]), + FOREGROUND_REQUIRED: Object.freeze(["LEASED", "EXPIRED", "PENDING"]), + ACKED: Object.freeze([]), + CONFLICT: Object.freeze([]), + EFFECT_UNKNOWN: Object.freeze(["ACKED", "PENDING"]), + EXPIRED: Object.freeze([]), +} satisfies Readonly>); + +export function isAllowedOfflineTransition( + from: OfflineCommandState, + to: OfflineCommandState, +): boolean { + const allowed: readonly OfflineCommandState[] = ALLOWED_TRANSITIONS[from]; + return allowed.includes(to); +} + +/** §19.21. Nothing sensitive reaches the UI: no payload, key, digest or partition. */ +export interface OfflineCommandSummary { + readonly commandId: string; + readonly operationLabelKey: string; + readonly state: OfflineCommandState; + readonly createdAt: string; + readonly nextAction: + | "WAIT" + | "OPEN_APP" + | "CHECK_STATUS" + | "RESOLVE_CONFLICT" + | "CONTACT_SUPPORT" + | "DISMISS"; +} + +export function validateOfflineCommandContribution( + contribution: InstalledOfflineCommandContribution, +): InstalledOfflineCommandContribution { + if (contribution.datasetId !== "OFFLINE_COMMANDS_V1") { + throw new TypeError("Offline command dataset identity is invalid."); + } + if ( + contribution.operations.length === 0 || + contribution.operations.length > OFFLINE_COMMAND_BOUNDS.operations + ) { + throw new TypeError("Offline command operation count is out of range."); + } + const seen = new Set(); + for (const operation of contribution.operations) { + if (!operation.operationId || seen.has(operation.operationId)) { + throw new TypeError("Duplicate offline command operation."); + } + seen.add(operation.operationId); + if ( + !Number.isSafeInteger(operation.maximumRequestBytes) || + operation.maximumRequestBytes < 1 || + operation.maximumRequestBytes > OFFLINE_COMMAND_BOUNDS.hardRequestBytes || + operation.retentionClass !== "STANDARD_7D" + ) { + throw new TypeError( + `Offline command operation bounds invalid: ${operation.operationId}`, + ); + } + } + return Object.freeze({ + datasetId: contribution.datasetId, + operations: Object.freeze([...contribution.operations]), + }); +} diff --git a/src/contracts/query-invalidation.ts b/src/contracts/query-invalidation.ts index 2569a9b..407ede5 100644 --- a/src/contracts/query-invalidation.ts +++ b/src/contracts/query-invalidation.ts @@ -19,6 +19,150 @@ export function defineQueryInvalidationTopic( return value as QueryInvalidationTopic; } +/** + * §12.2. Many-to-many topic/namespace registry. + * + * Topics stay opaque on the wire; the registry is what turns one received topic + * into the local namespaces that must revalidate. Bounds are checked at startup + * so a fan-out explosion cannot be introduced at runtime. + */ +export const INVALIDATION_REGISTRY_BOUNDS = Object.freeze({ + maxTopics: 256, + maxNamespaces: 256, + maxEdges: 1_024, + maxTopicFanOut: 64, + maxNamespaceFanIn: 64, + maxIdBytes: 80, +}); + +export type InvalidationRegistryEdge = Readonly<{ + topicId: string; + namespace: string; +}>; + +export interface InvalidationRegistry { + readonly topics: readonly string[]; + readonly namespaces: readonly string[]; + readonly edges: readonly InvalidationRegistryEdge[]; +} + +export type InvalidationRegistryIndex = Readonly<{ + namespacesForTopic: ReadonlyMap; + topicsForNamespace: ReadonlyMap; +}>; + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 0x1f || codePoint === 0x7f) return true; + } + return false; +} + +/** + * Rejects duplicates, orphan topics and orphan namespaces at startup. An edge + * that points at an unregistered endpoint is a composition defect, not a + * runtime condition to be tolerated. + */ +export function indexInvalidationRegistry( + registry: InvalidationRegistry, +): InvalidationRegistryIndex { + const bounds = INVALIDATION_REGISTRY_BOUNDS; + const encoder = new TextEncoder(); + const assertId = (value: string, label: string) => { + if ( + typeof value !== "string" || + value.length === 0 || + hasControlCharacter(value) || + encoder.encode(value).byteLength > bounds.maxIdBytes + ) { + throw new TypeError(`Invalidation registry ${label} is invalid.`); + } + }; + + if ( + registry.topics.length > bounds.maxTopics || + registry.namespaces.length > bounds.maxNamespaces || + registry.edges.length > bounds.maxEdges + ) { + throw new TypeError("Invalidation registry exceeds its bounds."); + } + + const topics = new Set(); + for (const topic of registry.topics) { + assertId(topic, "topic"); + if (topics.has(topic)) { + throw new TypeError(`Duplicate invalidation topic: ${topic}`); + } + topics.add(topic); + } + const namespaces = new Set(); + for (const namespace of registry.namespaces) { + assertId(namespace, "namespace"); + if (namespaces.has(namespace)) { + throw new TypeError(`Duplicate invalidation namespace: ${namespace}`); + } + namespaces.add(namespace); + } + + const namespacesForTopic = new Map(); + const topicsForNamespace = new Map(); + const seenEdges = new Map>(); + for (const edge of registry.edges) { + if (!topics.has(edge.topicId) || !namespaces.has(edge.namespace)) { + throw new TypeError("Invalidation edge references an unknown endpoint."); + } + const seenNamespaces = seenEdges.get(edge.topicId) ?? new Set(); + if (seenNamespaces.has(edge.namespace)) { + throw new TypeError("Duplicate invalidation edge."); + } + seenNamespaces.add(edge.namespace); + seenEdges.set(edge.topicId, seenNamespaces); + + const fanOut = namespacesForTopic.get(edge.topicId) ?? []; + fanOut.push(edge.namespace); + if (fanOut.length > bounds.maxTopicFanOut) { + throw new TypeError(`Invalidation topic fan-out exceeded: ${edge.topicId}`); + } + namespacesForTopic.set(edge.topicId, fanOut); + + const fanIn = topicsForNamespace.get(edge.namespace) ?? []; + fanIn.push(edge.topicId); + if (fanIn.length > bounds.maxNamespaceFanIn) { + throw new TypeError( + `Invalidation namespace fan-in exceeded: ${edge.namespace}`, + ); + } + topicsForNamespace.set(edge.namespace, fanIn); + } + + for (const topic of topics) { + if (!namespacesForTopic.has(topic)) { + throw new TypeError(`Orphan invalidation topic: ${topic}`); + } + } + for (const namespace of namespaces) { + if (!topicsForNamespace.has(namespace)) { + throw new TypeError(`Orphan invalidation namespace: ${namespace}`); + } + } + + return Object.freeze({ + namespacesForTopic: new Map( + [...namespacesForTopic].map(([key, value]) => [ + key, + Object.freeze([...value]) as readonly string[], + ]), + ), + topicsForNamespace: new Map( + [...topicsForNamespace].map(([key, value]) => [ + key, + Object.freeze([...value]) as readonly string[], + ]), + ), + }); +} + export type QueryMutationLease = Readonly<{ /** * Releases one local mutation fence. Remote hints coalesced while the fence diff --git a/src/contracts/release-artifacts.ts b/src/contracts/release-artifacts.ts new file mode 100644 index 0000000..05f2b47 --- /dev/null +++ b/src/contracts/release-artifacts.ts @@ -0,0 +1,268 @@ +import { z } from "zod"; + +import { contractSetSchema } from "./contract-set.ts"; + +const versionSchema = z.string().regex(/^\d+(?:\.\d+){0,2}$/); + +function assertEndpointUrl( + value: string, + local: boolean, + options: Readonly<{ trailingSlashPath?: boolean }> = {}, +): void { + const parsed = new URL(value); + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") || + parsed.username || + parsed.password || + parsed.hash || + parsed.search || + (!local && parsed.protocol !== "https:") + ) { + throw new TypeError("invalid"); + } + if (options.trailingSlashPath && !parsed.pathname.endsWith("/")) { + throw new TypeError("invalid"); + } +} + +export function isValidReleaseManifestUrl(value: string): boolean { + if (!value.startsWith("/") || value.startsWith("//")) return false; + if (new TextEncoder().encode(value).byteLength > 256) return false; + if (value.includes("?") || value.includes("#") || value.includes("\\")) { + return false; + } + if (/%2f|%5c/i.test(value)) return false; + return !value + .split("/") + .some((segment) => segment === "." || segment === ".."); +} + +export const capabilityOverrideArtifactSchema = z + .object({ + REALTIME: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + WEB_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + SERVICE_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + OFFLINE_COMMANDS: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + }) + .strict() + .default({ + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }); + +type RuntimeConfigArtifactDraft = Readonly<{ + APP_ENV: "local" | "development" | "staging" | "production"; + API_BASE_URL: string; + TELEMETRY_ENABLED: boolean; + TELEMETRY_ENDPOINT?: string; + AUTH_MODE: "external" | "demo"; + RELEASE_MANIFEST_URL: string; +}>; + +function runtimeConfigArtifactInvariants( + config: RuntimeConfigArtifactDraft, + context: z.RefinementCtx, +): void { + const local = config.APP_ENV === "local" || config.APP_ENV === "development"; + if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) { + context.addIssue({ + code: "custom", + path: ["TELEMETRY_ENDPOINT"], + message: "required when telemetry is enabled", + }); + } + if (!local && config.AUTH_MODE === "demo") { + context.addIssue({ + code: "custom", + path: ["AUTH_MODE"], + message: "demo authentication is limited to local environments", + }); + } + try { + assertEndpointUrl(config.API_BASE_URL, local, { trailingSlashPath: true }); + } catch { + context.addIssue({ + code: "custom", + path: ["API_BASE_URL"], + message: + "absolute credential-free URL ending in / is required; HTTPS outside local", + }); + } + if (config.TELEMETRY_ENDPOINT) { + try { + assertEndpointUrl(config.TELEMETRY_ENDPOINT, local); + } catch { + context.addIssue({ + code: "custom", + path: ["TELEMETRY_ENDPOINT"], + message: + "absolute credential-free URL is required; HTTPS outside local", + }); + } + } + if (!isValidReleaseManifestUrl(config.RELEASE_MANIFEST_URL)) { + context.addIssue({ + code: "custom", + path: ["RELEASE_MANIFEST_URL"], + message: "same-origin absolute path without query, hash or traversal", + }); + } +} + +const runtimeConfigArtifactFields = { + APP_ENV: z.enum(["local", "development", "staging", "production"]), + API_BASE_URL: z.url(), + REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000), + MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), + TELEMETRY_ENABLED: z.boolean(), + TELEMETRY_ENDPOINT: z.url().optional(), + AUTH_MODE: z.enum(["external", "demo"]), + RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), + RELEASE_ID: z.string().min(1).optional(), + BUILD_ID: z.string().min(1).optional(), +} as const; + +export const runtimeConfigV1ArtifactSchema = z + .object({ + ...runtimeConfigArtifactFields, + CONFIG_SCHEMA_VERSION: z.literal("1"), + API_CONTRACT_VERSION: versionSchema, + }) + .strict() + .superRefine(runtimeConfigArtifactInvariants); + +export const runtimeConfigV2ArtifactSchema = z + .object({ + ...runtimeConfigArtifactFields, + CONFIG_SCHEMA_VERSION: z.literal("2.0"), + CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema, + }) + .strict() + .superRefine(runtimeConfigArtifactInvariants); + +export const runtimeConfigArtifactSchema = z.discriminatedUnion( + "CONFIG_SCHEMA_VERSION", + [ + runtimeConfigV1ArtifactSchema, + runtimeConfigV2ArtifactSchema, + ], +); + +const releaseManifestArtifactFields = { + appVersion: z.string().min(1), + buildId: z.string().min(1), + commitSha: z.string().min(1), + assetManifestHash: z.string().min(1), + releaseId: z.string().min(1), + builtAt: z.string().min(1), + routeChunks: z.record(z.string().min(1), z.string().min(1)), +} as const; + +export const releaseManifestV1ArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + ...releaseManifestArtifactFields, + configSchemaVersion: versionSchema, + apiContractVersion: versionSchema, + }) + .strict(); + +export const releaseManifestV2ArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + ...releaseManifestArtifactFields, + configSchemaVersion: z.literal("2.0"), + contractSet: contractSetSchema, + }) + .strict(); + +export const releaseManifestArtifactSchema = z.discriminatedUnion( + "schemaVersion", + [releaseManifestV1ArtifactSchema, releaseManifestV2ArtifactSchema], +); + +export const buildManifestArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + buildId: z.string().min(1), + commitSha: z.string().min(1), + releaseId: z.string().min(1), + moduleInventoryHash: z.string().min(1), + generatedAt: z.string().min(1), + buildContext: z + .object({ + nodeVersion: z.string().min(1), + packageManagerVersion: z.string().min(1), + runnerImage: z.string().min(1), + sourceDateEpoch: z.string().min(1).nullable(), + }) + .strict(), + outputs: z + .object({ + directory: z.string().min(1), + viteManifest: z.string().min(1), + moduleInventory: z.string().min(1), + routeChunks: z.record(z.string().min(1), z.string().min(1)), + runtimeConfigSchema: z.string().min(1), + }) + .strict(), + }) + .strict(); + +export type RuntimeConfigV1Artifact = z.output< + typeof runtimeConfigV1ArtifactSchema +>; +export type RuntimeConfigV2Artifact = z.output< + typeof runtimeConfigV2ArtifactSchema +>; +export type CapabilityOverrideArtifact = z.output< + typeof capabilityOverrideArtifactSchema +>; +export type RuntimeConfigArtifact = z.output; +export type ReleaseManifestV1Artifact = z.output< + typeof releaseManifestV1ArtifactSchema +>; +export type ReleaseManifestV2Artifact = z.output< + typeof releaseManifestV2ArtifactSchema +>; +export type ReleaseArtifact = z.output; +export type BuildManifestArtifact = z.output; + +export function parseReleaseArtifact(value: unknown): ReleaseArtifact { + return releaseManifestArtifactSchema.parse(value); +} + +export function parseRuntimeConfigArtifact(value: unknown): RuntimeConfigArtifact { + return runtimeConfigArtifactSchema.parse(value); +} + +export function parseBuildManifestArtifact(value: unknown): BuildManifestArtifact { + return buildManifestArtifactSchema.parse(value); +} + +export function projectReleaseTokens(release: ReleaseArtifact) { + const common = { + schemaVersion: release.schemaVersion, + appVersion: release.appVersion, + buildId: release.buildId, + commitSha: release.commitSha, + configSchemaVersion: release.configSchemaVersion, + assetManifestHash: release.assetManifestHash, + releaseId: release.releaseId, + builtAt: release.builtAt, + } as const; + + return release.schemaVersion === 1 + ? Object.freeze({ + ...common, + schemaVersion: 1 as const, + apiContractVersion: release.apiContractVersion, + }) + : Object.freeze({ + ...common, + schemaVersion: 2 as const, + contractSetDigest: release.contractSet.setDigest, + }); +} diff --git a/src/contracts/release-tokens.ts b/src/contracts/release-tokens.ts index 1a5c6b8..3895402 100644 --- a/src/contracts/release-tokens.ts +++ b/src/contracts/release-tokens.ts @@ -12,7 +12,12 @@ export const RELEASE_TOKEN_REGISTRY = Object.freeze({ apiContractVersion: token( "apiContractVersion", "frontend/backend agreement", - "schema compatibility", + "legacy V1 scalar; superseded by contractSetDigest", + ), + contractSetDigest: token( + "contractSetDigest", + "compiled external contract package set", + "release coherence for multi-package contracts", ), assetManifestHash: token( "assetManifestHash", @@ -37,23 +42,33 @@ export function compareReleaseToRuntime( release: Readonly<{ buildId: string; configSchemaVersion: string; - apiContractVersion: string; + apiContractVersion?: string; assetManifestHash: string; releaseId: string; }>, runtimeConfig: Readonly<{ BUILD_ID: string; CONFIG_SCHEMA_VERSION: string; - API_CONTRACT_VERSION: string; + /** + * §5.1. Removed from Runtime Config V2. When neither side declares it there + * is nothing to disagree about: contract identity is verified by the + * Release Manifest V2 `contractSet` check instead. + */ + API_CONTRACT_VERSION?: string; RELEASE_ID: string; }>, ) { + const declaredContractVersion = + runtimeConfig.API_CONTRACT_VERSION ?? release.apiContractVersion ?? "0"; return verifyCompatibilityTuple({ - frontend: release, + frontend: { + ...release, + apiContractVersion: release.apiContractVersion ?? declaredContractVersion, + }, runtime: { buildId: runtimeConfig.BUILD_ID, configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, - apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, + apiContractVersion: declaredContractVersion, assetManifestHash: release.assetManifestHash, releaseId: runtimeConfig.RELEASE_ID, }, diff --git a/src/contracts/route-runtime-contract.ts b/src/contracts/route-runtime-contract.ts index a76981e..ddbbdfa 100644 --- a/src/contracts/route-runtime-contract.ts +++ b/src/contracts/route-runtime-contract.ts @@ -17,6 +17,12 @@ export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({ paramsCodec: "none", searchCodec: "none", }), + EXAMPLES_PLATFORM: runtime({ + routeId: "EXAMPLES_PLATFORM", + moduleId: "platform-overview-page", + paramsCodec: "none", + searchCodec: "none", + }), EXAMPLES_UI: runtime({ routeId: "EXAMPLES_UI", moduleId: "ui-gallery-page", diff --git a/src/contracts/routes.ts b/src/contracts/routes.ts index aae12c8..8318f31 100644 --- a/src/contracts/routes.ts +++ b/src/contracts/routes.ts @@ -3,7 +3,7 @@ export type RouteDefinition = Readonly<{ path: string; paramsSchema: string | null; searchSchema: string | null; - access: "public" | "session-required" | "integration-defined"; + access: "public" | "session-required"; loadingSurface: string; errorSurface: string; chunkId: string; @@ -30,6 +30,19 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ navigationLabel: "시작", navigationOrder: 10, }), + EXAMPLES_PLATFORM: route({ + routeId: "EXAMPLES_PLATFORM", + path: "/examples/platform", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-platform", + title: "플랫폼 구성", + navigationLabel: "플랫폼 구성", + navigationOrder: 15, + }), EXAMPLES_UI: route({ routeId: "EXAMPLES_UI", path: "/examples/ui", diff --git a/src/contracts/runtime-capabilities.ts b/src/contracts/runtime-capabilities.ts new file mode 100644 index 0000000..e6d448f --- /dev/null +++ b/src/contracts/runtime-capabilities.ts @@ -0,0 +1,266 @@ +import type { InstalledOfflineCommandContribution } from "./offline-command.ts"; +import type { InstalledServiceWorkerSelection } from "./service-worker.ts"; +import type { InstalledWebWorkerContribution } from "./web-worker.ts"; + +/** + * §3.4–§3.6. Optional runtime capability selection and hosting. + * + * Source contribution decides what is installed. Runtime Config may only carry + * `DEFAULT | DISABLED`, so a configuration document can never switch on a + * capability whose source is absent. + */ + +export type RuntimeCapabilityOverride = "DEFAULT" | "DISABLED"; + +export type RuntimeStopReason = + | "APPLICATION_SHUTDOWN" + | "SCOPE_FENCED" + | "FEATURE_DISABLED" + | "HIDDEN_POLICY" + | "INCIDENT_CONTAINMENT"; + +export interface RuntimeLifecycle { + start(): void | Promise; + stop(reason: RuntimeStopReason): void | Promise; + dispose(): void | Promise; +} + +/** §20.2. `FAILED -> STARTING` is never automatic. */ +export type RuntimeLifecycleState = + | "NEW" + | "STARTING" + | "RUNNING" + | "STOPPING" + | "STOPPED" + | "FAILED" + | "DISPOSING" + | "DISPOSED"; + +/** §22.12. Health is per capability; there is no global `healthy` boolean. */ +export type RuntimeHealth = + | "AVAILABLE" + | "DEGRADED" + | "UNAVAILABLE" + | "INCOMPATIBLE" + | "DISABLED"; + +// §13.2. Realtime product contribution. +export type RealtimeEffectKind = + | "INVALIDATE_TOPICS" + | "APPLY_AUTHORITATIVE_DELTA" + | "EPHEMERAL_NOTIFICATION"; + +export interface InstalledRealtimeEventEffect { + readonly eventType: string; + readonly mapperId: string; + readonly effect: RealtimeEffectKind; + readonly invalidationTopics: readonly string[]; +} + +export type InstalledRealtimeTransport = + | Readonly<{ kind: "SSE"; endpointId: string }> + | Readonly<{ kind: "WEBSOCKET"; endpointId: string }> + | Readonly<{ kind: "POLLING"; operationId: string; intervalMs: number }>; + +export interface InstalledRealtimeContribution { + readonly contributionId: string; + readonly featureId: string; + readonly contractSourcePackageId: string; + readonly streamId: string; + readonly recoveryMode: "CURSOR" | "SNAPSHOT_ONLY" | "SESSION_REBUILD"; + readonly eventEffects: readonly InstalledRealtimeEventEffect[]; + readonly transport: InstalledRealtimeTransport; +} + +export const REALTIME_CONTRIBUTION_BOUNDS = Object.freeze({ + contributions: 64, + streams: 128, + eventTypes: 512, + effectsPerEvent: 8, + invalidationTopicsPerEvent: 32, + minimumPollingIntervalMs: 5_000, + maximumPollingIntervalMs: 300_000, +}); + +export interface InstalledRuntimeCapabilities { + readonly realtime: readonly InstalledRealtimeContribution[]; + readonly webWorkers: readonly InstalledWebWorkerContribution[]; + readonly serviceWorker: InstalledServiceWorkerSelection | null; + readonly offlineCommands: InstalledOfflineCommandContribution | null; +} + +export type CapabilityOverrideMap = Readonly<{ + REALTIME: RuntimeCapabilityOverride; + WEB_WORKER: RuntimeCapabilityOverride; + SERVICE_WORKER: RuntimeCapabilityOverride; + OFFLINE_COMMANDS: RuntimeCapabilityOverride; +}>; + +/** + * The effective selection after applying runtime overrides. `serviceWorkerMode` + * keeps the §3.6 persistent-registration exception explicit: a statically + * `ACTIVE` worker that runtime config disables still performs exactly one + * owned-registration lookup and at most one unregister, and deletes no cache. + */ +export type ResolvedRuntimeCapabilities = Readonly<{ + realtime: readonly InstalledRealtimeContribution[]; + webWorkers: readonly InstalledWebWorkerContribution[]; + serviceWorker: InstalledServiceWorkerSelection | null; + serviceWorkerDisabledCleanup: boolean; + offlineCommands: InstalledOfflineCommandContribution | null; +}>; + +export function resolveRuntimeCapabilities( + installed: InstalledRuntimeCapabilities, + overrides: CapabilityOverrideMap, +): ResolvedRuntimeCapabilities { + const realtimeDisabled = overrides.REALTIME === "DISABLED"; + const workersDisabled = overrides.WEB_WORKER === "DISABLED"; + const serviceWorkerDisabled = overrides.SERVICE_WORKER === "DISABLED"; + const offlineDisabled = overrides.OFFLINE_COMMANDS === "DISABLED"; + + return Object.freeze({ + realtime: realtimeDisabled ? Object.freeze([]) : installed.realtime, + webWorkers: workersDisabled ? Object.freeze([]) : installed.webWorkers, + serviceWorker: serviceWorkerDisabled ? null : installed.serviceWorker, + serviceWorkerDisabledCleanup: + serviceWorkerDisabled && installed.serviceWorker?.mode === "ACTIVE", + offlineCommands: offlineDisabled ? null : installed.offlineCommands, + }); +} + +export function validateRealtimeContributions( + contributions: readonly InstalledRealtimeContribution[], +): readonly InstalledRealtimeContribution[] { + const bounds = REALTIME_CONTRIBUTION_BOUNDS; + if (contributions.length > bounds.contributions) { + throw new TypeError("Realtime contributions exceed their bound."); + } + const contributionIds = new Set(); + const streamIds = new Set(); + let eventTypeCount = 0; + + for (const contribution of contributions) { + if ( + !contribution.contributionId || + contributionIds.has(contribution.contributionId) + ) { + throw new TypeError("Duplicate realtime contribution identity."); + } + contributionIds.add(contribution.contributionId); + streamIds.add(contribution.streamId); + if (streamIds.size > bounds.streams) { + throw new TypeError("Realtime streams exceed their bound."); + } + + const transport = contribution.transport; + if (transport.kind === "POLLING") { + if ( + !Number.isSafeInteger(transport.intervalMs) || + transport.intervalMs < bounds.minimumPollingIntervalMs || + transport.intervalMs > bounds.maximumPollingIntervalMs + ) { + throw new TypeError( + `Realtime polling interval is out of range: ${contribution.contributionId}`, + ); + } + } + + const seenEvents = new Map(); + for (const effect of contribution.eventEffects) { + eventTypeCount += 1; + if (eventTypeCount > bounds.eventTypes) { + throw new TypeError("Realtime event types exceed their bound."); + } + const count = (seenEvents.get(effect.eventType) ?? 0) + 1; + if (count > bounds.effectsPerEvent) { + throw new TypeError( + `Realtime effects per event exceeded: ${effect.eventType}`, + ); + } + seenEvents.set(effect.eventType, count); + if ( + effect.invalidationTopics.length > bounds.invalidationTopicsPerEvent + ) { + throw new TypeError( + `Realtime invalidation topics exceeded: ${effect.eventType}`, + ); + } + if ( + effect.effect === "INVALIDATE_TOPICS" && + effect.invalidationTopics.length === 0 + ) { + throw new TypeError( + `INVALIDATE_TOPICS effect declares no topic: ${effect.eventType}`, + ); + } + } + } + return Object.freeze([...contributions]); +} + +export type RuntimeCapabilityId = + | "REALTIME" + | "WEB_WORKER" + | "SERVICE_WORKER" + | "OFFLINE_COMMANDS"; + +/** + * §3.5. A bounded, serialisable view of one capability. `selected` is the + * static SSOT count and `active` is what survived the runtime override, so the + * difference between the two is exactly the operator's effect. An override can + * only subtract, which is why a never-selected capability stays at zero. + */ +export type RuntimeCapabilityStatus = Readonly<{ + capabilityId: RuntimeCapabilityId; + selected: number; + active: number; + override: RuntimeCapabilityOverride; +}>; + +export type RuntimeCapabilitySnapshot = readonly RuntimeCapabilityStatus[]; + +const CAPABILITY_ORDER = Object.freeze([ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", +] as const); + +export function describeRuntimeCapabilities( + installed: InstalledRuntimeCapabilities, + overrides: CapabilityOverrideMap, +): RuntimeCapabilitySnapshot { + const resolved = resolveRuntimeCapabilities(installed, overrides); + const counts: Readonly< + Record> + > = Object.freeze({ + REALTIME: Object.freeze({ + selected: installed.realtime.length, + active: resolved.realtime.length, + }), + WEB_WORKER: Object.freeze({ + selected: installed.webWorkers.length, + active: resolved.webWorkers.length, + }), + SERVICE_WORKER: Object.freeze({ + selected: installed.serviceWorker === null ? 0 : 1, + active: resolved.serviceWorker === null ? 0 : 1, + }), + OFFLINE_COMMANDS: Object.freeze({ + selected: installed.offlineCommands === null ? 0 : 1, + active: resolved.offlineCommands === null ? 0 : 1, + }), + }); + + return Object.freeze( + CAPABILITY_ORDER.map((capabilityId) => + Object.freeze({ + capabilityId, + selected: counts[capabilityId].selected, + active: counts[capabilityId].active, + override: overrides[capabilityId], + }), + ), + ); +} diff --git a/src/contracts/server-state-scope.ts b/src/contracts/server-state-scope.ts index 2d545dc..632aa2b 100644 --- a/src/contracts/server-state-scope.ts +++ b/src/contracts/server-state-scope.ts @@ -4,11 +4,33 @@ export type CacheScopeSnapshot = Readonly<{ generation: number; fingerprint: string; identities: RuntimeIdentityRegistry; + /** Aborted synchronously when this generation is fenced or disposed. */ + signal: AbortSignal; isCurrent(): boolean; }>; +/** + * §10.5. Client scope authority lifecycle. + * + * `FENCED` is published synchronously so no subscriber can render a value that + * belonged to the previous identity. `READY` arrives only after the exact reset + * sequence in §10.6 has completed. + */ +export type ClientScopeLifecycleEvent = + | Readonly<{ kind: "FENCED"; previousGeneration: number }> + | Readonly<{ kind: "READY"; snapshot: CacheScopeSnapshot }> + | Readonly<{ kind: "FAILED"; generation: number }> + | Readonly<{ kind: "DISPOSED" }>; + +/** UI reads `FENCED` as the `scope-transition` state, never as stale data. */ +export type ClientScopePhase = "READY" | "FENCED" | "FAILED" | "DISPOSED"; + export type ServerStateScopeRuntime = Readonly<{ getSnapshot(): CacheScopeSnapshot; + getPhase(): ClientScopePhase; subscribe(listener: () => void): () => void; + subscribeLifecycle( + listener: (event: ClientScopeLifecycleEvent) => void, + ): () => void; dispose(): void; }>; diff --git a/src/contracts/server-state.ts b/src/contracts/server-state.ts index 9750879..7dbaf32 100644 --- a/src/contracts/server-state.ts +++ b/src/contracts/server-state.ts @@ -3,6 +3,17 @@ import type { QueryInvalidationTopic } from "./query-invalidation.ts"; import type { RuntimeIdentityBinding } from "./query-keys.ts"; import type { CacheScopeSnapshot } from "./server-state-scope.ts"; +/** + * §10.2. The four fixed profiles. A feature selects one by ID; it never + * declares its own numbers. If none of the four can express a requirement, the + * design document and this registry are amended together. + */ +export type ServerStateProfileId = + | "DETAIL_STANDARD" + | "LIST_STANDARD" + | "LOOKUP_STABLE" + | "VOLATILE_STATUS"; + export type ServerStateProfile = Readonly<{ profileId: string; staleTimeMs: number; @@ -15,12 +26,128 @@ export type ServerStateProfile = Readonly<{ maxEstimatedResultBytes: number; }>; +export const SERVER_STATE_PROFILES: Readonly< + Record +> = Object.freeze({ + DETAIL_STANDARD: Object.freeze({ + profileId: "DETAIL_STANDARD", + staleTimeMs: 30_000, + gcTimeMs: 300_000, + refetchOnMount: true, + refetchOnFocus: true, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 1, + maxEstimatedResultBytes: 262_144, + }), + LIST_STANDARD: Object.freeze({ + profileId: "LIST_STANDARD", + staleTimeMs: 15_000, + gcTimeMs: 300_000, + refetchOnMount: true, + refetchOnFocus: true, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 200, + maxEstimatedResultBytes: 1_048_576, + }), + LOOKUP_STABLE: Object.freeze({ + profileId: "LOOKUP_STABLE", + staleTimeMs: 300_000, + gcTimeMs: 1_800_000, + refetchOnMount: false, + refetchOnFocus: false, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 500, + maxEstimatedResultBytes: 2_097_152, + }), + VOLATILE_STATUS: Object.freeze({ + profileId: "VOLATILE_STATUS", + staleTimeMs: 0, + gcTimeMs: 60_000, + refetchOnMount: "always", + refetchOnFocus: true, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 1, + maxEstimatedResultBytes: 65_536, + }), +}); + +export function getServerStateProfile( + profileId: ServerStateProfileId, +): ServerStateProfile { + const profile = SERVER_STATE_PROFILES[profileId]; + if (!profile) { + throw new TypeError(`Unregistered server-state profile: ${profileId}`); + } + return profile; +} + +/** §10.3. Feature-owned, mandatory result measurement. */ +export type QueryResultMeasure = Readonly<{ + itemCount: number; + estimatedBytes: number; +}>; + +export type ResultAdmission = + | Readonly<{ ok: true; measure: QueryResultMeasure }> + | Readonly<{ + ok: false; + code: "RESULT_MEASUREMENT_FAILED" | "RESULT_BUDGET_EXCEEDED"; + }>; + +/** + * §10.4. There is no generic fallback: `JSON.stringify` sizing, wire DTO + * re-serialization and recursive walkers are all prohibited, so a definition + * without a working `measureResult` fails closed instead of guessing. + */ +export function admitQueryResult( + measureResult: (value: Value) => QueryResultMeasure, + value: Value, + profile: ServerStateProfile, +): ResultAdmission { + let measure: QueryResultMeasure; + try { + measure = measureResult(value); + } catch { + return Object.freeze({ + ok: false as const, + code: "RESULT_MEASUREMENT_FAILED" as const, + }); + } + if ( + !measure || + !Number.isSafeInteger(measure.itemCount) || + measure.itemCount < 0 || + !Number.isSafeInteger(measure.estimatedBytes) || + measure.estimatedBytes < 0 + ) { + return Object.freeze({ + ok: false as const, + code: "RESULT_MEASUREMENT_FAILED" as const, + }); + } + if ( + measure.itemCount > profile.maxResultItems || + measure.estimatedBytes > profile.maxEstimatedResultBytes + ) { + return Object.freeze({ + ok: false as const, + code: "RESULT_BUDGET_EXCEEDED" as const, + }); + } + return Object.freeze({ ok: true as const, measure: Object.freeze(measure) }); +} + export type BoundQuery = Readonly<{ definitionId: string; queryKey: readonly unknown[]; profile: ServerStateProfile; identity: RuntimeIdentityBinding; scope: CacheScopeSnapshot; + measureResult(value: Value): QueryResultMeasure; execute(context: Readonly<{ signal: AbortSignal }>): Promise>; }>; @@ -31,7 +158,8 @@ export type QueryDefinition = Readonly<{ namespace: string; namespaceVersion: number; operationId: string; - profile: ServerStateProfile; + profileId: ServerStateProfileId; + measureResult(value: Value): QueryResultMeasure; execute( input: Input, context: Readonly<{ signal: AbortSignal }>, @@ -43,9 +171,16 @@ export function bindQuery( input: Input, scope: CacheScopeSnapshot, ): BoundQuery { + if (typeof definition.measureResult !== "function") { + throw new TypeError( + `Query definition requires measureResult: ${definition.definitionId}`, + ); + } const identity = scope.identities.intern(input); return Object.freeze({ definitionId: definition.definitionId, + // §10.7. Opaque runtime identity only. No raw account/resource ID, URL, + // filter object, document or cursor ever enters a query key. queryKey: Object.freeze([ "query", 1, @@ -53,23 +188,54 @@ export function bindQuery( definition.namespace, definition.namespaceVersion, definition.definitionVersion, - identity, + identity.token, ]), - profile: definition.profile, + profile: getServerStateProfile(definition.profileId), identity, scope, + measureResult: (value: Value) => definition.measureResult(value), execute: (context) => definition.execute(input, context), }); } +/** + * §11.2. `REJECT_WHILE_ACTIVE` is the command default. `JOIN_IDENTICAL` is + * only valid when scope, definition, canonical input and user intent all match; + * `ALLOW_PARALLEL` is an explicit per-feature decision. + */ +export type MutationDuplicatePolicy = + | "JOIN_IDENTICAL" + | "REJECT_WHILE_ACTIVE" + | "ALLOW_PARALLEL"; + +/** §11.5. Ordered optimistic layer bounds. Overflow means pessimistic execution. */ +export const OPTIMISTIC_LAYER_BOUNDS = Object.freeze({ + maxLayersPerQueryKey: 8, + maxSingleLayerBytes: 262_144, + maxTotalLayerBytesPerQueryKey: 2_097_152, + maxLayerAgeGraceMs: 60_000, +}); + +/** §11.3. Duplicate coordinator bounds. The map is never used as a result cache. */ +export const MUTATION_COORDINATOR_BOUNDS = Object.freeze({ + activeDefinitionsPerRuntime: 256, + activeIntentsTotal: 1_024, + canonicalIdentityBytes: 16_384, + waitersPerJoinedIntent: 32, + settledRetentionMs: 0, +}); + export type BoundMutation = Readonly<{ definitionId: string; definitionVersion: number; operationId: string; owner: string; - duplicatePolicy: "JOIN_IDENTICAL" | "REJECT_DUPLICATE" | "ALLOW_INDEPENDENT"; + duplicatePolicy: MutationDuplicatePolicy; scope: CacheScopeSnapshot; - execute(input: Input): Promise>; + execute( + input: Input, + context: Readonly<{ signal: AbortSignal }>, + ): Promise>; invalidate: readonly QueryInvalidationTopic[]; optimistic?: Readonly<{ queryKey: readonly unknown[]; diff --git a/src/contracts/service-worker.ts b/src/contracts/service-worker.ts new file mode 100644 index 0000000..c92b7c0 --- /dev/null +++ b/src/contracts/service-worker.ts @@ -0,0 +1,152 @@ +/** + * §17–§18. Service Worker contract. + * + * One physical registration per scope covers PWA lifecycle, verified static + * asset fetch, Web Push and the optional sync wake-up. A separate registration + * for any of those is prohibited. + */ + +export const SERVICE_WORKER_PROTOCOL_VERSION = 1 as const; +export const SERVICE_WORKER_CACHE_SCHEMA_VERSION = 1 as const; +export const SERVICE_WORKER_SCRIPT_PATH = "service-worker.js" as const; + +export const SERVICE_WORKER_BOUNDS = Object.freeze({ + assets: 256, + singleAssetBytes: 2 * 1024 * 1024, + assetSetBytes: 5 * 1024 * 1024, + fetchConcurrency: 4, + installDeadlineMs: 60_000, + clientDrainMs: 30_000, + updateCheckIntervalMs: 6 * 60 * 60 * 1_000, + retainedPreviousCaches: 1, + reloadGuardBytes: 512, + reloadGuardTtlMs: 10 * 60 * 1_000, +}); + +export type ServiceWorkerHandlerId = + | "WEB_PUSH" + | "PWA_STATIC_ASSETS" + | "OFFLINE_SYNC_WAKEUP"; + +/** + * §17.3. Removal is staged. `ACTIVE` never transitions directly to `null`: + * a registration that already exists in a browser must first be unregistered, + * then have its owned resources purged, before the source may disappear. + */ +export type InstalledServiceWorkerSelection = + | Readonly<{ + mode: "ACTIVE"; + scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH; + handlers: readonly ServiceWorkerHandlerId[]; + }> + | Readonly<{ + mode: "REMOVE_REGISTRATION"; + scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH; + }> + | Readonly<{ + mode: "PURGE_OWNED_RESOURCES"; + scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH; + }>; + +/** §17.7. Page and worker read the same compile-time identity tuple. */ +export type ServiceWorkerProtocolIdentity = Readonly<{ + serviceWorkerProtocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION; + cacheSchemaVersion: typeof SERVICE_WORKER_CACHE_SCHEMA_VERSION; + buildId: string; + releaseId: string; + contractSetDigest: string; + staticAssetSetDigest: string; +}>; + +export type ServiceWorkerMessageKind = + | "PAGE_HELLO" + | "WORKER_HELLO_ACK" + | "UPDATE_READY" + | "ACTIVATE_REQUEST" + | "ACTIVATE_ACCEPTED" + | "ACTIVATE_REJECTED" + | "CLIENT_DRAIN_REQUEST" + | "CLIENT_DRAINED" + | "ACTIVATED_RELOAD_REQUIRED" + | "CACHE_RESET_REQUEST" + | "CACHE_RESET_RESULT" + | "SYNC_WAKE_OBSERVED"; + +export type ServiceWorkerMessage = Readonly<{ + protocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION; + kind: ServiceWorkerMessageKind; + messageId: string; + sourceBuildId: string; + targetBuildId?: string; + nonce?: string; + /** Present only on a successful CACHE_RESET_RESULT. */ + cachesDeleted?: number; +}>; + +/** §18.3. Compile-time asset manifest; the worker never fetches one. */ +export interface StaticAssetManifestV1 { + readonly schemaVersion: 1; + readonly buildId: string; + readonly releaseId: string; + readonly setDigest: `sha256:${string}`; + readonly assets: readonly Readonly<{ + url: string; + sha256: `sha256:${string}`; + bytes: number; + contentType: string; + }>[]; +} + +/** §18.2. `ca-static-v1-`. */ +export const STATIC_CACHE_PREFIX = "ca-static-v1-" as const; + +export function staticCacheName(setDigest: string): string { + const hex = setDigest.replace(/^sha256:/, "").slice(0, 16); + if (!/^[0-9a-f]{16}$/.test(hex)) { + throw new TypeError("Static asset set digest is invalid."); + } + return `${STATIC_CACHE_PREFIX}${hex}`; +} + +export function isOwnedStaticCacheName(name: string): boolean { + return ( + name.startsWith(STATIC_CACHE_PREFIX) && + /^[0-9a-f]{16}$/.test(name.slice(STATIC_CACHE_PREFIX.length)) + ); +} + +export type ServiceWorkerStartOutcome = + | Readonly<{ kind: "ACTIVE"; buildId: string }> + | Readonly<{ kind: "RELOAD_TO_ENABLE" }> + | Readonly<{ kind: "UPDATE_WAITING" }> + | Readonly<{ kind: "DISABLED" }> + | Readonly<{ kind: "INCOMPATIBLE" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerActivationOutcome = + | Readonly<{ kind: "ACTIVATED_RELOAD_REQUIRED" }> + | Readonly<{ kind: "BLOCKED_DIRTY_CLIENT" }> + | Readonly<{ kind: "CLIENT_DRAIN_TIMEOUT" }> + | Readonly<{ kind: "NO_WAITING_WORKER" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerResetOutcome = + | Readonly<{ kind: "RESET"; cachesDeleted: number }> + | Readonly<{ kind: "NOT_CONTROLLED" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerRemovalOutcome = + | Readonly<{ kind: "ABSENT" }> + | Readonly<{ kind: "UNREGISTERED" }> + | Readonly<{ kind: "PURGED"; cachesDeleted: number; metadataDeleted: number }> + | Readonly<{ kind: "OWNERSHIP_MISMATCH" }> + | Readonly<{ kind: "FAILED"; operation: "LOOKUP" | "UNREGISTER" | "PURGE" }>; + +export interface ServiceWorkerRuntimeHost { + start(): Promise; + requestActivation(): Promise; + resetOwnedCaches(): Promise; + stop(): Promise; +} diff --git a/src/contracts/web-worker.ts b/src/contracts/web-worker.ts new file mode 100644 index 0000000..894e9c9 --- /dev/null +++ b/src/contracts/web-worker.ts @@ -0,0 +1,139 @@ +/** + * §16. Generic CPU Web Worker contract. + * + * The capability is `NOT_SELECTED` by default (§16.2). These types exist so a + * selection can be expressed and validated, but no production worker entry is + * created until a measured, CPU-bound task with an owner is contributed. This + * runtime is separate from the OPFS dedicated worker and the Service Worker. + */ + +export const WEB_WORKER_PROTOCOL = "CA_WEB_WORKER_V1" as const; + +export const WEB_WORKER_BOUNDS = Object.freeze({ + taskGroups: 16, + tasksPerGroup: 32, + activeGroups: 4, + queuedPerGroup: 32, + queuedBytesPerGroup: 16 * 1024 * 1024, + defaultInputBytes: 1024 * 1024, + hardInputBytes: 8 * 1024 * 1024, + defaultOutputBytes: 1024 * 1024, + hardOutputBytes: 8 * 1024 * 1024, + defaultDeadlineMs: 5_000, + hardDeadlineMs: 30_000, + cancelGraceMs: 250, + idleTerminateMs: 60_000, + restartsPerWindow: 3, + restartWindowMs: 300_000, + transferables: 16, + mainThreadChunkBudgetMs: 8, + mainThreadFallbackInputBytes: 1024 * 1024, +}); + +export interface InstalledWorkerTask { + readonly taskId: string; + readonly taskVersion: 1; + readonly maximumInputBytes: number; + readonly maximumOutputBytes: number; + readonly deadlineMs: number; +} + +export interface InstalledWebWorkerContribution { + readonly taskGroupId: string; + readonly tasks: readonly InstalledWorkerTask[]; + readonly fallback: "MAIN_THREAD_CHUNKED" | "UNSUPPORTED"; +} + +export type WorkerRequestMessage = Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "EXECUTE"; + taskId: string; + taskVersion: 1; + requestId: string; + workerGeneration: number; + deadlineEpochMs: number; + payload: unknown; +}>; + +export type WorkerCancelMessage = Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "CANCEL"; + requestId: string; + workerGeneration: number; +}>; + +export type WorkerFailureCode = + | "TASK_UNKNOWN" + | "VERSION_UNSUPPORTED" + | "INPUT_INVALID" + | "INPUT_TOO_LARGE" + | "OUTPUT_INVALID" + | "OUTPUT_TOO_LARGE" + | "QUEUE_FULL" + | "DEADLINE_EXCEEDED" + | "CANCELLED" + | "CRASHED" + | "TRANSFER_FAILED" + | "STALE_RESULT" + | "RUNTIME_PROTOCOL_FAILURE"; + +export type WorkerResponseMessage = + | Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "SUCCESS"; + requestId: string; + workerGeneration: number; + payload: unknown; + }> + | Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "FAILURE"; + requestId: string; + workerGeneration: number; + code: WorkerFailureCode; + }>; + +export function validateWebWorkerContributions( + contributions: readonly InstalledWebWorkerContribution[], +): readonly InstalledWebWorkerContribution[] { + const bounds = WEB_WORKER_BOUNDS; + if (contributions.length > bounds.taskGroups) { + throw new TypeError("Web Worker task groups exceed their bound."); + } + const groupIds = new Set(); + const taskIds = new Set(); + for (const contribution of contributions) { + if (!contribution.taskGroupId || groupIds.has(contribution.taskGroupId)) { + throw new TypeError("Web Worker task group identity is invalid."); + } + groupIds.add(contribution.taskGroupId); + if ( + contribution.tasks.length === 0 || + contribution.tasks.length > bounds.tasksPerGroup + ) { + throw new TypeError("Web Worker task count is out of range."); + } + for (const task of contribution.tasks) { + const qualified = `${contribution.taskGroupId}/${task.taskId}`; + if (!task.taskId || taskIds.has(qualified)) { + throw new TypeError("Duplicate Web Worker task."); + } + taskIds.add(qualified); + if ( + task.taskVersion !== 1 || + !Number.isSafeInteger(task.maximumInputBytes) || + task.maximumInputBytes < 1 || + task.maximumInputBytes > bounds.hardInputBytes || + !Number.isSafeInteger(task.maximumOutputBytes) || + task.maximumOutputBytes < 1 || + task.maximumOutputBytes > bounds.hardOutputBytes || + !Number.isSafeInteger(task.deadlineMs) || + task.deadlineMs < 1 || + task.deadlineMs > bounds.hardDeadlineMs + ) { + throw new TypeError(`Web Worker task bounds invalid: ${qualified}`); + } + } + } + return Object.freeze([...contributions]); +} diff --git a/src/features/installed-contract-contributions.ts b/src/features/installed-contract-contributions.ts new file mode 100644 index 0000000..3483efc --- /dev/null +++ b/src/features/installed-contract-contributions.ts @@ -0,0 +1,27 @@ +import { + composeContractContributions, + type InstalledContractContribution, + type InstalledContractPackageIdentity, +} from "../contracts/external-contract-runtime.ts"; +import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts"; + +/** + * §4.8. Static contract selection SSOT. + * + * A product feature adds exactly one entry per service package here and imports + * the generated package only from + * `src/features//contracts/-contract-contribution.ts`. + */ +export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = + Object.freeze([REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]); + +export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( + INSTALLED_CONTRACT_CONTRIBUTIONS, +); + +/** + * §5.4. `TEMPLATE_FIXTURE` provenance is filtered out before the contract set + * is canonicalized, so a fixture can never influence the release digest. + */ +export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageIdentity[] = + COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages; diff --git a/src/features/installed-runtime-capabilities.ts b/src/features/installed-runtime-capabilities.ts new file mode 100644 index 0000000..822fa31 --- /dev/null +++ b/src/features/installed-runtime-capabilities.ts @@ -0,0 +1,49 @@ +import type { InstalledOfflineCommandContribution } from "../contracts/offline-command.ts"; +import type { + InstalledRealtimeContribution, + InstalledRuntimeCapabilities, +} from "../contracts/runtime-capabilities.ts"; +import { + validateRealtimeContributions, +} from "../contracts/runtime-capabilities.ts"; +import type { InstalledServiceWorkerSelection } from "../contracts/service-worker.ts"; +import { + validateWebWorkerContributions, + type InstalledWebWorkerContribution, +} from "../contracts/web-worker.ts"; + +/** + * §3.5. Static optional capability selection SSOT. + * + * Adding an entry here is what installs a capability. Runtime Config can only + * disable what this file already selected, and no dynamic import path is ever + * built from a configuration string. + * + * Current template state, matching the §25 stop conditions: + * + * - realtime: `NOT_SELECTED`. The RT-01..RT-04 common runtime exists and is + * independently verified, but no product contribution supplies an exact + * endpoint, event descriptor and recovery mode (§13.6). + * - webWorkers: `NOT_SELECTED`. No profiled CPU-bound task with an owner + * exists, so no production worker entry is created (§16.2). + * - serviceWorker: `null`. Switching to `ACTIVE` also enables the two-pass + * build in `scripts/build-frontend.ts`. + * - offlineCommands: `NOT_SELECTED`. No external package declares a `KEYED` + * operation with a recovery descriptor (§19.3). + */ + +const REALTIME: readonly InstalledRealtimeContribution[] = Object.freeze([]); + +const WEB_WORKERS: readonly InstalledWebWorkerContribution[] = Object.freeze([]); + +const SERVICE_WORKER: InstalledServiceWorkerSelection | null = null; + +const OFFLINE_COMMANDS: InstalledOfflineCommandContribution | null = null; + +export const INSTALLED_RUNTIME_CAPABILITIES: InstalledRuntimeCapabilities = + Object.freeze({ + realtime: validateRealtimeContributions(REALTIME), + webWorkers: validateWebWorkerContributions(WEB_WORKERS), + serviceWorker: SERVICE_WORKER, + offlineCommands: OFFLINE_COMMANDS, + }); diff --git a/src/features/reference-feature/adapters/create-reference-feature-input.ts b/src/features/reference-feature/adapters/create-reference-feature-input.ts index 2272025..0147e56 100644 --- a/src/features/reference-feature/adapters/create-reference-feature-input.ts +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -1,65 +1,174 @@ -import type { ApiOperation } from "../../../contracts/api-operations.ts"; +import type { Result } from "../../../application/result.ts"; +import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts"; +import type { FailureEffectCertainty } from "../../../contracts/errors.ts"; +import { + createFailure, + kindForStatus, +} from "../../../contracts/errors.ts"; +import type { HttpExecutionOutcome } from "../../../adapters/http/http-execution-v3.ts"; import { createReferenceFeatureInput } from "../application/reference-feature-api.ts"; import { - REFERENCE_FEATURE_CONTRACT, REFERENCE_FEATURE_ID, } from "../contracts/reference-feature-contract.ts"; -import { - mapWithBoundaryRegistry, - type MappingResult, -} from "../../../contracts/boundary-mapper.ts"; -import { validateWithRuntimeSchemaRegistry } from "../../../contracts/schema-registry.ts"; +import { mapReferenceOperation } from "../contracts/reference-mapper.ts"; import { createReferenceHttpGateway, type RawReferenceHttpExecutor, + type ReferenceHttpRequest, + type ReferenceOperationId, } from "./reference-http-gateway.ts"; -type HttpContract = Readonly<{ - getOperation(operationId: string): ApiOperation; - validatePayload(schemaId: string, value: unknown): ReturnType< - typeof validateWithRuntimeSchemaRegistry - >; - validateRequest(schemaId: string, value: unknown): ReturnType< - typeof validateWithRuntimeSchemaRegistry - >; - validatePath(schemaId: string, value: unknown): ReturnType< - typeof validateWithRuntimeSchemaRegistry - >; - mapPayload(operationId: string, payload: unknown): MappingResult; +export type InstalledContractOperationExecutor = Readonly<{ + execute( + operationId: string, + input: unknown, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; }>; -type HttpExecutor = RawReferenceHttpExecutor; - +/** + * The installed feature consumes the composed external-contract operation + * registry through one descriptor-driven executor. Legacy ApiOperation/schema + * registries are intentionally absent from this production composition seam. + */ export function createReferenceFeatureInstalledInput(context: Readonly<{ - createHttpClient(contract: HttpContract): HttpExecutor; + contractOperations: InstalledContractOperationExecutor; }>) { - const operations = - REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly>; - const schemas = REFERENCE_FEATURE_CONTRACT.runtimeSchemas; - const mappers = REFERENCE_FEATURE_CONTRACT.mappers; - const validate = (schemaId: string, value: unknown) => - validateWithRuntimeSchemaRegistry(schemaId, value, schemas); - const rawHttp = context.createHttpClient({ - getOperation(operationId) { - const operation = operations[operationId]; - if (!operation) { - throw new Error(`Unknown reference operation: ${operationId}`); - } - return operation; - }, - validatePayload: validate, - validateRequest: validate, - validatePath: validate, - mapPayload(operationId, payload) { - const operation = operations[operationId]; - if (!operation?.mapperId) { - return { ok: false, code: "MAPPING_INVARIANT_REJECTED" }; - } - return mapWithBoundaryRegistry(operation.mapperId, payload, mappers); + const rawHttp: RawReferenceHttpExecutor = Object.freeze({ + async execute(request) { + const operationId = request.operationId; + const input = inputFor(request); + const signal = "signal" in request ? request.signal : undefined; + const outcome = await context.contractOperations.execute( + operationId, + input, + signal === undefined ? {} : { signal }, + ); + return projectExecutionOutcome(operationId, outcome); }, }); + return Object.freeze({ featureId: REFERENCE_FEATURE_ID, input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)), }); } + +function inputFor( + request: ReferenceHttpRequest, +): unknown { + switch (request.operationId) { + case "LIST_REFERENCE_RESOURCES": + return request.searchParams; + case "CREATE_REFERENCE_RESOURCE": + return request.body; + case "GET_REFERENCE_RESOURCE": + return request.pathParams; + } +} + +function projectExecutionOutcome( + operationId: ReferenceOperationId, + outcome: HttpExecutionOutcome, +): Result { + switch (outcome.kind) { + case "SUCCESS": { + const mapped = mapReferenceOperation(operationId, outcome.value); + return mapped.ok + ? Object.freeze({ ok: true as const, value: mapped.value }) + : failure( + "MAPPING_CONTRACT_VIOLATION", + operationId, + mapped.code, + { effect: outcome.effect }, + ); + } + case "PROBLEM": + return failure( + kindForStatus(outcome.metadata.status), + operationId, + "CONTRACT_PROBLEM", + { httpStatus: outcome.metadata.status, effect: outcome.effect }, + ); + case "UNAUTHENTICATED": + return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", { + effect: outcome.effect, + }); + case "FORBIDDEN": + return failure("FORBIDDEN", operationId, "FORBIDDEN", { + effect: outcome.effect, + }); + case "RATE_LIMITED": + return failure("RATE_LIMITED", operationId, "RATE_LIMITED", { + ...(outcome.retryAfterMs === undefined + ? {} + : { retryAfterMs: outcome.retryAfterMs }), + effect: outcome.effect, + }); + case "CANCELLED": + return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", { + effect: outcome.effect, + }); + case "TRANSPORT_FAILURE": + return failure( + outcome.failure.kind === "TIMEOUT" + ? "REQUEST_TIMEOUT" + : outcome.failure.kind === "ABORTED_BY_SCOPE" + ? "SCOPE_GENERATION_CHANGED" + : "NETWORK_UNREACHABLE", + operationId, + outcome.failure.kind, + { effect: outcome.effect }, + ); + case "CONTRACT_VIOLATION": + return failure( + failureKindForViolation(outcome.violation.kind), + operationId, + outcome.violation.kind, + { effect: outcome.effect }, + ); + } +} + +function failureKindForViolation( + violation: Extract< + HttpExecutionOutcome, + { kind: "CONTRACT_VIOLATION" } + >["violation"]["kind"], +): FailureKind { + switch (violation) { + case "CONTENT_TYPE_MISMATCH": + return "CONTENT_TYPE_MISMATCH"; + case "RESPONSE_TOO_LARGE": + return "RESPONSE_BODY_LIMIT"; + case "UTF8_INVALID": + case "JSON_INVALID": + return "MALFORMED_JSON"; + case "MAPPING_CONTRACT_VIOLATION": + return "MAPPING_CONTRACT_VIOLATION"; + case "SCOPE_FENCED": + return "SCOPE_GENERATION_CHANGED"; + case "SUCCESS_SCHEMA_INVALID": + case "PROBLEM_SCHEMA_INVALID": + case "VALIDATOR_RUNTIME_FAILURE": + return "SCHEMA_MISMATCH"; + default: + return "ENVELOPE_MISMATCH"; + } +} + +function failure( + kind: FailureKind, + operationId: string, + code: string, + details: Readonly<{ + httpStatus?: number; + retryAfterMs?: number; + effect?: FailureEffectCertainty; + }> = {}, +): Result { + return Object.freeze({ + ok: false as const, + error: createFailure(kind, operationId, 0, { code, ...details }), + }); +} diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts index 765fb54..76ef346 100644 --- a/src/features/reference-feature/adapters/reference-http-gateway.ts +++ b/src/features/reference-feature/adapters/reference-http-gateway.ts @@ -25,6 +25,7 @@ type ReferenceOperationMap = Readonly<{ operationId: "CREATE_REFERENCE_RESOURCE"; routeId: "REFERENCE_RESOURCE_LIST"; body: ReferenceCreateCommand; + signal?: AbortSignal; }>; value: ReferenceResource; }>; @@ -71,11 +72,15 @@ export function createReferenceHttpGateway( }); return projectListResult(result); }, - async create(command: ReferenceCreateCommand) { + async create( + command: ReferenceCreateCommand, + context?: Readonly<{ signal?: AbortSignal }>, + ) { const result = await http.execute({ operationId: "CREATE_REFERENCE_RESOURCE", routeId: "REFERENCE_RESOURCE_LIST", body: command, + signal: context?.signal, }); return projectResourceResult("CREATE_REFERENCE_RESOURCE", result); }, diff --git a/src/features/reference-feature/application/reference-feature-api.ts b/src/features/reference-feature/application/reference-feature-api.ts index a07d957..9a766d9 100644 --- a/src/features/reference-feature/application/reference-feature-api.ts +++ b/src/features/reference-feature/application/reference-feature-api.ts @@ -26,6 +26,7 @@ export type ReferenceFeatureInput = Readonly<{ ): Promise>; createResource( command: ReferenceCreateCommand, + context?: Readonly<{ signal?: AbortSignal }>, ): Promise>; getResource( resourceId: string, @@ -46,6 +47,7 @@ export type ReferenceGateway = Readonly<{ ): Promise>; create( command: ReferenceCreateCommand, + context?: Readonly<{ signal?: AbortSignal }>, ): Promise>; get( resourceId: string, @@ -66,8 +68,8 @@ export function createReferenceFeatureInput( } : result; }, - async createResource(command) { - const result = await gateway.create(command); + async createResource(command, context) { + const result = await gateway.create(command, context); return result.ok ? { ok: true as const, value: toReferenceView(result.value) } : result; diff --git a/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts b/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts new file mode 100644 index 0000000..a16c67b --- /dev/null +++ b/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts @@ -0,0 +1,255 @@ +import { z } from "zod"; + +import type { + CommandEffectDescriptor, + HttpRequestProjection, + InstalledContractContribution, + InstalledHttpContract, + RuntimeValidator, +} from "../../../contracts/external-contract-runtime.ts"; +import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts"; +import { + referenceResourceListQuerySchema, + referenceResourceParamsSchema, +} from "./reference-schemas.ts"; + +/** + * §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference + * HTTP vertical executable as deterministic template data and is excluded from + * `contractSet`. A product feature MUST instead pin an external package and + * import it from its own `contracts/-contract-contribution.ts`. + */ + +function zodValidator( + schemaId: string, + schema: z.ZodType, +): RuntimeValidator { + return Object.freeze({ + schemaId, + safeParse(value: unknown) { + const result = schema.safeParse(value); + if (result.success) { + return Object.freeze({ + success: true as const, + data: structuredClone(result.data), + }); + } + return Object.freeze({ + success: false as const, + issues: Object.freeze( + result.error.issues.map((issue) => + Object.freeze({ + path: Object.freeze( + issue.path.map((segment): string | number => + typeof segment === "number" ? segment : String(segment), + ), + ), + code: String(issue.code), + }), + ), + ), + }); + }, + }); +} + +const referenceResourceDto = z + .object({ + id: z.string().min(1).max(120), + name: z.string().min(1).max(240), + createdAt: z.string().min(1).optional(), + }) + .strip(); + +const problemSchema = z + .object({ + type: z.string().min(1).max(512), + title: z.string().min(1).max(240), + status: z.int().min(100).max(599), + code: z.string().min(1).max(120).optional(), + }) + .strip(); + +export type ReferenceProblem = z.output; + +const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema); + +const createCommandSchema = z + .object({ + name: z.string().trim().min(1).max(120), + note: z.string().trim().max(500).optional(), + }) + .strict(); + +/** + * Fixture-side classifier standing in for the package-provided pure bounded + * classifier. A status the fixture does not describe stays `MAYBE_APPLIED`. + */ +const CREATE_EFFECT: CommandEffectDescriptor = Object.freeze({ + successEffect: "APPLIED_CONFIRMED" as const, + classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) { + if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED"; + return "MAYBE_APPLIED"; + }, +}); + +const LIST_REFERENCE_RESOURCES: InstalledHttpContract< + z.output, + readonly z.output[], + ReferenceProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "LIST_REFERENCE_RESOURCES", + method: "GET" as const, + pathTemplate: "/api/reference-resources", + inputValidator: zodValidator( + "ReferenceResourceListQuery", + referenceResourceListQuerySchema, + ), + outputValidator: zodValidator( + "ReferenceResourceListPayload", + z.array(referenceResourceDto).max(100), + ), + problemValidator: PROBLEM_VALIDATOR, + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: z.output) { + const entries: (readonly [string, string])[] = []; + if (input.cursor !== undefined) entries.push(["cursor", input.cursor]); + entries.push(["limit", String(input.limit)]); + for (const tag of input.tags ?? []) entries.push(["tags", tag]); + return Object.freeze({ + pathValues: Object.freeze({}), + queryEntries: Object.freeze(entries), + body: null, + }); + }, + }), + frontend: Object.freeze({ + policyId: "REFERENCE_LIST_V1", + requestByteLimit: 0, + responseByteLimit: 262_144, + totalDeadlineMs: 10_000, + retryBudget: 2 as const, + authProfileId: "REFERENCE_EXTERNAL_BEARER", + diagnosticsOperation: "reference.list", + }), +}); + +const GET_REFERENCE_RESOURCE: InstalledHttpContract< + z.output, + z.output, + ReferenceProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "GET_REFERENCE_RESOURCE", + method: "GET" as const, + pathTemplate: "/api/reference-resources/{resourceId}", + inputValidator: zodValidator( + "ReferenceResourceParams", + referenceResourceParamsSchema, + ), + outputValidator: zodValidator( + "ReferenceResourcePayload", + referenceResourceDto, + ), + problemValidator: PROBLEM_VALIDATOR, + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: z.output) { + return Object.freeze({ + pathValues: Object.freeze({ resourceId: input.resourceId }), + queryEntries: Object.freeze([]), + body: null, + }); + }, + }), + frontend: Object.freeze({ + policyId: "REFERENCE_DETAIL_V1", + requestByteLimit: 0, + responseByteLimit: 32_768, + totalDeadlineMs: 10_000, + retryBudget: 2 as const, + authProfileId: "REFERENCE_EXTERNAL_BEARER", + diagnosticsOperation: "reference.detail", + }), +}); + +const CREATE_REFERENCE_RESOURCE: InstalledHttpContract< + z.output, + z.output, + ReferenceProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "CREATE_REFERENCE_RESOURCE", + method: "POST" as const, + pathTemplate: "/api/reference-resources", + inputValidator: zodValidator( + "CreateReferenceResourceCommand", + createCommandSchema, + ), + outputValidator: zodValidator( + "ReferenceResourcePayload", + referenceResourceDto, + ), + problemValidator: PROBLEM_VALIDATOR, + acceptedStatuses: Object.freeze([200, 201]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "KEYED" as const, + requestBody: "JSON" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: Object.freeze({ + mode: "IDEMPOTENCY_REPLAY" as const, + operationIdentityField: "idempotencyKey", + }), + commandEffect: CREATE_EFFECT, + projectRequest(input: z.output) { + return Object.freeze({ + pathValues: Object.freeze({}), + queryEntries: Object.freeze([]), + body: Object.freeze({ ...input }), + }); + }, + }), + frontend: Object.freeze({ + policyId: "REFERENCE_CREATE_V1", + requestByteLimit: 32_768, + responseByteLimit: 32_768, + totalDeadlineMs: 10_000, + /** + * §8.3. A KEYED command has no automatic retry after a dispatched attempt + * lost its response; the fixture therefore declares a zero budget rather + * than relying on a runtime special case. + */ + retryBudget: 0 as const, + authProfileId: "REFERENCE_EXTERNAL_BEARER", + diagnosticsOperation: "reference.create", + }), +}); + +export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution = + Object.freeze({ + contributionId: "reference-feature-http-v1", + featureId: REFERENCE_FEATURE_ID, + source: Object.freeze({ + kind: "TEMPLATE_FIXTURE" as const, + fixtureId: "REFERENCE_FEATURE_V1" as const, + revision: 1 as const, + }), + http: Object.freeze([ + LIST_REFERENCE_RESOURCES, + GET_REFERENCE_RESOURCE, + CREATE_REFERENCE_RESOURCE, + ]) as readonly InstalledHttpContract[], + events: Object.freeze([]), + }); diff --git a/src/features/reference-feature/contracts/reference-feature-contract.ts b/src/features/reference-feature/contracts/reference-feature-contract.ts index 142046e..e91a6bc 100644 --- a/src/features/reference-feature/contracts/reference-feature-contract.ts +++ b/src/features/reference-feature/contracts/reference-feature-contract.ts @@ -84,7 +84,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ path: "/examples/reference-resources", paramsSchema: null, searchSchema: "ReferenceResourceListQuery", - access: "integration-defined", + access: "session-required", loadingSurface: "reference-resource-list", errorSurface: "feature-boundary", chunkId: "route-reference-resources", @@ -97,7 +97,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ path: "/examples/reference-resources/:resourceId", paramsSchema: "ReferenceResourceParams", searchSchema: null, - access: "integration-defined", + access: "session-required", loadingSurface: "reference-resource-detail", errorSurface: "feature-boundary", chunkId: "route-reference-resource-detail", @@ -110,7 +110,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ path: "/examples/reference-resources/new", paramsSchema: null, searchSchema: null, - access: "integration-defined", + access: "session-required", loadingSurface: "reference-resource-form", errorSurface: "feature-boundary", chunkId: "route-reference-resource-form", @@ -123,7 +123,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ path: "/examples/reference-resources/status", paramsSchema: null, searchSchema: null, - access: "integration-defined", + access: "session-required", loadingSurface: "reference-resource-status", errorSurface: "feature-boundary", chunkId: "route-reference-resource-status", diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts index 75a6c6b..651eab5 100644 --- a/src/features/reference-feature/presentation/use-reference-feature.ts +++ b/src/features/reference-feature/presentation/use-reference-feature.ts @@ -16,22 +16,38 @@ import type { import type { ReferenceResourceView } from "../contracts/reference-mapper.ts"; import { bindQuery, - defineServerStateProfile, type BoundMutation, + type QueryResultMeasure, } from "../../../contracts/server-state.ts"; import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx"; -const REFERENCE_READ_PROFILE = defineServerStateProfile({ - profileId: "reference-resource-read-v1", - staleTimeMs: 30_000, - gcTimeMs: 300_000, - refetchOnMount: true, - refetchOnFocus: true, - refetchOnReconnect: true, - retryOwner: "TRANSPORT", - maxResultItems: 100, - maxEstimatedResultBytes: 262_144, -}); +const UTF8 = new TextEncoder(); + +/** + * §10.4. Feature-owned measurement over the mapped application value. There is + * no generic fallback: bounded string bytes plus fixed primitive width plus a + * small per-item overhead, never `JSON.stringify` or a recursive walker. + */ +function measureResourceView(view: ReferenceResourceView): QueryResultMeasure { + return { + itemCount: 1, + estimatedBytes: + UTF8.encode(view.resourceId).byteLength + + UTF8.encode(view.title).byteLength + + UTF8.encode(view.createdAt ?? "").byteLength + + 32, + }; +} + +function measureResourceList( + views: readonly ReferenceResourceView[], +): QueryResultMeasure { + let estimatedBytes = 16; + for (const view of views) { + estimatedBytes += measureResourceView(view).estimatedBytes; + } + return { itemCount: views.length, estimatedBytes }; +} export function useReferenceFeatureInput(): ReferenceFeatureInput { return useApplication().features.get(REFERENCE_FEATURE_ID); @@ -49,7 +65,8 @@ export function useReferenceDetail(resourceId: string) { namespace: "reference-resource", namespaceVersion: 1, operationId: "GET_REFERENCE_RESOURCE", - profile: REFERENCE_READ_PROFILE, + profileId: "DETAIL_STANDARD", + measureResult: measureResourceView, execute: (selectedResourceId: string, { signal }) => input.getResource(selectedResourceId, { signal }), }, @@ -71,7 +88,7 @@ export function useReferenceCreate() { definitionVersion: 1, operationId: "CREATE_REFERENCE_RESOURCE", owner: REFERENCE_FEATURE_ID, - duplicatePolicy: "JOIN_IDENTICAL", + duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, execute: input.createResource, invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC], @@ -93,7 +110,8 @@ export function useReferenceFeature() { namespace: "reference-resource", namespaceVersion: 1, operationId: "LIST_REFERENCE_RESOURCES", - profile: REFERENCE_READ_PROFILE, + profileId: "LIST_STANDARD", + measureResult: measureResourceList, execute: (selectedFilters: ReferenceListFilters, { signal }) => input.listResources(selectedFilters, { signal }), }, @@ -106,7 +124,7 @@ export function useReferenceFeature() { definitionVersion: 1, operationId: "CREATE_REFERENCE_RESOURCE", owner: REFERENCE_FEATURE_ID, - duplicatePolicy: "JOIN_IDENTICAL", + duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, execute: input.createResource, invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC], diff --git a/src/presentation/adapters/query/application-query.ts b/src/presentation/adapters/query/application-query.ts index eafab2c..2f14ffd 100644 --- a/src/presentation/adapters/query/application-query.ts +++ b/src/presentation/adapters/query/application-query.ts @@ -21,9 +21,11 @@ import { type AppFailure, } from "../../../contracts/errors.ts"; import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts"; -import type { - BoundMutation, - BoundQuery, +import { + admitQueryResult, + type BoundMutation, + type BoundQuery, + type MutationDuplicatePolicy, } from "../../../contracts/server-state.ts"; import { runtimeIdentityToken } from "../../../contracts/query-keys.ts"; import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx"; @@ -64,6 +66,8 @@ export function useApplicationQuery( const profile = "profile" in options ? options.profile : undefined; const scope = "scope" in options ? options.scope : undefined; const identity = "identity" in options ? options.identity : undefined; + const measureResult = + "measureResult" in options ? options.measureResult : undefined; const queryDefinitionId = "definitionId" in options ? options.definitionId : "APPLICATION_QUERY"; const [staleFailure, setStaleFailure] = useState(false); @@ -105,22 +109,22 @@ export function useApplicationQuery( ); } if (result.ok) { - if ( - profile && - !isAdmissibleResult( + if (profile && measureResult) { + const admission = admitQueryResult( + measureResult, result.value, - profile.maxResultItems, - profile.maxEstimatedResultBytes, - ) - ) { - throw new ApplicationQueryError( - createFailure( - "RESULT_LIMIT_EXCEEDED", - "APPLICATION_QUERY", - 0, - { code: "RESULT_ADMISSION_LIMIT_EXCEEDED" }, - ), + profile, ); + if (!admission.ok) { + throw new ApplicationQueryError( + createFailure( + "RESULT_LIMIT_EXCEEDED", + queryDefinitionId, + 0, + { code: admission.code }, + ), + ); + } } return result.value; } @@ -175,26 +179,34 @@ export function useApplicationQuery( }); } -export function useApplicationMutation( - options: - | BoundMutation - | Readonly<{ - execute(input: Input): Promise>; - invalidate?: readonly QueryInvalidationTopic[]; - optimistic?: Readonly<{ - queryKey: readonly unknown[]; - update(previous: unknown, input: Input): unknown; - }>; - currentData?: unknown; - }>, -): Readonly<{ +type LegacyMutationOptions = Readonly<{ + execute(input: Input): Promise>; + duplicatePolicy?: MutationDuplicatePolicy; + invalidate?: readonly QueryInvalidationTopic[]; + optimistic?: Readonly<{ + queryKey: readonly unknown[]; + update(previous: unknown, input: Input): unknown; + }>; + currentData?: unknown; +}>; + +type ApplicationMutationController = Readonly<{ state: AsyncState; submit(input: Input): Promise>; resolveConflict(): Promise; -}> { +}>; + +export function useApplicationMutation( + options: BoundMutation, +): ApplicationMutationController; +export function useApplicationMutation( + options: LegacyMutationOptions, +): ApplicationMutationController; +export function useApplicationMutation( + options: BoundMutation | LegacyMutationOptions, +): ApplicationMutationController { const queryClient = useQueryClient(); const invalidationCoordinator = useQueryInvalidationCoordinator(); - const { execute } = options; const invalidate = useMemo( () => options.invalidate ?? [], [options.invalidate], @@ -204,7 +216,7 @@ export function useApplicationMutation( const definitionId = "definitionId" in options ? options.definitionId : "LEGACY_MUTATION"; const duplicatePolicy = - "duplicatePolicy" in options ? options.duplicatePolicy : "JOIN_IDENTICAL"; + "duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE"; const [conflict, setConflict] = useState(null); const scope = "scope" in options ? options.scope : undefined; const mutation = useMutation({ @@ -220,14 +232,21 @@ export function useApplicationMutation( ), ); } - const result = await execute(input); + const result = + "scope" in options + ? await options.execute(input, { signal: options.scope.signal }) + : await options.execute(input); if (scope && !scope.isCurrent()) { + const effect = + !result.ok && result.error.effect !== undefined + ? result.error.effect + : "MAYBE_APPLIED"; throw new ApplicationQueryError( createFailure( "SCOPE_GENERATION_CHANGED", definitionId, 0, - { code: "MUTATION_SCOPE_CHANGED" }, + { code: "MUTATION_SCOPE_CHANGED", effect }, ), ); } @@ -276,7 +295,7 @@ export function useApplicationMutation( identityLease?.release(); return active; } - if (active && duplicatePolicy === "REJECT_DUPLICATE") { + if (active && duplicatePolicy === "REJECT_WHILE_ACTIVE") { identityLease?.release(); return Promise.resolve({ ok: false, @@ -379,7 +398,7 @@ export function useApplicationMutation( mutationExecutions(queryClient).delete(identity); } }); - if (duplicatePolicy !== "ALLOW_INDEPENDENT") { + if (duplicatePolicy !== "ALLOW_PARALLEL") { mutationExecutions(queryClient).set(identity, pending); } return pending; @@ -445,36 +464,3 @@ function optimisticLayers( OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created); return created; } - -function isAdmissibleResult( - value: unknown, - maxItems: number, - maxBytes: number, -): boolean { - try { - const seen = new WeakSet(); - let items = 0; - const visit = (candidate: unknown): boolean => { - if (candidate === null || ["string", "number", "boolean"].includes(typeof candidate)) { - return true; - } - if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return false; - seen.add(candidate); - if (Array.isArray(candidate)) { - items += candidate.length; - return items <= maxItems && candidate.every(visit); - } - const prototype = Object.getPrototypeOf(candidate); - return ( - (prototype === Object.prototype || prototype === null) && - Object.values(candidate).every(visit) - ); - }; - return ( - visit(value) && - new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes - ); - } catch { - return false; - } -} diff --git a/src/presentation/adapters/query/optimistic-layer-runtime.ts b/src/presentation/adapters/query/optimistic-layer-runtime.ts index 2ff0ebb..6c3edbd 100644 --- a/src/presentation/adapters/query/optimistic-layer-runtime.ts +++ b/src/presentation/adapters/query/optimistic-layer-runtime.ts @@ -1,6 +1,7 @@ import { hashKey, type QueryClient } from "@tanstack/react-query"; import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts"; +import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts"; export type OptimisticLayerLease = Readonly<{ commit(): void; @@ -88,11 +89,31 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { } else if (entry.scope !== scope) { return null; } + // §11.5. Overflow falls back to pessimistic execution; an existing + // layer is never silently evicted to make room for a new one. + if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) { + if (entry.layers.length === 0) entries.delete(key); + return null; + } const layer: Layer = { id: nextId++, status: "pending", apply: (value) => update(value, input), }; + let projected: unknown; + try { + projected = update(entry.base, input); + } catch { + if (entry.layers.length === 0) entries.delete(key); + return null; + } + if ( + estimateLayerBytes(projected) > + OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes + ) { + if (entry.layers.length === 0) entries.delete(key); + return null; + } entry.layers.push(layer); project(key, entry); let settled = false; @@ -120,3 +141,36 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { }, }); } + +const encoder = new TextEncoder(); + +/** + * A local, bounded estimate of one optimistic projection. This is not the + * §10.4 query result measurement: it only decides whether a rollback snapshot + * stays inside the layer budget, and it stops as soon as the budget is passed. + */ +function estimateLayerBytes(value: unknown): number { + let total = 0; + const stack: unknown[] = [value]; + let visited = 0; + while (stack.length > 0) { + if (visited++ > 4_096) return Number.POSITIVE_INFINITY; + if (total > OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes) return total; + const current = stack.pop(); + if (typeof current === "string") { + total += encoder.encode(current).byteLength; + } else if (typeof current === "number" || typeof current === "boolean") { + total += 8; + } else if (Array.isArray(current)) { + total += 8; + for (const item of current) stack.push(item); + } else if (current && typeof current === "object") { + total += 8; + for (const [key, item] of Object.entries(current)) { + total += encoder.encode(key).byteLength; + stack.push(item); + } + } + } + return total; +} diff --git a/src/presentation/adapters/query/server-state-generation-provider.tsx b/src/presentation/adapters/query/server-state-generation-provider.tsx new file mode 100644 index 0000000..72f5024 --- /dev/null +++ b/src/presentation/adapters/query/server-state-generation-provider.tsx @@ -0,0 +1,52 @@ +import { + QueryClientProvider, + type QueryClient, +} from "@tanstack/react-query"; +import { type ReactNode, useSyncExternalStore } from "react"; + +import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts"; +import type { ServerStateScopeRuntime } from "../../../contracts/server-state-scope.ts"; +import { QueryInvalidationProvider } from "./query-invalidation-provider.tsx"; +import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx"; + +export type ServerStateGenerationSource = Readonly<{ + getSnapshot(): Readonly<{ + generation: number; + queryClient: QueryClient; + queryInvalidation: QueryInvalidationCoordinator; + }>; + subscribe(listener: () => void): () => void; +}>; + +export function ServerStateGenerationProvider({ + store, + scope, + children, + transitionFallback, +}: Readonly<{ + store: ServerStateGenerationSource; + scope: ServerStateScopeRuntime; + children: ReactNode; + transitionFallback?: ReactNode; +}>) { + const generation = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); + return ( + + + + {children} + + + + ); +} diff --git a/src/presentation/adapters/query/server-state-scope-provider.tsx b/src/presentation/adapters/query/server-state-scope-provider.tsx index e5e5e48..a99e2f2 100644 --- a/src/presentation/adapters/query/server-state-scope-provider.tsx +++ b/src/presentation/adapters/query/server-state-scope-provider.tsx @@ -16,13 +16,27 @@ const ServerStateScopeContext = export function ServerStateScopeProvider({ runtime, children, + transitionFallback = null, }: Readonly<{ runtime: ServerStateScopeRuntime; children: ReactNode; + transitionFallback?: ReactNode; }>) { + const phase = useSyncExternalStore( + runtime.subscribe, + runtime.getPhase, + runtime.getPhase, + ); + const content = + phase === "READY" + ? children + : phase === "DISPOSED" + ? null + : transitionFallback; + return ( - {children} + {content} ); } diff --git a/src/presentation/examples/platform-overview-page.tsx b/src/presentation/examples/platform-overview-page.tsx new file mode 100644 index 0000000..ce7b1d4 --- /dev/null +++ b/src/presentation/examples/platform-overview-page.tsx @@ -0,0 +1,484 @@ +import { useEffect, useState } from "react"; + +import { HTTP_EXECUTION_CEILINGS } from "../../contracts/external-contract-runtime.ts"; +import { SERVER_STATE_PROFILES } from "../../contracts/server-state.ts"; +import { + COMPOSED_CONTRACT_CONTRIBUTIONS, + EXPECTED_CONTRACT_SET_PACKAGES, +} from "../../features/installed-contract-contributions.ts"; +import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts"; +import type { + RuntimeCapabilityId, + RuntimeCapabilityStatus, +} from "../../contracts/runtime-capabilities.ts"; +import { + Badge, + Card, + DataTable, + EmptySurface, + PageHeader, + type DataTableColumn, +} from "../design-system/index.ts"; +import { useApplication } from "../providers/application-provider.tsx"; + +/** + * Every number and row on this page is read from an installed registry at + * render time. Nothing is transcribed by hand, so deleting a feature removes + * its rows and the page keeps describing what the repository actually is. + */ + +function kilobytes(bytes: number): string { + if (bytes === 0) return "없음"; + if (bytes >= 1_048_576) return `${bytes / 1_048_576} MiB`; + return `${bytes / 1024} KiB`; +} + +function seconds(milliseconds: number): string { + return milliseconds < 1000 + ? `${milliseconds}ms` + : `${milliseconds / 1000}초`; +} + +function Metric({ + label, + value, + hint, +}: Readonly<{ label: string; value: string; hint?: string }>) { + return ( +
+
{label}
+
+ {value} + {hint ? {hint} : null} +
+
+ ); +} + +type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY]; + +const ROUTE_COLUMNS: readonly DataTableColumn[] = Object.freeze([ + { + id: "routeId", + header: "라우트", + cell: (row) => {row.routeId}, + }, + { id: "path", header: "경로", cell: (row) => {row.path} }, + { + id: "access", + header: "접근", + cell: (row) => ( + + {row.access} + + ), + }, + { + id: "schemas", + header: "입력 스키마", + cell: (row) => + [row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음", + }, + { + id: "chunkId", + header: "청크", + cell: (row) => {row.chunkId}, + }, +]); + +type OperationRow = Readonly<{ + operationId: string; + method: string; + pathTemplate: string; + retrySemantics: string; + retryBudget: number; + totalDeadlineMs: number; + requestByteLimit: number; + responseByteLimit: number; + effect: string; + recovery: string; +}>; + +const OPERATION_COLUMNS: readonly DataTableColumn[] = + Object.freeze([ + { + id: "operationId", + header: "오퍼레이션", + cell: (row) => ( + <> + {row.operationId} + + {row.method} {row.pathTemplate} + + + ), + }, + { + id: "retry", + header: "재시도", + cell: (row) => ( + <> + + {row.retrySemantics} + + + 예산 {row.retryBudget}회 + + + ), + }, + { + id: "effect", + header: "효과 확정성", + cell: (row) => row.effect, + }, + { + id: "recovery", + header: "복구", + cell: (row) => row.recovery, + }, + { + id: "budget", + header: "예산", + cell: (row) => ( + <> + + 요청 {kilobytes(row.requestByteLimit)} · 응답{" "} + {kilobytes(row.responseByteLimit)} + + + 마감 {seconds(row.totalDeadlineMs)} + + + ), + }, + ]); + +type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES]; + +const PROFILE_COLUMNS: readonly DataTableColumn[] = Object.freeze([ + { + id: "profileId", + header: "프로파일", + cell: (row) => {row.profileId}, + }, + { id: "stale", header: "stale", cell: (row) => seconds(row.staleTimeMs) }, + { id: "gc", header: "gc", cell: (row) => seconds(row.gcTimeMs) }, + { + id: "refetch", + header: "재조회", + cell: (row) => + [ + row.refetchOnMount === "always" + ? "mount(always)" + : row.refetchOnMount && "mount", + row.refetchOnFocus && "focus", + row.refetchOnReconnect && "reconnect", + ] + .filter(Boolean) + .join(" · "), + }, + { + id: "budget", + header: "결과 예산", + cell: (row) => + `${row.maxResultItems}건 · ${kilobytes(row.maxEstimatedResultBytes)}`, + }, +]); + +const CAPABILITY_COPY: Readonly< + Record> +> = Object.freeze({ + REALTIME: Object.freeze({ + label: "실시간 수신", + description: + "WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.", + }), + WEB_WORKER: Object.freeze({ + label: "웹 워커", + description: + "워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.", + }), + SERVICE_WORKER: Object.freeze({ + label: "서비스 워커", + description: + "참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.", + }), + OFFLINE_COMMANDS: Object.freeze({ + label: "오프라인 명령", + description: + "명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.", + }), +}); + +/** + * A capability that was never selected and one an operator switched off look + * identical if both are reported as "off". The snapshot separates them, and so + * does this badge. + */ +function capabilityBadge( + status: RuntimeCapabilityStatus, +): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> { + if (status.selected === 0) return { text: "미선택", variant: "neutral" }; + if (status.active === 0) { + return { text: "운영자가 비활성화함", variant: "warning" }; + } + return { text: `활성 (${status.active})`, variant: "success" }; +} + +function buildOperationRows(): readonly OperationRow[] { + return Object.freeze( + [...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map( + (installed) => { + const { contract, frontend } = installed; + return Object.freeze({ + operationId: contract.operationId, + method: contract.method, + pathTemplate: contract.pathTemplate, + retrySemantics: contract.retrySemantics, + retryBudget: frontend.retryBudget, + totalDeadlineMs: frontend.totalDeadlineMs, + requestByteLimit: frontend.requestByteLimit, + responseByteLimit: frontend.responseByteLimit, + effect: + contract.commandEffect === null + ? "해당 없음" + : contract.commandEffect.successEffect, + recovery: + contract.commandRecovery === null + ? "해당 없음" + : contract.commandRecovery.mode, + }); + }, + ), + ); +} + +export default function PlatformOverviewPage() { + const { runtime } = useApplication(); + const [release, setRelease] = useState< + Awaited> | null + >(null); + + useEffect(() => { + let active = true; + void runtime.getReleaseSummary().then((summary) => { + if (active) setRelease(summary); + }); + return () => { + active = false; + }; + }, [runtime]); + + const routes = Object.values(ROUTE_REGISTRY); + const operations = buildOperationRows(); + const capabilities = runtime.getCapabilitySnapshot(); + const activeCapabilityCount = capabilities.filter( + (status) => status.active > 0, + ).length; + const fixtureContributions = + COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter( + (contribution) => contribution.source.kind === "TEMPLATE_FIXTURE", + ).length; + + return ( +
+ + +
+
+

릴리스 신원

+

+ 부팅 시 경계 검사를 통과한 런타임 설정과 릴리스 매니페스트에서 옵니다. +

+
+
+ {release ? ( + <> + + + + + + ) : ( + + )} +
+
+ +
+
+

설치 요약

+

레지스트리 항목 수를 그대로 센 값입니다.

+
+
+ + + + +
+
+ +
+
+

설치된 라우트

+

+ 라우트 레지스트리가 단일 진실 공급원입니다. 접근 정책, 코드 분할 청크, + 입력 스키마가 한 항목에 함께 선언됩니다. +

+
+ row.routeId} + empty={ + + } + /> +
+ +
+
+

계약과 HTTP 오퍼레이션

+

+ 외부 계약 패키지 {EXPECTED_CONTRACT_SET_PACKAGES.length}개가 설치되어 + 있습니다. 아래 오퍼레이션은 템플릿 픽스처가 제공하며 릴리스 다이제스트에 + 포함되지 않습니다. 제품은 픽스처를 지우고 자기 패키지를 고정합니다. +

+
+ row.operationId} + empty={ + + } + /> +
+ +
+
+

서버 상태와 실행 상한

+

+ 조회는 네 개의 고정 프로파일 중 하나를 골라야 하고, 실행 정책은 아래 + 상한을 넘을 수 없습니다. +

+
+ row.profileId} + empty={} + /> +
+ + + + +
+
+ +
+
+

선택적 런타임 능력

+

+ 정적 선택 파일이 단일 진실 공급원입니다. 런타임 설정은 이미 선택된 + 능력을 끌 수만 있고, 설정 문자열로 새 능력을 켜거나 모듈 경로를 만들지 + 못합니다. 여기 표시되는 상태는 정적 선택에 런타임 오버라이드를 적용한 + 결과이므로, 애초에 선택되지 않은 능력과 운영자가 끈 능력이 구분됩니다. +

+
+
+ {capabilities.map((status) => { + const copy = CAPABILITY_COPY[status.capabilityId]; + const badge = capabilityBadge(status); + return ( + {badge.text}} + > +

{copy.description}

+
+ ); + })} +
+
+
+ ); +} diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts index c5750f4..857d0bb 100644 --- a/src/presentation/i18n/catalog.ts +++ b/src/presentation/i18n/catalog.ts @@ -40,6 +40,8 @@ const PLATFORM_KO_MESSAGES = { "shell.session.actionFailed": "세션 작업을 완료하지 못했습니다.", "route.APP_HOME.navigation": "홈", "route.APP_HOME.title": "Clean Architecture Frontend", + "route.EXAMPLES_PLATFORM.navigation": "플랫폼 구성", + "route.EXAMPLES_PLATFORM.title": "플랫폼 구성", "route.EXAMPLES_UI.navigation": "UI 구성요소", "route.EXAMPLES_UI.title": "UI 구성요소", "route.EXAMPLES_STATES.navigation": "화면 상태", @@ -190,6 +192,8 @@ const PLATFORM_EN_MESSAGES = { "shell.session.actionFailed": "The session action could not be completed.", "route.APP_HOME.navigation": "Home", "route.APP_HOME.title": "Clean Architecture Frontend", + "route.EXAMPLES_PLATFORM.navigation": "Platform composition", + "route.EXAMPLES_PLATFORM.title": "Platform composition", "route.EXAMPLES_UI.navigation": "UI components", "route.EXAMPLES_UI.title": "UI components", "route.EXAMPLES_STATES.navigation": "Screen states", diff --git a/src/presentation/pages/home-page.tsx b/src/presentation/pages/home-page.tsx index dfd3abd..4e42cff 100644 --- a/src/presentation/pages/home-page.tsx +++ b/src/presentation/pages/home-page.tsx @@ -59,10 +59,19 @@ export default function HomePage() {

준비된 화면 살펴보기

-

공통 구성요소와 비동기 화면 상태를 예제 라우트에서 확인하세요.

+

+ 설치된 라우트와 계약, 런타임 능력은 플랫폼 구성 화면에서, 공통 + 구성요소와 비동기 화면 상태는 예제 라우트에서 확인하세요. +

- + + 플랫폼 구성 보기 + + UI 구성요소 보기 import("../pages/home-page.tsx")), + EXAMPLES_PLATFORM: runtime( + "EXAMPLES_PLATFORM", + () => import("../examples/platform-overview-page.tsx"), + ), EXAMPLES_UI: runtime( "EXAMPLES_UI", () => import("../examples/ui-gallery-page.tsx"), diff --git a/src/presentation/styles/theme.css b/src/presentation/styles/theme.css index 9099db8..e12c1dd 100644 --- a/src/presentation/styles/theme.css +++ b/src/presentation/styles/theme.css @@ -1189,6 +1189,46 @@ gap: 1rem; } + .platform-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 1rem; + margin-block: 0; + } + + .platform-metric { + display: grid; + gap: 0.25rem; + padding: 0.85rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-surface); + background: var(--color-panel); + } + + .platform-metric dt { + font-size: 0.8125rem; + color: var(--color-content-muted); + } + + .platform-metric dd { + display: grid; + gap: 0.15rem; + margin-inline-start: 0; + } + + .platform-metric__value { + font-weight: 600; + overflow-wrap: anywhere; + } + + .platform-metric__hint, + .platform-operation__path { + display: block; + font-size: 0.8125rem; + color: var(--color-content-muted); + overflow-wrap: anywhere; + } + .badge-row { display: flex; flex-wrap: wrap; diff --git a/tests/browser-capabilities/cross-context-invalidation.spec.ts b/tests/browser-capabilities/cross-context-invalidation.spec.ts index a5b2daf..bdead72 100644 --- a/tests/browser-capabilities/cross-context-invalidation.spec.ts +++ b/tests/browser-capabilities/cross-context-invalidation.spec.ts @@ -4,7 +4,7 @@ import { type Page, } from "../support/browser/strict-browser-test.ts"; -const TOPIC = "reference-resources"; +const TOPIC = "sample-topic-alpha"; const TOPIC_VERSION = 1; const STATE_KEY = "__crossContextInvalidationCapability"; const STORAGE_PULSE_KEY = diff --git a/tests/component/application-query.test.tsx b/tests/component/application-query.test.tsx index 586608b..3d9a082 100644 --- a/tests/component/application-query.test.tsx +++ b/tests/component/application-query.test.tsx @@ -20,10 +20,44 @@ import { type QueryInvalidationCoordinator, } from "../../src/contracts/query-invalidation.ts"; import { createFailure } from "../../src/contracts/errors.ts"; +import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts"; +import { + bindQuery, + type QueryResultMeasure, +} from "../../src/contracts/server-state.ts"; +import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts"; const RESOURCE_INVALIDATION_TOPIC = defineQueryInvalidationTopic("resource"); +/** + * §24.12: the scope-bound commit fence is common runtime, so it is verified + * here with a local scope fixture rather than through the removable sample + * feature. + */ +function scopeSnapshot(): CacheScopeSnapshot & { fence(): void } { + let current = true; + const lifetime = new AbortController(); + const identities = createRuntimeIdentityRegistry({ + tokenFactory: () => "scope-identity-token-0001", + }); + return { + generation: 1, + fingerprint: "scope-fingerprint-0001", + identities, + signal: lifetime.signal, + isCurrent: () => current, + fence() { + current = false; + lifetime.abort(); + }, + }; +} + +function measureOne(): QueryResultMeasure { + return { itemCount: 1, estimatedBytes: 8 }; +} + function queryClient() { return new QueryClient({ defaultOptions: { @@ -181,7 +215,303 @@ describe("application query inbound bridge", () => { }); }); +describe("scope-bound query commit fence", () => { + it("discards a successful result whose scope was fenced during execution", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + let complete: (value: ApplicationResult) => void = () => {}; + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "fenced-detail-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "fenced", + namespaceVersion: 1, + operationId: "GET_FENCED", + profileId: "DETAIL_STANDARD", + measureResult: measureOne, + execute: () => + new Promise>((resolve) => { + complete = resolve; + }), + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + // §10.8: the scope goes stale after dispatch but before commit. + scope.fence(); + await act(async () => { + complete({ ok: true, value: "late" }); + }); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "SCOPE_GENERATION_CHANGED", + ), + ); + expect(hook.result.current.data).toBeUndefined(); + }); + + it("refuses to start when the captured scope is already stale", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); + scope.fence(); + + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "stale-detail-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "stale", + namespaceVersion: 1, + operationId: "GET_STALE", + profileId: "DETAIL_STANDARD", + measureResult: measureOne, + execute, + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "SCOPE_GENERATION_CHANGED", + ), + ); + expect(execute).not.toHaveBeenCalled(); + }); + + it("rejects a result that exceeds the profile budget instead of caching it", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "oversized-list-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "oversized", + namespaceVersion: 1, + operationId: "LIST_OVERSIZED", + profileId: "VOLATILE_STATUS", + // §10.4: VOLATILE_STATUS admits 1 item and 64KiB. + measureResult: (): QueryResultMeasure => ({ + itemCount: 2, + estimatedBytes: 8, + }), + execute: async () => ({ ok: true as const, value: "value" }), + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "RESULT_LIMIT_EXCEEDED", + ), + ); + expect(hook.result.current.data).toBeUndefined(); + }); + + it("treats a throwing measurement as a measurement failure, not a cache commit", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "unmeasurable-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "unmeasurable", + namespaceVersion: 1, + operationId: "GET_UNMEASURABLE", + profileId: "DETAIL_STANDARD", + measureResult: (): QueryResultMeasure => { + throw new Error("estimator defect"); + }, + execute: async () => ({ ok: true as const, value: "value" }), + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "RESULT_LIMIT_EXCEEDED", + ), + ); + expect(hook.result.current.data).toBeUndefined(); + }); +}); + +describe("scope-bound mutation fence", () => { + it("rejects a submit whose scope is already fenced", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "fenced-mutation-v1", + definitionVersion: 1, + operationId: "CREATE_FENCED", + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + scope.fence(); + const outcome = await hook.result.current.submit("value"); + expect(outcome).toMatchObject({ + ok: false, + error: { kind: "SCOPE_GENERATION_CHANGED" }, + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it("discards a mutation result whose scope was fenced after dispatch", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const execute = vi.fn(async () => { + scope.fence(); + return { ok: true as const, value: "committed" }; + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "late-mutation-v1", + definitionVersion: 1, + operationId: "CREATE_LATE", + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + const outcome = await hook.result.current.submit("value"); + expect(outcome).toMatchObject({ + ok: false, + error: { kind: "SCOPE_GENERATION_CHANGED" }, + }); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("aborts a hung mutation when its captured scope is fenced", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + let observedSignal: AbortSignal | undefined; + const execute = vi.fn( + (_input: string, context: Readonly<{ signal: AbortSignal }>) => + new Promise>((resolve) => { + observedSignal = context.signal; + context.signal.addEventListener( + "abort", + () => + resolve({ + ok: false, + error: createFailure("REQUEST_ABORTED", "CREATE_HUNG", 0), + }), + { once: true }, + ); + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "hung-mutation-v1", + definitionVersion: 1, + operationId: "CREATE_HUNG", + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + const outcome = hook.result.current.submit("value"); + await waitFor(() => expect(observedSignal).toBe(scope.signal)); + + scope.fence(); + + expect(observedSignal?.aborted).toBe(true); + await expect(outcome).resolves.toMatchObject({ + ok: false, + error: { + kind: "SCOPE_GENERATION_CHANGED", + effect: "MAYBE_APPLIED", + retryable: false, + action: "contact-support", + }, + }); + }); +}); + describe("application mutation inbound bridge", () => { + it("rejects a duplicate submit by default while one is active", async () => { + const client = queryClient(); + let complete: (value: ApplicationResult) => void = () => {}; + const execute = vi.fn( + () => + new Promise>((resolve) => { + complete = resolve; + }), + ); + const hook = renderHook( + () => useApplicationMutation({ execute }), + { wrapper: wrapper(client) }, + ); + + let first: Promise> | null = null; + let duplicate: Promise> | null = null; + act(() => { + first = hook.result.current.submit("created"); + duplicate = hook.result.current.submit("created"); + }); + + if (!first || !duplicate) throw new Error("expected two submissions"); + await expect(duplicate).resolves.toMatchObject({ + ok: false, + error: { kind: "DUPLICATE_IN_FLIGHT" }, + }); + expect(execute).toHaveBeenCalledOnce(); + + complete({ ok: true, value: "created" }); + await act(() => first as Promise>); + }); + it("deduplicates submit and commits one optimistic mutation", async () => { const client = queryClient(); const key = ["resource", "list"]; @@ -196,6 +526,8 @@ describe("application mutation inbound bridge", () => { const hook = renderHook( () => useApplicationMutation({ + // §11.2: joining is opt-in. The default is REJECT_WHILE_ACTIVE. + duplicatePolicy: "JOIN_IDENTICAL", execute, invalidate: [RESOURCE_INVALIDATION_TOPIC], currentData: client.getQueryData(key), diff --git a/tests/component/platform-overview-page.test.tsx b/tests/component/platform-overview-page.test.tsx new file mode 100644 index 0000000..17f2393 --- /dev/null +++ b/tests/component/platform-overview-page.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts"; +import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts"; +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; +import PlatformOverviewPage from "../../src/presentation/examples/platform-overview-page.tsx"; +import { LocaleProvider } from "../../src/presentation/i18n/index.ts"; +import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx"; +import { createTestApplication } from "../helpers/create-test-application.ts"; +import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts"; + +/** + * The page must stay a projection of the installed registries. Every assertion + * below derives its expectation from the same registry the page reads, so a + * template that removes its sample feature still satisfies this suite. + */ + +function renderPage() { + return render( + + + + + , + ); +} + +function tableByCaption(caption: string): HTMLElement { + return screen.getByRole("table", { name: caption }); +} + +/** A metric is a `dt`/`dd` pair, which carries no ARIA role to query by. */ +function metricByLabel(scope: HTMLElement, label: string): HTMLElement { + const term = within(scope).getByText(label).closest(".platform-metric"); + if (!(term instanceof HTMLElement)) { + throw new Error(`No metric is labelled ${label}`); + } + return term; +} + +describe("platform overview page", () => { + it("renders one route row per installed route registry entry", () => { + renderPage(); + + const table = tableByCaption("설치된 라우트 목록"); + const dataRows = within(table).getAllByRole("row").slice(1); + + expect(dataRows).toHaveLength(Object.keys(ROUTE_REGISTRY).length); + for (const definition of Object.values(ROUTE_REGISTRY)) { + expect( + within(table).getByText(definition.routeId), + ).toBeInTheDocument(); + } + }); + + it("renders one operation row per installed HTTP contract", () => { + const operationIds = [ + ...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.keys(), + ]; + renderPage(); + + if (operationIds.length === 0) { + expect( + screen.getByRole("heading", { + name: "설치된 HTTP 오퍼레이션이 없습니다.", + }), + ).toBeVisible(); + return; + } + + const table = tableByCaption("설치된 HTTP 오퍼레이션"); + expect(within(table).getAllByRole("row").slice(1)).toHaveLength( + operationIds.length, + ); + for (const operationId of operationIds) { + expect(within(table).getByText(operationId)).toBeInTheDocument(); + } + }); + + it("renders every fixed server-state profile with its own budget", () => { + renderPage(); + + const table = tableByCaption("서버 상태 프로파일"); + for (const profile of Object.values(SERVER_STATE_PROFILES)) { + expect(within(table).getByText(profile.profileId)).toBeInTheDocument(); + } + expect(within(table).getAllByRole("row").slice(1)).toHaveLength( + Object.keys(SERVER_STATE_PROFILES).length, + ); + }); + + it("labels a capability that was never selected as unselected", () => { + renderPage(); + + const region = screen.getByRole("region", { name: "선택적 런타임 능력" }); + + expect(within(region).getAllByText("미선택")).toHaveLength(4); + expect(within(region).queryByText("운영자가 비활성화함")).toBeNull(); + }); + + it("separates an operator disable from a capability that was never selected", () => { + render( + + + + + , + ); + + const region = screen.getByRole("region", { name: "선택적 런타임 능력" }); + + expect(within(region).getByText("운영자가 비활성화함")).toBeVisible(); + expect(within(region).getByText("활성 (1)")).toBeVisible(); + expect(within(region).getAllByText("미선택")).toHaveLength(2); + }); + + it("counts installed contract packages separately from template fixtures", () => { + const fixtures = COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter( + (contribution) => contribution.source.kind === "TEMPLATE_FIXTURE", + ).length; + renderPage(); + + const summary = screen.getByRole("region", { name: "설치 요약" }); + const packages = metricByLabel(summary, "외부 계약 패키지"); + + expect( + within(packages).getByText( + `${COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages.length}개`, + ), + ).toBeVisible(); + expect( + within(packages).getByText(`템플릿 픽스처 ${fixtures}개`), + ).toBeVisible(); + expect( + within(metricByLabel(summary, "라우트")).getByText( + `${Object.keys(ROUTE_REGISTRY).length}개`, + ), + ).toBeVisible(); + }); + + it("renders the verified release identity once the runtime resolves it", async () => { + renderPage(); + + const region = screen.getByRole("region", { name: "릴리스 신원" }); + await waitFor(() => + expect( + within(metricByLabel(region, "빌드")).getByText("test-build"), + ).toBeVisible(), + ); + expect( + within(metricByLabel(region, "릴리스")).getByText("test-release"), + ).toBeVisible(); + }); +}); diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx index ad8431a..cb98eb0 100644 --- a/tests/component/router.test.tsx +++ b/tests/component/router.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; @@ -22,6 +22,29 @@ function renderRouter() { } describe("generic application router", () => { + it("reaches the platform overview from the home starter actions", async () => { + const user = userEvent.setup(); + window.history.pushState({}, "", "/"); + renderRouter(); + + // The starter action lives inside the lazily loaded home chunk, so this is + // the first wait in the file that has to outlast a chunk load rather than + // an already-mounted shell element. + await user.click( + await screen.findByRole( + "link", + { name: "플랫폼 구성 보기" }, + { timeout: 5000 }, + ), + ); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }), + ).toHaveFocus(), + ); + }); + it("renders the app shell and not-found route without a feature input", async () => { window.history.pushState({}, "", "/missing"); renderRouter(); @@ -49,8 +72,29 @@ describe("generic application router", () => { ).toBeVisible(); expect(window.location.pathname).toBe("/examples/ui"); expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"); - expect( - screen.getByRole("heading", { name: "UI 구성요소", level: 1 }), - ).toHaveFocus(); + await waitFor(() => + expect( + screen.getByRole("heading", { name: "UI 구성요소", level: 1 }), + ).toHaveFocus(), + ); + }); + + it("focuses the route heading again when the lazy chunk is already cached", async () => { + // §9.7. The first visit resolves the route module asynchronously, so the + // router lifecycle and the page header commit separately. A later visit + // renders the cached module in the same commit, which is the ordering that + // must still hand focus to the heading rather than leaving it on main. + const user = userEvent.setup(); + window.history.pushState({}, "", "/"); + renderRouter(); + + for (const label of ["UI 구성요소", "화면 상태", "UI 구성요소"]) { + await user.click(await screen.findByRole("link", { name: label })); + await waitFor(() => + expect( + screen.getByRole("heading", { name: label, level: 1 }), + ).toHaveFocus(), + ); + } }); }); diff --git a/tests/component/server-state-generation-provider.test.tsx b/tests/component/server-state-generation-provider.test.tsx new file mode 100644 index 0000000..9f0effc --- /dev/null +++ b/tests/component/server-state-generation-provider.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom + +import { act, render, waitFor } from "@testing-library/react"; +import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; + +import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts"; +import { createServerStateGenerationStore } from "../../src/bootstrap/server-state-generation-store.ts"; +import { ServerStateGenerationProvider } from "../../src/presentation/adapters/query/server-state-generation-provider.tsx"; + +describe("server-state generation provider", () => { + it("remounts consumers with the QueryClient owned by the READY generation", async () => { + const store = createServerStateGenerationStore(() => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return { + queryClient, + queryInvalidation: { + invalidate: async () => {}, + beginMutation: () => ({ release: async () => {} }), + resetLocal: async () => { + await queryClient.cancelQueries(); + queryClient.clear(); + }, + dispose() {}, + }, + crossContextStatus: () => "DEGRADED_LOCAL_ONLY" as const, + }; + }); + let sessionListener: () => void = () => {}; + let token = 0; + const scope = createServerStateScopeRuntime({ + session: { + subscribe(listener) { + sessionListener = listener; + return () => {}; + }, + }, + queryInvalidation: { resetLocal: () => store.resetCurrent() }, + activateNextGeneration: () => store.activateNext(), + tokenFactory: () => + `scope-generation-provider-${String(token++).padStart(4, "0")}`, + }); + const renderedClients: QueryClient[] = []; + function Probe() { + renderedClients.push(useQueryClient()); + return
generation-content
; + } + + render( + scope-transition
} + > + + , + ); + const firstClient = renderedClients.at(-1); + if (!firstClient) throw new Error("expected initial QueryClient"); + + act(() => sessionListener()); + + await waitFor(() => expect(scope.getPhase()).toBe("READY")); + await waitFor(() => + expect(renderedClients.at(-1)).toBe(store.getSnapshot().queryClient), + ); + expect(renderedClients.at(-1)).not.toBe(firstClient); + + scope.dispose(); + store.dispose(); + }); +}); diff --git a/tests/component/server-state-scope-provider.test.tsx b/tests/component/server-state-scope-provider.test.tsx new file mode 100644 index 0000000..70393ef --- /dev/null +++ b/tests/component/server-state-scope-provider.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts"; +import { ServerStateScopeProvider } from "../../src/presentation/adapters/query/server-state-scope-provider.tsx"; + +const activeRuntimes: Array<{ dispose(): void }> = []; + +afterEach(() => { + for (const runtime of activeRuntimes.splice(0)) runtime.dispose(); +}); + +function scopeFixture(resetLocal: () => Promise) { + let sessionListener: () => void = () => {}; + let token = 0; + const runtime = createServerStateScopeRuntime({ + session: { + subscribe(listener) { + sessionListener = listener; + return () => {}; + }, + }, + queryInvalidation: { + resetLocal, + }, + tokenFactory: () => `scope-provider-token-${String(token++).padStart(4, "0")}`, + }); + activeRuntimes.push(runtime); + return { runtime, triggerSessionChange: () => sessionListener() }; +} + +describe("server-state scope provider", () => { + it("removes previous-scope children synchronously while reset is pending", async () => { + let completeReset: () => void = () => {}; + const reset = new Promise((resolve) => { + completeReset = resolve; + }); + const fixture = scopeFixture(async () => reset); + + render( + scope-transition} + > +
previous-account-secret
+
, + ); + + expect(screen.getByText("previous-account-secret")).toBeVisible(); + + act(() => fixture.triggerSessionChange()); + + expect(screen.queryByText("previous-account-secret")).toBeNull(); + expect(screen.getByText("scope-transition")).toBeVisible(); + + completeReset(); + await waitFor(() => + expect(screen.getByText("previous-account-secret")).toBeVisible(), + ); + }); + + it("never remounts previous-scope children after mandatory cleanup failure", async () => { + const fixture = scopeFixture(async () => { + throw new Error("reset failed"); + }); + render( + scope-transition} + > +
previous-account-secret
+
, + ); + + act(() => fixture.triggerSessionChange()); + + await waitFor(() => expect(fixture.runtime.getPhase()).toBe("FAILED")); + expect(screen.queryByText("previous-account-secret")).toBeNull(); + expect(screen.getByText("scope-transition")).toBeVisible(); + }); +}); diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index 07e4c90..a30a541 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -25,7 +25,7 @@ test("@a11y keyboard reaches the primary route action with visible focus", async page, }) => { await page.goto("/"); - const action = page.getByRole("link", { name: "UI 구성요소 보기" }); + const action = page.getByRole("link", { name: "플랫폼 구성 보기" }); await expect(action).toBeVisible(); await page.keyboard.press("Tab"); await expect(action).toBeFocused(); diff --git a/tests/e2e/compact-smoke.spec.ts b/tests/e2e/compact-smoke.spec.ts index 8d9d081..7326995 100644 --- a/tests/e2e/compact-smoke.spec.ts +++ b/tests/e2e/compact-smoke.spec.ts @@ -6,8 +6,14 @@ test("boots and navigates the compact production shell", async ({ page }) => { const menu = page.getByRole("button", { name: "메뉴", exact: true }); await expect(menu).toHaveCSS("min-width", "44px"); await menu.click(); - await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible(); - await page.getByRole("link", { name: "UI 구성요소" }).click(); + const navigation = page.getByRole("navigation", { name: "주요 탐색" }); + await expect(navigation).toBeVisible(); + // The drawer is a non-modal dialog, so page content stays in the + // accessibility tree while it is open. Scope to the navigation and match the + // whole name, or a route call to action on the page behind it also matches. + await navigation + .getByRole("link", { name: "UI 구성요소", exact: true }) + .click(); await expect(page).toHaveURL(/\/examples\/ui$/); await expect(page.locator("html")).toHaveAttribute("data-build-id", "local-build"); const overflow = await page.evaluate( diff --git a/tests/e2e/platform-overview.spec.ts b/tests/e2e/platform-overview.spec.ts new file mode 100644 index 0000000..6cfeafd --- /dev/null +++ b/tests/e2e/platform-overview.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from "../support/browser/strict-browser-test.ts"; +import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts"; +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; + +/** + * The overview is a projection of the installed registries. These assertions + * read the same registries the shipped bundle was built from, so they keep + * meaning after a feature is added or removed. + */ + +test("projects the installed route registry into the shipped bundle", async ({ + page, +}) => { + await page.goto("/examples/platform"); + await expect( + page.getByRole("heading", { name: "플랫폼 구성", level: 1 }), + ).toBeVisible(); + + const table = page.getByRole("table", { name: "설치된 라우트 목록" }); + await expect(table.locator("tbody tr")).toHaveCount( + Object.keys(ROUTE_REGISTRY).length, + ); + for (const routeId of Object.keys(ROUTE_REGISTRY)) { + await expect(table.getByText(routeId, { exact: true })).toBeVisible(); + } +}); + +test("shows every fixed server-state profile", async ({ page }) => { + await page.goto("/examples/platform"); + + const table = page.getByRole("table", { name: "서버 상태 프로파일" }); + await expect(table.locator("tbody tr")).toHaveCount( + Object.keys(SERVER_STATE_PROFILES).length, + ); +}); + +test("states the release contract identity verified at boot", async ({ + page, +}) => { + await page.goto("/examples/platform"); + + const release = page.locator( + "section[aria-labelledby='platform-release-title']", + ); + await expect(release.getByText(/^sha256:/)).toBeVisible(); +}); + +test("reports an unselected capability without claiming it was disabled", async ({ + page, +}) => { + await page.goto("/examples/platform"); + + const capabilities = page.locator( + "section[aria-labelledby='platform-capabilities-title']", + ); + await expect(capabilities.getByText("미선택")).toHaveCount(4); + await expect(capabilities.getByText("운영자가 비활성화함")).toHaveCount(0); +}); diff --git a/tests/e2e/reference-route.spec.ts b/tests/e2e/reference-route.spec.ts index 75d242d..dd41082 100644 --- a/tests/e2e/reference-route.spec.ts +++ b/tests/e2e/reference-route.spec.ts @@ -1,25 +1,21 @@ import { expect, test } from "../support/browser/strict-browser-test.ts"; -import { successEnvelope } from "../mocks/contracts/envelopes.ts"; import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; test("opens the protected integration route through the local demo seam", async ({ page, }) => { - const protectedRoute = Object.values(ROUTE_REGISTRY).find( - (definition) => definition.access === "integration-defined", - ); - if (!protectedRoute) throw new Error("An integration route is required"); + const protectedRoute = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST; await page.route( "http://localhost:8080/api/reference-resources?*", (route) => route.fulfill({ - json: successEnvelope([ + json: [ { id: "browser-reference", name: "Browser reference", createdAt: "2026-07-26T00:00:00.000Z", }, - ]), + ], }), ); await page.goto(protectedRoute.path); diff --git a/tests/features/reference-feature/reference-contract.test.ts b/tests/features/reference-feature/reference-contract.test.ts index b95e61a..b2f38f0 100644 --- a/tests/features/reference-feature/reference-contract.test.ts +++ b/tests/features/reference-feature/reference-contract.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildRouteUrl, @@ -19,8 +19,34 @@ import { } from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts"; import type { ReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.ts"; import { createTestApplication } from "../../helpers/create-test-application.ts"; +import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; describe("reference feature boundary contracts", () => { + it("propagates uncertain command effect certainty into the application failure", async () => { + const execute = vi.fn(async () => ({ + kind: "CONTRACT_VIOLATION" as const, + violation: { + kind: "SUCCESS_SCHEMA_INVALID" as const, + operation: "VALIDATION" as const, + }, + effect: "MAYBE_APPLIED" as const, + })); + const installed = createReferenceFeatureInstalledInput({ + contractOperations: { execute }, + }); + + await expect( + installed.input.createResource({ name: "uncertain" }), + ).resolves.toMatchObject({ + ok: false, + error: { + effect: "MAYBE_APPLIED", + retryable: false, + action: "contact-support", + }, + }); + }); + it("round-trips one canonical filter through URL and query identity", () => { const filters = { tags: ["open", "new"], diff --git a/tests/fixtures/coverage/below-threshold.json b/tests/fixtures/coverage/below-threshold.json new file mode 100644 index 0000000..9b766e5 --- /dev/null +++ b/tests/fixtures/coverage/below-threshold.json @@ -0,0 +1,8 @@ +{ + "total": { + "lines": { "pct": 0 }, + "statements": { "pct": 0 }, + "functions": { "pct": 0 }, + "branches": { "pct": 0 } + } +} diff --git a/tests/helpers/create-test-application.ts b/tests/helpers/create-test-application.ts index 909e6a3..243e2c9 100644 --- a/tests/helpers/create-test-application.ts +++ b/tests/helpers/create-test-application.ts @@ -4,6 +4,7 @@ import { type ApplicationOutputPorts, } from "../../src/application/create-application.ts"; import type { ApplicationFeatureInputs } from "../../src/application/ports/in/application-api.ts"; +import { createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts"; type TestApplicationOverrides = Partial & Readonly<{ @@ -49,6 +50,8 @@ export function createTestApplication( }, }), }, + runtimeCapabilities: + overrides.runtimeCapabilities ?? createRuntimeCapabilitiesStub(), navigation: overrides.navigation ?? { reload: () => {} }, }, overrides.featureInputs, diff --git a/tests/helpers/runtime-capabilities-stub.ts b/tests/helpers/runtime-capabilities-stub.ts new file mode 100644 index 0000000..6157404 --- /dev/null +++ b/tests/helpers/runtime-capabilities-stub.ts @@ -0,0 +1,34 @@ +import type { + RuntimeCapabilityId, + RuntimeCapabilityStatus, +} from "../../src/contracts/runtime-capabilities.ts"; +import type { RuntimeCapabilitiesPort } from "../../src/application/ports/runtime-capabilities-port.ts"; + +const CAPABILITY_IDS = Object.freeze([ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", +] as const); + +/** + * A capability port whose snapshot is fixed by the test rather than by the + * repository's installed selection, so a suite asserting presentation behaviour + * does not change meaning when a capability is later installed. + */ +export function createRuntimeCapabilitiesStub( + overrides: Readonly>>> = {}, +): RuntimeCapabilitiesPort { + const snapshot = Object.freeze( + CAPABILITY_IDS.map((capabilityId) => + Object.freeze({ + capabilityId, + selected: 0, + active: 0, + override: "DEFAULT" as const, + ...overrides[capabilityId], + }), + ), + ); + return Object.freeze({ getSnapshot: () => snapshot }); +} diff --git a/tests/mocks/handlers/reference-resources.ts b/tests/mocks/handlers/reference-resources.ts index 2e7fa42..e8c3c12 100644 --- a/tests/mocks/handlers/reference-resources.ts +++ b/tests/mocks/handlers/reference-resources.ts @@ -1,9 +1,5 @@ -import { delay, http, HttpResponse } from "msw"; +import { delay, http, HttpResponse, type JsonBodyType } from "msw"; -import { - failureEnvelope, - successEnvelope, -} from "../contracts/envelopes.ts"; import type { HttpScenarioId } from "../scenarios/catalog.ts"; import { assertOperationScenario } from "../scenarios/catalog.ts"; @@ -36,7 +32,7 @@ const DEFAULT_RESOURCE = Object.freeze({ async function scenarioResponse( scenario: HttpScenarioId, - payload: unknown, + payload: JsonBodyType, attempt: number, ) { if (scenario === "slow") await delay(50); @@ -56,35 +52,33 @@ async function scenarioResponse( return HttpResponse.json({ data: payload }); } if (scenario === "schema-mismatch") { - return HttpResponse.json(successEnvelope({ unexpected: true })); + return HttpResponse.json({ unexpected: true }); } if ( scenario === "auth-persistent-401" || (scenario === "auth-recover-once" && attempt === 1) ) { - return HttpResponse.json(failureEnvelope("AUTH_REQUIRED"), { + return HttpResponse.json(problem(401, "AUTH_REQUIRED"), { status: 401, }); } if (scenario === "forbidden-403") { - return HttpResponse.json(failureEnvelope("FORBIDDEN"), { status: 403 }); + return HttpResponse.json(problem(403, "FORBIDDEN"), { status: 403 }); } if (scenario === "not-found-404") { - return HttpResponse.json(failureEnvelope("NOT_FOUND"), { status: 404 }); + return HttpResponse.json(problem(404, "NOT_FOUND"), { status: 404 }); } if (scenario === "conflict-409") { - return HttpResponse.json(failureEnvelope("CONFLICT"), { status: 409 }); + return HttpResponse.json(problem(409, "CONFLICT"), { status: 409 }); } if (scenario === "validation-422") { return HttpResponse.json( - failureEnvelope("VALIDATION_REJECTED", { - issues: [{ path: "name", code: "too_small" }], - }), + problem(422, "VALIDATION_REJECTED"), { status: 422 }, ); } if (scenario === "rate-limited-429") { - return HttpResponse.json(failureEnvelope("RATE_LIMITED"), { + return HttpResponse.json(problem(429, "RATE_LIMITED"), { status: 429, headers: { "Retry-After": "1" }, }); @@ -93,11 +87,20 @@ async function scenarioResponse( scenario === "server-terminal-500" || (scenario === "server-retry-success" && attempt === 1) ) { - return HttpResponse.json(failureEnvelope("SERVER_FAILURE"), { + return HttpResponse.json(problem(503, "SERVER_FAILURE"), { status: 503, }); } - return HttpResponse.json(successEnvelope(payload)); + return HttpResponse.json(payload); +} + +function problem(status: number, code: string) { + return Object.freeze({ + type: `https://api.test/problems/${code.toLowerCase()}`, + title: code, + status, + code, + }); } export function createReferenceScenarioHandlers(options: ScenarioOptions = {}) { diff --git a/tests/runtime-schema/release-manifest.test.ts b/tests/runtime-schema/release-manifest.test.ts index bde9fe5..c14efc9 100644 --- a/tests/runtime-schema/release-manifest.test.ts +++ b/tests/runtime-schema/release-manifest.test.ts @@ -4,6 +4,9 @@ import { loadReleaseManifest, ReleaseManifestError, } from "../../src/bootstrap/load-release-manifest.ts"; +import { computeContractSetDigest } from "../../src/contracts/contract-set-canonical.ts"; + +const EMPTY_SET_DIGEST = await computeContractSetDigest([]); const runtime: Parameters[0] = { build: { @@ -22,12 +25,45 @@ const runtime: Parameters[0] = { RELEASE_MANIFEST_URL: "/release-manifest.json", BUILD_ID: "build-a", RELEASE_ID: "release-a", - CONFIG_SCHEMA_VERSION: "1", - API_CONTRACT_VERSION: "1", + CONFIG_SCHEMA_VERSION: "2.0", + CAPABILITY_OVERRIDES: { + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }, }, + configSchema: "V2", validationDurationMs: 0, }; const manifest = { + schemaVersion: 2, + appVersion: "0.1.0", + buildId: "build-a", + commitSha: "abc123", + configSchemaVersion: "2.0", + assetManifestHash: "hash-a", + releaseId: "release-a", + builtAt: "2026-07-25T00:00:00Z", + routeChunks: { "route-home": "assets/home.js" }, + contractSet: { + setAlgorithm: "CA_CONTRACT_SET_V1", + setDigest: EMPTY_SET_DIGEST, + packages: [], + }, +}; + +const runtimeV1: Parameters[0] = { + ...runtime, + config: { + ...runtime.config, + CONFIG_SCHEMA_VERSION: "1", + LEGACY_API_CONTRACT_VERSION: "1", + }, + configSchema: "V1", +}; + +const manifestV1 = { schemaVersion: 1, appVersion: "0.1.0", buildId: "build-a", @@ -40,12 +76,18 @@ const manifest = { routeChunks: { "route-home": "assets/home.js" }, }; +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); +} + describe("release manifest boot boundary", () => { it("loads a coherent release tuple", async () => { await expect( loadReleaseManifest( runtime, - { fetcher: async () => new Response(JSON.stringify(manifest)) }, + { fetcher: async () => jsonResponse(manifest) }, ), ).resolves.toMatchObject({ releaseId: "release-a" }); }); @@ -56,7 +98,7 @@ describe("release manifest boot boundary", () => { runtime, { fetcher: async () => - new Response(JSON.stringify({ ...manifest, buildId: "build-b" })), + jsonResponse({ ...manifest, buildId: "build-b" }), }, ), ).rejects.toMatchObject({ @@ -66,18 +108,6 @@ describe("release manifest boot boundary", () => { }); it.each([ - [ - { configSchemaVersion: "2" }, - {}, - "CONFIG_MISMATCH", - "MANIFEST_CONFIG_SCHEMA_MISMATCH", - ], - [ - { apiContractVersion: "2" }, - {}, - "API_CONTRACT_MISMATCH", - "MANIFEST_API_CONTRACT_MISMATCH", - ], [ { releaseId: "release-b" }, {}, @@ -98,7 +128,7 @@ describe("release manifest boot boundary", () => { runtime, { fetcher: async () => - Response.json({ ...manifest, ...manifestOverride }), + jsonResponse({ ...manifest, ...manifestOverride }), ...options, }, ), @@ -106,6 +136,98 @@ describe("release manifest boot boundary", () => { }, ); + it.each(["3.0", "9.0"])( + "rejects unsupported V2 manifest config version %s at the schema boundary", + async (configSchemaVersion) => { + await expect( + loadReleaseManifest(runtime, { + fetcher: async () => + jsonResponse({ ...manifest, configSchemaVersion }), + }), + ).rejects.toMatchObject({ + kind: "RELEASE_MANIFEST_FAILURE", + code: "MANIFEST_SCHEMA_INVALID", + }); + }, + ); + + it("rejects a contract set the build did not compile", async () => { + await expect( + loadReleaseManifest(runtime, { + fetcher: async () => + jsonResponse({ + ...manifest, + contractSet: { + setAlgorithm: "CA_CONTRACT_SET_V1", + setDigest: EMPTY_SET_DIGEST, + packages: [ + { + packageId: "@org-contracts/worklog", + version: "1.2.3", + digest: `sha256:${"a".repeat(64)}`, + runtimeProtocolVersion: 1, + sourceRevision: "abc1234", + }, + ], + }, + }), + }), + ).rejects.toMatchObject({ + kind: "CONTRACT_SET_MISMATCH", + code: "CONTRACT_SET_PACKAGE_UNEXPECTED", + }); + }); + + it("still reads a V1 manifest during the compatibility window", async () => { + await expect( + loadReleaseManifest( + runtimeV1, + { + fetcher: async () => jsonResponse(manifestV1), + }, + ), + ).resolves.toMatchObject({ schemaVersion: 1, contractSet: null }); + }); + + it("rejects a V2 runtime paired with a V1 manifest", async () => { + await expect( + loadReleaseManifest(runtime, { + fetcher: async () => jsonResponse({ + ...manifestV1, + configSchemaVersion: "2.0", + }), + }), + ).rejects.toMatchObject({ + kind: "PROTOCOL_PAIR_MISMATCH", + code: "MANIFEST_PROTOCOL_PAIR_MISMATCH", + }); + }); + + it("rejects a V1 runtime paired with a V2 manifest", async () => { + await expect( + loadReleaseManifest(runtimeV1, { + fetcher: async () => jsonResponse(manifest), + }), + ).rejects.toMatchObject({ + kind: "PROTOCOL_PAIR_MISMATCH", + code: "MANIFEST_PROTOCOL_PAIR_MISMATCH", + }); + }); + + it("requires matching legacy scalar versions for a V1 pair", async () => { + await expect( + loadReleaseManifest(runtimeV1, { + fetcher: async () => jsonResponse({ + ...manifestV1, + apiContractVersion: "2", + }), + }), + ).rejects.toMatchObject({ + kind: "API_CONTRACT_MISMATCH", + code: "MANIFEST_API_CONTRACT_MISMATCH", + }); + }); + it("rejects a manifest without a complete route chunk map", async () => { const malformed = Object.fromEntries( Object.entries(manifest).filter(([key]) => key !== "routeChunks"), @@ -113,7 +235,7 @@ describe("release manifest boot boundary", () => { await expect( loadReleaseManifest( runtime, - { fetcher: async () => Response.json(malformed) }, + { fetcher: async () => jsonResponse(malformed) }, ), ).rejects.toBeInstanceOf(ReleaseManifestError); }); diff --git a/tests/runtime-schema/runtime-config.test.ts b/tests/runtime-schema/runtime-config.test.ts index 90a392f..c3090a0 100644 --- a/tests/runtime-schema/runtime-config.test.ts +++ b/tests/runtime-schema/runtime-config.test.ts @@ -6,17 +6,22 @@ import { assertSafeConfigNames } from "../../src/contracts/env.ts"; const validConfig = { APP_ENV: "local", - API_BASE_URL: "http://localhost:8080", + API_BASE_URL: "http://localhost:8080/", REQUEST_TIMEOUT_MS: 10_000, MAX_RETRY_ATTEMPTS: 2, TELEMETRY_ENABLED: false, AUTH_MODE: "external", - CONFIG_SCHEMA_VERSION: "1", - API_CONTRACT_VERSION: "1", + CONFIG_SCHEMA_VERSION: "2.0", RELEASE_MANIFEST_URL: "/release-manifest.json", BUILD_ID: "build-a", }; +const validV1Config = { + ...validConfig, + CONFIG_SCHEMA_VERSION: "1", + API_CONTRACT_VERSION: "1.4.0", +}; + describe("runtime configuration boundary", () => { it.each([ [{ ...validConfig, API_BASE_URL: undefined }, "required key"], @@ -24,6 +29,30 @@ describe("runtime configuration boundary", () => { [{ ...validConfig, MAX_RETRY_ATTEMPTS: 3 }, "retry cap"], [{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"], [{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"], + [ + { ...validConfig, API_CONTRACT_VERSION: "1" }, + "§5.1 scalar contract version is removed from V2", + ], + [ + { ...validConfig, API_BASE_URL: "http://localhost:8080/api" }, + "§6.2 base URL path must end with /", + ], + [ + { ...validConfig, API_BASE_URL: "http://localhost:8080/#frag" }, + "§6.2 hash in base URL", + ], + [ + { ...validConfig, API_BASE_URL: "http://user:pw@localhost:8080/" }, + "§6.2 credentials in URL", + ], + [ + { ...validConfig, RELEASE_MANIFEST_URL: "/a/../b.json" }, + "§6.2 dot segment traversal", + ], + [ + { ...validConfig, RELEASE_MANIFEST_URL: "/manifest.json?v=1" }, + "§6.2 query in manifest URL", + ], [{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"], ])("rejects invalid config: %s (%s)", (candidate, _reason) => { void _reason; @@ -44,12 +73,55 @@ describe("runtime configuration boundary", () => { validateRuntimeConfig({ ...validConfig, APP_ENV: "production", - API_BASE_URL: "https://api.example.test", + API_BASE_URL: "https://api.example.test/", AUTH_MODE: "demo", }).success, ).toBe(false); }); + it("accepts only the explicitly supported V1 and V2 boot versions", () => { + expect(validateRuntimeConfig(validV1Config)).toMatchObject({ + success: true, + schema: "V1", + }); + expect(validateRuntimeConfig(validConfig)).toMatchObject({ + success: true, + schema: "V2", + }); + + for (const version of ["0", "1.0", "2.0.1", "3.0"]) { + expect( + validateRuntimeConfig({ + ...validV1Config, + CONFIG_SCHEMA_VERSION: version, + }).success, + ).toBe(false); + } + }); + + it.each([ + "file:///tmp/api/", + "data:text/plain,/", + "blob:https://example.test/00000000-0000-0000-0000-000000000000", + ])("rejects a non-HTTP API endpoint in local mode: %s", (API_BASE_URL) => { + expect(validateRuntimeConfig({ ...validConfig, API_BASE_URL }).success).toBe( + false, + ); + }); + + it.each(["file:///tmp/telemetry", "data:text/plain,telemetry"])( + "rejects a non-HTTP telemetry endpoint in local mode: %s", + (TELEMETRY_ENDPOINT) => { + expect( + validateRuntimeConfig({ + ...validConfig, + TELEMETRY_ENABLED: true, + TELEMETRY_ENDPOINT, + }).success, + ).toBe(false); + }, + ); + it("validates a fetched config under the 500ms budget excluding network", async () => { let current = 100; const result = await loadRuntimeConfig({ @@ -59,12 +131,38 @@ describe("runtime configuration boundary", () => { routerBasePath: "/", runtimeConfigUrl: "/config.json", }, - fetcher: async () => new Response(JSON.stringify(validConfig)), + fetcher: async () => + new Response(JSON.stringify(validConfig), { + headers: { "content-type": "application/json" }, + }), now: () => (current += 2), }); expect(result.validationDurationMs).toBeLessThanOrEqual(500); - expect(result.config.API_BASE_URL).toBe("http://localhost:8080"); + expect(result.config.API_BASE_URL).toBe("http://localhost:8080/"); + expect(result.configSchema).toBe("V2"); + expect(result.config.CAPABILITY_OVERRIDES.SERVICE_WORKER).toBe("DEFAULT"); + }); + + it("does not include network acquisition in validationDurationMs", async () => { + let current = 0; + const result = await loadRuntimeConfig({ + buildConfig: { + buildId: "build-a", + commitSha: "local", + routerBasePath: "/", + runtimeConfigUrl: "/config.json", + }, + fetcher: async () => { + current = 10_000; + return new Response(JSON.stringify(validConfig), { + headers: { "content-type": "application/json" }, + }); + }, + now: () => current, + }); + + expect(result.validationDurationMs).toBe(0); }); it("returns only safe boot fields on failure", async () => { @@ -76,7 +174,10 @@ describe("runtime configuration boundary", () => { routerBasePath: "/", runtimeConfigUrl: "/config.json", }, - fetcher: async () => new Response("{"), + fetcher: async () => + new Response("{", { + headers: { "content-type": "application/json" }, + }), }), ).rejects.toMatchObject({ safe: { diff --git a/tests/unit/application-boundary.test.ts b/tests/unit/application-boundary.test.ts index d84ad75..5d860e5 100644 --- a/tests/unit/application-boundary.test.ts +++ b/tests/unit/application-boundary.test.ts @@ -5,6 +5,7 @@ import { type ApplicationOutputPorts, } from "../../src/application/create-application.ts"; import { createTestApplication } from "../helpers/create-test-application.ts"; +import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts"; declare module "../../src/application/ports/in/application-api.ts" { interface ApplicationFeatureInputs { @@ -97,6 +98,7 @@ describe("application input/output boundary", () => { routeChunks: { "route-home": "assets/home.js" }, }), }, + runtimeCapabilities: createRuntimeCapabilitiesStub(), navigation: { reload: () => {} }, } satisfies ApplicationOutputPorts; const application = createApplication(ports); diff --git a/tests/unit/chunk-recovery-runtime.test.ts b/tests/unit/chunk-recovery-runtime.test.ts index 90e8efc..0c618d4 100644 --- a/tests/unit/chunk-recovery-runtime.test.ts +++ b/tests/unit/chunk-recovery-runtime.test.ts @@ -5,6 +5,7 @@ import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external- import type { StoragePort } from "../../src/application/ports/storage-port.ts"; import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts"; import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts"; +import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts"; type ReleaseFixture = { buildId: string; @@ -51,6 +52,7 @@ function applicationWith(options: { preferences: options.storage ?? memoryStorage(), diagnostics: options.diagnostics ?? { record: () => {} }, telemetry: options.telemetry ?? { emit: () => {} }, + runtimeCapabilities: createRuntimeCapabilitiesStub(), releaseInfo: { getCurrent: async () => current, refresh: diff --git a/tests/unit/ci-step-result.test.ts b/tests/unit/ci-step-result.test.ts new file mode 100644 index 0000000..19d3ab6 --- /dev/null +++ b/tests/unit/ci-step-result.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { classifyGateStepResult } from "../../scripts/lib/ci-step-result.ts"; + +describe("CI gate step result classification", () => { + it("accepts a negative fixture only with its exact exit and diagnostic identity", () => { + expect( + classifyGateStepResult( + { + kind: "fail", + expectedExitCode: 2, + expectedDiagnosticId: "error TS2322:", + }, + { + status: 2, + signal: null, + stdout: "fixture.ts(1,1): error TS2322: incompatible type\n", + stderr: "", + }, + ), + ).toEqual({ kind: "EXPECTED_FAILURE", expectationMet: true }); + }); + + it("rejects a negative fixture with the wrong non-zero exit code", () => { + expect( + classifyGateStepResult( + { + kind: "fail", + expectedExitCode: 2, + expectedDiagnosticId: "error TS2322:", + }, + { + status: 1, + signal: null, + stdout: "fixture.ts(1,1): error TS2322: incompatible type\n", + stderr: "", + }, + ), + ).toEqual({ kind: "UNEXPECTED_EXIT", expectationMet: false }); + }); + + it("rejects a negative fixture without the exact case-sensitive diagnostic identity", () => { + expect( + classifyGateStepResult( + { + kind: "fail", + expectedExitCode: 2, + expectedDiagnosticId: "error TS2322:", + }, + { + status: 2, + signal: null, + stdout: "fixture.ts(1,1): error ts2322: incompatible type\n", + stderr: "", + }, + ), + ).toEqual({ kind: "UNEXPECTED_DIAGNOSTIC", expectationMet: false }); + }); + + it("matches an exact diagnostic identity emitted on stderr", () => { + expect( + classifyGateStepResult( + { + kind: "fail", + expectedExitCode: 1, + expectedDiagnosticId: "Risk coverage failed:", + }, + { + status: 1, + signal: null, + stdout: "", + stderr: "Risk coverage failed:\n- expected fixture finding\n", + }, + ), + ).toEqual({ kind: "EXPECTED_FAILURE", expectationMet: true }); + }); + + it.each(["ENOENT", "EACCES", "ETIMEDOUT", "ENOBUFS"])( + "never treats spawn infrastructure error %s as an expected negative fixture", + (code) => { + expect( + classifyGateStepResult( + { + kind: "fail", + expectedExitCode: 1, + expectedDiagnosticId: "fixture failed:", + }, + { + status: null, + signal: null, + stdout: "", + stderr: "", + error: { code }, + }, + ), + ).toEqual({ + kind: "INFRASTRUCTURE_FAILURE", + expectationMet: false, + detail: code, + }); + }, + ); + + it("treats a code-less spawn error as infrastructure failure", () => { + expect( + classifyGateStepResult( + { + kind: "fail", + expectedExitCode: 1, + expectedDiagnosticId: "fixture failed:", + }, + { + status: 1, + signal: null, + stdout: "", + stderr: "fixture failed:\n", + error: {}, + }, + ), + ).toEqual({ + kind: "INFRASTRUCTURE_FAILURE", + expectationMet: false, + detail: "SPAWN_ERROR", + }); + }); + + it("treats a signal-terminated or status-less child as infrastructure failure", () => { + expect( + classifyGateStepResult( + { + kind: "fail", + expectedExitCode: 1, + expectedDiagnosticId: "fixture failed:", + }, + { + status: null, + signal: "SIGTERM", + stdout: "", + stderr: "", + }, + ), + ).toEqual({ + kind: "INFRASTRUCTURE_FAILURE", + expectationMet: false, + detail: "SIGTERM", + }); + }); +}); diff --git a/tests/unit/conditional-validator-store.test.ts b/tests/unit/conditional-validator-store.test.ts index 8586e7c..75ddd8c 100644 --- a/tests/unit/conditional-validator-store.test.ts +++ b/tests/unit/conditional-validator-store.test.ts @@ -5,6 +5,7 @@ import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts function scope() { let current = true; + const lifetime = new AbortController(); return { snapshot: { generation: 3, @@ -12,10 +13,12 @@ function scope() { identities: createRuntimeIdentityRegistry({ tokenFactory: () => crypto.randomUUID(), }), + signal: lifetime.signal, isCurrent: () => current, }, expire: () => { current = false; + lifetime.abort(); }, }; } diff --git a/tests/unit/cross-tab-invalidation.test.ts b/tests/unit/cross-tab-invalidation.test.ts index bc84638..15d2ad7 100644 --- a/tests/unit/cross-tab-invalidation.test.ts +++ b/tests/unit/cross-tab-invalidation.test.ts @@ -9,6 +9,7 @@ import { } from "../../src/contracts/cache-invalidation.ts"; import { createBrowserCrossContextInvalidation, + createBrowserCrossContextInvalidationFromHost, type BroadcastChannelFacade, type BroadcastMessageListener, type BrowserCrossContextInvalidationDependencies, @@ -21,7 +22,7 @@ import { const NOW = 1_000_000; const CACHE_EPOCH = "cache-epoch-0001"; -const TOPIC = "reference-resources"; +const TOPIC = "sample-topic-alpha"; const CHANNEL_NAME = "cache-invalidation-v1"; const STORAGE_PULSE_KEY = "ca-frontend:cache-invalidation:v1:pulse"; @@ -307,6 +308,30 @@ describe("cache invalidation wire contract", () => { }); }); +describe("browser cross-context host", () => { + it("does not inspect browser capabilities when no topic is installed", () => { + const reads: string[] = []; + const host = new Proxy>( + {}, + { + get(_target, property) { + reads.push(String(property)); + throw new DOMException("Capability access denied", "SecurityError"); + }, + }, + ); + + expect( + createBrowserCrossContextInvalidationFromHost({ + host, + cacheEpoch: CACHE_EPOCH, + topics: [], + }), + ).toBeUndefined(); + expect(reads).toEqual([]); + }); +}); + describe("browser cross-context invalidation transport", () => { it("publishes one exact BroadcastChannel event and filters self echo", () => { const network = new FakeBroadcastNetwork(); @@ -525,7 +550,7 @@ describe("browser cross-context invalidation transport", () => { true, ); expect(JSON.stringify(observations)).not.toMatch( - /sensitive-event-identifier|reference-resources|cache-epoch-0001/, + /sensitive-event-identifier|sample-topic-alpha|cache-epoch-0001/, ); runtime.close(); }); diff --git a/tests/unit/external-contract-runtime.test.ts b/tests/unit/external-contract-runtime.test.ts new file mode 100644 index 0000000..615da2f --- /dev/null +++ b/tests/unit/external-contract-runtime.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { + ContractContributionError, + composeContractContributions, + type InstalledContractContribution, +} from "../../src/contracts/external-contract-runtime.ts"; +import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts"; + +function contribution( + contributionId: string, + http: InstalledContractContribution["http"], +): InstalledContractContribution & Readonly<{ contributionId: string }> { + return Object.freeze({ + ...REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, + contributionId, + http: Object.freeze([...http]), + }); +} + +function captureContributionError(operation: () => unknown): ContractContributionError { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(ContractContributionError); + return error as ContractContributionError; + } + throw new Error("Expected contract composition to fail."); +} + +describe("external contract contribution composition", () => { + it("allows one feature to install multiple uniquely identified contributions", () => { + const operations = REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http; + const composed = composeContractContributions([ + contribution("reference-read-contracts", operations.slice(0, 2)), + contribution("reference-command-contracts", operations.slice(2)), + ]); + + expect(composed.contributions).toHaveLength(2); + expect(composed.httpByOperationId.size).toBe(3); + }); + + it("rejects duplicate contribution identities even across different feature entries", () => { + const first = contribution( + "reference-contracts", + REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(0, 1), + ); + const second = contribution( + "reference-contracts", + REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(1), + ); + + const error = captureContributionError(() => + composeContractContributions([first, second]), + ); + expect(error.reason).toContain("duplicate contributionId"); + }); + + it("maps malformed source values to the closed composition error", () => { + const malformed = { + ...contribution("malformed-source", []), + source: null, + } as unknown as InstalledContractContribution; + + const error = captureContributionError(() => + composeContractContributions([malformed]), + ); + expect(error.reason).toContain("source"); + }); + + it("rejects method and body vocabulary outside the runtime protocol", () => { + const installed = REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http[0]!; + const malformedOperation = { + ...installed, + contract: { ...installed.contract, method: "TRACE" }, + } as unknown as InstalledContractContribution["http"][number]; + + const error = captureContributionError(() => + composeContractContributions([ + contribution("invalid-http-vocabulary", [malformedOperation]), + ]), + ); + expect(error.reason).toContain("method"); + }); +}); diff --git a/tests/unit/http-execution-v3.test.ts b/tests/unit/http-execution-v3.test.ts new file mode 100644 index 0000000..98bfb7d --- /dev/null +++ b/tests/unit/http-execution-v3.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts"; +import type { InstalledHttpContract } from "../../src/contracts/external-contract-runtime.ts"; +import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts"; + +const installed = (() => { + const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get( + "LIST_REFERENCE_RESOURCES", + ); + if (!candidate) throw new Error("reference list contract is not installed"); + return candidate; +})(); + +const scope = Object.freeze({ + generation: 1, + fingerprint: "scope-1", + identities: Object.freeze({}) as never, + signal: new AbortController().signal, + isCurrent: () => true, +}); + +const createInstalled = (() => { + const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get( + "CREATE_REFERENCE_RESOURCE", + ); + if (!candidate) throw new Error("reference create contract is not installed"); + return candidate; +})(); + +function operation( + overrides: Readonly<{ + deadlineMs?: number; + responseBody?: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE"; + }> = {}, +): InstalledHttpContract { + return { + ...installed, + contract: { + ...installed.contract, + responseBody: overrides.responseBody ?? installed.contract.responseBody, + }, + frontend: { + ...installed.frontend, + totalDeadlineMs: overrides.deadlineMs ?? installed.frontend.totalDeadlineMs, + }, + }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("descriptor-driven HTTP execution lifetime", () => { + it.each([ + [{}, "limit=20"], + [{ limit: "7" }, "limit=7"], + ])("projects the canonical validated input %#", async (input, expectedQuery) => { + const fetcher = vi.fn(async () => Response.json([])); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher, + }); + + await expect(executor.execute(installed, input, { scope })).resolves.toMatchObject({ + kind: "SUCCESS", + }); + expect(String((fetcher.mock.calls as unknown[][])[0]?.[0])).toContain( + expectedQuery, + ); + }); + + it("contains throwing and malformed external request projections", async () => { + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher: vi.fn(), + }); + const throwing = { + ...installed, + contract: { + ...installed.contract, + projectRequest: () => { + throw new Error("external descriptor defect"); + }, + }, + }; + const malformed = { + ...installed, + contract: { + ...installed.contract, + projectRequest: () => ({ pathValues: {}, queryEntries: [["limit"]], body: null }), + }, + } as unknown as typeof installed; + + await expect(executor.execute(throwing, { limit: 20 }, { scope })).resolves.toMatchObject({ + kind: "CONTRACT_VIOLATION", + effect: "NOT_APPLICABLE", + }); + await expect(executor.execute(malformed, { limit: 20 }, { scope })).resolves.toMatchObject({ + kind: "CONTRACT_VIOLATION", + effect: "NOT_APPLICABLE", + }); + }); + + it("preserves MAYBE_APPLIED for a malformed command response after dispatch", async () => { + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher: vi.fn(async () => Response.json({ malformed: true }, { status: 201 })), + }); + + await expect( + executor.execute( + createInstalled, + { name: "created" }, + { + scope, + intent: { intentId: "intent-1", startedBy: "USER", idempotencyKey: "key-1" }, + }, + ), + ).resolves.toMatchObject({ + kind: "CONTRACT_VIOLATION", + violation: { kind: "SUCCESS_SCHEMA_INVALID" }, + effect: "MAYBE_APPLIED", + }); + }); + + it("preserves MAYBE_APPLIED when a command response arrives after its scope fence", async () => { + let current = true; + const lifetime = new AbortController(); + const fencedScope = Object.freeze({ + ...scope, + signal: lifetime.signal, + isCurrent: () => current, + }); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher: vi.fn(async () => { + current = false; + lifetime.abort(); + return Response.json( + { id: "created", name: "Created" }, + { status: 201 }, + ); + }), + }); + + await expect( + executor.execute( + createInstalled, + { name: "created" }, + { + scope: fencedScope, + intent: { intentId: "intent-2", startedBy: "USER", idempotencyKey: "key-2" }, + }, + ), + ).resolves.toMatchObject({ + kind: "CONTRACT_VIOLATION", + violation: { kind: "SCOPE_FENCED" }, + effect: "MAYBE_APPLIED", + }); + }); + + it("settles a credential hang at the total operation deadline", async () => { + vi.useFakeTimers(); + let settled = false; + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => new Promise(() => {}), + }); + + const result = executor + .execute(operation({ deadlineMs: 5 }), { limit: 20 }, { scope }) + .then((outcome) => { + settled = true; + return outcome; + }); + await vi.advanceTimersByTimeAsync(5); + await flushMicrotasks(); + + expect(settled).toBe(true); + await expect(result).resolves.toMatchObject({ + kind: "TRANSPORT_FAILURE", + failure: { kind: "TIMEOUT" }, + }); + vi.useRealTimers(); + }); + + it("cancels a non-cooperative retry sleep when the caller aborts", async () => { + const caller = new AbortController(); + let sleepSignal: AbortSignal | undefined; + let settled = false; + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 2, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher: vi.fn(async () => + Response.json( + { type: "about:blank", title: "temporary", status: 503 }, + { status: 503 }, + ), + ), + sleep: (_ms, signal) => { + sleepSignal = signal; + return new Promise(() => {}); + }, + random: () => 0, + }); + + const result = executor + .execute(installed, { limit: 20 }, { scope, signal: caller.signal }) + .then((outcome) => { + settled = true; + return outcome; + }); + await vi.waitFor(() => expect(sleepSignal).toBeDefined()); + caller.abort(); + await flushMicrotasks(); + + expect(sleepSignal?.aborted).toBe(true); + expect(settled).toBe(true); + await expect(result).resolves.toMatchObject({ kind: "CANCELLED" }); + }); + + it("treats an unreadable forbidden-body probe as a transport failure", async () => { + const body = new ReadableStream({ + pull(controller) { + controller.error(new TypeError("stream failed")); + }, + }); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher: vi.fn(async () => new Response(body, { status: 200 })), + }); + + await expect( + executor.execute( + operation({ responseBody: "NONE" }), + { limit: 20 }, + { scope }, + ), + ).resolves.toMatchObject({ + kind: "TRANSPORT_FAILURE", + failure: { kind: "RESPONSE_STREAM_FAILURE" }, + }); + }); +}); diff --git a/tests/unit/indexeddb-runtime.test.ts b/tests/unit/indexeddb-runtime.test.ts index d579507..af3b02a 100644 --- a/tests/unit/indexeddb-runtime.test.ts +++ b/tests/unit/indexeddb-runtime.test.ts @@ -380,6 +380,48 @@ describe("IndexedDB runtime", () => { }); }); + it("shares one native open request across concurrent callers", async () => { + const memory = new MemoryIndexedDbFactory(); + const nativeOpen = vi.spyOn(memory.factory, "open"); + const runtime = createIndexedDbRuntime(dependencies(memory)); + + const results = await Promise.all([ + runtime.open(), + runtime.open(), + runtime.open(), + ]); + + expect(nativeOpen).toHaveBeenCalledOnce(); + expect(results).toEqual([ + { ok: true, value: undefined }, + { ok: true, value: undefined }, + { ok: true, value: undefined }, + ]); + }); + + it("isolates a caller abort from the shared native open request", async () => { + const memory = new MemoryIndexedDbFactory(); + memory.blockNextOpen(); + const nativeOpen = vi.spyOn(memory.factory, "open"); + const runtime = createIndexedDbRuntime(dependencies(memory)); + const cancelledCaller = new AbortController(); + + const surviving = runtime.open(); + const cancelled = runtime.open(cancelledCaller.signal); + await Promise.resolve(); + cancelledCaller.abort(); + + expect(await cancelled).toMatchObject({ + ok: false, + error: { code: "ABORTED", operation: "INDEXEDDB_OPEN" }, + }); + expect(nativeOpen).toHaveBeenCalledOnce(); + + memory.releaseBlockedOpen(); + expect(await surviving).toEqual({ ok: true, value: undefined }); + expect(runtime.getStatus()).toEqual({ kind: "READY", schemaVersion: 1 }); + }); + it("times out a blocked upgrade, then closes a late successful connection", async () => { const memory = new MemoryIndexedDbFactory(); memory.blockNextOpen(); @@ -422,6 +464,50 @@ describe("IndexedDB runtime", () => { }); }); + it("retains native open ownership after a blocked timeout until late success", async () => { + const memory = new MemoryIndexedDbFactory(); + memory.blockNextOpen(); + const nativeOpen = vi.spyOn(memory.factory, "open"); + let timeout: (() => void) | undefined; + const runtime = createIndexedDbRuntime( + dependencies(memory, { + blockedTimeoutMs: 25, + scheduler: { + setTimeout: (callback) => { + timeout = callback; + return "blocked-timer"; + }, + clearTimeout: () => undefined, + }, + }), + ); + + const first = runtime.open(); + await Promise.resolve(); + timeout?.(); + await expect(first).resolves.toMatchObject({ + ok: false, + error: { code: "BLOCKED" }, + }); + + const second = runtime.open(); + await Promise.resolve(); + const nativeOpenCountBeforeLateSuccess = nativeOpen.mock.calls.length; + memory.releaseBlockedOpen(); + + await expect(second).resolves.toMatchObject({ + ok: false, + error: { code: "BLOCKED" }, + }); + await Promise.resolve(); + expect(nativeOpenCountBeforeLateSuccess).toBe(1); + expect(memory.isConnectionClosed()).toBe(true); + expect(runtime.getStatus()).toEqual({ + kind: "CLOSED", + reason: "NOT_OPENED", + }); + }); + it("closes immediately on versionchange and isolates listener failures", async () => { const memory = new MemoryIndexedDbFactory(); const onVersionChange = vi.fn(() => { diff --git a/tests/unit/json-schema.test.ts b/tests/unit/json-schema.test.ts new file mode 100644 index 0000000..8ed35c7 --- /dev/null +++ b/tests/unit/json-schema.test.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { assertMatchesJsonSchema } from "../../scripts/lib/json-schema.ts"; + +async function json(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as unknown; +} + +describe("checked-in JSON Schema execution", () => { + it("accepts a build manifest and rejects undeclared output fields", async () => { + const schema = await json("schemas/artifacts/build-manifest.schema.json"); + const manifest = { + schemaVersion: 1, + buildId: "build-1", + commitSha: "commit-1", + releaseId: "release-1", + moduleInventoryHash: "inventory-hash", + generatedAt: "2026-08-01T00:00:00.000Z", + buildContext: { + nodeVersion: "v24.11.0", + packageManagerVersion: "11.17.0", + runnerImage: "test-runner", + sourceDateEpoch: null, + }, + outputs: { + directory: "dist", + viteManifest: "dist/.vite/manifest.json", + moduleInventory: "artifacts/quality/vite-module-inventory.json", + routeChunks: { home: "assets/home.js" }, + runtimeConfigSchema: "dist/runtime-config.schema.json", + }, + }; + + expect(() => + assertMatchesJsonSchema(schema, manifest, "build manifest"), + ).not.toThrow(); + expect(() => + assertMatchesJsonSchema( + schema, + { ...manifest, undocumented: true }, + "build manifest", + ), + ).toThrow(/checked-in JSON Schema/u); + }); + + it("resolves local schema definitions in the recipe catalog", async () => { + const [schema, catalog] = await Promise.all([ + json("schemas/config/frontend-capability-recipes.schema.json"), + json("config/recipes/frontend-capability-recipes.json"), + ]); + + expect(() => + assertMatchesJsonSchema(schema, catalog, "optional recipe catalog"), + ).not.toThrow(); + }); +}); diff --git a/tests/unit/navigation-policy.test.ts b/tests/unit/navigation-policy.test.ts index 0e5553e..1973e2c 100644 --- a/tests/unit/navigation-policy.test.ts +++ b/tests/unit/navigation-policy.test.ts @@ -28,6 +28,15 @@ describe("installed route registry", () => { }); }); + it("treats every non-public route as explicitly session-required", () => { + expect(ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST.access).toBe( + "session-required", + ); + expect( + decideRouteAccess("REFERENCE_RESOURCE_LIST", "unauthenticated"), + ).toEqual({ allowed: false, action: "show-sign-in" }); + }); + it("bounds automatic redirects by pair and maximum hops", () => { const guard = createRedirectLoopGuard(2); expect(guard.allow("/private", "/signin")).toBe(true); @@ -36,5 +45,8 @@ describe("installed route registry", () => { expect(guard.allow("/signin", "/continue")).toBe(true); expect(guard.allow("/continue", "/final")).toBe(false); expect(guard.hopCount).toBe(2); + + guard.reset(); + expect(guard.allow("/unrelated", "/canonical-unrelated")).toBe(true); }); }); diff --git a/tests/unit/opfs-worker-runtime.test.ts b/tests/unit/opfs-worker-runtime.test.ts index 209e99c..ffc1ee7 100644 --- a/tests/unit/opfs-worker-runtime.test.ts +++ b/tests/unit/opfs-worker-runtime.test.ts @@ -537,6 +537,87 @@ describe("OPFS dedicated worker runtime", () => { }); describe("OPFS worker client lifecycle", () => { + it("rejects a colliding concurrent request id without replacing the first RPC", async () => { + let listener: ((event: MessageEvent) => void) | undefined; + const posted: OpfsWorkerRequest[] = []; + const worker: OpfsWorkerLike = { + postMessage(message) { + posted.push(message); + }, + addEventListener(_type, next) { + listener = next; + }, + removeEventListener() { + listener = undefined; + }, + }; + const gateway = createOpfsWorkerGateway({ + worker, + policy: runtimePolicy, + createRequestId: () => "request_collision_1234", + }); + + const first = gateway.capabilities(); + const second = gateway.capabilities(); + expect(posted).toHaveLength(1); + + listener?.({ + data: { + requestId: "request_collision_1234", + ok: true, + value: { + available: true, + dedicatedWorkerRequired: true, + crossContextMutationLockAvailable: true, + synchronousAccessHandleAvailable: true, + }, + }, + } as MessageEvent); + const secondResult = await second; + gateway.close(); + await expect(first).resolves.toMatchObject({ ok: true }); + expect(secondResult).toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE" }, + }); + }); + + it("rejects pending RPCs immediately when the worker reports a fatal event", async () => { + let failureListener: ((event: Event) => void) | undefined; + const worker: OpfsWorkerLike = { + postMessage() {}, + addEventListener() {}, + removeEventListener() {}, + addFailureEventListener(listener) { + failureListener = listener; + }, + removeFailureEventListener() { + failureListener = undefined; + }, + }; + const gateway = createOpfsWorkerGateway({ + worker, + policy: runtimePolicy, + createRequestId: () => "request_crash_1234", + }); + const pending = gateway.capabilities(); + + expect(failureListener).toBeTypeOf("function"); + if (!failureListener) { + gateway.close(); + return; + } + failureListener(new Event("error")); + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE" }, + }); + await expect(gateway.capabilities()).resolves.toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE" }, + }); + }); + it("scopes the open signal to verification and leaves the acquired source readable", async () => { let listener: | ((event: MessageEvent) => void) diff --git a/tests/unit/optimistic-layer-runtime.test.ts b/tests/unit/optimistic-layer-runtime.test.ts index 6990d3d..16c90a2 100644 --- a/tests/unit/optimistic-layer-runtime.test.ts +++ b/tests/unit/optimistic-layer-runtime.test.ts @@ -6,6 +6,7 @@ import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts function scope() { let current = true; + const lifetime = new AbortController(); return { snapshot: { generation: 1, @@ -13,10 +14,12 @@ function scope() { identities: createRuntimeIdentityRegistry({ tokenFactory: () => crypto.randomUUID(), }), + signal: lifetime.signal, isCurrent: () => current, }, expire: () => { current = false; + lifetime.abort(); }, }; } diff --git a/tests/unit/optional-runtime-host.test.ts b/tests/unit/optional-runtime-host.test.ts new file mode 100644 index 0000000..b6d5f4b --- /dev/null +++ b/tests/unit/optional-runtime-host.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createOptionalRuntimeHost } from "../../src/bootstrap/optional-runtime-host.ts"; +import type { ResolvedRuntimeCapabilities } from "../../src/contracts/runtime-capabilities.ts"; +import type { + ServiceWorkerRuntimeHost, + ServiceWorkerStartOutcome, +} from "../../src/contracts/service-worker.ts"; + +const capabilities: ResolvedRuntimeCapabilities = Object.freeze({ + realtime: Object.freeze([]), + webWorkers: Object.freeze([]), + serviceWorker: null, + serviceWorkerDisabledCleanup: false, + offlineCommands: null, +}); + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve } as const; +} + +describe("optional runtime host", () => { + it("keeps stop-before-start terminal without acquiring browser resources", async () => { + const serviceWorker: ServiceWorkerRuntimeHost = { + start: vi.fn( + async (): Promise => ({ + kind: "ACTIVE", + buildId: "build-1", + }), + ), + requestActivation: vi.fn(), + resetOwnedCaches: vi.fn(), + stop: vi.fn(async () => {}), + }; + const addEventListener = vi.fn(); + const runtime = createOptionalRuntimeHost({ + capabilities, + routerBasePath: "/", + buildId: "build-1", + serviceWorkerHost: serviceWorker, + browserLifecycleHost: { + addEventListener, + removeEventListener: vi.fn(), + }, + }); + + await runtime.stop(); + await runtime.startAfterMount(); + + expect(runtime.browserLifecycle()).toBeNull(); + expect(runtime.health().serviceWorker).toBe("DISABLED"); + expect(addEventListener).not.toHaveBeenCalled(); + expect(serviceWorker.start).not.toHaveBeenCalled(); + expect(serviceWorker.stop).not.toHaveBeenCalled(); + }); + + it("serializes stop behind an in-flight start and suppresses late health", async () => { + const start = deferred(); + const serviceWorker: ServiceWorkerRuntimeHost = { + start: vi.fn(() => start.promise), + requestActivation: vi.fn(), + resetOwnedCaches: vi.fn(), + stop: vi.fn(async () => {}), + }; + const runtime = createOptionalRuntimeHost({ + capabilities, + routerBasePath: "/", + buildId: "build-1", + serviceWorkerHost: serviceWorker, + browserLifecycleHost: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + + const starting = runtime.startAfterMount(); + const stopping = runtime.stop(); + start.resolve({ kind: "ACTIVE", buildId: "build-1" }); + + await Promise.all([starting, stopping]); + + expect(serviceWorker.stop).toHaveBeenCalledTimes(1); + expect(runtime.health().serviceWorker).toBe("DISABLED"); + expect(runtime.browserLifecycle()).toBeNull(); + }); +}); diff --git a/tests/unit/query-invalidation-registry.test.ts b/tests/unit/query-invalidation-registry.test.ts new file mode 100644 index 0000000..f5d4253 --- /dev/null +++ b/tests/unit/query-invalidation-registry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { indexInvalidationRegistry } from "../../src/contracts/query-invalidation.ts"; + +describe("query invalidation registry", () => { + it.each([ + { + label: "topic", + registry: { + topics: ["orders\u0000private"], + namespaces: ["orders"], + edges: [{ topicId: "orders\u0000private", namespace: "orders" }], + }, + }, + { + label: "namespace", + registry: { + topics: ["orders"], + namespaces: ["orders\u001fprivate"], + edges: [{ topicId: "orders", namespace: "orders\u001fprivate" }], + }, + }, + ])("rejects control characters in a registry $label", ({ registry }) => { + expect(() => indexInvalidationRegistry(registry)).toThrow(/is invalid/u); + }); +}); diff --git a/tests/unit/read-bounded-boot-json.test.ts b/tests/unit/read-bounded-boot-json.test.ts new file mode 100644 index 0000000..e36e3c6 --- /dev/null +++ b/tests/unit/read-bounded-boot-json.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + BOOT_JSON_POLICIES, + readBoundedBootJson, +} from "../../src/bootstrap/read-bounded-boot-json.ts"; + +describe("bounded boot JSON admission", () => { + it("does not call fetch when the caller signal is already aborted", async () => { + const caller = new AbortController(); + caller.abort(); + let fetchCalls = 0; + + const outcome = await readBoundedBootJson( + "/config.json", + BOOT_JSON_POLICIES.RUNTIME_CONFIG, + { + signal: caller.signal, + fetcher: async () => { + fetchCalls += 1; + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }, + }, + ); + + expect(fetchCalls).toBe(0); + expect(outcome).toEqual({ ok: false, failure: "FETCH_FAILED" }); + }); +}); diff --git a/tests/unit/realtime/fixture.ts b/tests/unit/realtime/fixture.ts index 09c38f7..faac373 100644 --- a/tests/unit/realtime/fixture.ts +++ b/tests/unit/realtime/fixture.ts @@ -80,7 +80,7 @@ export const TEST_MAPPER: InstalledBoundaryMapper = Object.freeze({ mapperVersion: 1, inputSchemaId: "ReferenceRealtimePayload", outputContractId: "ReferenceRealtimeEvent", - owner: "reference-feature", + owner: "sample-owner", maxOutputItems: 1, map(input) { if ( @@ -113,7 +113,7 @@ const snapshotOperation: ApiOperation = Object.freeze({ requestSource: "none", requestSchema: "NoRequest", responseSchema: "ReferenceRealtimeCheckpoint", - owner: "reference-feature", + owner: "sample-owner", contractVersion: 2, protocol: "REST", semantics: "QUERY", @@ -161,7 +161,7 @@ export function createTestRealtimeRegistry( } as const); const eventType: RealtimeEventTypeRegistration = { id: EVENT_TYPE, - owner: "reference-feature", + owner: "sample-owner", payloadSchemaId: "ReferenceRealtimePayload", mapperId: "ReferenceRealtimeMapper", effectProfileId: EFFECT_PROFILE_ID, @@ -170,7 +170,7 @@ export function createTestRealtimeRegistry( const stream: RealtimeStreamRegistration = { id: STREAM_ID, protocol: "REALTIME_EVENT_V1", - owner: "reference-feature", + owner: "sample-owner", scope: "ACCOUNT_BOUND", primaryTransport: "SSE", endpointId: ENDPOINT_ID, diff --git a/tests/unit/realtime/realtime-stream-registry.test.ts b/tests/unit/realtime/realtime-stream-registry.test.ts index 676c465..dfb7ed6 100644 --- a/tests/unit/realtime/realtime-stream-registry.test.ts +++ b/tests/unit/realtime/realtime-stream-registry.test.ts @@ -51,7 +51,7 @@ describe("realtime policy registry", () => { limits: { maxQueueEvents: TEST_LIMITS.maxQueueEvents }, }); expect(registry.findEventType(EVENT_TYPE)?.owner).toBe( - "reference-feature", + "sample-owner", ); expect( registry.findStreamEventType(STREAM_ID, EVENT_TYPE)?.id, diff --git a/tests/unit/release-artifacts.test.ts b/tests/unit/release-artifacts.test.ts new file mode 100644 index 0000000..99140cf --- /dev/null +++ b/tests/unit/release-artifacts.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { + parseBuildManifestArtifact, + parseReleaseArtifact, + parseRuntimeConfigArtifact, + projectReleaseTokens, +} from "../../scripts/contracts/release-artifacts.ts"; + +const EMPTY_CONTRACT_SET_DIGEST = + "sha256:ad6aab71fea6a9ff87cbd170b984b339965afc90d85bb57f87801c9e0c020da2"; + +const releaseV2 = { + schemaVersion: 2, + appVersion: "0.1.0", + buildId: "build-a", + commitSha: "abc1234", + configSchemaVersion: "2.0", + assetManifestHash: "asset-hash", + releaseId: "release-a", + builtAt: "2026-08-01T00:00:00.000Z", + routeChunks: { "route-home": "assets/home.js" }, + contractSet: { + setAlgorithm: "CA_CONTRACT_SET_V1", + setDigest: EMPTY_CONTRACT_SET_DIGEST, + packages: [], + }, +} as const; + +const buildManifest = { + schemaVersion: 1, + buildId: "build-a", + commitSha: "abc1234", + releaseId: "release-a", + moduleInventoryHash: "inventory-hash", + generatedAt: "2026-08-01T00:00:00.000Z", + buildContext: { + nodeVersion: "v24.14.0", + packageManagerVersion: "11.17.0", + runnerImage: "linux-x64", + sourceDateEpoch: null, + }, + outputs: { + directory: "dist", + viteManifest: "dist/.vite/manifest.json", + moduleInventory: "artifacts/quality/vite-module-inventory.json", + routeChunks: { "route-home": "assets/home.js" }, + runtimeConfigSchema: "dist/runtime-config.schema.json", + }, +} as const; + +describe("release artifact contracts", () => { + it("projects the nested V2 contract-set digest without a legacy scalar", () => { + const release = parseReleaseArtifact(releaseV2); + + expect(projectReleaseTokens(release)).toMatchObject({ + schemaVersion: 2, + buildId: "build-a", + contractSetDigest: EMPTY_CONTRACT_SET_DIGEST, + }); + expect(projectReleaseTokens(release)).not.toHaveProperty( + "apiContractVersion", + ); + }); + + it("projects the legacy scalar only for V1", () => { + const { contractSet: _contractSet, ...releaseWithoutContractSet } = releaseV2; + void _contractSet; + const release = parseReleaseArtifact({ + ...releaseWithoutContractSet, + schemaVersion: 1, + configSchemaVersion: "1", + apiContractVersion: "1.4.0", + }); + + expect(projectReleaseTokens(release)).toMatchObject({ + schemaVersion: 1, + apiContractVersion: "1.4.0", + }); + expect(projectReleaseTokens(release)).not.toHaveProperty( + "contractSetDigest", + ); + }); + + it("rejects a V2 release carrying the removed scalar", () => { + expect(() => + parseReleaseArtifact({ + ...releaseV2, + apiContractVersion: "1", + }), + ).toThrow(); + }); + + it("accepts the exact build manifest emitted by the generator", () => { + expect(parseBuildManifestArtifact(buildManifest)).toEqual(buildManifest); + }); + + it("rejects unknown build manifest output fields", () => { + expect(() => + parseBuildManifestArtifact({ + ...buildManifest, + outputs: { ...buildManifest.outputs, unexpected: "value" }, + }), + ).toThrow(); + }); + + it("accepts Runtime Config V2 without a legacy API scalar", () => { + expect( + parseRuntimeConfigArtifact({ + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080/", + REQUEST_TIMEOUT_MS: 10_000, + MAX_RETRY_ATTEMPTS: 2, + TELEMETRY_ENABLED: false, + AUTH_MODE: "demo", + CONFIG_SCHEMA_VERSION: "2.0", + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "build-a", + RELEASE_ID: "release-a", + CAPABILITY_OVERRIDES: { + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }, + }), + ).not.toHaveProperty("API_CONTRACT_VERSION"); + }); +}); diff --git a/tests/unit/release-coherence.test.ts b/tests/unit/release-coherence.test.ts index 7ae2756..12e8977 100644 --- a/tests/unit/release-coherence.test.ts +++ b/tests/unit/release-coherence.test.ts @@ -6,8 +6,12 @@ import { } from "../../src/contracts/release-tokens.ts"; describe("release coherence", () => { - it("owns all eight release tokens and keeps builtAt diagnostic-only", () => { - expect(Object.keys(RELEASE_TOKEN_REGISTRY)).toHaveLength(8); + it("owns all nine release tokens and keeps builtAt diagnostic-only", () => { + // §5.2 adds contractSetDigest beside the legacy apiContractVersion scalar. + expect(Object.keys(RELEASE_TOKEN_REGISTRY)).toHaveLength(9); + expect(RELEASE_TOKEN_REGISTRY.contractSetDigest.compatibilityRole).toContain( + "multi-package", + ); expect(RELEASE_TOKEN_REGISTRY.builtAt.compatibilityRole).toContain( "never cache identity", ); diff --git a/tests/unit/rest-profile-contract.test.ts b/tests/unit/rest-profile-contract.test.ts index e3956d5..6dd534b 100644 --- a/tests/unit/rest-profile-contract.test.ts +++ b/tests/unit/rest-profile-contract.test.ts @@ -1,11 +1,49 @@ import { describe, expect, it } from "vitest"; +import { defineRestOperation } from "../../src/contracts/api-operations.ts"; import { createRestProviderProfile, resolveRestSecurityProfiles, validateRestProfileBindings, } from "../../src/contracts/rest-profiles.ts"; -import { REFERENCE_FEATURE_CONTRACT } from "../../src/features/reference-feature/contracts/reference-feature-contract.ts"; + +/** + * §24.12: the common REST profile contract must stay independently verifiable + * after the sample feature is removed, so this suite owns its own operation + * fixture instead of importing an installed feature contract. + */ +const SAMPLE_COMMAND = defineRestOperation({ + method: "POST", + path: "/api/sample-resources", + operationId: "CREATE_SAMPLE_RESOURCE", + auth: "external-session", + timeoutMs: null, + idempotency: "keyed", + retry: "runtime", + requestSource: "body", + requestSchema: "SampleCommand", + responseSchema: "SamplePayload", + owner: "platform-test-fixture", + contractVersion: 2, + protocol: "REST", + semantics: "COMMAND", + replayPolicy: "KEYED_COMMAND", + idempotencyKeyPolicy: "REQUIRED", + mapperId: "SampleMapper", + successStatuses: [200, 201], + responseMediaTypes: ["application/json"], + maxResponseBytes: 32_768, + providerId: "PRIMARY_API", + authProfileId: "REFERENCE_EXTERNAL_BEARER", + csrfProfileId: "NO_CSRF_BEARER", + pathSchema: "NoRequest", + pathParameterNames: [], + maxEncodedSearchBytes: 0, +}); + +const SAMPLE_OPERATIONS = Object.freeze({ + CREATE_SAMPLE_RESOURCE: SAMPLE_COMMAND, +}); describe("REST provider/auth/CSRF profiles", () => { it("preserves the provider prefix and rejects unsafe endpoint forms", () => { @@ -33,10 +71,8 @@ describe("REST provider/auth/CSRF profiles", () => { }); it("resolves bearer auth to omit credentials and no CSRF", () => { - const operation = - REFERENCE_FEATURE_CONTRACT.apiOperations.CREATE_REFERENCE_RESOURCE; const resolved = resolveRestSecurityProfiles( - operation, + SAMPLE_COMMAND, createRestProviderProfile("PRIMARY_API", "https://api.test", ["omit"]), ); expect(resolved).toMatchObject({ @@ -49,18 +85,14 @@ describe("REST provider/auth/CSRF profiles", () => { }); }); - it("validates every installed reference profile binding as a set", () => { + it("validates every installed profile binding as a set", () => { expect( - validateRestProfileBindings( - REFERENCE_FEATURE_CONTRACT.apiOperations, - { PRIMARY_API: ["omit"] }, - ), + validateRestProfileBindings(SAMPLE_OPERATIONS, { + PRIMARY_API: ["omit"], + }), ).toBe(true); expect(() => - validateRestProfileBindings( - REFERENCE_FEATURE_CONTRACT.apiOperations, - {}, - ), + validateRestProfileBindings(SAMPLE_OPERATIONS, {}), ).toThrow("Unregistered REST provider binding"); }); }); diff --git a/tests/unit/runtime-adapters.test.ts b/tests/unit/runtime-adapters.test.ts index cf77b9c..783e998 100644 --- a/tests/unit/runtime-adapters.test.ts +++ b/tests/unit/runtime-adapters.test.ts @@ -4,6 +4,8 @@ import { createRuntimeAdapters, createRuntimeHttpClient, } from "../../src/bootstrap/runtime-adapters.ts"; +import { QUERY_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; +import { REFERENCE_FEATURE_ID } from "../../src/features/reference-feature/contracts/reference-feature-contract.ts"; import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts"; type Runtime = Parameters[0]["runtime"]; @@ -18,9 +20,15 @@ const runtime: Runtime = { REQUEST_TIMEOUT_MS: 4321, MAX_RETRY_ATTEMPTS: 0, RELEASE_MANIFEST_URL: "/release-manifest.json", - CONFIG_SCHEMA_VERSION: "1", - API_CONTRACT_VERSION: "1", + CONFIG_SCHEMA_VERSION: "2.0", + CAPABILITY_OVERRIDES: { + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }, }, + configSchema: "V2", build: { buildId: "build-a", commitSha: "abc123", @@ -30,12 +38,16 @@ const runtime: Runtime = { validationDurationMs: 0, }; const release: Release = { - schemaVersion: 1, + schemaVersion: 2, appVersion: "0.1.0", buildId: "build-a", commitSha: "abc123", - configSchemaVersion: "1", - apiContractVersion: "1", + configSchemaVersion: "2.0", + contractSet: { + setAlgorithm: "CA_CONTRACT_SET_V1", + setDigest: `sha256:${"0".repeat(64)}`, + packages: [], + }, assetManifestHash: "hash-a", releaseId: "release-a", builtAt: "2026-07-25T00:00:00Z", @@ -67,6 +79,67 @@ describe("runtime adapter composition", () => { adapters.infrastructure.dispose(); }); + it("executes installed feature HTTP through the composed contract registry", async () => { + const fetcher = vi.fn(async () => + Response.json([{ id: "reference-1", name: "Direct contract payload" }]), + ); + const adapters = await createRuntimeAdapters({ + runtime, + release, + host: {}, + fetcher, + }); + await adapters.outputPorts.session.beginSignIn(); + await vi.waitFor(() => + expect(adapters.infrastructure.serverStateScope.getPhase()).toBe("READY"), + ); + + await expect( + adapters.featureInputs[REFERENCE_FEATURE_ID].listResources({ limit: 20 }), + ).resolves.toEqual({ + ok: true, + value: [ + { + resourceId: "reference-1", + title: "Direct contract payload", + createdAt: null, + }, + ], + }); + expect(fetcher).toHaveBeenCalledWith( + "http://localhost:8080/api/reference-resources?limit=20", + expect.objectContaining({ + method: "GET", + redirect: "error", + cache: "no-store", + }), + ); + adapters.infrastructure.dispose(); + }); + + it("replaces the QueryClient and coordinator for each session generation", async () => { + const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); + const previousClient = adapters.infrastructure.queryClient; + const previousCoordinator = adapters.infrastructure.queryInvalidation; + const invalidatePrevious = vi.spyOn(previousClient, "invalidateQueries"); + + await adapters.outputPorts.session.beginSignIn(); + + await vi.waitFor(() => + expect(adapters.infrastructure.queryClient).not.toBe(previousClient), + ); + expect(adapters.infrastructure.queryInvalidation).not.toBe( + previousCoordinator, + ); + + const topic = Object.values(QUERY_REGISTRY)[0]?.invalidationTopic; + if (!topic) throw new Error("expected an installed invalidation topic"); + await previousCoordinator.invalidate([topic]); + expect(invalidatePrevious).not.toHaveBeenCalled(); + + adapters.infrastructure.dispose(); + }); + it("does not fail boot when Web Storage capability getters throw", async () => { const host: Record = {}; Object.defineProperties(host, { @@ -108,6 +181,73 @@ describe("runtime adapter composition", () => { expect(adapters.outputPorts.session.getState()).toBe("integration-failed"); }); + it("reports the static selection when no capability override disables it", async () => { + const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); + + const snapshot = adapters.outputPorts.runtimeCapabilities.getSnapshot(); + + expect(snapshot.map((status) => status.capabilityId)).toEqual([ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", + ]); + expect(snapshot.every((status) => status.override === "DEFAULT")).toBe(true); + }); + + it("carries a disabling override into the capability snapshot", async () => { + const adapters = await createRuntimeAdapters({ + runtime: { + ...runtime, + config: { + ...runtime.config, + CAPABILITY_OVERRIDES: { + ...runtime.config.CAPABILITY_OVERRIDES, + SERVICE_WORKER: "DISABLED", + }, + }, + }, + release, + host: {}, + }); + + const serviceWorker = adapters.outputPorts.runtimeCapabilities + .getSnapshot() + .find((status) => status.capabilityId === "SERVICE_WORKER"); + + expect(serviceWorker?.override).toBe("DISABLED"); + expect(serviceWorker?.active).toBe(0); + }); + + it("states contract identity as a digest for a V2 release manifest", async () => { + const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); + + const current = await adapters.outputPorts.releaseInfo.getCurrent(); + + expect(current.contractSetDigest).toBe(release.contractSet?.setDigest); + expect(current.apiContractVersion).toBeUndefined(); + expect(current).not.toHaveProperty("contractSet"); + }); + + it("states contract identity as the legacy scalar for a V1 release manifest", async () => { + const adapters = await createRuntimeAdapters({ + runtime, + release: { + ...release, + schemaVersion: 1, + contractSet: null, + legacyApiContractVersion: "1.4", + }, + host: {}, + }); + + const current = await adapters.outputPorts.releaseInfo.getCurrent(); + + expect(current.apiContractVersion).toBe("1.4"); + expect(current.contractSetDigest).toBeUndefined(); + expect(current).not.toHaveProperty("legacyApiContractVersion"); + }); + it("refetches the active release manifest with no-store semantics", async () => { const activeRelease = { ...release, @@ -115,7 +255,11 @@ describe("runtime adapter composition", () => { releaseId: "release-b", routeChunks: { "route-home": "assets/home-b.js" }, }; - const fetcher = vi.fn(async () => Response.json(activeRelease)); + const fetcher = vi.fn(async () => + new Response(JSON.stringify(activeRelease), { + headers: { "content-type": "application/json" }, + }), + ); const adapters = await createRuntimeAdapters({ runtime, release, @@ -127,10 +271,17 @@ describe("runtime adapter composition", () => { buildId: "build-b", releaseId: "release-b", }); - expect(fetcher).toHaveBeenCalledWith("/release-manifest.json", { - cache: "no-store", - headers: { Accept: "application/json" }, - }); + expect(fetcher).toHaveBeenCalledWith( + "/release-manifest.json", + expect.objectContaining({ + method: "GET", + cache: "no-store", + credentials: "same-origin", + redirect: "error", + referrerPolicy: "no-referrer", + headers: { Accept: "application/json" }, + }), + ); }); it("injects runtime timeout and max-attempt policy into HTTP execution", async () => { diff --git a/tests/unit/runtime-capability-snapshot.test.ts b/tests/unit/runtime-capability-snapshot.test.ts new file mode 100644 index 0000000..617801b --- /dev/null +++ b/tests/unit/runtime-capability-snapshot.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + describeRuntimeCapabilities, + type CapabilityOverrideMap, + type InstalledRuntimeCapabilities, +} from "../../src/contracts/runtime-capabilities.ts"; +import { SERVICE_WORKER_SCRIPT_PATH } from "../../src/contracts/service-worker.ts"; + +const DEFAULTS: CapabilityOverrideMap = Object.freeze({ + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", +}); + +const EMPTY: InstalledRuntimeCapabilities = Object.freeze({ + realtime: Object.freeze([]), + webWorkers: Object.freeze([]), + serviceWorker: null, + offlineCommands: null, +}); + +const SELECTED: InstalledRuntimeCapabilities = Object.freeze({ + realtime: Object.freeze([]), + webWorkers: Object.freeze([]), + serviceWorker: Object.freeze({ + mode: "ACTIVE" as const, + scriptPath: SERVICE_WORKER_SCRIPT_PATH, + handlers: Object.freeze(["PWA_STATIC_ASSETS" as const]), + }), + offlineCommands: null, +}); + +describe("runtime capability snapshot", () => { + it("describes every capability id in a fixed order", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS); + + expect(snapshot.map((status) => status.capabilityId)).toEqual([ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", + ]); + }); + + it("reports nothing selected and nothing active for an empty selection", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS); + + for (const status of snapshot) { + expect(status.selected).toBe(0); + expect(status.active).toBe(0); + expect(status.override).toBe("DEFAULT"); + } + }); + + it("keeps a selected capability active while the override is DEFAULT", () => { + const snapshot = describeRuntimeCapabilities(SELECTED, DEFAULTS); + const serviceWorker = snapshot.find( + (status) => status.capabilityId === "SERVICE_WORKER", + ); + + expect(serviceWorker).toMatchObject({ + selected: 1, + active: 1, + override: "DEFAULT", + }); + }); + + it("keeps a disabled capability selected but not active", () => { + const snapshot = describeRuntimeCapabilities(SELECTED, { + ...DEFAULTS, + SERVICE_WORKER: "DISABLED", + }); + const serviceWorker = snapshot.find( + (status) => status.capabilityId === "SERVICE_WORKER", + ); + + expect(serviceWorker).toMatchObject({ + selected: 1, + active: 0, + override: "DISABLED", + }); + }); + + it("cannot activate a capability that was never selected", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, { + ...DEFAULTS, + REALTIME: "DEFAULT", + }); + const realtime = snapshot.find( + (status) => status.capabilityId === "REALTIME", + ); + + expect(realtime).toMatchObject({ selected: 0, active: 0 }); + }); + + it("returns a frozen snapshot and frozen entries", () => { + const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS); + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(snapshot.every((status) => Object.isFrozen(status))).toBe(true); + }); +}); diff --git a/tests/unit/server-state-scope-runtime.test.ts b/tests/unit/server-state-scope-runtime.test.ts index fc989bd..ef182c9 100644 --- a/tests/unit/server-state-scope-runtime.test.ts +++ b/tests/unit/server-state-scope-runtime.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it, vi } from "vitest"; import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts"; +import type { ClientScopeLifecycleEvent } from "../../src/contracts/server-state-scope.ts"; describe("server-state session generation runtime", () => { - it("fences the old generation before reset and publishes the new scope after reset", async () => { + it("fences the old generation synchronously and publishes READY after the reset order", async () => { let sessionListener: () => void = () => {}; let completeReset: () => void = () => {}; const reset = new Promise((resolve) => { completeReset = resolve; }); const resetLocal = vi.fn(() => reset); + const participantOrder: string[] = []; let tokenSequence = 0; const runtime = createServerStateScopeRuntime({ session: { @@ -19,34 +21,177 @@ describe("server-state session generation runtime", () => { }, }, queryInvalidation: { - invalidate: async () => {}, - beginMutation: () => ({ release: async () => {} }), resetLocal, - dispose() {}, }, - tokenFactory: () => `scope-token-${String(tokenSequence++).padStart(8, "0")}`, + participants: [ + { + order: 9, + label: "realtime", + close: () => void participantOrder.push("realtime"), + }, + { + order: 4, + label: "admission", + close: () => void participantOrder.push("admission"), + }, + ], + tokenFactory: () => + `scope-token-${String(tokenSequence++).padStart(8, "0")}`, }); const changed = vi.fn(); + const lifecycle: ClientScopeLifecycleEvent[] = []; runtime.subscribe(changed); + runtime.subscribeLifecycle((event) => lifecycle.push(event)); const before = runtime.getSnapshot(); const identity = before.identities.intern({ id: "private" }); identity.acquire(); sessionListener(); + + // §10.6 steps 1-3 are synchronous: the old snapshot is immediately stale, + // FENCED is published, and subscribers are notified before any await. expect(before.isCurrent()).toBe(false); - expect(runtime.getSnapshot()).toBe(before); - expect(changed).not.toHaveBeenCalled(); + expect(before.signal.aborted).toBe(true); + expect(runtime.getPhase()).toBe("FENCED"); + expect(lifecycle[0]).toEqual({ kind: "FENCED", previousGeneration: 1 }); + expect(changed).toHaveBeenCalledOnce(); + // Nothing may read as current while the scope is fenced. + expect(runtime.getSnapshot().isCurrent()).toBe(false); + await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce()); + // Participants close in §10.6 step order, before the local cache reset. + expect(participantOrder).toEqual(["admission", "realtime"]); completeReset(); - await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY")); const after = runtime.getSnapshot(); expect(after.generation).toBe(before.generation + 1); expect(after.fingerprint).not.toBe(before.fingerprint); expect(after.isCurrent()).toBe(true); + expect(after.signal.aborted).toBe(false); + expect(after.signal).not.toBe(before.signal); expect(before.identities.inspect().closed).toBe(true); + expect(lifecycle.at(-1)).toMatchObject({ kind: "READY" }); + expect(changed).toHaveBeenCalledTimes(2); runtime.dispose(); + expect(runtime.getPhase()).toBe("DISPOSED"); expect(after.identities.inspect().closed).toBe(true); + expect(after.isCurrent()).toBe(false); + }); + + it("does not let a throwing snapshot subscriber prevent reset", async () => { + let sessionListener: () => void = () => {}; + const resetLocal = vi.fn(async () => {}); + const runtime = createServerStateScopeRuntime({ + session: { + subscribe(listener) { + sessionListener = listener; + return () => {}; + }, + }, + queryInvalidation: { + resetLocal, + }, + tokenFactory: () => "scope-listener-token-0001", + }); + runtime.subscribe(() => { + throw new Error("subscriber defect"); + }); + + expect(() => sessionListener()).not.toThrow(); + await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY")); + }); + + it("remains failed when a mandatory participant cannot close", async () => { + let sessionListener: () => void = () => {}; + const resetLocal = vi.fn(async () => {}); + const runtime = createServerStateScopeRuntime({ + session: { + subscribe(listener) { + sessionListener = listener; + return () => {}; + }, + }, + queryInvalidation: { + resetLocal, + }, + participants: [ + { + order: 4, + label: "mandatory-admission", + close: async () => { + throw new Error("close failed"); + }, + }, + ], + tokenFactory: () => "scope-participant-token-0001", + }); + + sessionListener(); + + await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect(runtime.getPhase() as string).toBe("FAILED"), + ); + expect(runtime.getSnapshot().isCurrent()).toBe(false); + }); + + it("remains failed when local cache reset rejects", async () => { + let sessionListener: () => void = () => {}; + const runtime = createServerStateScopeRuntime({ + session: { + subscribe(listener) { + sessionListener = listener; + return () => {}; + }, + }, + queryInvalidation: { + resetLocal: async () => { + throw new Error("reset failed"); + }, + }, + tokenFactory: () => "scope-reset-token-0000001", + }); + + sessionListener(); + + await vi.waitFor(() => + expect(runtime.getPhase() as string).toBe("FAILED"), + ); + expect(runtime.getSnapshot().isCurrent()).toBe(false); + }); + + it("activates the next generation after reset and fails closed on activation error", async () => { + let sessionListener: () => void = () => {}; + const steps: string[] = []; + const runtime = createServerStateScopeRuntime({ + session: { + subscribe(listener) { + sessionListener = listener; + return () => {}; + }, + }, + queryInvalidation: { + resetLocal: async () => { + steps.push("reset"); + }, + }, + activateNextGeneration: async () => { + steps.push("activate"); + throw new Error("activation failed"); + }, + tokenFactory: () => "scope-activation-token-001", + } as Parameters[0] & { + activateNextGeneration(): Promise; + }); + + sessionListener(); + + await vi.waitFor(() => expect(steps).toEqual(["reset", "activate"])); + await vi.waitFor(() => + expect(runtime.getPhase() as string).toBe("FAILED"), + ); }); }); diff --git a/tests/unit/service-worker-build-input.test.ts b/tests/unit/service-worker-build-input.test.ts new file mode 100644 index 0000000..e22b690 --- /dev/null +++ b/tests/unit/service-worker-build-input.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { resolveServiceWorkerBuildInput } from "../../scripts/lib/service-worker-build-input.ts"; + +const digest = (character: string) => `sha256:${character.repeat(64)}`; + +describe("service worker build input", () => { + const selection = { + mode: "ACTIVE" as const, + scriptPath: "service-worker.js" as const, + handlers: ["PWA_STATIC_ASSETS" as const], + }; + const assets = { + schemaVersion: 1 as const, + buildId: "build-1", + releaseId: "release-1", + setDigest: digest("a"), + assets: [], + }; + + it("rejects a direct worker build when ACTIVE selection or generated inputs are absent", () => { + expect(() => + resolveServiceWorkerBuildInput({ + selection: null, + assets, + contractSet: { setDigest: digest("b") }, + runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, + buildId: "build-1", + releaseId: "release-1", + }), + ).toThrow(/ACTIVE/u); + expect(() => + resolveServiceWorkerBuildInput({ + selection, + assets: null, + contractSet: { setDigest: digest("b") }, + runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, + buildId: "build-1", + releaseId: "release-1", + }), + ).toThrow(/asset manifest/u); + }); + + it("rejects stale generated identity instead of compiling a mismatched worker", () => { + expect(() => + resolveServiceWorkerBuildInput({ + selection, + assets: { ...assets, buildId: "old-build" }, + contractSet: { setDigest: digest("b") }, + runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, + buildId: "build-1", + releaseId: "release-1", + }), + ).toThrow(/identity/u); + }); + + it("rejects WEB_PUSH selection until its product-owned worker contribution exists", () => { + expect(() => + resolveServiceWorkerBuildInput({ + selection: { ...selection, handlers: ["WEB_PUSH"] }, + assets, + contractSet: { setDigest: digest("b") }, + runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, + buildId: "build-1", + releaseId: "release-1", + }), + ).toThrow(/WEB_PUSH.*contribution/u); + }); + + it("returns only fully matched, digest-bearing generated inputs", () => { + expect( + resolveServiceWorkerBuildInput({ + selection, + assets, + contractSet: { setDigest: digest("b") }, + runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, + buildId: "build-1", + releaseId: "release-1", + }), + ).toMatchObject({ + assets, + handlers: ["PWA_STATIC_ASSETS"], + contractSetDigest: digest("b"), + releaseManifestUrl: "/release-manifest.json", + }); + }); +}); diff --git a/tests/unit/service-worker-runtime.test.ts b/tests/unit/service-worker-runtime.test.ts new file mode 100644 index 0000000..4513d2e --- /dev/null +++ b/tests/unit/service-worker-runtime.test.ts @@ -0,0 +1,567 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createServiceWorkerPageController } from "../../src/adapters/service-worker/service-worker-page-controller.ts"; +import { createServiceWorkerRuntime } from "../../src/adapters/service-worker/service-worker-lifecycle.ts"; +import { createServiceWorkerMessage } from "../../src/adapters/service-worker/service-worker-protocol.ts"; +import { installStaticAssets } from "../../src/adapters/service-worker/service-worker-static-assets.ts"; +import { + SERVICE_WORKER_BOUNDS, + SERVICE_WORKER_SCRIPT_PATH, + STATIC_CACHE_PREFIX, + staticCacheName, + type ServiceWorkerProtocolIdentity, + type StaticAssetManifestV1, +} from "../../src/contracts/service-worker.ts"; + +const ORIGIN = "https://app.example"; +const SCRIPT_URL = `${ORIGIN}/service-worker.js`; + +function pageContainer(options: { waiting?: boolean; controlled?: boolean } = {}) { + const listeners = new Set<(event: MessageEvent) => void>(); + const waitingMessages: unknown[] = []; + const controllerMessages: unknown[] = []; + const worker = (messages: unknown[]) => + ({ + scriptURL: SCRIPT_URL, + postMessage(message: unknown) { + messages.push(message); + }, + }) as unknown as ServiceWorker; + const waiting = options.waiting ? worker(waitingMessages) : null; + const active = options.waiting ? null : worker([]); + const controlled = options.controlled ? worker(controllerMessages) : null; + const registration = { + scope: `${ORIGIN}/`, + installing: null, + waiting, + active, + update: vi.fn(async () => {}), + } as unknown as ServiceWorkerRegistration; + const container = { + controller: controlled, + register: vi.fn(async () => registration), + addEventListener(_type: string, listener: (event: MessageEvent) => void) { + listeners.add(listener); + }, + removeEventListener(_type: string, listener: (event: MessageEvent) => void) { + listeners.delete(listener); + }, + } as unknown as ServiceWorkerContainer; + return { + container, + registration, + waitingMessages, + controllerMessages, + listenerCount: () => listeners.size, + dispatch(data: unknown, source?: { postMessage(message: unknown): void }) { + const event = { data, origin: ORIGIN, source } as unknown as MessageEvent; + for (const listener of [...listeners]) listener(event); + }, + }; +} + +function pageController(container: ServiceWorkerContainer, blockers = [() => false]) { + return createServiceWorkerPageController({ + selection: { + mode: "ACTIVE", + scriptPath: SERVICE_WORKER_SCRIPT_PATH, + handlers: [], + }, + disabledCleanup: false, + routerBasePath: "/", + origin: ORIGIN, + buildId: "page-build", + container, + blockers, + }); +} + +const identity: ServiceWorkerProtocolIdentity = { + serviceWorkerProtocolVersion: 1, + cacheSchemaVersion: 1, + buildId: "worker-build", + releaseId: "release-1", + contractSetDigest: `sha256:${"1".repeat(64)}`, + staticAssetSetDigest: `sha256:${"2".repeat(64)}`, +}; + +function workerScope() { + const deleted: string[] = []; + const clients = ["client-a", "client-b"].map((id) => ({ + id, + url: `${ORIGIN}/app/${id}`, + messages: [] as unknown[], + postMessage(message: unknown) { + this.messages.push(message); + }, + })); + const scope = { + caches: { + open: vi.fn(), + keys: vi.fn(async () => [ + `${STATIC_CACHE_PREFIX}${"a".repeat(16)}`, + `${STATIC_CACHE_PREFIX}${"b".repeat(16)}`, + "foreign-cache", + ]), + delete: vi.fn(async (name: string) => { + deleted.push(name); + return true; + }), + match: vi.fn(), + }, + clients: { matchAll: vi.fn(async () => clients) }, + registrationScope: `${ORIGIN}/app/`, + skipWaiting: vi.fn(async () => {}), + fetcher: vi.fn(), + digest: vi.fn(), + }; + return { scope, clients, deleted }; +} + +describe("service worker page protocol", () => { + it("does not attach late listeners when stopped during registration", async () => { + const browser = pageContainer(); + let completeRegistration: ((value: ServiceWorkerRegistration) => void) | undefined; + const deferredRegistration = new Promise((resolve) => { + completeRegistration = resolve; + }); + vi.mocked(browser.container.register).mockReturnValue(deferredRegistration); + const controller = pageController(browser.container); + + const starting = controller.start(); + await Promise.resolve(); + await controller.stop(); + completeRegistration?.(browser.registration); + + await expect(starting).resolves.toEqual({ kind: "FAILED", code: "STOPPED" }); + expect(browser.listenerCount()).toBe(0); + }); + + it("answers a worker drain request only after local blockers are clear", async () => { + const browser = pageContainer({ waiting: true }); + const controller = pageController(browser.container); + await controller.start(); + const source = { postMessage: vi.fn() }; + + browser.dispatch( + createServiceWorkerMessage({ + kind: "CLIENT_DRAIN_REQUEST", + sourceBuildId: "worker-build", + targetBuildId: "page-build", + nonce: "drain-1", + }), + source, + ); + + expect(source.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "CLIENT_DRAINED", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "drain-1", + }), + ); + await controller.stop(); + }); + + it("settles a pending activation and removes its listener when stopped", async () => { + const browser = pageContainer({ waiting: true }); + const controller = pageController(browser.container); + await controller.start(); + + const activation = controller.requestActivation(); + await Promise.resolve(); + expect(browser.listenerCount()).toBe(2); + + await controller.stop(); + + await expect(activation).resolves.toEqual({ kind: "FAILED", code: "STOPPED" }); + expect(browser.listenerCount()).toBe(0); + }); + + it("waits for the correlated cache reset result", async () => { + const browser = pageContainer({ controlled: true }); + const controller = pageController(browser.container); + await controller.start(); + + let settled = false; + const result = controller.resetOwnedCaches().then((outcome) => { + settled = true; + return outcome; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + const request = browser.controllerMessages.at(-1) as { nonce?: string }; + browser.dispatch({ + ...createServiceWorkerMessage({ + kind: "CACHE_RESET_RESULT", + sourceBuildId: "worker-build", + targetBuildId: "page-build", + nonce: request.nonce, + }), + cachesDeleted: 2, + }); + + await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 }); + await controller.stop(); + }); +}); + +describe("service worker worker-side protocol", () => { + it("retains the immediately previous verified static cache on activation", async () => { + const current = staticCacheName(identity.staticAssetSetDigest); + const stale = `${STATIC_CACHE_PREFIX}${"3".repeat(16)}`; + const previous = `${STATIC_CACHE_PREFIX}${"4".repeat(16)}`; + const names = [stale, previous, current, "foreign-cache"]; + const deleted: string[] = []; + const markerPut = vi.fn(async () => {}); + const cache = { + match: vi.fn(async () => undefined), + put: markerPut, + delete: vi.fn(async () => true), + } as unknown as Cache; + const scope = { + caches: { + open: vi.fn(async () => cache), + keys: vi.fn(async () => names), + delete: vi.fn(async (name: string) => { + deleted.push(name); + return true; + }), + match: vi.fn(), + }, + clients: { matchAll: vi.fn(async () => []) }, + skipWaiting: vi.fn(async () => {}), + fetcher: vi.fn(), + digest: vi.fn(), + }; + const runtime = createServiceWorkerRuntime(scope as never, { + identity, + handlers: ["PWA_STATIC_ASSETS"], + manifest: { + schemaVersion: 1, + buildId: identity.buildId, + releaseId: identity.releaseId, + setDigest: identity.staticAssetSetDigest as `sha256:${string}`, + assets: [], + }, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }); + + await expect(runtime.onActivate()).resolves.toBe(1); + expect(deleted).toEqual([stale]); + expect(markerPut).toHaveBeenCalledOnce(); + }); + + it("waits for every exact client drain acknowledgement before activation", async () => { + const fixture = workerScope(); + const runtime = createServiceWorkerRuntime(fixture.scope as never, { + identity, + handlers: [], + manifest: null, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }); + const request = createServiceWorkerMessage({ + kind: "ACTIVATE_REQUEST", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-1", + }); + + const activation = runtime.onActivateRequest(request); + await Promise.resolve(); + expect(fixture.scope.skipWaiting).not.toHaveBeenCalled(); + + runtime.onClientMessage( + createServiceWorkerMessage({ + kind: "CLIENT_DRAINED", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-1", + }), + "client-a", + ); + await Promise.resolve(); + expect(fixture.scope.skipWaiting).not.toHaveBeenCalled(); + + runtime.onClientMessage( + createServiceWorkerMessage({ + kind: "CLIENT_DRAINED", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-1", + }), + "client-b", + ); + + await expect(activation).resolves.toBe("ACCEPTED"); + expect(fixture.scope.skipWaiting).toHaveBeenCalledTimes(1); + }); + + it("enumerates uncontrolled window clients before an activation drain", async () => { + const fixture = workerScope(); + const runtime = createServiceWorkerRuntime(fixture.scope as never, { + identity, + handlers: [], + manifest: null, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }); + const request = createServiceWorkerMessage({ + kind: "ACTIVATE_REQUEST", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-uncontrolled", + }); + + const activation = runtime.onActivateRequest(request); + await Promise.resolve(); + for (const client of fixture.clients) { + runtime.onClientMessage( + createServiceWorkerMessage({ + kind: "CLIENT_DRAINED", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-uncontrolled", + }), + client.id, + ); + } + + await expect(activation).resolves.toBe("ACCEPTED"); + expect(fixture.scope.clients.matchAll).toHaveBeenCalledWith({ + type: "window", + includeUncontrolled: true, + }); + }); + + it("drains only page clients inside the exact registration scope", async () => { + const inScope = { + id: "client-in-scope", + url: `${ORIGIN}/app/nested/page`, + messages: [] as unknown[], + postMessage(message: unknown) { + this.messages.push(message); + }, + }; + const outOfScope = { + id: "client-out-of-scope", + url: `${ORIGIN}/application/page`, + messages: [] as unknown[], + postMessage(message: unknown) { + this.messages.push(message); + }, + }; + const fixture = workerScope(); + fixture.scope.clients.matchAll.mockResolvedValue([ + inScope, + outOfScope, + ]); + const runtime = createServiceWorkerRuntime(fixture.scope as never, { + identity, + handlers: [], + manifest: null, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }); + const activation = runtime.onActivateRequest( + createServiceWorkerMessage({ + kind: "ACTIVATE_REQUEST", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-scope", + }), + ); + await Promise.resolve(); + runtime.onClientMessage( + createServiceWorkerMessage({ + kind: "CLIENT_DRAINED", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-scope", + }), + inScope.id, + ); + runtime.onClientMessage( + createServiceWorkerMessage({ + kind: "CLIENT_DRAINED", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "activation-scope", + }), + outOfScope.id, + ); + + await expect(activation).resolves.toBe("ACCEPTED"); + expect(inScope.messages).not.toHaveLength(0); + expect(outOfScope.messages).toEqual([]); + }); + + it("deletes only owned caches and returns the correlated reset count", async () => { + const fixture = workerScope(); + const runtime = createServiceWorkerRuntime(fixture.scope as never, { + identity, + handlers: [], + manifest: null, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }); + const source = { + id: "client-a", + url: `${ORIGIN}/app/client-a`, + postMessage: vi.fn(), + }; + + await runtime.onCacheResetRequest( + createServiceWorkerMessage({ + kind: "CACHE_RESET_REQUEST", + sourceBuildId: "page-build", + targetBuildId: "worker-build", + nonce: "reset-1", + }), + source, + ); + + expect(fixture.deleted).toEqual([ + `${STATIC_CACHE_PREFIX}${"a".repeat(16)}`, + `${STATIC_CACHE_PREFIX}${"b".repeat(16)}`, + ]); + expect(source.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "CACHE_RESET_RESULT", + nonce: "reset-1", + cachesDeleted: 2, + }), + ); + }); +}); + +describe("service worker static asset install", () => { + const manifest: StaticAssetManifestV1 = { + schemaVersion: 1, + buildId: "worker-build", + releaseId: "release-1", + setDigest: `sha256:${"a".repeat(64)}`, + assets: [ + { + url: `${ORIGIN}/assets/app.js`, + sha256: `sha256:${"b".repeat(64)}`, + bytes: 1, + contentType: "application/javascript", + }, + ], + }; + + it("aborts and rolls back a candidate cache at the overall install deadline", async () => { + vi.useFakeTimers(); + const deleteCache = vi.fn(async () => true); + const fetcher = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Install deadline", "AbortError")); + }); + }), + ); + + const result = installStaticAssets(manifest, { + caches: { + open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache), + delete: deleteCache, + }, + fetcher: fetcher as typeof fetch, + digest: vi.fn(), + }); + await vi.advanceTimersByTimeAsync(SERVICE_WORKER_BOUNDS.installDeadlineMs); + + await expect(result).resolves.toEqual({ + kind: "REJECTED", + code: "INSTALL_DEADLINE_EXCEEDED", + }); + expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(deleteCache).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("stops reading as soon as the streamed body exceeds declared bytes", async () => { + const put = vi.fn(async () => {}); + const cancel = vi.fn(async () => {}); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + }, + cancel, + }); + + await expect( + installStaticAssets(manifest, { + caches: { + open: vi.fn(async () => ({ put }) as unknown as Cache), + delete: vi.fn(async () => true), + }, + fetcher: vi.fn(async () => + new Response(body, { + status: 200, + headers: { "content-type": "application/javascript" }, + }), + ), + digest: vi.fn(), + }), + ).resolves.toEqual({ kind: "REJECTED", code: "BYTES_MISMATCH" }); + expect(put).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("aborts sibling asset fetches after the first install failure", async () => { + vi.useFakeTimers(); + try { + let siblingSignal: AbortSignal | undefined; + const twoAssetManifest: StaticAssetManifestV1 = { + ...manifest, + assets: [ + manifest.assets[0]!, + { + url: `${ORIGIN}/assets/chunk.js`, + sha256: `sha256:${"c".repeat(64)}`, + bytes: 1, + contentType: "application/javascript", + }, + ], + }; + const fetcher = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("app.js")) { + return new Response(null, { status: 500 }); + } + siblingSignal = init?.signal ?? undefined; + await new Promise((resolve) => setTimeout(resolve, 10)); + return new Response(new Uint8Array([1]), { + status: 200, + headers: { "content-type": "application/javascript" }, + }); + }, + ); + + const installing = installStaticAssets(twoAssetManifest, { + caches: { + open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache), + delete: vi.fn(async () => true), + }, + fetcher: fetcher as typeof fetch, + digest: vi.fn(async () => twoAssetManifest.assets[1]!.sha256), + }); + await vi.advanceTimersByTimeAsync(10); + + await expect(installing).resolves.toEqual({ + kind: "REJECTED", + code: "STATUS_INVALID", + }); + expect(siblingSignal?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/unit/tanstack-cache-coordinator.test.ts b/tests/unit/tanstack-cache-coordinator.test.ts index 23bf4fc..a590873 100644 --- a/tests/unit/tanstack-cache-coordinator.test.ts +++ b/tests/unit/tanstack-cache-coordinator.test.ts @@ -219,6 +219,41 @@ describe("TanStack cross-context cache coordinator", () => { expect(harness.publish).not.toHaveBeenCalled(); }); + it("rejects a mandatory local reset when query cancellation fails", async () => { + const client = createClient(); + client.setQueryData(["resource-a", 1, "list"], ["private-old-scope"]); + vi.spyOn(client, "cancelQueries").mockRejectedValue( + new Error("cancellation failed"), + ); + const clear = vi.spyOn(client, "clear"); + const coordinator = createTanStackCacheCoordinator({ + queryClient: client, + queryRegistry: queryRegistry(), + }); + + await expect(coordinator.resetLocal()).rejects.toThrow( + "mandatory query cancellation failed", + ); + expect(clear).toHaveBeenCalledOnce(); + }); + + it("composes with no installed query topics", () => { + // §24.12: removing the reference feature leaves the common runtime intact. + // A template with no installed feature has zero invalidation topics, which + // is a legitimate state, not a configuration defect. + const harness = crossContextHarness(); + const coordinator = createTanStackCacheCoordinator({ + queryClient: createClient(), + queryRegistry: Object.freeze({}), + crossContext: harness.transport, + }); + + // A remote hint for a topic this build does not install is ignored, not fatal. + harness.deliver("qinv.topic-a"); + expect(() => coordinator.beginMutation([])).not.toThrow(); + coordinator.dispose(); + }); + it("rejects an unregistered topic before opening a mutation lease", () => { const coordinator = createTanStackCacheCoordinator({ queryClient: createClient(), diff --git a/tests/unit/web-push-store-port-compatibility.test.ts b/tests/unit/web-push-store-port-compatibility.test.ts new file mode 100644 index 0000000..6e97b4c --- /dev/null +++ b/tests/unit/web-push-store-port-compatibility.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import type { IndexedDbRepositoryPort } from "../../src/application/ports/browser-file-storage/indexeddb-port.ts"; +import type { + PushControlRepository, +} from "../../src/adapters/web-push/push-association-fence-store.ts"; +import type { PushControlV1 } from "../../src/contracts/web-push.ts"; + +/** + * The Web Push fence store declares its own narrow durable-store port so the + * Web Push and browser file/storage capabilities stay independently removable. + * + * This suite is what keeps that decoupling honest: it asserts at type level + * that the generic IndexedDB repository still satisfies the narrow port, so the + * composition root can join the two without an adapter shim. It lives in the + * browser-data import graph on purpose, so the storage removal harness drops it + * along with the runtime it checks. + */ +describe("push control store port compatibility", () => { + it("is satisfied by the generic IndexedDB repository", () => { + type GenericRepository = IndexedDbRepositoryPort; + const satisfiesNarrowPort = ( + repository: GenericRepository, + ): PushControlRepository => repository; + + expect(typeof satisfiesNarrowPort).toBe("function"); + }); +}); diff --git a/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/app-shell-compact-pseudo-drawer-chromium-visual-linux.png b/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/app-shell-compact-pseudo-drawer-chromium-visual-linux.png index 08190f45a2eb77c413b719237b38d0bd3c168c0a..48d24001b0874941fef4fb2683a556ed91347f96 100644 GIT binary patch literal 24400 zcmce-Wk8%k(=8Yy5D4z>9vFfT8rZ8?%!?HgzzJL2c@Zy zO?KrTaizk#?BFG&Y1%~b&ZXv$0H16m zO}?Y~nIt-fAW*)DVoy!^b9`tI0WwXKx-%ff_GFJDNtD}f6c_$~o-T5RsGM4dJuhg+JJK=rMJ9pE&6PjPR z)$S^9vA68hM2mz!a6pxVqoLx}ls;_)E5E$6EjmI|{bk1*N6?R!%y0VUMipr#52F@S z^{1?qUwi*;qQ$JMUh`71qqAvIykAX@CzA5`JGDdYlOdWJ&UH%85IPb9itI=jI#cSu z9Xj3B(bGxnPm|O!OK{0MP=+*2Emod7py(->(#3^3TE?MaTKUI(*<1n9sQ*>(QRIPd z3ju$2qIpr^=5R`ytS-j9N^bjw(-{v~VnTK3JIze}5E2WYYDC!7r66Aqrd8Fqwl^hM z)L{&fq;ex{P$@`~ES^zAJYKHPu0;*ry?u*@$p{@4=XZhdaEzL`=p_ag)|EKSBtqsf zW0}cfO=B9&;F_J`G%~rha!TfK{inaRa#YpEvgq}lIeZ_AloNYH3q@7D_3M+f8QD2V zo!zZ`f%UJ(8UJHuQalL!@k6zWX7S<1jS5?oeM~`gEo0^%&}}VstSqLkY-@6Qo>}_p z7r=-4glQFLvXZ~V|18aNTHP`4m~=B5r^%sje6V#m=@c6uH0ZRa^awg66tMS7B2mJu4Q!A zKg@4^I1hw)ea%L~d=L5Ax`%JC( zHP^|vUF2HhO!rd=$=3S^fATe0hw1`|CqQNOU$ zss``9>WLxSw%*0(kOCUPtjQDF4naTj;1vZxM{&uW!KS}3xyQVnf6bjiV918YrN-j< zdXv#^?Am_Cq(#Kq@p{4P0Y}MJxdWK&hxF^(%s4MoGPcSz3kL!zrV+>}RU`oOy8#$Y z2h3Xn^5;2RsZb#YM%qbAS$-byq8xa&8Z(vYmWmY3wTP`#`Rfb1&}51uvs&w1BWasv zh3-j>?`_j&5|d7k&x;4_PQ}~%@qoNjugIA;4trFq${#wb<5l@1q zz%%K@rAQkQ?Mh&gEP+f&WiCpnVw?@NCaXAs{%k0{Wv1^5kXSCD72WtfEH5f z{UCi7{%&xti;K|e>U}A5GC-?T^Z^LzbXc!VYj|F|-06Dm{0(+n^oPw>lc8H})L)Lj zee56T3nj6hE$hz__M;9(PL>`�FW@+EC3&#Yv{+fsB*VO3j*!?P^gIvrCICLO;6L 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�Js+ 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 Rc<>h@H6<;@3I*%%{{l_~>Y@Mu literal 21273 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 diff --git a/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/app-shell-wide-light-chromium-visual-linux.png b/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/app-shell-wide-light-chromium-visual-linux.png index afc60e1c7be8a7324f7070fdccb9beeba8187eaa..55ad48e888b46cb521abccde19efbda9d6855021 100644 GIT binary patch literal 92844 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

ld|9q74f zcRFq$;GkJ^R_YM$Hi^4OYFc2<@MA`!*p5nNxc+xpL5caq-G)02o`&DQxJd+9|*c0_$Y@&x4o zHEK=spB{=pI%mbyLFEQV@)iOs%`uf5QU_PH^j?~c#6{dIV%9@*maOIIwC5*ueqj&th`G;a(~QK~-z}QP3u;4{NQmsSu6RjP{z5?{ zT>S$sUDX_ACA$@j(-U~&pD0jRQnTCWV%K(pfplNP-`PO`6Rc*15;S^gc;TD_%@05i z27bnb8^7*t*nnQYQ1Z5;6XGFeTH^ttR-nST67ny+PaFm}t8jH>bHSzNY(W3!7!X*2H5@Rc049EJflU{b2J`ouW8XP@@ zS+KH;!=6unmfx?S`MMak?D^bVzAeSRqsA_k~2w=GwstKn=tx%O;y zYMrNgqhINYp+RDTF>>evQ0!88IzMYiW>$v3{lpaSM^6`KH=f24qZ+JNBKgsEyl4}Q zbdO&=hYngItv8oJitTd19qM-!QN^;7bMt+0Fa`96njHf_vDG0Qy8sVo$KsXk?a${O zid1ah*-355wYNDvWovWM&@HV&YG%;hx#99fasYOU zA$(aiODV;`c&DZ3C9Fx=79uZnZMhnK9S{C4?MqsMD$}X@9bQ}YX>uUk5NM=nSxFP_ z=bnOT-Qb3~{-2~ML2Z<=YUdmT*}J+l>ba=TT+U|aa;CPGu0l>K&fkm$^X5yqb>6B+ z%h}(ybs;%v^x(5nI-|c~gQ(U!j;GD+`802@O?Ds#?0?0n0Kci2@DNp&RSWN)6o)4AYy+p4!UZ>J<05`{xG9CE=>9N`_QtAxwAgxBHc~|~7bg&-%*@kZtvb5p z5>=NmJx({`JDWZRn?i~XdG-C=PR81|+#QqsvSX%=(lu2r z{TwZ2@X@{%XsU%)ixY^|^2Gb-7frIj<(I-B5c_Kz+ zTTBj_zz6H0ly!}a;}Ung`L4^QyuBK<<%W1cU}_9T3@IMv;aJn6j7)Rx70SU0a4{Q! z5Jy@hd0}E0A{Kg^Q|MzUt@w-=b&X|O)TVDS74!O)5%I`3fboRc8dqiGen^)+Bj!K{ z*k6uUm#C=A^zQTOH&Zz3Ya*axRzSS}wqL~-sN7s$kn6k^=B<3+{Y*63Q`O>B)e4BG z%h+a9TRw3x7M0HeZ0M*U@Lge;x~j1C{)N_N;JQ_&N=cVV;*X|@EKAP@$P&h3=y|Xi zx|Z0@?mWIU{3b?fdoPgwnDOX}j)$VRc8=ZRc$>0&hc`=@t||~g50y8N;gq5jehmAb zhB(~dljChU3;X^jaSn?ad&1;Hd-F+@DLFAxOgaoWSjWRQ;Bk^EZQ$cF2p)pG0&6*j zJkF0>hZ}hAQQJBaZLJ(A)mz4aiMJeX_7dAfKV8x-=H`0pi91hZy580s$c4)%@`hUF z0N2$mQT8^rH!bkY>Pj*}JJ_)wfYyBamu_b$F(WttwbXOK{t|f{5pBP|HeBMk6QMuq zY!-VHgoZ~EUr^UN;hyGG*wCs6YE-XIuYx^Ey6$9Tuy*hNW4-U2)4gZN8*_PjjdI>l zFW#NGb2Mde<@R{X_-35Oa2o^WLu)XBPg@xQuY2y)UI_Z&C+|0JpvEbX>z|pjmwtG>trOAS9DJ3_mjskN1f2biOAIFpU`)2%*6sAYm=Ie zEN9aLQYMp0K+jS+m(m2fq%aBq3 z2u7rd_?NiYQK5cw)w%xZtI5oyfR**m_GZySeB4IJrONHE{GZSxImat(wY1ys+8XUT z)qXyO`{$I=G9sY#!K*i!*9XlNfn}`Juj^3VmH*=|Qd_3UjZF@F`aYBlz{=?+dFM1^ zlsBVPSOrZWItv0!9^(3M<8-+N>4Go&;Ye(dvZx!P>@TZh)W5}EEO@~4J7CZ)bC0b_ zDwD}Je;J|rqB3je%GVh)U=xb?WTxoCa5!(DYpZZud}lC7PblB+mHI&&Xyy#n*LA-a zB{;f1?^mkPUUWdVUsH6h#g(+tMM#zM34dZ9*q;C8!oG=z0jQu@WaoH;8SydmEAQ}7 zuulEfei6%E-Q$K-xNiyYr6EX?j=MOk*g6+qdazXQS;hEBJ);hS6xQFGb<>_8w@Q>h zp2N5a3c>-*{O_FfHul!Dejq7FI~@xQ#&ZuBGXd#`bH}Fp-OdiWYX8!9i1#BArtIb# zSq~Vn&>{>HNC|X${*w#nVSr!9@&3(cJ9{l5uD%GO;k>z3w6g&DswYH1{2^X1fr?Wy zaYaVwc6lm8$r6AyV;Q{!Y)o-utb-9UKC4>Y*R&SR-}+))#wi81?~&H+?p7oGs2Mod zb1qShC1c1~1~yA!+(HhW{gN=djCO!#Rwmn}s9vGTsLxHfj|7QKT~#|Ky}shNtK9O( zD#xL_&<=e{a=h@`d)YZJ&z4-B# zdG{czC;eq{ogW5^e>E~1I*8hs-473`u6sL%_R#z zj^;DwdAcZG_Ht$sr2H~?K+46ylr{pzxEUmZ5T9M2R)?hSmll?C{Nx;2M4D8k5{D%r zEEf?UrT$23jyUCH@`rj_iOF$i-v}7nAHw;eVz;*+?^1q|Ngv!?*(}pdYSx;ry8hgE z|F&_bg_zY(T^{!VY&4Y@0DjM4IPuHO;0JyQe5eKQ-X}3-u|>$bx6W&AwY>E`;(jqW z(s^LeW{@eg8=@(*6z@in3D(?trQOlet@CO2z}%&YwA=t#h*VwTcAatZHF8 zsWveXQ;BOPJG82?l5j8?GuF9ZTm4Oh$b(H7=a&l3JuL5aq~1To;s4)}a14RXU1EtYrMji! z^DYMiQLM~%Z({sn3%<^C(Ckql;E8OZqbX_AO-M$N=3uf-(-+0m*&(3(`8&a(e00`> zSrpe1BGs8l=l$;h6^gil?;2yt+YOlDMHP)Pa|<=XvlIjJZ$nDww?er>iY+u+3v4BX zSw9}kj%{Cr9T_E2;9fD04;67GKff+ zfzo{>#c;cJc4wi`n&B~%Bsu~ya(G;`zsaBSk0a17G^g2q5BI!V)bSP``2b59!vZX| zlYy9ThD3ZwqLsGS-jk=+mvCJ^miQ^#-B#bouUs8x(B}ZQ;zWQ$kwwNG9pua;V*r}B zqKo#iCPAzYm3K%;FOe;m@7N;$XBZey1dHXynwZKceF`k3k>GWv4cz1J8gLMSnySs+ z$$wN4_2lp^{xaNL#-fS!i<`D5bg!=F^u8|U{#T2Ra!RVMc@aur_<>CutvtBfkEoX2 zXh9SwzdLj(xf-5R*&VAQx=kiMf?GYgvG_jOV(6oFTYC&u zZREJ7!pcG+VO<`#Z+kOK)LmjJ%a^%ylx^+uj+|HF)6m?2i&D*+{4W4^;u0krP-&gN z>X=l!YHg-H)TR6Eicz{OxuwwBP&ItA3SU19aQiacdoBfcZINuE#TiM-YT&A7Z!VPU zn*o{`mOue`sI5K9Yi7TBEP%Tl&2n)tRdU`k@5AL|v>XRk+f%;`d%%x27w$O;{_) ze4CeR@eh6Q3O>lm+#}uUP(Fw>6Hd>x=y6Cg@MC|BCi_tB2vRyQkvpp}_hUqQtOSM<(j-gFnBfS6V{9d02@w)yT|eC9o_F;> zh@Xo*j73zhG{;PKA#PRMz~UOt+n#aYXQpCfY`z27D7gO6VqyNSu=3QEy_B*pvqZaT z521Xi5H_3Ly`^|sU2Zln6@NNL|J+Py9e`G=?=k!ZtGFq*!AD8+25%a3h@l|b`ZCh0 zf*Mm<`wKq}J{XPd*NG6+rB`vw(*b|eBnh?PM*}vxIM;8Gbst6~w7HbD=$)s@irh*S zxy3D)F~XIx?lfkGPP(mylGZY_m0dndxGf&W&i3?i%jLnxg>9DA-;5)$E^{=} zq464BMp2QJbB0YS(Nw%8=Fwzqn%a<^M6S}p2vdG?L9_1sWnq#2$$du0l1xOB(zX)U zHaaTVpZWVs(M6c+h;RLo6ua;_yJ1#}scO2{%9FvrdM)D_ zfZsG@k`v0EKEhkM-AqI;#MmRQbM!1(2u!hCt=iQaeh0gRfTUo(gt;VN(n7kb)RZOOJx51?R=Q( zj?P{1m*e#reE0{v{^h`ooJ5dAo>?``9SCHk5yNa7RG{f|B0yt(%yu2fO<}%2;z`Lo zc)V@Ax_KLt(2Lxj?0PCC{APm6cE4KUs(e~g1|QKrYJ4hMH0rJ7@JGbfKsOL|SpaER z5_6}r`yUCfCH|pv#&_H~1F{QMg3e=IK$$<5>K}mHhWp3<_?i*u^(pDhKlZLgvuBZ! zW%vAhTE&xk0q^>3CVR=@mUj@w?!MAFfk-%QZ+uJK z_4te%9l^H_4^*EJ3yY{}Nz6`fq*58Suu-{}1wnY})v}MtsR{BB$-kGAgt(s4p;$16 z`cXtaYW?>BMa@+f+vez;JS8DrilxeD8Ns2jpL)+7vo#jh(y_p_2BROVNGPHesc8Fp zE6ivACtkZ*`;@h8k&KRrSx~Q7Gu>QRExdc(A`|FH$l7YtBjSBr{-bkNm-5tpCJoM2Pig z^tPH`LE|P?+ah!#MXzqMm#6Ry-*1RarFZp+*Q39;(qbJs-%x@i*Nadqfb1h!vA`Hq zIRXhZ%P`bM0z?4TZrryP1-)k0f+TjhUAb*j$(V)&_v4hCFvS*1Y>%2aE1?kyC~2-# zDbs)0Zp-YxGS3wM;XSLrjC{*R%)MwJH*UT$S5Lq98cjw9gu+BuNt5%5WLb{2@zk_qO=5rTR;h`uV0qy+~9Aqq_03Ur3N^zzgAmNC^Gv? zuhVPke{s~EJeH5#knD`45P*#VHf>O+TaH}ue_SJKrbYZ+QH!mmYAie{dnEj1!X5d~ zmQWUOb9J|+{Bjn{kYs1Ru;d0xfLD`9ckjZ^oq5WYa6O)_S=%+DGhOItxJhX$eo<;* zvU4Uu?8A6&^fr#wie5sqi4=dgSE_^TeO3?QRvyrm%O=0Y)gKXjIo{QhW4K5m;O9&- zqubzjSoK@tHZh6Jb6X8670;>M+4p=0bw6rYVvNIF;&ssn8Xqo`*p zx6wsY@)Q()=Mi&4c_}e8s%$RSSgXn!;h2w>XCqx~9vra0jpJ1HZ3vDfv%Njj+ge)4 zm{p#Gg2%XQer>*MjnsCrSN3_bHgvD3k6yZaws#CkjN8GdULmE6x$0sp>0wZBtL4^- zgndfV-KcYRpiI{OBOH&9zLJMjnfg2f^OYOGDKaz;4RVCuA1so`mWYuy4~Z-_ zZfYbC`YBvGKlt<*>T#_->IA1*^<7AlyJ&Ezzu5jG#DRqtXH!#Gw|l_hazj?gE?|+}U_Su6|>=mYAzf+2=RbD{<3$?Mq;(QVyC&k)jS{1o~ zI^pvdg$8Emtbv^f-kg?9W>HY%P3>&SFd$PF0@;%f1WWZuVXA3>oL$jrlQm`wq`Rz6 z(w!S0a@gt6=fqix%~|8nBA2EWKkq@X6M-UHB}3DC|H&{sm?4|V#i0D#pjh_wpK$3J zq}=ft$0+H*a{stICqd-2JHcSqSU z%pzsnaU{u9=*n}a>e6;URwF{=qt45?4Xvzy75BHNWXQEYHHf>0&eA92EE}uh$aziG zpRnD)>|qk6$h;W3GzPEnUec0W0vZdm z7?^WUWRsA%F@69^kd7~zh0v2x;13j`1&A}CGV=q+D z5AmW2G#Mk1hLJK|r&FG;ZSB%_Y(5L`c8%lAZ!A(QG3m#p@?^6qQb1wMiTP^LZTj@q z5aHXiJ$-{#YgAZy`sA(fsnQIPa4>v%~qwer5XtTrGclf<0WoGT2xPe~-x#}2lo zo=C^x=9q(sB@UxlOLj$9tk`G*SC^WW$S*w>@0$$br0g#`gi?TN3*%@IYXBax(wXq26DUAQ5u)efF^M%04W`(99`G~24LJT<;B z$@^A3h~U_{SDz5i&-bhOjM%`RgfZioA>`=Na8wv}zg=~n7w~O(OqWObu&wFH+K7G2 zTORzj5yk0{y(`n>aqm-+xA$*y$QC%}PwwmW1- z%tAyqNrU8^w-HZEjO)<_?cX_H zOi&|e3-L0TE^D=BnS=Sep{jeOQj?|yL@-+lHo=5~WLih?)B^@B(j+)KDyiEl7AQZn z3y_haj^Rh!bq^d2B&YZk67flb%jS_nyBRORRc`VLvyTzCt&Qx~rDoemT>QlGgUt5l zALDxWkCBeso^wR9Z52znh^Dp829uY(00ts-BpgmDF&kQ47BWeaIqFh?w9G_{!rS6G zQ=0g^keKt2`nQ)7-q}_u)S~Kciph%ub8GQXw{>6XM3dYcB$NhH5XTlfm9m9lphLc` z(0!XV{URlARx$)}^DU#aUA5!fRKY0EzuXkXnk*-eh(EK3DVXI{D|0va-<4#p=s2uH^GhZ==7FM77!R*<5d zU5!kvey;X7u2UO{k8&f7edU+xZ51_oonF%-fZA}@P8rYpcM*iUcZ!Q}ca?4=gxMUt z%K4}0eYff}IN2)dx#JCYt}Yi~;+510ViaE;c!wx=i~NfHv0n?4?6RDKiXz5Kw#APwqGo1LKome%ccY$YI4>DuINbdl1=UhfTNZ3FC;k*<~Yi+Z^vwL(;q?Uet)ewWiO;>T;e zW&Ju@8#@`p&e=G>J4$do5)Lz^ada6ReqK54lnNkk2B#~^Suv&BqYlR@I)p@n&B(?8 zDkuS;#u>1fr>d+RYfa!v122yTQNFpijPoykHBFHPFLx#Qh0b_wB#$`2_l;~}4m-;& z`#@BwtMdb202hvfX+<+sCbK3C#!WzKX<+tBqQeb_vH3jPf+w26cA1}A$s>$qg^EgV zz2g$l3fzn%s!Xmyu{3WKoix`Bu$02wkK^OyS3(HtzM8_2SAu{hE969wCK|!UbNkxxQoR5^2;Waw9BMCjIL{ZGfN~9PXruNx19Osb z;9QPZbaW!-ptqKj zXzI|Zb2?fiU^ENfo@~qw+hiL~1b>vL$FHdnLBRf6i2+#&uCt`?t7qfVyltM{n02Eb zft!ggiZ5zTGn{>xZJxu!Vkw9NqyHPUiuH7!rK0Z0&CktyM7_=KmTe>NF(dJ@E75zA z)t}Kvw}#D}8;vizq~71-YD=pB=0e_P#U+o;cDAWsfseJ2>#QST+dkwnM2$)KQ2hO; zeY#+eM6A(O4$A4AKtqtW80}JPCKUJ$L(!)CdiCf&?N~# z_&on=kFFbx zxypp~9}3i{uqv7Xo^DNM4Y?)m%YtqMS}y!_aT9jINgTu_3!x&DGgBpYe*}i$HDaY~ zX;r1`gO!`7zcTCzuHDYEtvcJDV?t|ghtb5 z(^q^80~)%WqiRQl;Ln%to&@|}GEkQv)!jtj2-#%A{d_T|f~pOc zYkT*DAa&$;322ka=ed}KHUJMb|B(s-$`S1#Hjy%);a{UzsPDV~brxy8EvfYQuW8B3e@#mc|7%+E{pSDfUxUS-?ys}<_aUIp5LoG#9L)z@ zVz)O?7DWDUe5@y{w(^=^cY#;;mwN_}43gJVJmZkM83_|b@Ak$k7*|X^j9(E{G_EY$ z-=Rbj5MTU~g}9$?`jBS^iy5x9X>1`w{g&0BOXsh=vkTjTfhrX&_=@z}4%d+udTIsl z0~GkZBYF)4kA1qoDw_HG80K}z;F}T_nl&#X$depQP$3LO^}Ie$xsk>DodE$vB%foU zwWB9>+SUvbC@9(~fRkm6m$rE~8a8Br)Pl43!CP-%N2w9X@O#FqTYQr!Av9a+;oPpB zWO57?)YsjYm7-h#oVa*B<3yz~%IhnTa|MH!ZtPy3XP9}NG z9Kl+raZd=rkkjt5tjc!Yq)S~Mm}tV-Lw-9>&t-R;cl~A#?)9cs5k}uL5MFN@ngwUU zj~RL;^Wx=CzWokLz=1zuId2MEMe+H&B*!oq6)?H3=m&w!*icro2}A|IilvqF703 zapZ&ycSbE{5wtxVRO>3*&?m^my~PlEy>Aaa2@c)s!XLL_(Deq&`3+=>IugEiH}n_qTRGVdGy|R;>{x!4o!tfGa9^{9*gF!`?fLh?}|mEZtv17I~Y8mXRddSytKOA zCY*i6jdTll>T97m(LY}gr&QIr?Edj zbe~t457Xt*@NhDiPG>WqizH61EVCQ+5GHsRvE1Yl=9EhOCl_!Y3iAGtV%Jg$je>LK z!fhgxWcM`{4yllN_L)P_7q-a=8&O0^A>PjrlS2dHa%_-+h*I6pAoq4HixTz}+ zm|^3V)~9jXN|#vn_k1yt{6&gw4K z(8E7?c}@FamjG-&H$^F|@wA(rX|@w$95VCBuoz9q;kwV}s=B(~g~17S`|74ueyOt4 zj8D`T^Oxnk72b4r1R}B5`Fv%OzH7ZRrfTY{IWP%ByufC3?s~kD-<|tZ?{E{!m+ID? zn>A?S5a?p{A)Df)<_4Jt+N5nK*2OkcNzdTnllBCbce)ABVtZzl<$~+%L4&m)H{Zp~9+nIj>4WnRo81BR4ri$hNXXAm zx`?eMD#nB_2PE!KV^fKJ8#mzx?7++P64<%1WC5G#g@GQ16Bg_7jhXD`3mf-ns$r0T zt~LPEhSw9YaGC=aMNnOtr*e4K@Sgd*1#l#Wn6`qQ(5vfgo^X8uY}LUr-t3QXl3_%kS@JNZ95SPUooFhXBaYv?)7d%Fhq6TRk(l z%P_EIA3lPo*ItfRkaaSycQS!(>JC>mTj>`sU3_lek8Mq|bEFuolWN3zpPq(S&;n3G zD&$|Bx@WlEy=!WUPvZ8|T9=oW67JKN%;r{U9IkC!&twybN1Ew zcJtA!>_0)^vWs+xvgIULGndcRcKZu~-ko{Tqxsq*GE)mUR4+P`TT?ehURP(d*HaC* zMsU#G?RY=Ajdcy5zkyC{fG_!L5fIq@%HZ;${P_Bvm{<_vr2`x`x7PCRUlu&=1A7!! z6J%UFT}CxM_&E6KjPe;2FF}+{SD`~=1T5EQXXXZyt53+Oa?e@MHo=679He(^cghZd zKwU2iwV%>xd|v~EbY7k=4zWX#2p^BHkb6`xuQey11WvN#YI&%LCx@)@2vL)5)Byj2)A^U!sf+^KPd{1sl`GcwGr^ z#}OYL*EQvbJv<=GJu6!tQi?pyv~}A<3a56gA#|b)d;oGXUz6*k-7VdXv1`gi+i}qK z@oxCGTd}7NuJOwcCpkR+nPV20$sP;!F9FGxDHNys=C`sQIc;KN)36PHi*;dAFGkXG zn{-W=!5Jw@IiK6wTnjyjd<|hx0;;d2oiwYScD+@2)+T|YZGdd7$_yJtG8OOkha1w7 z>#5HBqkvdR=EcKfL6_a)mZa0vy|spK^#WtYn{ypB~Hr^!Wt ztKC)J;H*K>z9VAnyruKg%LTOW9b(Dm{iCzr#o0+ay^_nwb{juD%AL}q1^aRW+|%L zODxKc-150Zli%+8=^!AT(dN+6%k5?WIiGi__pFTSvHe7=0sK4Tb!(cF_gAwD?S5IYL1s z$CGDt(;Z_`i_><%m7pyFP@FowvBB7=5#Sn*`ruvZaTf+Gf8K75 z2j~>86&{a<+k`HVCOp=!S2jetE+o#Y-o}1oAH;V0dzATI^14lZa&(a!%dCFXY+Ybj z@ov{XhZPj&=y1E#Wx#xi2lv!F#&i8TIJDH*H@N`o8-_Op&_B*9?W>-X&#byzFGC0GtN1g+!I}G8Tru zi7M(nAAP%uO17g@?QlkpdHh-F_izXwV-oCqGrY2re5+D4n{3@GRc2zh_w`-s(I~}q z?}zAt34{B}q?-G+7BfvC1M)TQ2B^*9$FpXcw|}$g-GICW&gl3>IL%i?XS}ndhgTaH$C*DUpLD41e5;5fro9L z$XXkBvBJz35PBW0k53by(v9+@*k2R{N08g-$3jZ{N03wM-OG*(fY<042lM(nDJ`yn zu57j}gp7!<-36wHWFC0lxU{J~?liyLgf5&cd&@gg?fzlU5zAbu->o35S#w`VFQjU{ zz1iePOl^GeP6~R@ZF;Or@#JxKoCE?nP`F{|k3AheWSVr7b<(H%mu+oA-IlzB-u&q z7yFG10!FYHlDxe}VJ}S3Wm7rVVRQ+k%WkuxJ~O*OoSYLX5(X+8>9SnRj$OEf#H_0&L-o^t|G2dWI+P4?{3tN2B94VUU4PKWAYv z=5=b03&V9)R|eQgqbFPJ_Tb&VkZrGPA-S%J_LkN^Sw4GiW*iv)OhVd21DGAP{Lj5#VJ{dOX=hHwKGSR}Xb2H|>kc{@`*h!FTMY){}3JuWwx1n$=_pYl*We zY+ekv{qEZg79Ley2n`8WGk@Is&IGewF6yju{RDZbwAe?}8ux8r@n@Nw#XTAw9lH_Q z3{rCaqDxA!jcWDm&L4Abf57hCc5{dOWgKD3o9S@+h%C?5-hSi?Ab%z%CaB|o#-7YX;bvwR40jEbZC)_sW*LV)1G69}%sog}zJu;9Vn-4omi?hveT z_eMInHtrg{X&f5sMo#nQ{qKFw9cO>O_tq!Ipu0wO)vC4Toa>p-&#J&5xiB}ftw24Y z?+QgC{$4ReJOf@!U()B&h4kCoRd}Zp9;7qk)&i&bjhB@vi+}9!H+a$!$7e%QaB;GWnM9Y0w1UkklTLl#4YjPPm;F<$<& z;voy1o?0EK{~xNo|NS{A54E`8l|SJi!f0w;7SC_f!!q+pb0VPo@{)27=VxX!;(b~0 z$_k4wJzJUMes_H?Tn1#i9a~1ygr{nOHspABjP%#p_XpeMUq9*E=cLcP`scjr$fxq# zH{p(hpSS;vFXBEcM)=aHs}+mkta6o(o!+k0Z)E7suHeIC<#;celRv39fU58OUZwfd zFK-F+6DL-88D4@~^opkHo2`J^zE^PdSfB+>X2og99*sUP@g{em$h!_F!May>lo4>{YA=v|ccd2GuC035Rw<2c& z^#Xt25MA&O6cn$ubFI^;Sz=+jdm<7(p3Ic26rh`6<#ZX+NBFveRd13T;}_e*^Pem?3}jj{Ou8i3he>dGVQo)>gPr;D9Z~$5jNV8qI>Id z11rv6vEYt6?;K&pSv@tuof#XypCq}{1SD0u`>ZL8m+qE*2%G4BQ8|M?b>qHGfjV!C zjjK&YpnF#wbN{I+LjP=snD)low1Yz3loQLKs@FzFJ{sq%-`GF*`qx9H_**jhrY`?g zHUoD=lk?_;pML5&$=>Iux}~7|>L`(&M- zz!GE_lPKmJ+c zr5Zn4u#;PO@UDqTjOU-r@O+G%A@-wBkpszQ*>S%wmQmp*^-6m`3nIeE)5f#MR#5&a5mDV_;dfYhR17R#;#4V^~>jho={^+=|_U_G{Dq zUiuX5_CYP#lcLeJWUiPLgc`W#yf;NNn>}uZrzpU7;`;EdLG00T&c=+h(m?T&!JBNC z5!My~>#7<=*y3lpW~RB1XlQhwRBU|{ejsQ%itmZB!z|A8f~E-jUhP&rxJr|` z-Pq&SVCQnU5q$4cd!|M{bV|O9#L_5rTzKtjvi#ks6GE{>T6tidV_77h%^S#$GKG21 zmkJY)t@@o-uc1~)xn-kSf zt=~&b(s#J7Omk*u(Enhw6_cb6#?WE}>kUN$JsB*u*2i>B1z5O53ss;mk_1|+4})rv z_HVOnQeg@n7A&|1MC|uh#q!(!gmM_slf4QOslqB64B+9jUrG(kuCp$yskE4^_#6hA zzuXk>RlNDmRo}IUAS|4PI>+#Jq;B%<`_uP&-T$u8V^j|>1%+yA`aK&*eckf)RlGvR z3S~pN){lzZ;}8mrjo(liKczhwe$c8fcx!iRFkEK6S>tBm>qL*0+zX!bKkZYitvvMI zZ8UqGE4Qg){VXDc{JEW+ox2!I=2qN+gw-Ji43;KVCsL(Uy0)Ej! z&2+a&u_0eL_AVyOZzS92#y--okbzy0PVjPPw$VF}C$-LQC`L~-7D`>kkAa*}3-46OVB1q~(}AN9idNVVMRG%yo`Z+@RX8z6y7KC^ z9U$XvxS#qSgRLHX^N5J0BYyQ26~E-|Do2ZYZ}#YVxEs0NJBg#ep@0w?^4__MWgG)YPLpsgUeAU~oJ210RqLIJo<~*R)PcE~LXK3GzhRma>wPtIPqVcOokCTVuDJ=`DI+as6E!J?S1!BscU!N-mH|#N}cBhh&Lrq_sPX0p}~T^qubJTJCfw ziUA6l7HhO$$92sK9gB}VkdZ0!@O&V{?MwBcQd*4YG!k3iY4?G=A|MRb4T!TGDi3mI zQA)uR7R`BVWDz=^C&`~#A(iv$gz(=Gy$xOW4(E8+EoA>8V%|kMqdbED&7+Xl=KA00 zmj5Xg_@7k}-*1bV8G*XFGd$^l0eQs*$8`jvd}l^=nT4tRSDHM-V1iyh!!+)U(kidR zH#5sm%m(QToux=?NJhH#-HB2eJHnNEn;m)=Y@cqlDpfFQwBm!V&w*N!KR8pn6uz~0ygQ!kyGU z>(qDpYT3a^sJ}PN{K9td^g%zOsoM68U1m14B=By&p9r z$MYS_Jmzv&f%NYJY&1djEy}4LjuFmdHCOW;EHf;5tSxDEtWI-rO}k=znTgnGo9>pf z?J_cg@yZfM&0RmMiifr%@$Fwl(4n`3r!dh8F*bgR6`I@S(@q*J2NEFuX#iV#8|!(iBNr?yl|a9^+~@pwveZ7 z4hH-Ab%Z_|&fc!Q>YomwV26>Y4HvmdM|EU?n;`h1JaiS|;zy5{q+LeZzYrS4Lv-oZK|~i<5HDGHh^9 z@?s#E=_^;naLZ3UrW16s?Fs8_*x-a=6xHe?*!lh+@Pg|F=u;w-LEjJ3@*q3ZX#IL9 z8K^fvVp+Hv*^!(Tl$zPs)5D6%>DA+WR;-LKJ;^G=fgY|VPk2msa`hti_bGlqUTS{O zCY>@JDATJX$n^^|FhQ@(`457dCME4}zub2|hw=&6&rTa-rbCi|DA5J~240mU2?xm_ zkNQ|8w^8xM$4=s8v}Ub#j`2{nt!F_C%H`R9V5MnIGuHwv9Xt`QH)?vSUl^=qo)+c8 zmPyKFP{0#(GK1$CpC0RVb(S2(?6>R-+j(&I-Po86UUj2=#o-D1GNzb;viKQa@lJUW zY!M1YV9into4+K*vx@?A9{PQpI(;KM>HuC|q~^lg73}sp{tmz=whNgdEMMc3CCaKj zS6SeVRBIfnJ6zH0orVr=qF!u8g+(MPZTnh>;JieEI3knbISQdqJKv!|cu-W=&*Qn} z*vCm25h?V0IIUIn>87aK{ZtctIr)KkLUi`zY0KwC zgRARIO%D;8v^@JmW#<@PUeZM&G^|4BgFnbD8H6R)=Lf1H=qth0y<4%}Ie29ma2V012-d`Mt3&7lDiSvy5}T!!ZmRsJ%^)^IsqsM#D!i z#w91K8GRPpv|n>oO13vcx;6HxW)bt?WvYC|VZhfjGaY2Zu)`6yXxWbqo&lh2GD8xz zT|Z96-nWjJzl4}Wbe}M1Fs4W8!24Z3TO*`=w85rAKrxEpXoF0eC?E8m!Bsr;d$iyT z1A4`Pv6pH&Y-M5jF`t^p35-(OQjuz=U+`eyhGN!5W zOt0Pof9%?93E0lZSr7#5ZhtOy=5_IUI-W&-R^;>8B6KL!#Oso^_IT@dc&4-x7@KyJ zQ`dCY6c^A7PNkqCww6@@Fb8Xh6|9|9<1V%d3#2Hae4KqSgk4dBuGbJp1zU)A>?C9@ zw{Eyg-}2<^Z^$v%GWE@}3fNwWzNxp&n~Q}IgNJ}HS+?C3j0x^-jU7Pd(67%LDXpo_ zr?BMV6N=sBy6v%R&R90!wYK_%*G;cl{30`SEGGo%qhi$sar6B2nDBF~hqLUu7q_Lv?;~~YL-Kuw=Tp4JcO;Sj1H7n_PtGwqQ z`HMKkTbK}Wkl6T+rEt{OcAxGOpwpH=)|pz4gzeS)sxY1~c4Z;x*r<+w#7_5I~O{@4<{J!Dqf{fLq$S0yb|a+@*% zTQOjM$AYX!NqgS-`<9T$W$XY!dp=&xP~dj&yUEXT}tD_`|NW-cg)b#c7seT^Dl;J=3b!~1eeB}NWOBN@c!^|+E8QsaYa^0BzWSY#MJ4s7Y4r;v~j!$`6Yq~&956@F*P7w){-9u;Z!)Q@e_ z#7$&aFaE`uC{rHa2vRI1)FE&Gtd&T^udk#A&Z5ERyTASeBUyTu zHA-jKw;`jQPU!MIKyec~87o%0S4E~+@4Q?<2arg_orj{12UXV)%V=tqC8k ziz|~ijf-hK6fjdwOlo6Y>r^PA;+vNr&1wrgAsMIt-p!$srlLpE&*kK2F<7~^n%8)0 z9JJtbq0z#Mv) z=x2C%ZgQm=oNA69;LjYaC};{};Rheix$5XkvmsoZG3-U?Ip^`8!n%B%MK&5saiH>CUWDXohU^f=6u22^z)zitZrOsWO4f|}dIqKc;pQ#n4 z=y{b$SE48~%5);-ylL~rEkqVIma7hHo9D=eNLCi}l3)MLCgEtqEJjOtGp?|_K+0`$MTqxFP*yaN-0{@sulmRy4rzrErb4$q zA^V9Mk2nLCgMDF>T^63MnZU;=z<-J$pY!^G83~Sj$+!)MBam;&*h2}@+7}0O1d8F& zZx(xTf$9Yn)8-{y2T8+whxxQ(1xQEYh6ZyZY$p?!mA6A@PLC;RVx)P2R<&9<@6fPt z*`rjWFaoy3D{>*B>PHD>B|NhH@GH!+k>O!Actj*V9mK6Lbt|wil;v1EwQ#I7yfX1; zuc4qa&I7tUmmeK8sIpWy) zM&cjUoe%D(%kW6+_-yOFO?%C<$~cs}dUyR=y-Ft_U_igc5lR7nKS0gZBdN|R(CJOg z+r18*5;w67r=L^m5W5(e9$2;1DsK*ekgZ=meHqA%*s5O=3$iEWvzz8$B<6I1PuV|z z=FYDZtIzzpexC`@pv1!L@P@`>c^3eXN^7OA_nymWN?~IbS<6F4xyDP@u@;eeD#Zli z#Wk)yrl|@zS{bQE3jzbT!}*sAN_N^#^^;p4HR*@k8pgsAxh*UUNoQ)hvhoUpYk__8 zDV;?5QoL=n%MP*Kp!TiD|6oSUOGGs^G7|;w4>sY7ay5MOiB;VVPsfatA&P|M1JS0;psguA;EFxU2Oop}h*ODYqgvfS`g^-ctLmu!Td6+|+E?1#XwrdGw)@3)so6Jpr6&ksG;Kicp z8e_@GTA;p}U>TaWRK{H@>G*Vf@lujQC~%0|I>~cb+h)PfY?0U3PV3ESKyHGjcB+oM z3abf|9%TLM6V)UOppZ;TWNNA&(e$j8f4pA6EX6qO{m$>Ka{(6Ammxo)>o={BW!}_= zThx^Px)e>dfCRVoxMP4_)ne&~s9SSyQ=n`F3!5#^LL#ow2;HdTK45q0s?8($4-Boe z-A`u-8`D-OTuoxK#1_e(Ht=>n`D|LI{U;?Ojg+yRLY$h$wxlWIvM*B0#DmKY2BV{4 z^`bg3-fLLtFX8L{BZ zKX~Of+nvSlW7&kXwz$_uKgsz2fI9%lMhfrUv^Jy=GO7*IELSz0c)uB={A zuZ6@LAq6bS=021gijwR;_95s0JD-;*UNxS?yg$5PLz^M%VR^n4n zdR+gm48$ldMBN=_tmUg~O;x6!?zi>SDWdCx2kWO(Q9wERhr5F}q(rsR zj!rgNh@3Nm3Gcza82wnPmK+#%>d`xYpuW0ih)2D`Zel^bs;-Mc2Szki&bWFhUChVq zX!Wx9(nu!7RWox-!j5}buA!K+n1&;^L|vaY&E6nZup^Go(uE4}*y1L)f0 z+icZvoaJtMO3eqIr zYp|3cdlx`lYs$$1BLm)0m^#mNJ10~fahc+4i_c%ZPDcbKdqX&7OiP_Ly7i_rwiRx_ zsVCy$DJRh~3dNduMDdKHtc*?M_63j2L244rv z)BwJkd1BSKCYtiQSbojQiCE{@=`>j7&5XR*W=kPeJAHy>n|Ig}S4 zhBD7r(&+1w5)3I2&n354SKf#`(w>gA+E8H(>@(0eSrY>OFp1UNz7Neh&)!NiCOMQ+ zWMFSTZ2eIo$XcC^HIC1p6jnFKmSX=cIN*Z~o;>hbXfJNitR2DgAlc8)X3S_f#S6cT zY{h+w=v;1JuI8j)O;O`oMMcdl&};D7XXlvgqU=Nx^Q;;dUca@szjSt@B!m13jnOY^ z$*w8|N;^1O+(wVAn`d%M;5OwESc|m%F3}Xw!S0ff*M+7{Fb0tmR|w4S7wDsYrX?US z?w!)f+I~|@-z#_fSOHRV&=t15CKdD1!THyTo#YQG_MjRz{^E%H;b-wySec8!1*OON z0g~9YtEQd|AC_|ENWmh%d?U8~nmkC6abt*`6JEVs{bMj094kxeW2K~p16mt9qD<}p z_k*p@HxJvH>ijxv5*yO!K0uxgeV94V1q|8Eop}*Q+7}xeg<<>(AA>Gr`vbP)*Y4ba zwdg0%$sh3LudVc^3FvMq?uWJ~`&#(oY0*U!YpO&1WGxH3w4W#1Ipnnai5U7n^R&b}(#$*OJo_}=qS$9zzgpc+N#aZBC zMxb?Y_k4)92w@Ja*0z8WK#S)g)Na^-Z602a`VV zqz=CAF|q7a8Ka~aW5Bg2^HWG;4P@g_!=kAp-dd&vw++^mSVku+rq@CfJ^FCtCuLhH z*G)gz>1Z6pzP3?)1Hf|*qb~S;gJAg5M8~8$=8L%)jPir%zl;NT`adg_$}||A{qR21 zt+0P=b`VWsQ;aU^qnV}3Fxo%BsKkAq@ImJNOTugI+lw$pt(rCm-Y!f@+*gpYwQV6y zE%a5)SNin~3+sC(-~(JV634*3b+fT+$H5+1NGaK|acxSl{re7`9lrm9Ma*KDDSeb0 za}RS5Z|w2MxV>=Pa#dD_9%5+vC!QPx`_hRg-K6zOQsUHUI{tYc8Ys5jOvP@?>mkIa zg8Ld@jP_a5bLr_)i1vo@BfHLpstzW{<3*>M*?rlC&m&p{A99~mQd-HjCl78CX+8sF zR@F7t@vDv0iJK|N3rN|fCll>%8tz(=d@0AQg3rw04UCdX4KQuL^79&)Qf`={*v*QV zA(b!Ip=n_n9bukKyCFa#8Un8a6pQhxx9|sB67xTrdz9A?QU(R@Z7lm1E`!J3mAr0L znTQ}V7-p+3%-=eUn>%nO(z~91?vxohlhhxbpNLLr8hN@=^LVQTlN!MkmgM}t0ilYF z8!0g?5XBB6pryWKM;k74ejmQ&UxDjY4T$Eq%TKxk@nMm&}0UIF2QN%ED!XAzmSfgaxgy?(dNCc4fe-tEmF#y5cVwASMf0*^a?JIH!7U4%-;RTzwyk5JY2 zPz8$D2Tkar5gsArQ-sYr+4N1zZ~=Ak|GQ5E6AJ*?ZsKw(k1nsAHv2v|+*mS(5H2}N zWMgDv&98pi-oHfX{L~U0&7EFG($}rb&n|T1Bt@$~Fh~v1Z&br5@p!VYkV|DKO`?#1 zkfP}p6GMG7sNkulEU}Q951xM}Ynb;$+a?HE@5h@CwngB}Fc%av431Js<*KWw$-io_ z|6uj7k0CQqR7G%(siiLQ<~6%b>U#^b!If8lAJdVV21#Cfs(K<-e0%|~G^=Hd%hZu* z9cZ}J`3!C!=JE4#{6+P*EyxrlS_WtBJNe40}xWP@|QmO#}$73Qvy#fbqrCo4PYrmqLIS@SnD!Yk`D zfWJ_WGxFUB!&pudtv=@+@x?g6pRb?e5{QL`*lBuaLq?xQrmQ-?-}(!JNLA;vSQ(&O zqe~b*J$cVm-pDKDA)8~a-P+yj$(jFA(8Y5^QhHD0{RdsL7;OUE>R^8)bKw>-vU-tG zC%@lA8K$hv(TN+UP@n?~BQ^&oWx?~3+&wL%CNZz%^~`9xe=*0&Dk@{`a~jgN#Zfpih1HUG(Ns z`C%M(D3=<3LSBfh21XDD3iHF_t3SnqX|u}EI3-qaq9}=_pJTidxhsFvC)I9#XnQo? zC{2O&C0+Jw3<+sH-CU=}BkT_()X}1?s95H=5(V*jgWBbp9R~$T$(!y`*otJhlrOhk z)QSg;TjX?OT8!4!SsTjpyU||6eArdUw&H*Mc}TwQWYQY&U-Sj=s?+~YUr1L=wnx*| zSk=%OI!_^b2FT}a>;_M0w{yD|gW0-24h3K;!L%VN)H)Q&rz*6Yok?3eo*BP+w8fJl za@QIpGy3m=;Q-bDz$l()nl!)YR<13o2}EKQkAElCTO96W=q#5lc^&n)Kh{v>(U=$) zQrfrGj(PQ7L`<+?9?$mU=BZ+5DZ>j}f!o?+h!1M6+D^BL3qHUHcKAdblk2#x)X##Q5N1qkU>yUy;-L)vc0@0$ z-fQCDRz;S|DaoO<4WJ>ZawJ{&hNj-BJJEu**WJla~7T zUfp>OzKTFv3=0J(M0YgTgx5CceNaRLPh{={X7pP*N^{|`Eh6GJ22Nlbb*49MStJGc zKQxa0A(&AB;4bTYd+|WM42O_noVoiLs}(^Aj!V z1OT7gIGo}=eXTpX&`zTBdB!$v@#HbqE&&rZ?y^5o%MW|(=+lPM=t=r6w(rapjPhhc z;}^$b$#0plNWX=_8(^Cu7F)=SZ}mcrRGPQhilW{(dXxyWh<-diBBiDRiW!;{#uIC&iNLcfv{sTY;wJf zSbL#7Fty3pN7IMT`Wyr$n;2t3m>b(9n5qME1X~UEM4(uQ&p<>y4F&IZCk*#7C zPn?bu{w+GMG=eH;9Z1>qXv_wwtNkva>ayK&y}fsLW@d{-dLht+ehSRGiDG>$j*zK4 zd}s7zfuR@4gCIT?V#Wv$)#ngvul&?FQQ?9QY=!D6eUDpDg@9vM|oY>~N^ z&w?x#f1y@$L^jXPxDYHV!)uNv3uf)Mm%i8sOEPH+kH=s#W9Si#w%M=4LQ1!8KHkR~ zY;=kOzln1HahHDI8Hr0PvMKihS-3lRn{tQ}?r4%6v*pwyd(%=C{k_>$?)mWINRXjk zqn_-)_=Sw?h;AEW_msZ&GYY_zJRw??ysG6;xGmvCa#GoXn`2Y9#p|Dpudw+G`r}l{ z08+$O_U3qev#k00<@P7pyUXI^7<&p)HfXW3qjL4Y%bCMSOr5&_3nc=y^&d*a&Ps?1 zlAg^$w%F7C^SEg_)}J=bQxMzrzR1S#+C%1aHCkV|TEK~wn_MD-%=gJhjRz_0p%OFd zuJyVg3yZGtE25aPI*JZPh_(kA{8PKLH#NjC|!HD{eTrs4&=Vz=rn}NI_XD- z>tY*Dyjm3Jb?C5k7`SA=W)9~h;|-`H)pe|^kQeX?I$Lb{F5S*+N^)cgl*_8SPO(r- z`fy?x3$FK)ibk*PQ8#N>;^iPqo>$ysU7lFR9!6vuWd)M1OvOBlDWTxL9Ex z1@xCKdtKb^gpnD^4@A5K>uEjdG%cgrnkdumbIq{W$DSNRm4vMG#g~^}9d9bi6YhO1 znQk)Qt80s}webY&2|t~ki7jin%H4WlR<3nO6|bux&u_ReVBVcuKxyEmh&v%l>ea7Q zGgYdkbC4+w7+#B7Zb~i&eFNK=oOZ9Fd!v!)?~B|@+7RZyn{gsCu81EW5m8(eiah_i z67P0oB4J&<)%W;!{LIeDM&Hwql|K_T@ZQ2`JW=2co-jzPLLuW9I?m=pJykc)p*4yF+o2- z-o)R~GT$DJ@k|f-?KD{U3(Ut<;p_e>zuWr35z@0enk#E^3(xh`jX68U#*HiTx;j2= z)7Qz~J?f`HTU-+p`Jn6Aj{Fh7+aIK;Jnb5SDGr+>2QosBgmB1Q7S8?Dl5KAtjR89^ zeEs$a%Y1$n>O;JMe7Iu);~k;CO#I|)$B|TsEUmMv6C-{pgGplh-0b}A9a&k$Pmxbh zP=3vJwNRn7IIx#KACJmnMMOsntIUilf+l8n_nz}Ry{Mvi(wu<`=Z7z8f?g=VR5*X` zNm2Nfw?`u{>AM~Va_bgx>=L_u>!*hO4GY3?-nRaO%SqVxrU{;m>THpU;w75R1z!?s zsKCX@fD}ga>2qm4hypzy=t}!t<95@K=rO%9Gw2b~^vtkk-ZY<+wbeF?5US6i@3@v) z@k?4D?H|WEE@H9=L3117gT!?W;KKFzS4cl=W66@X#$|s__JMBP=h2VZ0wp6Q29fr; zUjDwfa4nC2?wUeQyfO;no5>F%&(NEfrFwWOE-_N5Pty+YeGVZU=HLw8aiJgQJLt%> z@8dn3{nIVVwWHS;Wuxhcb;%eCqhCr0@$44KnR?Q$utT&m5q;z*zZL#m{VQ$!pT`(7 zL`8n`;c)%ym4wJGr5nEE=RlZo{qvtFw(yr9E2mczZw2gX{74Ms$j;AWfGr6@cG8ZW zbB`*8RNOB1((VibxUVl`Z;vuBYf?Kt>Gt}a_-+IPZeb0hAy~WCitqZAfD?h-Ft6@ zWHZMI4{xE~_D0CpfztLt<#Qg=gmtd7gUQRiQ0yuJ;AjLrVqve2O9&z)O5Rgq8UjDVMdl z=1&b(>Ep-#&sfgm6Ye9UGSVyjTy}~#i@k@eG<QA?Pn7UQ@G>) zcVd^2>wlh1)`Jq&vsBoB?ND;nySF|{bBe3>V%gyF)98JXXcwPUt5Y@I%zeN`0U;{O zrchD4n)c?3NXlyAaIwDM`r4D+n8L!dPK^c4v)R=WyQzkkFL?!eZ^!pyl9F3p3d9ls z#oU>j@Zk<>J*Uk26I7W7{MpJXP(VP8+x@rGHI#{A890{Xt>NyURcz4kY98+r6Wh4i zm8|!5`siy9H}Qe##*}^qiXT|CDT{@gHVu;90s7AO3=Mc!yUdW!M-xGk_yiyK@pbKXjOv$}r+w*snwGXfBgP7ur+b^=J&~lX8BT55OHGF* z;8mmEnkL=t#MT9Vi(QSrkKIm-fljVX;(cUNS*utmmh5HwOzSUghj$lPvPhQ~SKsT) zS;1FcD#}+~F*3Rl_*I$D9^JJbGK9D-1PtCRWm~3X&fwqnPfZnFUvInV?`5VQeGM!z zF_yV?TEP;TtxM^5^}F!M5m>z8v&=bNv~kkVGX#0AN9Y{d-SM#^ z($hyk%FxH5+%u~~qlCz@4tt8+pMt5!MA)j02L4MnElMh71?}oYgZI(KS3^(I`p95nVTYe~W|#bvWbWa2J{;n+!ouxKXjwjJjFxsO zP-}}2c?Gl`5UaH&;nt^5J?J5a_}D`66@R+@Pxe5Q)&C07Z@cgBd=9&p`S{Jc(rr4G zQmh#cF_AdrsS1L3Pa6KzyKMt+i~;=!CsSy%wr6K5KvO65IyjqO5^HW4F?M7xy z$?dal3bU+;-(iwH{ZlKtI} zgcoT^x#Yf1XDgChx5UDiL~NU>BEoyeA1{x8MCBx9AzQOg+}ac7mBcnhClQX;k*4|P|EB+P(V`#pztYv!tdbzN`PxGC<^c+ z@}#oudgob0e6qqaeEH6Y2>bj9s$B+xnu(g{<<@LJGFWc$%w%U&Ib62|RszGd2e-vt zc6Ix|XU^Jiu*=~OW>exSp-wUAdI@oJHfYB+x_zfO!wKF5QNW<;su&qzDmd7vkl3k-pb#;hVHy>obk3YR=_>IKcWevcJg_IqXZwrtGm?`VJVFxs_loA+0*f3gG&Ji1LOc}W!_ zGMQa$_KAHw1i%mt`K8S6?DyP{ljr#<$#zQn{M=Uboki^65=bc2S z^5yaX81#|{vfNb|+^e8}Y=UuOy_q_B!rD0drz=50GTB*kUmZId6)F&{$9%MTm#>OF zpM4;36V;qXz##5g5%(v!4W4EfQSO-SwKe_o64Y|4axn^>71TP2D>>F@2VnXOUk*iP z=RN&w6N?+Xg2p{fi&j?w{K@y3k3 z#Z3F``I|now0086>76!KH@a+RHS z`wOnaDsB1i1kXl5>_%=kYj$#b`&B88C(a4oOCP4R_n+l{$*Q`uuUM0B=XV+%-2AmE z(g-{qW-FE-`=~}I0G}p{kcAw2-)0;7+k4p>*UC@|Rk?nL_O2!Fv2N9P>-l)WOGg<@ zvH@8L$07gA5S_2IRgM^X2Jw0%BU68xV8#1=+}ou$7j_h8Z-R}B`5TWY?4qq_2!N|)*JK|GHkYCXc|&ha^}w46D* zdV;0k%Zr$58!e@{+&M>3iN7{Z-i-oB*GULU(q7dk=@@fDyN2 zgFZXaGJZja|Cs6h4?wXS4A^i98_>3`kVomo3 zJv5bmEJBWgp0?nH%R5fKVfRP8_uEm=(j)VVREcRIP7Ta9WyD*xS63A~DIy8_*-~Xn zOBQC@ZPyPe$?r*IU+zb{sm7zB%2x_LVJ1Cg@GjK*RqqWY;#+tVKi?G}zq=y>X=QS} zuLyHv7vsOnZs?DQ_Jn<4`52<$yK@2QRm3p*ec|%9HFFn5QZw};TZD^?82bgESk8>| zTYS?`m+gV`-_cv{r9^{XYh+E5d~y*FTF}0QkNWtGAy_Yt6a`z|bF42EZ?Q%$y}MR# zPc093?zQi1S;mE};D5{;u=a~Inw&dMX26#7Z7V!<)*M(eXSH}-+aQ(uV;6cAMNze@ z<=O6^IbFBGIN}yZmD<-9(P{SStsb*ku_9&J%`ix^c5jR@x6fIHd}WEqQfW{hkDwQNj)J_8GNwps3TmBJ9%%_j+r!v4*WH(D6G z#8w@U%6&c0pts0o-MKC5UNo-zw(Emg8Bq$-g5l1C=|5xm8S|~2cYGN8%Fh zVPS(J%u*sC$HRiUdao7AVS{HC^Yw41@}Kw32HWiTdaT&%H>B>v4$qz;UqVF^WMa}b zp4TTXQY7qY-h-j1L94>;S~%co)Y?ZvW94!D)10p?Y->!KO{h&%bM`g=fY02aKMmPIiBe6P3i>^R*HfFa`-#ZED0DW3UFV2{I#Vr*oO`fC(vu}`WQS|AwS8pr*Wh$*41 zX)ejg=;&&z6kvU?<~rsok9m3jtt0%DBN4YgOP2_ zUFlTBE$(A#x}JW?2K!j*ze!HKX4;_2*Il!z-D4*$6K!8+E9YDkPL_*fYW=pKjX%OX zYq3B+izvwHO+~r>%vjL%wV(C;UBM2Q$2YG?Ja=pn?^?T_i8Rg1Q!C?a^7dTXrqSmZ zCb%Lb+wO;0v6Y^DSY#sk?I$mky`8r@^W?3*2@&2Eaw+@YCEk}bF>yq4Ejr>lBMn|@ zTYE>A>pgy?@Uh;6>h=kC;$YqNdEAMKOv^hVdbW^Fk?a;-xoo8TI~vKAxT_2G>}|p3 zqW{jBu!lL5QK;Re7P`FT+cC=bk}2^CIrem7nqMMO_-Xz{-l)v|?%ck11J&NvN7`(t z*8FW>KpMb}$=T4h!`CO^iH_bn4$N6|D-RV+P=)|BtuIUNuQN@9po}W8T}36rdtGIi zki%YK2p0J?ezIWGCn(tU8T=37a^WfBI{IAugyitFWt-W&1Ruyg*)A;Q=x=;Hexgv( z-Q{;%dTO7#thzduis`Pba&NdA?oZ(cxze->rHp@U2livl0Oh{VA|bghsg!;l&iwkU zxij{}EA%F3Lmwe8N(6hfM&oN%YtOL8wUqkpEQ^HbP43yrH=fl%!Wh}3wf<&-R=A;?Xc?VH-x-C`z2q{iR7MNYlf$^0r1}Xk`1(dz@0D~zj5Enl^4en4r+v{vF7fa zxtnyH9^WMH_Us6+7?QqC0uxsHC<-RnyrOIh5@Y^~2>$@<^0XQi>1H-Ux4twBf!Uei zGAih3{yw)XdYh-Bk-#wDMK#;nNis-n9C)6wd>ioCGGnhav+jbC_rKRat8+IGQ?T49 z57PzjC%gea-!*3&u8zng&y5g$+Z5lO?(0RIn4ka{g-;ngx#WlQCCLw{YkJ+{D`r`Dw~YoSY@ec8R~wAo7EDIG!5P`aC} zy38|wIh)3hMx{{0QM0nIUwp~T>EG`%1D-QAK2aP=M6ZTZ*j`~ZLQoIav(tFASL8B^tz6++&aQ_FqFjBAgtq8Z!M1xjJJWvo`iIK|&tmyLc_Z=I&9Gl$c5`h|Xtlh|@>J<%! z0l5%TmcBC2;NwAd^6T_}wRfdKO=Vd)5fK#$I@*A2Y1$>WHnItEAnaSYk=7VQ6vC=D z2w@k2EF~%+0xdQovV=tz*%Aa4!V(1m5zx@=C=x+PSOk$kFp^~M1G{Q!rn+k8$5eGq z`F^BQ$$j^pd)|5H-gm$IzAw4tmoen~!45VDUQI%ph@CG z_qeJmx;OP|rTi-6Kjqhbb}TP_ZcBZ-p_cqc$*vmf&CAcX=8vj;Aw=I5=2tb~I_bpf zfcf7SUbI@)Mc-)0(VWgHvkErKeP`^OK3umeb;8*ER9f4%)AOdUAL}KzRaAz4PPhfX z5!jrf(!DHDz@as!j*j#m?Yg7e-tI=v6A|AWmJ%ggmkz|nD(z7(IA{;#d{_%y0tBd(PW)hElJ&D?0g(Wb_$L#Zl^5 z+Ok3!hgNngu!~al+hwjM#qzhOqH9etlF+UL6%7IElTG5Vrs933TYQ#QQz$Ml6YA@;-R zDnjlvEz;IL7G?SbDRoV6p!<%|w_k8qSsIzS`w~4B(05S#h-5+KiN01?l;_3UTT8{C zKb8694JGf@jaP5gPN|-J!EK>yQ>q>`JbNQKK&yV5K*%$gjY`b8oOpbAHnec?^F9^V<(fU zo6Al*x&fMpT6UdYGW?X%ayDbk${m$+b!>@4e+NyK%@Tmb*b_RQ(9n~lTHHOPZ?IFN z743|yoj{ACq7QANnq`R`~3z2h$BIXfe- zd#6etT(`;fOqa~nq{24xtt2_6nTbT^)rHUUgDSbq8qsr?`kW=SUE_dNu=xkHrjQfL z=S@l8#MOGbY7dSFDtxDBPV>smU>upw8cp7vK=2YD5%eVzYi)uai0fHfm~=Q;KW%&z53=G3uRFVZjGt8s69{r*2Te% zBJ$!!-m&iI)(;zLsv3PiiHf@pe1i%FI1WP#~}Sv>}*&@!S8Eu zaa~}%3Ui+s6iga%GGoWI&J6F6>bsM2tp8DxbFsCWOev@GF_#H;y!pD!TU<9v6*aq$ zFg?=sx>|XsxPdIgVeOQKvkvb^#>$9~aDphoC+0J~!)`o3?v11Kft;1csN0>~Irp0F zo3~{8{E5~=&^7Xr`3+}o`NRiXbR8#1`hSkhk?p%k-j?)II6PA2f!BaRpyi$F+ zl1m@>q(Bs%43DK|P>s4xFYJM1x4dpdKW|Lt1&D^ES^l&4?*0TSd2+kY*C-f#fevcM ze@1Pv__VPa314h{G2inTON&YfTMpHBfsWxY}cy>I|8 z)Bw=S7P$<684t0V@x7q;FX9AbwBJr79U0Et60ulA-^3u3Z4Cx!$Fh0e1m3(g?Vymh z5X-SLwH9y!GW(ig_MZ@(A3nyq~gA9%Mtb()l z`1x2Hff*s{ok7@cVJ-VSSFr?Oa*u4#0NH|&yS6N;Rk#ZA_8hGLw%CLAfnI|}MC=~B zw|BO5|7@u^V_XA{?J=`6l{_te7uipYs=m26;i48gIyFY92*OQyu(q};O)+FQ=6fjSsU!%^6ti=A2*vwZE!k}@JosE645bXZl9`$rvkF=N z72x2Y95U(4&%wc~taD~s7K(wq21I8gz)SyC^aI!qt6T5X4mz!`5D}dLt+V@DEq*An zrUEP!9ASzjcMyL3s?aNd!f%Kg@3#2i^c?sRG8c=ATqc-8u1teCKV*22G(7W59!Dk^rdlzS>CtD499=dy^_q z?bC9u3 zVR;l*Lt?-x3i;%v$RQwkKZh}J5pc@oWUH(NQ?#L${#Sbr0r~Lwefh(2XRHfaR$xb1 z$YD|JVsnWb8YP2>8s%`V zAkxGaZEY0*ZJs#wtA`F1FbzAT>gI=TWyz*A;}51A5>Cm(>K;*nuCk;(SxsJo-fd?1a=G!f^5(^SjqYyJ_2>6~SnF?8JXxYCNO1n=oIMCmVYr&WV zW1yg{OEq4}=F=M>)fX46ANYiYUxCK?Z>In!eDfF-5P#i(0WmI-WVxQZ2CQ5MO|qn5 XSoBXin71`oA;S+@+gX)bxLo-MSr5xX literal 26374 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 diff --git a/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png b/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..b874f27a71c9b69c302ad538d0508d5f57b5f83e GIT binary patch literal 405018 zcmce-WmuG7)HkZ4peP{HEz;f6-QC^YAk7Rd0s_+AIdmx9ASvApIdpgT%scRZp7Z&f z5AV6Z%)ahx$J(pcZ_OR5q#%icjF0@{#S0W^DKV87FJ6Cy5AEN+g1@;UGqZj1;^PZx zF<~{&^n)cNJ&bJ_(uw3?z&i@zx1R~n)s&Q#gmc`~_Lh}vO^DRXdYXFFvGyz6rqye0 zGTxM1iPKf++-lR?^aLPdBk4qw%94M6NAWUp9!6pO`k%0W_U9}Qz}({Ik#nP0EZ?%F z&Exg5-)s26FJ1^GZ2dRzfAb3Q`HfMK|I6or7OBzm?+cB$xX%NBtpD3nbe?FD!NkJi z_dMZ}O@3tMr4nrS7kacE7wPNB1_g9vs}^1zx16IEGc596TJNQbVvHZO!=PmFWkOQk z-V@~n9?e`H8|MH9lnd^3?ALo)YN=TnkF1&xiW^HinMX^y3pz2WU9F|)5qjs8M;5sk zD-6nV5`XiygXUuWqyvpReER+*)S1#{ysLO1{z{np?)mO!YiplWR42Tyov2Q>^ibc| z*RVQH_7+g`u0p1z4=VtwiHfl6%b+0!7kqldscwWUhbc=hRCZxM;JB@An}H z_V)HjeRZ$#gC)_jm}C4^w^64ZLcI!U_6%JTpgxy|BZ3nmO)oh%HKo&lk}&-d-r=+u zoyHhyA3raZyg^=0TZu0?brrG{2*K)aUicR%P!DaL1772Q*m7s)6whFAI%XGtv^97c z2N+4fZ1KER?25w19g=1(9Qg4yU&A--BS&wn`&PP#p_laD3;Eb5s=QJ%CMSflgaVC? zW?6Y<%O6mVG75_Ph7(x0$L;>)|1LuRfjp4>HYAi#XRp?3rp!Ve#9Pjg;y#L*cK z>=2kQeP`WbV0Qc=Gr>1Z5J+EUI6eQi9D;h2M z>EiM(o(tF0t|v8O!1hQC+M@^R6iJ;53{PUr;<+XO=(Yea4a@IQIN65?#y$C^-)D{> zDI5dciZ8Y=jy~&_h%-1Z52w|#ZhIS*PNu3(?0%ofWa)t}qs$nlf}Ohy@r4U5;UvnZ z9`I%jZrUw3NNiSL9|^X2b9f&O(a>h;w;J!G-6P20M7$0j=j%U(t-2~04NnY$oZAAPxzE7_HES4qv7dPC=oBKKwKVw4-pk3rSC$`joY03iuq*3Sw-`t(C~)4| zs;zvcgW~hDZ|Q&t>$Hn5Hjbh-^R9A@w!VsEmPKd9Ja_g;eQLTUW*aK~x$@a-`Rg>f z-i~%;8@_UP)bvZ*L5lk7*Pg~;WOQ-3=``iXSxmZo8?KniZP>)M($%J(KAjY<3H4r6 z3rg5^8sD(LzB!(43zVR)WT~YaIdWWXn9nvJ9B8IDWRJQnZGi%M-&>-MZf%B~3j+B( z$HO?p#j85(&bA??iHv2tpZOwQsS;-yYKfmnTP_K-Y-yE+G1SnvwxutE#S$G0tFtI9 z*wWNyWx|z?Q*{Yk%%ec>l6(bjbnQ-+DjDSX1@Htr8z~k_!$@EchtM751got;i{amN z+^oD?hs=&8GjVKci5N-z5JAm$5%TQPW-}|7q++@; z@WsK)EUzeS*ySeg+YI>pu6{V>9f^r6X03x~MrOQ*s5Z(~m9*T{v3hJdjjk>P#0UNgp=UU)yN<0- zya-TeygR@4+bWuE4qD$3U(fLTq0oxu%JNlrbmH)_Vyn^5u=$kwLGGt%n&N?$L4xIy zd9pRlc2?0Mt8!R(`_^k@7-+Z*>~EL|KX5jX_gs}p`U2_ z<@W(D!~S{wtf>OlSE7eDK6pQ9OOMbwvnS}K%}>l}NWvht_Md{E!eDkHG_G`$Sr&y9?ArJoIwGY&1#`fI?(uM^3HZ^~0dhua&vSlCRiL z(PX`C`O&mHLn>5lei$@3L?ck4pCOI%7)NCco%YhKcO4E8m4tX4vv}Q+Z#lI?j&Bt* zZk9$`QK*c@Ryam0E7Fj9*lhJ#2n%X6o&T*@$JXcM!6ixkD6eI`?7j$+D^kk8F|nReQ7okZV5OW_ns^rwvOKo< zbA5xXwm=;IJqJY_nnytIAfd9baZaR)vmX_jY%QxZRx>aoC_YmpHQT&+&=2;nucgNCtZF5HXoJ$uSrH?BWM@ycM9} z817vB)nc96y5*|OAsTKWe(U)5SAvGG$Y2%wx-_2F9D$nWo%xJ9!YZ1ppt}(fecL3WV(00%CB7@KXWM}Yy{Jy90n||t1IK0bA$}NZ-=3O!vA{f;@E>&&g*IkZM|z} zyRz5ObV`q}aU}~NEfm+jKi_6zm6!h|i&I`spW@eiO{~;p(GlX)Pmnuj3zaF?sl|2= z6o8JUs#c6;0>Rz>t(aMqRRin{3^Hx+`erAD_LVmr+vnpd5O4hqZ^&AIuAAi+ikJMO znPkd?;oy_eb-}3aqP`UxlcmUfu%0hBwS-bm-&PWD#r+#iG-#meFWz-fV?)K@OHgag zFWl0HNjfTxYJ^BTkp$1=Q*%`?Z(l0B4S2Ss zOGs^Z&e#|X%(ho{%j|X+maywnTuO_by@UKJTRVeHa@sp+sV2+e9BHqFsuLrJ z=GH*fQ;#@TR}Zmyc*F!Ur#n`&G?Q?#`= zPyiTUy}_}T@;v>BVB%OAkob?hz8Fgd8khR<$8|I)Hj%3mR5|KrFY*Mnf}W0p(H+Rn_MlWU6N7k-Q{hIsvwD8qx*Wa@_I@kw8gUy zBw%v@sk*TNZ0f` z1_6LyR5bgqEu=-8jiU+-Tbzdtmxs~S683s?=kX3oNs7bFq(T#gt)ZEdP0QWtkF=|@?DU)jx{mVxME!1CT#D+P=*~{H<@;Vq3X0SJg?-(~j zKGeORElUuKYY{yjm`nKE*M^dj=#@8pTgf~BD z$~EqHHnOF}8tLmZVq`_ve{Zy=EAYI}i-G7Iw{&gjr5w-$7#j3O6z|;~V8*Z;={C6_ ze_PJ&JZN*uB3p6wHLxi-Sf8O)SZ-x|Jdan^M;QNuy#6^nn~(7;MaK2C7f2O&%=)4t zw%rSZ4kn11q8-C*2jlcZvH8|Lv_0as!a#Cwx|-FrHMCN{T_&ycF(5_W7B%sTX*c-s z&7~*T&<`lct|zCYxZ*SCsS2hlxvG#Wi=J<#e!zA9WEgo3ll)p{<5J$%`pZ%a`_Wqg zx6Desdy0AEw_)qnupCN_&V%j+pJJn;_<1Htvf;hb{&v=AkwD-(Lc2j#=Lc2 zT&;^opqRxTP;55a<}|!BTKMZSrOj^}JHWUnGF^_;0V@KmtEmFX(Y zpwkl*B_^my5<0-%)_XK+1_o18GBQ!o34a#6nMpyUmORkqeD0f|I`_KfHeE$_`$enD z<7>j%Mlh`U4;k6<(y|}(Nk_%86hdB%ZAJNIWdg5UKc zN^9&KvDrcqw-YDm5_SBSMLB)r5kne!o$@bNh*RBq-Or>p%-?obVhzme8Q%I8nn-|k ziWva{7{6Z(i8xk4b)CZm`Rfl)HAD0x>o&%*^p-_AD#XH#MHxUKYlSOuOkG*;VtZ+X8xmxQp> z$)aE$g1?)4B`qy{^=4lY-AuUihTb3Xr*hII1@7}&b+5OZCV=BnfrDHveub9H24Q`{ zh=^Ww_7C3k>9uKfEK!^l+T(d%niD5ekE#1g$5QWS&sJ|)+TRd*D+%CwV*7sc^X&hg zxb!oWKkLEu=B2LEDfL-Y7(-IL;V)LdQOjPhAL>0^-)$eq&lpHz_Lv>RtX_jSin4a0 zj99F*^I5)j8Z)F@%MWf|6BvB7jvB-a`q0N}w|;tcWOgEN9`588%Kz*SUhNC0#;wdO zi36zB>m3-AMqr(rQ$^v0l!uE|nYX3CV~g#T8c#j;HCg;#5A1JE+}Kg=J9 zIDe0fxlTi4`aPu2qq2vB+}+ix5Og_X@u#NL#&WvTIC}Kp`h1RaYz;RJx271FcE*qt z29=h-Yc}iLUJLwKkoi|nQJu{<6*Fea^4*_C8wW!DV7LL5m<*P^Gu+! zyUQ&_@|YfcwK-;YoDtEbGraU3#RPW`w9RiR@Nv}9sK@(qGp{(`y6I>Hwx^zEhVH%n zd_MEXUzcMxli5+y#RnJam6?u{Ue+6IvinU!~xyr`#u9VMNA2j z^8tt^Rh4_a`dx@rY?xB-e}L z*J!eM7<`a+faE&6{U{60$!^Pg^Zh9{3sno?-;jz=yZ!5GRvsU)-;|v$$_Y;?4*jAh zEd{)4^NK>`Fd0otc+e`3aVZ&V(RSI2-8;Xvaaf-3o$tt|0O(q^PNwIH{<$_A!>!$; zi!VwQYHCWd5@vA>VYlPPi(O{@NATkq^{HYt2`iFbqEF|IyCqAVoS!do-uEtgZ*N~B zk>q-p|8C>36Fi+04rREzzEt2Uvz9CBu-%z{k0K%(RRmxCB0I-=rYwats&sDmy+O4{ z>L5g<+9KkHq2=q7OtOpNaFQgg56ZB=u)a!Y&6~m?9^EyFb=V!a1rvh-cYoi;)<$Ro zEE@vslY%X!BJ%YDBhH3nd9t{l+?&it~YTu%zxkG?c zdgkipT2osCv4*r5U!WK%1ajjM$%J#Wkoo9ukuMjEwudtT_c(j8wVLLKDKE>ME@P@J z;0O^3T%1<(pVqP{C@8LX_k~yF-@|LRx`^7^2vF+6I%Ha3z7n8%Wp6bzVaO((SG8%n z-soea?UV11{{#|F%+Cx=3_Lh%aRqSF9z@xGtxMz>Xk3F#D>J~cur2WD?{I)AerRoV z2OL%y z^nPzO5I2XJmv`SBj`#%Jfxl0F?>~M2I+%!;d&*qT??1SZ)0`6n6!JwZDienfXpqcs zo5YTbxadKsYt5Uy<9*b7Y}`ef>i?wd@JA~L4PFq#pWn|29(+*tA1?Ib1>Lh7fAOLN z`Kf09|6_^_9Y0;t^HDE~a;HT~@t$e)FDTKjK0_C-UgH=xgV`2Lmp!W}oFxSn>~_?V z*qEoURJ+DnxSLp>*9u7woful91lmcL0@(iN4oYS00@M}8R>9cP&vWE;t7Vjhck$V! zpP=a%FR)~6ag}H7jW;)fLR$FEzk*68_+m zN4)tkn)P^Y8IySZnar@%PdA@G&4;w->bsrKwX-gVBe>_rZw6+;2jHeC>nN@~op9G- z$cGKJNj|U7emS95xUHGH=yzUQGr!A0rshHyxRoy+ev7bNzBo`6^s=vZxOr(fwf^() z4MXkCMtgda_^tT=9R4|Cnt6lmCBpdHU?jLKNP~@NVzK{M*qBX$%{Ow&CeH*>19Lu% z`;6g7Rcr?KYRcy$$5yQDw5G9v$2Ff}p=OOR*jYG~?sH}CwAmZh4E(<>y^1O z!*|N zbYe(OSO}74ePG3(lknIb^C&mmVnBF9yzm;X_Os9-O&nicE%QfCAo+s8LDWM6g( zzUgd0$-Lqf|D!FZxbLW!;X=pLuSjvIs69)qQCs#XA1$qxc{0pb=)XC_)`^B%%S3OX z@-htnKF7y#Pv_nZ*JKRU>cCq#@W<*ST=AbrFCr4QB>uA-|9aH_+uf4q1OsmC|1a7y z3D^O5PWvNEFzBP=>1^l3V&_nN$HJO;!O1qpUr;zPzkV~f2kFxt9LZjPs3lF1fra^M zHO>AB>}o#V)0nlGxM*yIJ>+>lOh3#`Qr>>_)HOp6()BoC)ZcLCdM0GWUcLRLXyMUu zl0~XvAS{C)G6Kn*YM-jvHWkRyYx3L@GunbN4gqTw7brc!Oc@jm^| zosY_D(DJt3snnIlF!foc*)o>|%yL-=qBvOUT6iQ`o?8z=6FsY{*diKVLCTK#h+Lbo_ zjg4Sv_g+`UXUtW?*6|34g$j`_7ILJHN%!@zvXmH8~xYo{jlri#^9bCnz%VE@l2=8}Ia8C@9=2 zO08B=fOy~mU4C#d&UI%?xoNgcn@xpN?saa9)`cVb)1Lmc9Od38=rDd8hsJH8j(XiT zCVhHZT9KiXyVko(J!l)?yb1AxiBrYd+F%m*v%4wzA;-+Fhei83mD5pSkCInX%>n&6 zhCTHygt{*Gru_!OhtsAxj6Na<4TS$uTvvC%Cb+-Yzf60}TUblltw`II zG1KV+hKF@MoW)F@?esvYEBj@a1Q4SLDfYpc!HckRXC#%m)E*>Qp{AM)))i#zc3nbsPat-#+ z|9Sb*Fq!D>X!iSTWViA5zXFa8ZbYF!!xluFOF+TH%iD#1dQFHiIp2qiF~-9j}|8;B}VY+bQ87Wu-U4!TB0j#9aTT$m1C! zp=LL{I>+&dAJ!HZgJd;{=j+3t*eXPExfnuvA~~TXf4As0mwWpf|F@iRqj1Cr6cnl9 zrO03Nb+(SXCW`e({CdskaJ0tSrGd`RAPY8q#;{!RtiVddHn{l6NFjCcUqR5TsH|=a z^Q6r}eqd8fsuiX_D=XWe4Q?uj?Tkv6qRo!+&^is zo@Yaq3_3Mh_m?WqY9aHHnH>|}Kq(o&!YCYtuAgOv^&v7`M%^5fI%ec=RJn_}*Og17 zu#A5noziA<5d+yK+kj9}53!=)+(`rbth3o`>{i2_h_s9XV|7$udl#mkP1KLz==;(vD@RL;zDCKrd_$lffM870YawO z=RKr01^eDC%r+Ea%^c4+#V}mA+l_k@5oPFm4Z4lN46Sg_ z!G%eE6K~EB4i}yRlz#!T4{ixgR`d<|mG!}7pkfc7?R&se^j5YP=cE%L+>gFJcd!15& ztX6$;OV^Lzlgloj6VTcskf8+FoMI9%Y%xNO8Ggrb>u_HYUvq&>fLk-QT;s0Z-qtGR z)*FexI#W*PA{k%$FWHB~t@+7J@o7+yK4kE^OZ^J{67g~Y^Pjq`g7ml=s!H{6{ho7C zefDi)1*6AlCxC7HmZp;MWEH7Ylb+>M5$}>Nfo>WZ)XTTs^>(&jHL0^g;n757`tsA$ z{p4S!l26OY*Z^?~6<*_K{zVXY*o>E)o&L7<2L$Q~6F(O`0EbKwR^yx}B|Ls|d;hfN z^~*q>Zlaer&S#sC&aqsQ9cd}Bb>7eExTMTi z_ouFj&^g%ZEynyK@Qk|HPwtCyrp`i5!n-Gic*=5=`x}F2BE&Fs>ZY5`O)l5H#+lDS3(b38I zEy))L+Pb{FbrHwQ(6}ijlLVV#PIfBq(HtZn6VlUjAJn`>)=m@!@2z2IjJVb~@Dm|6Rg^~zi0#{v*Csw+7l8*T>1Mv`0M4zCxu@PZ!&|g2gTu0X+V)dt7>g1R7ey7;T6jC zWg0ptDuah>hC)Xv8ZG*==bE_4{Co8o`Cp3CqSkikB+ zw2qFB)3sxq#FzGLqul`xs?i6XrrypW*YhSvc)a`SICJ6U$KT+#U19PFzl_|_Z$GdG zyd@V9EOxx)fuggQgxY?;b84FV&Lj7ThDqZm$&&yeltso$hv4@(_h^q|+&auGUwIzZ z9eR`=8C+c-uDy?^{{Fd`^BTXtN5gYkF)~tCMh^(iFZW5C=+mrRGCIO6uI5KVP4s%# z{PihZtuO@n=j8tL8qe|-=z+6&G&4bd%G}u4x7*Lr7DQ9(=GGYS(rYnczN&o)y?pMs z`N|24+;8FRzc(A}&aLT%BdM^PV=Wb=!FFwuyS{`8aZ2@z-A&e5|=*IzmjP4wbuH^UYVJYasSq?kPj*bcm&t+@4g26vMO*ZzH=O->I$Ki z1mZEVB7GoGZx*&#&!qfy23GJ6m@e=Oh3C#7=MwYWg+197knEUN|3at*tFK21P1hH48>4wh*`- zclM0^E?vdS#Ai9kVMGVMIFh9JRBdKx?f${I6N#}H=_~q==JPK zkX!)#DvsGVBa!VSB5r28TKnoUycAw%DI1Lb`1sdyl+71Bm4(e--S`7Yv(ljT-;kVN=HEmqO3x_m79+-I zJ1^x}!bcqSZEasJ=edU(w+mqOMVX&ni`j1#lz%OM1kU4R9p9cB1^v#Mg0}$Pwvh5K zHtF-ZtTht}5^~{c-D*?P7L9vvWbnG_t#N|Obv2)vUT53ngPUA}Fp37GtF*Q^ZT(i^ z*gd^`eY~iXXO*Kypi4k$_cOBLZn#QZe{d8XUOq7!FXFy(L`@phuCsz(3W^92adg7{ zVlft_)a?0VB3mCCnGT$H{*<}ezQ&KJ2zQn@!~fcI`)yeqE~s9KB3#zP_Mw5HlUuHP zJ1eto`t1U(O*U5eD=P3p$kOlLaA=*%udgQx_bTSNM*8(zyz|=)xD%o#UeMCjN97iU z{MZSJ`r;rd5E>yXBjcMIot%(S?pzHQOu0S%!fc)U-fT{xE@x&Y@e|jqWcK)tUKFkT z%}e%kKF5rzb*s^D;(A84tcw00cyFldNx%QU0e+=mT;`u9qD*>iWckvqvE&A=AAw~0 zxg&K!ow!@^(2`@-5`La-ZId$KF-Sp+DV3Z-fJIsjdN;KrFrh7d8fwkqh2*Nxw5C64 zt(-I}k-t$~J-IKfKb?i>Lcz2wt$EJA;mFLVT+XQ#_oJMv;ejo)bxlXH78W8xcby7MMu{e!_Qo1RplbA)#EPZY zmvD(r4U1=tEz1c9%Ew}LKhP;z}F89 z`l1B3Tk$);N|8K%)TI7)2#fP=)HiW7c-MZ%%t*8le1IsdJ*aof4N$gL=H8@Zeu(p?Kb)N z7eWMEI?>Bm!^NQLq+y*#SGMxCUp<`e_7_PX^f>EP=MTqRL`EGdzD~Cb*LihAX{!dM zB6K)=c&=o2xGRJeEin%Fu9RDlleUN4b4unpp$7w1Uc*1=imgqoLFF~Bx^r6RG=Q(v zKaN{ZWHm*vL`&RqoHO+Vyjd?Sz8_Qz;mu#3<8#c{Ze&d0Nh^MOCo52`0r$Q`jza_pDVWebfo%do<-~7)0>2 z7OE)ES}u|az>Z5TI5_7mH@qPyzli3!FTP#x%#Dj>L?O~B=qKFXTMs7Wce9RI>=W?(qO#H3(Dl`1_QbI5mMksfN@Nkhm3;g$lm) zdXlro5CqQq-k|8{M_smEKpXM&_HT+&)5)-~og17lCd#)EmLMEicM5l1E=TW6kDLjH zXpD<wE&g?5lNJ@2Z<4L%TDl5+0Nd zF`~H>(fIC(Ij^cTJn2^8k;qD&*y^J8`oig~hst>Yc!#R&islC(M>2%7yfw8P(&ifb zp!?I!G_jN8g=aN8KJyq}&dZPy3f+79tQBk8Ru{X`nGVsr?*P+z^~-T4x%QT;G*u@V zLG0}rCV@iFZtLUbY()@1^}v{$!ESvNwZ-Ti0!zO%3z{=cff5v8Lo7ewd=MKS?GqxB znRapSJ!OSPAcKqX$&-)z+bF!YV&A51i%c*WtXeMV5iO_NGBLk=M^~u^Nw)-#{EbnZ zlF*LQ0-A}l$L&fP^{!oox(@!M@70Se@Rdv&H2>G+W)ucY+ zrat{4c+HJ~e^8DVZNqAJsk*OH;}6R!H(*qUmMJivJmem!Cj}6JZ!(y(5tD3P8`LsU zmY(}XhPvE_jPg%;GDKCVr1yAdvtCF~14lUA5s|3_35x^VUYTS1G>7?vJO3@^L&`H<X!P zC6rrj^;_{Vu~y)Jd)02jSLvZY$U$vzKMwIJ8#GYU=+`J*(~`FP*LeSB>+j+FZQw%j z2t34cMm~DpWG2RKM}t&(<_1eBdj6n&VbtfU*=Xvz>CXcu`h2CnKQrVQkc^>7^Rn9} zjcg{OGGXmuRNdRA*#q zP83p&OYvI0;X=)5$&!y&R$_N=^xl}b+`H4>1<)X2^@J*r*ohuQOW zM9;{EEtVc>h%>_Ql4&$+YJRh>A2*MiqmsN4hb zz>`8bTpbn*BbnN$yX8rORwEVdwv@FwUaxtN16Q|YR9MHi>PDZk{X34n>LZh`Rw67+ zGp_7!(y;Cux8F z8s2onHp{TsiP+F-)lIyB4_lYuXL8dr4q`0|uLpoMftVY%i)XyOq zzMk3rO+7J{xzbkG&D3x!X{Et0UZXMvA)$&$XR)r83e|Px95aZKFT^)f0Zrldcx~f@ z>!11rjuZ8Zh46@mPu}`AoSl;ak1A~FOF^C{^I;e``1q6NdfSj{)T_ku%5UQ}Z?Jm| zAHVEUP%2oNAC4&(qagq$t+Xvt3&N)s`HrP`R`(m-k1ELb?pEm-ZR*s=Ui612zb?)W z4Cfoj{WagHrMjIP*ONJ|{&Ot$OH7Y;ZoOjZTt2_s}S zi?4WiuU^L4(uXx>Bu|cq(*3$vR@2GQ`t+o4ETDJ`KAG4|(p(~$yRTAg>($l{R0;g= zktg#+E5v#q9=Uk52m5X3c@zQn5s)~I+NJ}Nll4xzUIn5yu{Z4mh-(1T(~QIjK)Or3>}pt;)TVp-SoK*@u3Qt)ou`p*tBT(%OGhP+O%$M3Jsa1HU0X18sa5fI!8% zEEeM-(8@|Sb@61fHH2s|flS4lASCh%q|V-?;RF8w=q)O0E2g*L3BY035!c*`(`Z11 z$#Mqb3mEOZK5G6_LsT`iu(5}RRObLb%Q#RsU)EEhhV_opU7Hr9Zmfyv`}niok8idV51Uxib1a(syhM zOWB`XhX4oT=Gmv+&Hh^;gxj{sY^@kV$HxrRkvoTA12Dm z6(@}izfC#nKW_`~G*~?+zlCS)Q>U!sOKM;xxWVbsSO7!(lvQF0-KGq1ytc{{Z(l zZ@8-s-D{{M>Qs;jF8=jSEbXgWBFYS+cM-srKPlzR^roL#|$r8G~j=E2{RLC*4eZ* zGT%g=P8z}tV~rIf3>nWjz%uO93m-Y!5)LSq{`BP@loS3#(?g-LWQU7GMtq-AMs{q; zHn%yT1x>VZnS4p3qLscY@D=Kgj6}rMZ|bA2=njBi@IiA!K_0tCXch5}huP<1QXa)* ze4ow5)o3pCK43?@blx=XfW*R-^^hFZToflmzuYM0D-1G+gyr5U;m62(BtdNLrqT0V z(_yMSOSx=6uA0lVhHD>6c~=hOWhR#DpFh`B#cTNYnFZqeJ1BI`j&)-_Js%@^>*8i^ zx#FRV8rnoLZf*+&t!RTX^u=|2%`f8;2b`M*D*xO=HMd4yu19aS{0!?cX^(|p<^yY-7f>IFyE#P4|a}ScUB|(Wx2g4BIRBdZOSzE zjlgESRaN&`UDZa&jKn^wINY9G&lIjSZ%rrG;;gp6bk^^3SO6a_mpU8%YoqN+(Kv=rQ4gI=7qFxkFWEfDJxWRB&E zGF2Hw8IR2N=l`LRq7ycKV;fx*hsfnVa>gD#@`s5iMmm;=C`&dhU#=W1M7POYO#L{a zktJK=P|AEXf$*oeIgO}(qL&YFy<8OmP!Q!K`kFO=-xZVo(!qlKO63HETYMW0aW7do z{W_F3!tr~5ZDv#>d~nmsg-egIhi@!_f8`qu50BBl0lFd%Is)8N|MeFRAIy=8(c3%-Ot8r(`skxCAyxeJ*9O z?4Oa1&HY5dhp-1ASM9SeFd*WTmlf~)V65Su%FG*Ot8d>fs@@7pNoO=3-j#`kwj{}) za=CinWVj{;l-Nq`)SQSCph9q11!1j}^!TeP&c=X@zMG0FzFMlCoz9;hsYaDmCAp?@ z-?M_u1)97I<*?-_8;a}-UZys35%MxEgAf27=6OLSkdS^0eG_EuCHV;AF;fmW5R`&WrIz}DIc0BH+w7;Sx#My-Q9fI$NhNNNp zr3H_gaP+KSDclhR->7$13i3CsOMBIdfF;90w8AWx2-O-FDYX5cCJ5OC_WMYWZxe{p zD)Zy^-T~!xIUxa7v5D60+WWmlY^Ye~X69XYp-NvrlG$cJAK9qDV^h+F zUqn~~Ch&IDOrf1dln2bPPfZYL+*%Uuev;;dG+3^rS5q~_5C72b;L>tYp{;d#M^Vvo zk(j8R>S-njhU9@{*&Txn_+{n0Y7@dkvAnK0vl?nbM)zwWamf z{=Q|VF4On&l~|drA%#Atrab4`HVW2bvX&b^^Yz+v0kN zdB%-8gm!iDl&)1K#k!kr@)+qp6x_Ohw|Vc|;4a#tM{+1w`%U6!HkRZCU)q)yS_%z` zvsk8n_J<0OtjQdikCH<^@vgs0~sUbN$iz5QIkvH-5?;P!KwlMikG7XQzHj~N( zOB3EM7SiB!V6;tkW9=87HI_&!VQt}G5^US*>P@KyY8IcY4|^mxn3(tck)iGyX$_$z z9ybu={a-9#;4Z^&xv*0hIZOQYQX)?Tv8BhO)IU&ira*~AIXXnA`oZd#GuFj98C}-N zjW@hTL#U42stLZY8J7l&I{uWqlRq7L&*RLlCD`_m z#i4co|dV;-YGHYiw zx=6r&_e)gHEW)>_k*?`$1qzCKJo|U^+~C1Pl!bdORMN|UktRG>#^BjoAb^r+5&aYH zyxU(TtaWtd_^+#67pcr{zhqBQ))vq*n)6-M#6B(S)QkMm34afgi$zb<<8x3Sak>3S zr{>U~4ZT|5=Gh%g3)-0n+Cz@TrwYdRBrgqO4iShOMyP%-hE*NVFB?CsQ7CANh*Tg| zq6nz5b`~^>_6hi^0yQ%Yf=9yU8$O2>6uZV5j9=)^m&tmYx z^TfY;^ z^A6M9L&0nK^IUFaACV@k(F;+R8J*9FCu$cjH5;8+>;t+wPKUAv3iT*QYIAgG6246z z6oAWyN(*Sj-bma0gA#enE6}#xxVpzCf3ru4R|scK0NZ@3Q&5Tzz~d1`VCRFE`mgPI zQ^rX-GtDkB|FwHlNOO`{oG4J%b-%cFC`hHJ8fQ5C zr;BEDTU|1nq)4I9?MV+Y&gG1|PUtq^0xGPrC~KTCL~?s{8}2j?)ZXyy)a~)x3R7_a zQ*D%kx3Kn4&BsNiedf*J->47gid*WTph|yg0P^xCztdy1=WPSe2=$D`eOFM`q$kzm zCrgZ7>b$YaoATl5_5+G{vEzQrhl z1Q6n-D^q-1X^%!~-H-Bi64c;{>uC@z)pmbSxgfQv*Iyr+H0Mu6xsyaKB?zzE z?bkL{0hvhsdG93ygBVr&nu~CWwBi$>58|oJWHj6yY~f$`4mrez@dr|i{vxFpUc9vY z;y3D)!Cz{h(zP+s%UH`W0vi86%)Mn)T+R0`n1s9#g1va~;10nZg3~xO(1FI?3GR>t z3m%%r-5r9v1oy@XG!mq7ch7Y4yZ?JVgp8f3Z9kb84 zb@#=ml?BlXLU$}=OVcDdaDJAtBvVoI{;u?FX$yP;E$eSRGwd%Ze|kTLWX}`hiz#2w zqinOEFgc#|&xL&kumiR7iE2GmBVxvfQ?`Wtu}ZycQ0%~VSDENFvF0CN%wD>}D#wF6 zDC@5`=af#!-&&wOC#;8*f!LlZ9;n^7w zn|dyWRWO;e&4E59uhsyp#m}is82Prs1)};k-d+|>v}vXgMtW13JW~<6D&n0RRkv-5 zp1}&)v2pvRerx|j7n)Mha-Nx(eQxfcz_zK)EStMIBM5M=eWMC_<$!EtI8R7prh2^qK?6Vr?-B`kQ*&DcJe4Ua32D+~|M9&-1NXnIRm zN;~1y!j-?7apxN7D4dv;E(ed-@N2Qiv%>E74{|c70b1ASX6Y6tX>nyqP7jgMNmu2} zr1u4lV8Y2tQ?0V@iyfuwBm7GFOQ+!RXkWY@PKs%*WZa~5Nw81URk3Amy9guBVH>pf zSlpyk)NSTSaC2mGf$U36)k4CWh{e}Zbm7v>@e*FSilLBFnbq5Ks;fYbK&Fmbrw!E{ z$wp?o&|xRlwc3et@t z8}4;oBT~DS*4=$-5WH0ks*KK3n0dwZ?sw6;jCzdQIz8E{Nzlqcqm_M*X z4VtIIS-hf6;WqhXfjI1oaAk9!;SSMK`NeHl zkMaCdzglfj2FcY$iX0lnCC=Tc+Jff&r0ak-dfe8$hex<{^QivmDXgc2}|8Jq#xQ&9A%<3 z2!~5Li#ku8>+3-w4AhE!@H!@O(Hpjb;?_u#5=LEt7Y#>##_!CJ^11ppI$RIY0;;2Y z-3AtZl{pk$0;@4HJnj`g`Z0zb5u%_vD8<@fWH4Q+dbyolX27dP7>qVtnPL($K_OO z6nK_6%=(tb{*?H2GglV8>hZOz5yOttL0BZUB*xXsG-#?E73MrzSMcSRF*1WnZcF-1 z^lWK?7)7{=D3<)amug;73Lc8T;z&p1_P@jWCHEG{vr3Hq)|g$A6@{a(mY;qWvw~(Q zMk!J6`o7yU7&t`YQX$4z!QJMEkAoKHkH(!wo0*NB#*3~ zTdARl@n>u`^$5XL?&VLP*r|GNx>J44ERpa3+#VvP=Ly^hp4kEEU zI#_}pVT0yF?08flHOsJyViF!Wn#l3_vzhcldHF59i?h@H0!NBs%nb4tci&)5S`Z$E zi)iR=$d;>{_m(?LA+8&##Jqq!zi}rK5Mn+voGX>caSUMF?|_u&Z`vawob-~DrKc}y zdy^R#6s_p(D2Jg%xBDnjM!C^DWo;?nia}vV{Hdq}iAi4hx}1ya#;Oa6^{4GFzn{&o>gT zdJ>r#c`g_Rm17gltoMMg97!x%sqP;hb;ci#jLOgXi4!^}*&>|kI`;D)E6d3U#mgqv zzi_)=>1~zEx$;#$-t@7`kEA{!$dT6)b#w_7E&qUUV@U>2NTsWqN zYTZ#1U!*pS(pm=HSryE=9X~rsZM)soaubQUH~B3%S43a^lt0&G*>F|Q`o)w&ZDVgT z8qtZizviowN`&jjw$stEO=t82QNb9CTA#|_k!MM&;_5vP)ZR=SHeo5$Rt?N~WC)uo zyl~LwM-;iSB2;Z>h|+y1bC0^1FCS5Kxb&XYJD0Z;kCHN#jY0?ItN}2VI&S-H;`pg4 zgzMKtLkcvcC>N~3t>*v{$HofPZI$RuSr9Xu23Oyc^Q-F)jpB(Zj zT#x(CQEPZ7G#w!_^%0^(P(_j0dza!MT#F-C(eLi7)YE;_!wek>;+BC4w*BXET9cGL z6aFPrn?`&x)37Q(vF(UGMljB5-S^l9I}Cx%iuBo)mMe-mJEce`J2S?q>6^>!NX;M} z&R3eZ>528%5xOWUtM3N~GT#Z#^3ehBr2z3SK%7h@00pH9i@_w|-G|zPLd*DkAG4pX z!>O6tPPyY~5A_UZG?VSpJd9KjiLIZPE{J*SsSO{^)+$NB`YcA{Q$%d!)BQiQ4c9aO zNVjk$BsQE!sZyT(w!Ag@94!srAE(T0H}u8Nc2R*~dQ*R1bB6A>rJzvULN75-SE$Ah zvAA{l`YIR%GVW6~6~-W@(c`b5k$yu`hdwHoJJ=b5Rh-M&9L~xG2~oq}xpR`ia_|a2 zcFSZ0yd)Ut9@o)3{aLTItRNx-Q+D@=-WLo&#GRq+5h~IJmstZxT**%nm7nnr*DVj+ zNnk~LKMv~5Q9_GdJ++qPE0@2m-}Y%yrI^ta|KQ_rD5YZ_=Pa#tI@C<+=bHbX5lzy$ z7fU$r{EJS00R?@2sz1goTb1}9hue=F`d6qpcZaBV37GPEd%YK>?z#X@2T|Y$@21u!6 z4l6DQKs_jpS5!fx=kldAymkG3=6sKcOuv#*A%HE;rH@qI#>UvoBYKU(%4BS~KG(xo?oLUsm$Q~6|rm9 z^DeR(w$AU1!+*(&$Yk2oKb%qIkLdqG;?(~{ZqGZ5y^vuUzRT{2vUl(h-tU$oX5Zo`!4@8if10JaNoT7{gYHFQ)MM4@51< z)Kvj%_CjZBJ^!YcH{1vG;SsK}pZ|x2I6>Fm@M*0tdIE!5V_~E3joW|40zyhNNAVf@ z#YZ%K6d@#3Y59W>+AA6m+HB+jr2hq5*)S~<+@aC}A*#~WLicDaIBqZ2HTqjoO3da|<4eLTpO?0OZn`Qx9X9^b+&;iR`l6SjX~ z8`xP9)XilB@{O1<#u!omarQliL+*dkk+y~@T}M}`#5{7td_+pi3au21R>eE`nZgf0 z=4&5U5&IGv_2Y${S|yctLM!VSjQhTPnmQzI((wu(wF*y}qS^YQDVo`!l|HbfG2FG7 zC|?Sy1*Z^GwYfYJ_JyOjT-Qc+y74G=81Hnr^;x&RCX*1XF$bA8hMf1qUCP9wf10Ir zbU+PEDzdYVTez-T{$`liz7KkiLchc9XT!>@5F|5}+jdRZ19qk5DK3{XZfDN^6k%S}*U;U+5gGc{gf!2kUxLaH9y@H_ zhJlCDTNU$2^)HFV)>!w2_NghpI?1{}tF8`20v})hAk{*|tkBSZhX(MNhx?sz* zMS3BnRNY^LM5PY#QpD&^RI6UCZYgH{H715GUW51*l;U}!70LRHjqtC|(s&;FSpK5f z8Ij`qli>|ES=z1l-^JO(qp3+nq}??_=MFJ3kTC0bNbr(s!llu#lJg z&8wIdqrAJ;&N0g??E9-$#52temK!eZS#dg>H)PgY=Yy@eGqhxsTF>b=6ZLB`DeX(1q4FU`vCey`52Dlg8hvfb&vyvV+ zh{fmbyCXMa^n`Jt7?j`cA->Ft^`!pW_wwoE4tCXbFJihy_ta&b^LW|aI_Tq6=_&S^ z)HTh}YLds3e@VG)yeNLv$UG#(R92R^Z?YomyISZtks{Nh4!N#y&aQF})XuuAsx#?N zO2?24yby-usMni<@Nsj3|pm+4BwWy&LzM1UH+JgNzO4&+`fGg+Fi4$WZB ziSiQl=A|JFFCc))7-i@)7+kP<#jAbX!&VVi#R%sKa07b~OIBW>nk*HdnF2ipT5B*p zHSP*3#FbSc+k1_s`r9rc7X%gyY9&SX<-Y}dDVfFCTRd&|#k{FL%}x_KT+_qwLwd8V zSiPULAru4VM?#Rg8%)P~16O8tNzbbABh)j>7ja>+%T^1OUoBstRGb}_FnNdjx+lCr z^5p1n2Ta@;ePxA&=5uRXz159vA;E*GGt;u&Wgm)%7hy+VZ3#H7Wqh(5J-!EqGOMRL7oDg-o*g5cFVPE|_6QJ%lYHXas@X1&1 zExPlX2!en9%#~$Ut?Rwj@+<5PC2~LCG}cD>bx#uU>c-~s4~>?(yzJ8feLV-8V(6*0 zzgoU;hG)-Wd>Q6%U8jPI1k|a70CQ?-_{YQ54J~Hji#d61KX5pulT1fq?1o5Zi4(}| zp@oswQrd7q`7|ffv9S(Z(v-Q%SQ7gEEPrO+7@`Y~qfbgUry`i?J2qno(sy>jd=k=n zeu3Lxkf3!|y(k)6ag*NsR6*L?v%Lij55vr5Wx!O>gjo*B1Bdj%4ND%X9+W(7h_4a< z-7Ia1GpznVj#GEf=50%j)sU{HfJGzZG(guXw!93^Pr)EPH+IQ%sArHIkTR3xQuLpX zvpDwCXVpkS_ZyG6`0H<#FA$^kMDr8t8di zrpfoIc@VT$gJD4hf3N{BKegr53~Gx-BYV;Z<2LoTA!KA+V%w*|i-`x$0e zvZm4SXK}OTcq!k#o9vFmLOYH>GGlB>vldSRoU*tHZk@50S77evkOq6YlP53)2;@vMZxS43@oy+uu(nUPimfG!Euc>KKn>=}DN4>-r{V)cmSdE#m zb8@X&$-+Eljcgr&MLEFaPDM3!hIa8R#Cwt?{+MB<4tM61d57Y94p)KSG0Gc@UPHpU zGqChI$WOE9Iglem>9)dK4OGNYg$(&MU83EHeoF+dUJeuM<836biJYd3ttFAe4BSpZ zjNj-_PJ;CmFWI}VR7k2GpJ2?sxJg1|q((GqOI(|R51#Z0&n)tI1jaW5g?GE>5^_*G zSQ*^Q06->(X;qZPkR!}H=UBJ*YSK1tJIzW`=acb=GnlGw75bqUG0k_M+>Q(!>eNSg z%1JaXVw?inm~|fMI`4`5GBS6g$gF5(8l>;IB)`y|?hzK2ntXDP{*pnutSBtNE)JN9 zIWcfx89i55uwv{hI+InT;QUVjjo%tdByVhMZ*FZWk4qS@tWc9O{WfAcyC8R)88F1@ zB3NmLYWnO?-s~sR3H`3O0MS+ld3Elpee2cm%&R*-cuHaI5!!=#Wp#j9OdL>(->UoC z|7VuK!7fiUw(uU+#YR?5!ByUN44JLAiU$xGwQByr<(ok#l*R#Jkd+PTw<;*IvWf_o zb|TIl;wh&tIGVB8IVeNAZx)etl?D4=o(wB_)0I42kkfB4JKj~@F(-QVXbin={QR@# z8r)nr2eccGkJ>U+Eutu&yVRR`0Bg01@`Mt9!!Hn`&V=V#>pP9w8o=%u(AQV6SG3K4 z)~GnQ`O{~+6}wShG-S`P`&fqpH+&vi}4|6l_%729eTzK_lBc zXQTU2#sqWvp#f@kUv?8yY?Wyko>`_jD}`?4dJ4RD#^xh?WGj$0! $7WxmSeq<&s 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

    ?+>l`Chm?q{sR7? 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�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 CF;l3Y6VK`0a)a6<0o)jwbmb!Asrdn`}b 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 R6pFb { + await page.setViewportSize({ width: 1280, height: 1100 }); + await page.goto("/examples/platform"); + await expect( + page.locator("section[aria-labelledby='platform-capabilities-title']"), + ).toBeVisible(); + await expect(page.getByRole("main")).toHaveScreenshot( + "platform-overview-light.png", + ); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 056ff7e..4793310 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,9 +1,22 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client"] + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vite/client" + ] }, - "include": ["src"], - "exclude": ["dist", "node_modules", "tests"] + "include": [ + "src" + ], + "exclude": [ + "dist", + "node_modules", + "tests", + "src/adapters/service-worker/service-worker-entry.ts" + ] } diff --git a/tsconfig.json b/tsconfig.json index 105b2be..76e73be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,8 @@ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.test.json" }, - { "path": "./tsconfig.recipes.json" } + { "path": "./tsconfig.recipes.json" }, + { "path": "./tsconfig.web-worker.json" }, + { "path": "./tsconfig.service-worker.json" } ] } diff --git a/tsconfig.service-worker.json b/tsconfig.service-worker.json new file mode 100644 index 0000000..9ca8f56 --- /dev/null +++ b/tsconfig.service-worker.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "types": [], + "skipLibCheck": false + }, + "include": [ + "src/adapters/service-worker", + "src/adapters/web-push", + "src/contracts/service-worker.ts" + ], + "exclude": [ + "src/adapters/service-worker/service-worker-page-controller.ts", + "src/adapters/service-worker/service-worker-removal.ts" + ] +} diff --git a/tsconfig.web-worker.json b/tsconfig.web-worker.json new file mode 100644 index 0000000..8d3399c --- /dev/null +++ b/tsconfig.web-worker.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "types": [], + "skipLibCheck": false + }, + "//": "§16.2 Generic CPU Worker is NOT_SELECTED: src/adapters/web-worker does not exist yet. Add it to include when a task group is contributed.", + "files": [] +} diff --git a/vite.service-worker.config.ts b/vite.service-worker.config.ts new file mode 100644 index 0000000..31b0455 --- /dev/null +++ b/vite.service-worker.config.ts @@ -0,0 +1,92 @@ +import { readFile } from "node:fs/promises"; +import { defineConfig } from "vite"; + +import { + SERVICE_WORKER_CACHE_SCHEMA_VERSION, + SERVICE_WORKER_PROTOCOL_VERSION, + SERVICE_WORKER_SCRIPT_PATH, +} from "./src/contracts/service-worker.ts"; +import { INSTALLED_RUNTIME_CAPABILITIES } from "./src/features/installed-runtime-capabilities.ts"; +import { resolveServiceWorkerBuildInput } from "./scripts/lib/service-worker-build-input.ts"; + +/** + * §17.2.2 step 5. The Service Worker build. + * + * Runs only when the static selection is `ACTIVE`, after the app build has + * completed and the hashed asset list has been generated. `emptyOutDir` is + * false so it adds `service-worker.js` beside the already-built app rather than + * replacing it. + */ + +const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker; + +async function readGenerated(path: string, exportName: string): Promise { + try { + const module = (await import(path)) as Record; + if (!(exportName in module)) { + throw new TypeError(`Generated module does not export ${exportName}.`); + } + return module[exportName] as T; + } catch (error) { + throw new TypeError(`Required generated module is unavailable: ${path}`, { + cause: error, + }); + } +} + +const assets = await readGenerated( + "./.generated/frontend-runtime/service-worker-assets.ts", + "SERVICE_WORKER_ASSETS", +); +const contractSet = await readGenerated<{ setDigest?: string }>( + "./.generated/frontend-runtime/contract-set.ts", + "BUILD_CONTRACT_SET", +); +const runtimeConfig = JSON.parse( + await readFile("public/config.json", "utf8"), +) as Record; + +const buildId = process.env.VITE_BUILD_ID ?? "local-build"; +const releaseId = process.env.RELEASE_ID ?? "local-release"; +const buildInput = resolveServiceWorkerBuildInput({ + selection, + assets, + contractSet, + runtimeConfig, + buildId, + releaseId, +}); + +export default defineConfig({ + build: { + // §17.2.2: exactly one input, exactly one output, no additional chunk. + emptyOutDir: false, + sourcemap: false, + rollupOptions: { + input: "src/adapters/service-worker/service-worker-entry.ts", + output: { + format: "es", + inlineDynamicImports: true, + entryFileNames: SERVICE_WORKER_SCRIPT_PATH, + }, + }, + }, + define: { + __CA_SERVICE_WORKER_BUILD_INFO__: JSON.stringify({ + serviceWorkerProtocolVersion: SERVICE_WORKER_PROTOCOL_VERSION, + cacheSchemaVersion: SERVICE_WORKER_CACHE_SCHEMA_VERSION, + buildId, + releaseId, + contractSetDigest: buildInput.contractSetDigest, + staticAssetSetDigest: buildInput.assets.setDigest, + }), + __CA_SERVICE_WORKER_ASSETS__: JSON.stringify(buildInput.assets), + __CA_SERVICE_WORKER_HANDLERS__: JSON.stringify(buildInput.handlers), + __CA_RUNTIME_CONFIG_URL__: JSON.stringify( + process.env.VITE_RUNTIME_CONFIG_URL ?? "/config.json", + ), + __CA_RELEASE_MANIFEST_URL__: JSON.stringify( + buildInput.releaseManifestUrl, + ), + }, +});