refactor: 리펙토링
This commit is contained in:
+103
-20
@@ -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/<feature>/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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+24
-24
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+5957
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. 문서 목적
|
||||
|
||||
이 문서는 도메인 기능과 실제 운영 환경의 배포 증적을 제외하고, 이 저장소가 새
|
||||
|
||||
@@ -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`을 계획 기준선으로 삼아, 현재 저장소에 선언된
|
||||
|
||||
@@ -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. 문서 목적
|
||||
|
||||
이 문서는 이 저장소에서 도메인 기능을 추가할 때 사용할 경계와 확장
|
||||
|
||||
@@ -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 조건을 정의한다.
|
||||
|
||||
@@ -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. 목적
|
||||
|
||||
이 문서는 도메인과 무관하게 다음을 바로 구현할 수 있는 기준을 제공한다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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. 목적
|
||||
|
||||
이 문서는 다음 질문에 대한 저장소 표준을 정의한다.
|
||||
|
||||
@@ -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<RuntimeCapabilityId, Readonly<{ selected: number; active: number }>>
|
||||
> = {
|
||||
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<ReleaseSummary>;
|
||||
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(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
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: 1,
|
||||
active: 0,
|
||||
override: "DISABLED" as const,
|
||||
}),
|
||||
Object.freeze({
|
||||
capabilityId: "OFFLINE_COMMANDS" as const,
|
||||
selected: 1,
|
||||
active: 1,
|
||||
override: "DEFAULT" as const,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<LocaleProvider>
|
||||
<PlatformOverviewPage />
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
|
||||
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<Record<RuntimeCapabilityId, Readonly<{
|
||||
label: string;
|
||||
description: string;
|
||||
}>>> = 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 (
|
||||
<Card
|
||||
key={status.capabilityId}
|
||||
title={copy.label}
|
||||
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
|
||||
>
|
||||
<p>{copy.description}</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
```
|
||||
|
||||
설치 요약의 능력 지표 힌트를 `"런타임 오버라이드 반영"`으로 바꾸고, 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
|
||||
<Link className="ui-button" to={routePath("EXAMPLES_PLATFORM")}>
|
||||
플랫폼 구성 보기
|
||||
</Link>
|
||||
```
|
||||
|
||||
기존 "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`가 세 태스크에서 동일하다.
|
||||
@@ -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<void>` 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.
|
||||
@@ -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` 이후에도 페이지가 빈 상태로 정상 렌더되어야 한다.
|
||||
- 실제 브라우저 렌더 확인
|
||||
@@ -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}`,
|
||||
|
||||
+7
-3
@@ -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",
|
||||
|
||||
+9
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
|
||||
type RegistryRow = Record<string, unknown>;
|
||||
type RegistryRows = Record<string, RegistryRow>;
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../src/contracts/release-artifacts.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<string, string> = {};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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<Record<string, string>> = 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<string> = 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<StaticAssetManifestV1> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<ServiceWorkerHandlerId>();
|
||||
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<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
+104
-13
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
+52
-63
@@ -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<string, unknown> &
|
||||
Readonly<{ releaseId: string; routeChunks: Readonly<Record<string, unknown>> }>;
|
||||
type RuntimeConfigDocument = Readonly<{
|
||||
BUILD_ID: string;
|
||||
CONFIG_SCHEMA_VERSION: string;
|
||||
API_CONTRACT_VERSION: string;
|
||||
RELEASE_ID: string;
|
||||
}>;
|
||||
type BuildManifestDocument = Readonly<
|
||||
Record<string, unknown> & {
|
||||
outputs?: Readonly<{
|
||||
runtimeConfigSchema?: unknown;
|
||||
routeChunks?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
supplyChainDigest,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
|
||||
@@ -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<BoundedBytesOutcome> {
|
||||
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<BodyProbeOutcome> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<Record<string, string>>;
|
||||
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<string> = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
"x-tenant-context",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = 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<Input, WireOutput, Problem>(
|
||||
installed: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
input: Input,
|
||||
baseUrl: string,
|
||||
): RequestProjectionOutcome {
|
||||
try {
|
||||
return projectRequestChecked(installed, input, baseUrl);
|
||||
} catch {
|
||||
return failure("PROJECTION_RUNTIME_FAILURE");
|
||||
}
|
||||
}
|
||||
|
||||
function projectRequestChecked<Input, WireOutput, Problem>(
|
||||
installed: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
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<unknown, unknown, unknown>["contract"]["projectRequest"]
|
||||
> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
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<string, unknown>);
|
||||
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<Record<string, string>>;
|
||||
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 });
|
||||
}
|
||||
@@ -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<Problem> = Readonly<{
|
||||
status: number;
|
||||
problem: Problem;
|
||||
descriptor: CommandEffectDescriptor<Problem> | null;
|
||||
}>;
|
||||
|
||||
export type ProblemEffectOutcome = Readonly<{
|
||||
effect: MutationEffectCertainty;
|
||||
contractRuntimeFailure: boolean;
|
||||
}>;
|
||||
|
||||
const CLASSIFICATIONS: ReadonlySet<CommandEffectClassification> = 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<Problem>(
|
||||
input: ProblemEffectInput<Problem>,
|
||||
): 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";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Document, "visibilityState"> & {
|
||||
addEventListener: Document["addEventListener"];
|
||||
removeEventListener: Document["removeEventListener"];
|
||||
};
|
||||
navigator?: Pick<Navigator, "onLine">;
|
||||
}>;
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<void>;
|
||||
}>;
|
||||
|
||||
export type ServerStateScopeDependencies = Readonly<{
|
||||
session: Pick<AuthSessionPort, "subscribe">;
|
||||
queryInvalidation: QueryInvalidationCoordinator;
|
||||
queryInvalidation: Pick<QueryInvalidationCoordinator, "resetLocal">;
|
||||
tokenFactory?: () => string;
|
||||
}>): ServerStateScopeRuntime {
|
||||
participants?: readonly ScopeResetParticipant[];
|
||||
activateNextGeneration?: () => void | Promise<void>;
|
||||
}>;
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/// <reference lib="webworker" />
|
||||
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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<Cache>;
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
match(request: string): Promise<Response | undefined>;
|
||||
}>;
|
||||
clients: Readonly<{
|
||||
matchAll(
|
||||
options?: Readonly<{
|
||||
type?: "window";
|
||||
includeUncontrolled?: boolean;
|
||||
}>,
|
||||
): Promise<
|
||||
readonly WorkerClientLike[]
|
||||
>;
|
||||
}>;
|
||||
registrationScope: string;
|
||||
skipWaiting(): Promise<void>;
|
||||
fetcher: typeof fetch;
|
||||
digest(bytes: Uint8Array): Promise<string>;
|
||||
}>;
|
||||
|
||||
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<string>();
|
||||
type PendingActivation = Readonly<{
|
||||
requesterBuildId: string;
|
||||
expectedClientIds: ReadonlySet<string>;
|
||||
acknowledgedClientIds: Set<string>;
|
||||
resolve(drained: boolean): void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>;
|
||||
const pendingActivations = new Map<string, PendingActivation>();
|
||||
|
||||
/**
|
||||
* §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<void> {
|
||||
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<number> {
|
||||
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<ActivationMarker | null>(
|
||||
(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<Response | null> {
|
||||
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<boolean> {
|
||||
if (clients.length === 0) return false;
|
||||
const drained = new Promise<boolean>((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<string>(),
|
||||
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<void> {
|
||||
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<string>,
|
||||
});
|
||||
|
||||
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<ActivationMarker | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<ServiceWorkerRegistration> | null = null;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
let updateTimer: ReturnType<typeof setInterval> | 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<ServiceWorkerStartOutcome> {
|
||||
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<ServiceWorkerActivationOutcome> {
|
||||
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<ServiceWorkerActivationOutcome>(
|
||||
(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<ServiceWorkerResetOutcome> {
|
||||
const container = dependencies.container;
|
||||
if (!container?.controller) {
|
||||
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
||||
}
|
||||
const nonce = nonces.issue();
|
||||
return new Promise<ServiceWorkerResetOutcome>((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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<string> = new Set<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",
|
||||
]);
|
||||
|
||||
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<string, unknown>;
|
||||
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<string>();
|
||||
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 });
|
||||
}
|
||||
@@ -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<ServiceWorkerRegistration | undefined>;
|
||||
}>;
|
||||
|
||||
export type CacheStorageLike = Readonly<{
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
}>;
|
||||
|
||||
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<ServiceWorkerRemovalOutcome> {
|
||||
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<ServiceWorkerRemovalOutcome> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §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<Cache>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
}>;
|
||||
fetcher: typeof fetch;
|
||||
digest(bytes: Uint8Array): Promise<string>;
|
||||
}>;
|
||||
|
||||
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<InstallOutcome> {
|
||||
const invalid = validateStaticAssetManifest(manifest);
|
||||
if (invalid) return invalid;
|
||||
|
||||
const cacheName = staticCacheName(manifest.setDigest);
|
||||
const abortController = new AbortController();
|
||||
let deadlineExceeded = false;
|
||||
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<InstallOutcome>((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<InstallOutcome> {
|
||||
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<void> => {
|
||||
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<InstallOutcome | null> {
|
||||
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<Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
): Promise<Value | typeof ABORTED> {
|
||||
if (signal.aborted) return ABORTED;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<typeof ABORTED>((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<InstallOutcome, { kind: "REJECTED" }>["code"]) {
|
||||
return Object.freeze({ kind: "REJECTED" as const, code });
|
||||
}
|
||||
@@ -591,6 +591,8 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
|
||||
});
|
||||
let connection: IDBDatabase | null = null;
|
||||
let openingRequest: IDBOpenDBRequest | null = null;
|
||||
let openingPromise: Promise<BrowserDataResult<void>> | null = null;
|
||||
let activeOpeningGeneration: object | null = null;
|
||||
let cancelPendingOpen: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
|
||||
@@ -654,56 +656,61 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
|
||||
updateStatus({ kind: "CLOSED", reason: "FORCED" });
|
||||
}
|
||||
|
||||
async function open(
|
||||
signal?: AbortSignal,
|
||||
function waitForOpeningAttempt(
|
||||
attempt: Promise<BrowserDataResult<void>>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
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<BrowserDataResult<void>>((resolve) => {
|
||||
let callerSettled = false;
|
||||
const finishCaller = (result: BrowserDataResult<void>) => {
|
||||
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<BrowserDataResult<void>>((resolve) => {
|
||||
function startOpeningAttempt(
|
||||
availableFactory: IDBFactory,
|
||||
generation: object,
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
const operation = "INDEXEDDB_OPEN" as const;
|
||||
|
||||
return new Promise<BrowserDataResult<void>>((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<Value, WireValue, Query>(
|
||||
blockedTimer = undefined;
|
||||
}
|
||||
};
|
||||
const detachAbort = () => signal?.removeEventListener("abort", onAbort);
|
||||
const finishCaller = (result: BrowserDataResult<void>) => {
|
||||
if (callerSettled) return;
|
||||
callerSettled = true;
|
||||
const finishOpeningAttempt = (result: BrowserDataResult<void>) => {
|
||||
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<Value, WireValue, Query>(
|
||||
// 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<Value, WireValue, Query>(
|
||||
});
|
||||
if (blockedTimer === undefined) {
|
||||
blockedTimer = scheduler.setTimeout(() => {
|
||||
finishCaller(
|
||||
finishOpeningAttempt(
|
||||
browserDataFailure("BLOCKED", operation, {
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
@@ -816,24 +815,24 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
|
||||
{ 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<Value, WireValue, Query>(
|
||||
"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<Value, WireValue, Query>(
|
||||
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<Value, WireValue, Query>(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (callerSettled || disposed) {
|
||||
if (requestSettled || disposed) {
|
||||
opened.close();
|
||||
if (!disposed) {
|
||||
updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" });
|
||||
@@ -915,12 +914,46 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
|
||||
appliedMigrations,
|
||||
);
|
||||
}
|
||||
finishCaller(browserDataSuccess(undefined));
|
||||
finishOpeningAttempt(browserDataSuccess(undefined));
|
||||
})();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function open(
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
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[],
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface OpfsWorkerLike {
|
||||
type: "message",
|
||||
listener: (event: MessageEvent<unknown>) => 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<string, PendingRequest>();
|
||||
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<unknown>): 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<OpfsWorkerResponse>((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,
|
||||
});
|
||||
|
||||
@@ -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<PushControlV1, never>,
|
||||
"open" | "read" | "compareAndSwap" | "remove" | "close"
|
||||
>;
|
||||
export type PushControlStoreFailure = Readonly<{
|
||||
code: string;
|
||||
retryable: boolean;
|
||||
}>;
|
||||
|
||||
export type PushControlStoreResult<Value> =
|
||||
| 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<PushControlStoreResult<void>>;
|
||||
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<PushControlStoreResult<PushControlWriteReceipt>>;
|
||||
remove(
|
||||
input: Readonly<{
|
||||
key: string;
|
||||
expectedRevision: number | null;
|
||||
idempotencyKey: string;
|
||||
signal?: AbortSignal;
|
||||
}>,
|
||||
): Promise<PushControlStoreResult<PushControlWriteReceipt>>;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
export interface PushAssociationFenceStore {
|
||||
read(input?: Readonly<{
|
||||
@@ -505,7 +548,7 @@ export function createPushAssociationFenceStore(
|
||||
}
|
||||
|
||||
async function callRepository<Value>(
|
||||
call: () => Promise<BrowserDataResult<Value>>,
|
||||
call: () => Promise<PushControlStoreResult<Value>>,
|
||||
operation: WebPushOperation,
|
||||
): Promise<WebPushResult<Value>> {
|
||||
try {
|
||||
@@ -519,7 +562,7 @@ async function callRepository<Value>(
|
||||
}
|
||||
|
||||
function mapRepositoryFailure(
|
||||
failure: BrowserDataFailure,
|
||||
failure: PushControlStoreFailure,
|
||||
operation: WebPushOperation,
|
||||
): WebPushResult<never> {
|
||||
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 &&
|
||||
|
||||
@@ -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<void>;
|
||||
}>;
|
||||
clients: Readonly<{
|
||||
matchAll(options: unknown): Promise<readonly unknown[]>;
|
||||
openWindow(url: string): Promise<unknown>;
|
||||
}>;
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ReleaseSummary>;
|
||||
/**
|
||||
* §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<{
|
||||
|
||||
@@ -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 }>;
|
||||
}>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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<string, unknown>;
|
||||
}>;
|
||||
|
||||
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<void> {
|
||||
// §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<
|
||||
|
||||
@@ -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<Record<string, string>>;
|
||||
contractSet: ContractSet | null;
|
||||
legacyApiContractVersion?: string;
|
||||
}>;
|
||||
|
||||
export type ReleaseManifest = z.output<typeof releaseManifestSchema>;
|
||||
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<BootLoadFailure, ReleaseManifestErrorCode>
|
||||
> = 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<Readonly<ReleaseManifest>> {
|
||||
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<ReleaseManifest> {
|
||||
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<Readonly<ReleaseManifest>> {
|
||||
): Promise<ReleaseManifest> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<typeof getBuildConfig>;
|
||||
now?: () => number;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type RuntimeConfigLoadResult = Readonly<{
|
||||
config: RuntimeConfig;
|
||||
configSchema: "V1" | "V2";
|
||||
build: ReturnType<typeof getBuildConfig>;
|
||||
validationDurationMs: number;
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
const READ_FAILURE_CODE: Readonly<Record<BootLoadFailure, string>> =
|
||||
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<RuntimeConfigLoadResult> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ async function boot(): Promise<void> {
|
||||
initializeColorScheme(composition.application.preferences);
|
||||
root.render(<RuntimeApplication composition={composition} />);
|
||||
import.meta.hot?.dispose(() => {
|
||||
composition.infrastructure.dispose();
|
||||
void composition.dispose();
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const safe =
|
||||
|
||||
@@ -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<Record<string, RuntimeHealth>>;
|
||||
startAfterMount(): Promise<void>;
|
||||
stop(reason?: RuntimeStopReason): Promise<void>;
|
||||
}>;
|
||||
|
||||
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<typeof createBrowserLifecycleRuntime>[0];
|
||||
host?: Parameters<typeof createServiceWorkerRuntimeHost>[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<void> | null = null;
|
||||
let stopPromise: Promise<void> | null = null;
|
||||
let stopRequested = false;
|
||||
const health: Record<string, RuntimeHealth> = {
|
||||
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<void> {
|
||||
// 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> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<Record<BootJsonOperation, BootJsonPolicy>>);
|
||||
|
||||
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<Record<string, unknown>> }>
|
||||
| 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<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function readBoundedBootJson(
|
||||
url: string,
|
||||
policy: BootJsonPolicy,
|
||||
options: ReadBoundedBootJsonOptions = {},
|
||||
): Promise<BootJsonOutcome> {
|
||||
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<Uint8Array | "TOO_LARGE" | "STREAM_FAILED"> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<Location, "origin">;
|
||||
navigator?: Pick<Navigator, "serviceWorker">;
|
||||
caches?: CacheStorage;
|
||||
document?: Pick<Document, "visibilityState">;
|
||||
}>;
|
||||
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<ServiceWorkerHostInput["host"]>);
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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<typeof createHttpClient>[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<ReleaseManifest>): 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<string, InstalledQueryInvalidationDefinition>
|
||||
> = 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,
|
||||
|
||||
@@ -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 (
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={composition.infrastructure.queryClient}>
|
||||
<ServerStateScopeProvider
|
||||
runtime={composition.infrastructure.serverStateScope}
|
||||
>
|
||||
<QueryInvalidationProvider
|
||||
coordinator={composition.infrastructure.queryInvalidation}
|
||||
>
|
||||
<ApplicationProvider application={composition.application}>
|
||||
<AppRouter
|
||||
basename={composition.config.build.routerBasePath}
|
||||
buildId={composition.release.buildId}
|
||||
/>
|
||||
</ApplicationProvider>
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
<ServerStateGenerationProvider
|
||||
store={composition.infrastructure.serverStateGeneration}
|
||||
scope={composition.infrastructure.serverStateScope}
|
||||
transitionFallback={
|
||||
<div
|
||||
aria-busy="true"
|
||||
className="state-surface state-surface--loading"
|
||||
data-scope-state="scope-transition"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ApplicationProvider application={composition.application}>
|
||||
<PostMountRuntimeStarter composition={composition} />
|
||||
<AppRouter
|
||||
basename={composition.config.build.routerBasePath}
|
||||
buildId={composition.release.buildId}
|
||||
/>
|
||||
</ApplicationProvider>
|
||||
</ServerStateGenerationProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof runtimeConfigSchema>;
|
||||
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<string, unknown>).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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
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 });
|
||||
}
|
||||
@@ -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<string>();
|
||||
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)))}`;
|
||||
}
|
||||
@@ -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<typeof contractSetSchema>;
|
||||
|
||||
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<ContractSetVerification> {
|
||||
const manifestIds = new Set<string>();
|
||||
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 });
|
||||
}
|
||||
+43
-4
@@ -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<Record<string, unknown>>,
|
||||
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<string, unknown>, 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;
|
||||
|
||||
+18
-2
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T> =
|
||||
| Readonly<{ success: true; data: T }>
|
||||
| Readonly<{ success: false; issues: readonly RuntimeValidationIssue[] }>;
|
||||
|
||||
export interface RuntimeValidator<T> {
|
||||
readonly schemaId: string;
|
||||
safeParse(value: unknown): RuntimeValidationResult<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> =
|
||||
| Readonly<{ outcome: "VALID"; value: T }>
|
||||
| Readonly<{ outcome: "INVALID"; issues: readonly RuntimeValidationIssue[] }>
|
||||
| Readonly<{ outcome: "THROWN" }>;
|
||||
|
||||
export function invokeValidator<T>(
|
||||
validator: RuntimeValidator<T>,
|
||||
value: unknown,
|
||||
): ValidatorInvocation<T> {
|
||||
let result: RuntimeValidationResult<T>;
|
||||
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<T> =
|
||||
| Readonly<{ ok: true; value: T }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
error: Readonly<{ kind: "MAPPING_CONTRACT_VIOLATION"; code: string }>;
|
||||
}>;
|
||||
|
||||
export function mappingViolation(code: string): MappingResult<never> {
|
||||
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<Problem> {
|
||||
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<Input, WireOutput, Problem> {
|
||||
readonly contract: Readonly<{
|
||||
operationId: string;
|
||||
method: HttpMethod;
|
||||
pathTemplate: string;
|
||||
inputValidator: RuntimeValidator<Input>;
|
||||
outputValidator: RuntimeValidator<WireOutput>;
|
||||
problemValidator: RuntimeValidator<Problem>;
|
||||
acceptedStatuses: readonly number[];
|
||||
emptyBodyStatuses: readonly number[];
|
||||
retrySemantics: RetrySemantics;
|
||||
requestBody: "NONE" | "JSON";
|
||||
responseBody: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE";
|
||||
commandRecovery: CommandRecoveryDescriptor | null;
|
||||
commandEffect: CommandEffectDescriptor<Problem> | 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<Record<string, string>>;
|
||||
/** 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<Envelope, Payload> {
|
||||
readonly eventType: string;
|
||||
readonly envelopeValidator: RuntimeValidator<Envelope>;
|
||||
readonly payloadValidator: RuntimeValidator<Payload>;
|
||||
}
|
||||
|
||||
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<unknown, unknown, unknown>[];
|
||||
readonly events: readonly InstalledEventContract<unknown, unknown>[];
|
||||
}
|
||||
|
||||
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<unknown>,
|
||||
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<unknown, unknown, unknown>,
|
||||
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<unknown, unknown>,
|
||||
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<unknown, unknown, unknown>
|
||||
>;
|
||||
eventByType: ReadonlyMap<string, InstalledEventContract<unknown, unknown>>;
|
||||
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<unknown, unknown, unknown>
|
||||
>();
|
||||
const eventByType = new Map<
|
||||
string,
|
||||
InstalledEventContract<unknown, unknown>
|
||||
>();
|
||||
const packagesById = new Map<string, InstalledContractPackageIdentity>();
|
||||
const contributionIds = new Set<string>();
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -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<Record<OfflineCommandState, readonly OfflineCommandState[]>>);
|
||||
|
||||
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<string>();
|
||||
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]),
|
||||
});
|
||||
}
|
||||
@@ -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<string, readonly string[]>;
|
||||
topicsForNamespace: ReadonlyMap<string, readonly string[]>;
|
||||
}>;
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, string[]>();
|
||||
const topicsForNamespace = new Map<string, string[]>();
|
||||
const seenEdges = new Map<string, Set<string>>();
|
||||
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<string>();
|
||||
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
|
||||
|
||||
@@ -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<typeof runtimeConfigArtifactSchema>;
|
||||
export type ReleaseManifestV1Artifact = z.output<
|
||||
typeof releaseManifestV1ArtifactSchema
|
||||
>;
|
||||
export type ReleaseManifestV2Artifact = z.output<
|
||||
typeof releaseManifestV2ArtifactSchema
|
||||
>;
|
||||
export type ReleaseArtifact = z.output<typeof releaseManifestArtifactSchema>;
|
||||
export type BuildManifestArtifact = z.output<typeof buildManifestArtifactSchema>;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
+14
-1
@@ -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",
|
||||
|
||||
@@ -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<void>;
|
||||
stop(reason: RuntimeStopReason): void | Promise<void>;
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** §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<string>();
|
||||
const streamIds = new Set<string>();
|
||||
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<string, number>();
|
||||
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<RuntimeCapabilityId, Readonly<{ selected: number; active: number }>>
|
||||
> = 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],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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<ServerStateProfileId, ServerStateProfile>
|
||||
> = 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<Value>(
|
||||
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<Value> = Readonly<{
|
||||
definitionId: string;
|
||||
queryKey: readonly unknown[];
|
||||
profile: ServerStateProfile;
|
||||
identity: RuntimeIdentityBinding;
|
||||
scope: CacheScopeSnapshot;
|
||||
measureResult(value: Value): QueryResultMeasure;
|
||||
execute(context: Readonly<{ signal: AbortSignal }>): Promise<Result<Value>>;
|
||||
}>;
|
||||
|
||||
@@ -31,7 +158,8 @@ export type QueryDefinition<Input, Value> = 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, Value>(
|
||||
input: Input,
|
||||
scope: CacheScopeSnapshot,
|
||||
): BoundQuery<Value> {
|
||||
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<Input, Value>(
|
||||
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<Input, Value> = Readonly<{
|
||||
definitionId: string;
|
||||
definitionVersion: number;
|
||||
operationId: string;
|
||||
owner: string;
|
||||
duplicatePolicy: "JOIN_IDENTICAL" | "REJECT_DUPLICATE" | "ALLOW_INDEPENDENT";
|
||||
duplicatePolicy: MutationDuplicatePolicy;
|
||||
scope: CacheScopeSnapshot;
|
||||
execute(input: Input): Promise<Result<Value>>;
|
||||
execute(
|
||||
input: Input,
|
||||
context: Readonly<{ signal: AbortSignal }>,
|
||||
): Promise<Result<Value>>;
|
||||
invalidate: readonly QueryInvalidationTopic[];
|
||||
optimistic?: Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
|
||||
@@ -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-<first 16 lower-hex of staticAssetSetDigest>`. */
|
||||
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<ServiceWorkerStartOutcome>;
|
||||
requestActivation(): Promise<ServiceWorkerActivationOutcome>;
|
||||
resetOwnedCaches(): Promise<ServiceWorkerResetOutcome>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -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<string>();
|
||||
const taskIds = new Set<string>();
|
||||
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]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user