Compare commits

...
Author SHA1 Message Date
donghyeon-ka 2c3eda4d6b feat: enforce classified browser storage registry 2026-07-25 20:56:58 +09:00
donghyeon-ka 0a3bf08308 Merge branch 'feature-server-state-caching-contract' into develop 2026-07-25 20:55:27 +09:00
donghyeon-ka 184eb67282 feat: add application-owned query cache contract 2026-07-25 20:55:27 +09:00
donghyeon-ka c570996a97 Merge branch 'feature-frontend-auth-session-integration-contract' into develop 2026-07-25 20:54:18 +09:00
donghyeon-ka 44414c5244 feat: integrate bounded external auth sessions 2026-07-25 20:54:18 +09:00
donghyeon-ka 37ca2c3172 Merge branch 'feature-frontend-error-classification-boundary-contract' into develop 2026-07-25 20:52:58 +09:00
donghyeon-ka 7f3569ce3c feat: normalize failures through a stable registry 2026-07-25 20:52:58 +09:00
donghyeon-ka a0ca15da65 Merge branch 'feature-runtime-schema-validation-contract' into develop 2026-07-25 20:51:27 +09:00
donghyeon-ka bf813f09ab feat: validate HTTP envelopes and payload schemas 2026-07-25 20:51:27 +09:00
donghyeon-ka 0934de44c7 Merge branch 'feature-api-client-response-envelope-contract' into develop 2026-07-25 20:49:50 +09:00
donghyeon-ka 5c36cd978c feat: add shared HTTP response and retry contract 2026-07-25 20:49:50 +09:00
donghyeon-ka b193feeddc Merge branch 'feature-frontend-env-runtime-config-contract' into develop 2026-07-25 20:46:07 +09:00
donghyeon-ka 6b51104010 feat: validate runtime configuration before mount 2026-07-25 20:46:06 +09:00
donghyeon-ka 7199d7d9b6 Merge branch 'feature-frontend-architecture-enforcement-lint-contract' into develop 2026-07-25 20:43:49 +09:00
donghyeon-ka f3e105f971 feat: enforce frontend architecture boundaries 2026-07-25 20:43:49 +09:00
donghyeon-ka 1b1fb9bac6 Merge branch 'feature-frontend-test-taxonomy-contract' into develop 2026-07-25 20:41:45 +09:00
donghyeon-ka 946cf407b0 test: establish frontend gate taxonomy 2026-07-25 20:41:44 +09:00
donghyeon-ka 0a0b7263b3 Merge branch 'feature-frontend-clean-architecture-layering-contract' into develop 2026-07-25 20:40:21 +09:00
donghyeon-ka d7480cbafd fix: make application port contracts checkJs-safe 2026-07-25 20:40:21 +09:00
donghyeon-ka 54d74fcf4a Merge branch 'feature-frontend-clean-architecture-layering-contract' into develop 2026-07-25 20:39:59 +09:00
donghyeon-ka 49b20bd557 feat: define clean architecture layers and ports 2026-07-25 20:39:59 +09:00
donghyeon-ka 24d7072d66 Merge branch 'feature-frontend-project-bootstrap-toolchain-contract' into develop 2026-07-25 20:39:06 +09:00
donghyeon-ka 7b35f6b551 feat: bootstrap Vite and pnpm toolchain contract 2026-07-25 20:39:06 +09:00
67 changed files with 5865 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
/** @type {import("dependency-cruiser").IConfiguration} */
module.exports = {
forbidden: [
{
name: "domain-is-framework-neutral",
severity: "error",
from: { path: "^src/domain" },
to: {
path: "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
},
},
{
name: "application-does-not-know-concrete-runtime",
severity: "error",
from: { path: "^src/application" },
to: {
path: "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
},
},
{
name: "presentation-does-not-know-adapters",
severity: "error",
from: { path: "^src/presentation" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
},
{
name: "adapters-do-not-know-presentation",
severity: "error",
from: { path: "^src/adapters" },
to: { path: "^src/(presentation|bootstrap)" },
},
{
name: "no-circular-dependencies",
severity: "error",
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: "node_modules" },
exclude: {
path: "^(dist|artifacts|tests/fixtures)",
},
enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
},
},
};
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
.vite/
playwright-report/
test-results/
coverage/
artifacts/**/*.json
artifacts/**/*.xml
artifacts/**/*.txt
artifacts/**/*.sarif
!artifacts/**/.gitkeep
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
save-exact=true
+1
View File
@@ -0,0 +1 @@
24.14.0
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+38
View File
@@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"layers": {
"domain": {
"root": "src/domain",
"mayImport": ["src/domain"]
},
"application": {
"root": "src/application",
"mayImport": ["src/application", "src/domain", "src/contracts"]
},
"presentation": {
"root": "src/presentation",
"mayImport": ["src/presentation", "src/application", "src/domain", "src/contracts"]
},
"adapters": {
"root": "src/adapters",
"mayImport": ["src/adapters", "src/application", "src/domain", "src/contracts"]
},
"bootstrap": {
"root": "src/bootstrap",
"mayImport": ["src"]
}
},
"forbidden": [
["domain", "application"],
["domain", "presentation"],
["domain", "adapters"],
["domain", "bootstrap"],
["application", "presentation"],
["application", "adapters"],
["application", "bootstrap"],
["presentation", "adapters"],
["presentation", "bootstrap"],
["adapters", "presentation"],
["adapters", "bootstrap"]
]
}
+34
View File
@@ -0,0 +1,34 @@
# Clean Architecture layer contract
The import direction is `domain <- application <- presentation`; concrete
adapters implement application-owned ports and are assembled only in
`src/bootstrap`.
| Layer | Owns | May depend on |
| --- | --- | --- |
| `domain` | framework-neutral models and pure policies | domain siblings |
| `application` | use cases, ports, orchestration, view-models | domain and application siblings |
| `presentation` | routes, components, user interaction and view state | application public API and shared UI |
| `adapters` | browser and third-party implementations of application ports | application ports and limited domain values |
| `bootstrap` | runtime configuration, adapter construction and React mount | all selected runtime modules |
The following edges are forbidden:
- domain to application, presentation, adapters, bootstrap, React, or browser globals
- application to presentation, concrete adapters, bootstrap, React, or browser globals
- presentation to concrete adapters, raw DTO schemas, or storage implementations
- an adapter to presentation, bootstrap internals, or another concrete adapter
`bootstrap` contains composition only. Business rules and page-specific
orchestration belong to domain/application.
Architecture reports use this shape:
```json
{
"schemaVersion": 1,
"generatedAt": "ISO-8601",
"rules": [{ "name": "rule-id", "severity": "error", "violations": 0 }],
"summary": { "errors": 0, "warnings": 0 }
}
```
+24
View File
@@ -0,0 +1,24 @@
# Test and evidence taxonomy
Each gate is blocking in its declared scope. Failures are not downgraded with
`continue-on-error` or warning-only scripts.
| Level | Command | Evidence |
| --- | --- | --- |
| runtime schema | `pnpm test:runtime-schema` | `artifacts/tests/runtime-schema.xml` |
| unit | `pnpm test:unit` | `artifacts/tests/unit.xml` |
| component | `pnpm test:component` | `artifacts/tests/component.xml` |
| integration | `pnpm test:integration` | `artifacts/tests/integration.xml` |
| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` |
A control is verified only when a positive fixture passes and its deliberately
failing negative fixture is rejected. Generated evidence is retained by CI;
the repository tracks only the evidence directory structure.
Promotion is an AND graph:
1. merge gates
2. merge gates plus release gates
3. release gates plus rollback/runbook drills
4. production promotion plus eligible field Web Vitals evidence
+101
View File
@@ -0,0 +1,101 @@
import eslint from "@eslint/js";
import globals from "globals";
const layerPatterns = {
domain: [
"**/application/**",
"**/presentation/**",
"**/adapters/**",
"**/bootstrap/**",
"react",
"react-dom",
"@tanstack/**",
],
application: [
"**/presentation/**",
"**/adapters/**",
"**/bootstrap/**",
"react",
"react-dom",
"@tanstack/**",
],
presentation: ["**/adapters/**", "**/bootstrap/**", "@tanstack/**"],
adapters: ["**/presentation/**", "**/bootstrap/**"],
};
function restrictedImports(patterns) {
return ["error", { patterns }];
}
export default [
{
ignores: [
"dist/**",
"node_modules/**",
"artifacts/**",
"tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**",
],
},
eslint.configs.recommended,
{
files: ["**/*.{js,jsx,mjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
},
{
files: ["src/domain/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.domain),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
},
},
{
files: ["src/application/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.application),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
},
},
{
files: ["src/presentation/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.presentation),
},
},
{
files: ["src/adapters/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.adapters),
},
},
{
files: ["tests/**/*.{js,jsx}"],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ["tests/fixtures/architecture/forbidden/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports([
"**/adapters/**",
"@tanstack/**",
"react",
"react-dom",
]),
},
},
];
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Clean Architecture frontend template" />
<title>Clean Architecture Frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/bootstrap/main.jsx"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
{
"name": "clean-architecture-frontend-template",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.17.0",
"engines": {
"node": ">=24.0.0 <25.0.0",
"pnpm": ">=11.0.0 <12.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/generate-build-manifest.mjs",
"preview": "vite preview",
"lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0",
"check:architecture": "node scripts/check-architecture.mjs",
"check:types": "tsc --allowJs --checkJs --noEmit",
"check:types:fixture": "tsc --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y",
"test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration"
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "4.4.3"
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.0",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/node": "24.13.3",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.4",
"dependency-cruiser": "18.1.0",
"eslint": "10.8.0",
"globals": "17.7.0",
"jsdom": "29.1.1",
"msw": "2.15.0",
"typescript": "7.0.2",
"vite": "8.1.5",
"vitest": "4.1.10"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
outputDir: "./artifacts/tests/e2e/results",
reporter: [
["list"],
["html", { outputFolder: "./artifacts/tests/e2e/report", open: "never" }],
],
use: {
baseURL: "http://127.0.0.1:5173",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: {
command: "pnpm dev --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
});
+2894
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
allowBuilds:
msw: true
minimumReleaseAgeExclude:
- '@playwright/test@1.62.0'
- playwright-core@1.62.0
- playwright@1.62.0
- eslint@10.8.0
+13
View File
@@ -0,0 +1,13 @@
{
"APP_ENV": "local",
"API_BASE_URL": "http://localhost:8080",
"REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false,
"AUTH_MODE": "external",
"CONFIG_SCHEMA_VERSION": "1",
"API_CONTRACT_VERSION": "1",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
"BUILD_ID": "local-build",
"RELEASE_ID": "local-release"
}
+69
View File
@@ -0,0 +1,69 @@
import { mkdir, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
await mkdir("artifacts/quality", { recursive: true });
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
if (!pnpmCli) {
throw new Error("check:architecture must run through the pnpm script");
}
/** @param {string[]} arguments_ */
function runPnpm(arguments_) {
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
encoding: "utf8",
});
}
const production = runPnpm(
[
"exec",
"depcruise",
"src",
"--config",
".dependency-cruiser.cjs",
"--output-type",
"json",
],
);
await writeFile(
"artifacts/quality/dependency-report.json",
production.stdout || JSON.stringify({ summary: { errors: 1 } }),
);
if (production.status !== 0) {
process.stderr.write(
production.error?.message ?? production.stderr ?? production.stdout ?? "failed",
);
process.exit(production.status ?? 1);
}
const allowed = runPnpm(
[
"exec",
"eslint",
"tests/fixtures/architecture/allowed",
"--no-ignore",
"--max-warnings=0",
],
);
const forbidden = runPnpm(
[
"exec",
"eslint",
"tests/fixtures/architecture/forbidden",
"--no-ignore",
"--max-warnings=0",
],
);
if (allowed.status !== 0 || forbidden.status === 0) {
process.stderr.write(allowed.stderr || allowed.stdout);
process.stderr.write(forbidden.stderr || forbidden.stdout);
process.exit(1);
}
process.stdout.write("Architecture fixtures: allowed PASS, forbidden rejected\n");
+30
View File
@@ -0,0 +1,30 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const manifest = {
schemaVersion: 1,
buildId,
commitSha,
generatedAt: new Date().toISOString(),
buildContext: {
nodeVersion: process.version,
packageManagerVersion,
runnerImage,
},
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
},
};
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/build-manifest.json",
`${JSON.stringify(manifest, null, 2)}\n`,
);
@@ -0,0 +1,45 @@
/**
* Creates the skeleton-owned side of an external session integration.
* Credential acquisition and storage stay inside the supplied external owner.
*
* @param {{
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
* attachCredential(request: Request): Promise<Request>,
* recoverSession(): Promise<"restored" | "no-session">,
* notifyUnauthenticated(): void
* }} owner
* @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort}
*/
export function createExternalAuthSessionAdapter(owner) {
return Object.freeze({
getState() {
return owner.readState();
},
async attach(request) {
const attached = await owner.attachCredential(request);
if (!(attached instanceof Request)) {
throw new TypeError("Auth owner returned an invalid request");
}
return attached;
},
async recover() {
const result = await owner.recoverSession();
if (result !== "restored" && result !== "no-session") {
throw new TypeError("Auth owner returned an invalid recovery state");
}
return result;
},
onUnauthenticated() {
owner.notifyUnauthenticated();
},
});
}
export function createAnonymousSessionAdapter() {
return createExternalAuthSessionAdapter({
readState: () => "unauthenticated",
attachCredential: async (request) => request,
recoverSession: async () => "no-session",
notifyUnauthenticated: () => {},
});
}
+407
View File
@@ -0,0 +1,407 @@
import { systemClock } from "../../application/ports/clock-port.js";
import { getApiOperation } from "../../contracts/api-operations.js";
import {
createFailure as failure,
kindForStatus as statusKind,
} from "../../contracts/errors.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.js";
const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
getState: () => /** @type {"unauthenticated"} */ ("unauthenticated"),
attach: async (request) => request,
recover: async () => /** @type {"no-session"} */ ("no-session"),
onUnauthenticated: () => {},
});
/**
* @typedef {{
* kind: string,
* code: string,
* retryable: boolean,
* operationId: string,
* attemptCount: number,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: string
* }} HttpFailure
*/
/**
* @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
* { ok: false, error: HttpFailure }} HttpResult
*/
/**
* @param {{
* baseUrl: string,
* fetcher?: typeof fetch,
* authSession?: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* clock?: import("../../application/ports/clock-port.js").ClockPort,
* random?: () => number,
* validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* idempotencyKeyFactory?: () => string
* }} dependencies
*/
export function createHttpClient(dependencies) {
const fetcher = dependencies.fetcher ?? fetch;
const authSession = dependencies.authSession ?? noAuthSession;
const clock = dependencies.clock ?? systemClock;
const random = dependencies.random ?? Math.random;
const validatePayload =
dependencies.validatePayload ?? validateOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
/**
* @param {string} operationId
* @param {{
* body?: unknown,
* routeId?: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} [input]
*/
async function execute(operationId, input = {}) {
const operation = getApiOperation(operationId);
const logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
: undefined;
let retryCount = 0;
let recoveryUsed = false;
while (true) {
const attempt = retryCount;
/** @type {HttpResult} */
const outcome = await performAttempt({
operation,
input,
attempt,
idempotencyKey: logicalIdempotencyKey,
});
if (outcome.ok) return outcome;
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true;
const recovered = await recoverSession(authSession, operation, outcome.error);
if (!recovered.ok) return recovered;
if (operation.idempotency === "none") {
return {
ok: false,
error: {
...outcome.error,
retryable: false,
action: "retry",
},
};
}
continue;
}
if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated();
return outcome;
}
if (!shouldRetry(operation, outcome.error, retryCount)) {
return outcome;
}
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
retryCount += 1;
try {
await clock.sleep(delay, input.signal);
} catch {
return {
ok: false,
error: failure("REQUEST_ABORTED", operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
};
}
}
}
/**
* @param {{
* operation: ReturnType<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal },
* attempt: number,
* idempotencyKey?: string
* }} context
* @returns {Promise<HttpResult>}
*/
async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context;
const controller = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
const headers = new Headers({ Accept: "application/json" });
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (input.body !== undefined) {
const requestValidation = validateOperationRequest(
operation.requestSchema,
input.body,
);
if (!requestValidation.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: "REQUEST_SCHEMA_INVALID",
}),
};
}
}
let request = new Request(new URL(operation.path, dependencies.baseUrl), {
method: operation.method,
headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body),
signal: controller.signal,
});
try {
if (operation.auth === "external-session") {
try {
request = await authSession.attach(request);
} catch {
return {
ok: false,
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
code: "AUTH_ATTACH_FAILED",
}),
};
}
}
const response = await fetcher(request);
return await parseResponse(response, operation, attempt, validatePayload);
} catch {
if (timedOut) {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "REQUEST_TIMEOUT",
}),
};
}
if (controller.signal.aborted || input.signal?.aborted) {
const externalReason = input.signal?.reason;
if (externalReason === "timeout") {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "REQUEST_TIMEOUT",
}),
};
}
if (
externalReason !== undefined &&
!["navigation", "user", "superseded"].includes(String(externalReason))
) {
return {
ok: false,
error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, {
code: "EXTERNAL_ABORT_UNRESOLVED",
}),
};
}
return {
ok: false,
error: failure("REQUEST_ABORTED", operation.operationId, attempt, {
code: "REQUEST_ABORTED",
}),
};
}
return {
ok: false,
error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, {
code: "NETWORK_UNREACHABLE",
}),
};
} finally {
clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort);
}
}
return Object.freeze({ execute });
}
/**
* @param {Response} response
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
* @param {number} attempt
* @param {(schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }} validatePayload
* @returns {Promise<HttpResult>}
*/
async function parseResponse(response, operation, attempt, validatePayload) {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return {
ok: false,
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
code: "CONTENT_TYPE_MISMATCH",
httpStatus: response.status,
}),
};
}
let envelope;
try {
envelope = await response.json();
} catch {
return {
ok: false,
error: failure("MALFORMED_JSON", operation.operationId, attempt, {
code: "MALFORMED_JSON",
httpStatus: response.status,
}),
};
}
const envelopeValidation = validateEnvelope(envelope);
if (!envelopeValidation.success) {
return {
ok: false,
error: failure(
response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status),
operation.operationId,
attempt,
{
code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE",
httpStatus: response.status,
},
),
};
}
const envelopeRecord =
/** @type {Record<string, unknown>} */ (envelopeValidation.data);
if (response.ok && envelopeRecord.success === true && "data" in envelopeRecord) {
const payload = validatePayload(operation.responseSchema, envelopeRecord.data);
if (!payload.success) {
return {
ok: false,
error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, {
code: "SCHEMA_MISMATCH",
httpStatus: response.status,
}),
};
}
return {
ok: true,
value: structuredClone(payload.data),
meta: safeMeta(envelopeRecord.meta),
};
}
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
return {
ok: false,
error: failure(kind, operation.operationId, attempt, {
code: safeBackendCode(envelope),
httpStatus: response.status,
requestId: safeMeta(envelopeRecord.meta).requestId,
traceId: safeMeta(envelopeRecord.meta).traceId,
retryAfterMs:
response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter)
: undefined,
}),
};
}
/**
* @param {import("../../application/ports/auth-session-port.js").AuthSessionPort} authSession
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
* @param {HttpFailure} originalFailure
* @returns {Promise<{ok: true} | {ok: false, error: HttpFailure}>}
*/
async function recoverSession(authSession, operation, originalFailure) {
try {
const result = await authSession.recover();
if (result === "restored") return { ok: true };
if (result === "no-session") {
authSession.onUnauthenticated();
return {
ok: false,
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount, {
code: "AUTH_REQUIRED",
httpStatus: 401,
}),
};
}
} catch {
// Normalized below.
}
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
originalFailure.attemptCount,
{ code: "AUTH_RECOVERY_FAILED" },
),
};
}
/**
* @param {string} kind
* @param {string} operationId
* @param {number} attempt
* @param {FailureDetails} [details]
* @returns {HttpFailure}
*/
/** @param {unknown} envelope */
function safeBackendCode(envelope) {
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
const error = /** @type {Record<string, unknown>} */ (envelope).error;
if (!error || typeof error !== "object") return "HTTP_FAILURE";
const code = /** @type {Record<string, unknown>} */ (error).code;
return typeof code === "string" ? code : "HTTP_FAILURE";
}
/** @param {unknown} meta @returns {Record<string, string>} */
function safeMeta(meta) {
if (!meta || typeof meta !== "object") return {};
const metaRecord = /** @type {Record<string, unknown>} */ (meta);
return {
...(typeof metaRecord.requestId === "string"
? { requestId: metaRecord.requestId }
: {}),
...(typeof metaRecord.traceId === "string" ? { traceId: metaRecord.traceId } : {}),
};
}
/** @param {string} value */
function parseRetryAfterHeader(value) {
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : undefined;
}
+79
View File
@@ -0,0 +1,79 @@
const retryKinds = new Set([
"NETWORK_UNREACHABLE",
"REQUEST_TIMEOUT",
"RATE_LIMITED",
"SERVER_FAILURE",
]);
/**
* @param {number} retryIndex
* @param {() => number} [random]
* @param {number} [baseDelayMs]
* @param {number} [maxDelayMs]
*/
export function calculateBackoff(
retryIndex,
random = Math.random,
baseDelayMs = 250,
maxDelayMs = 2_000,
) {
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
}
/** @param {string | null | undefined} value @param {number} [now] */
export function parseRetryAfter(value, now = Date.now()) {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) {
return seconds < 0 ? null : seconds * 1_000;
}
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return null;
return Math.max(0, timestamp - now);
}
/**
* @param {{ idempotency: "safe" | "keyed" | "none" }} operation
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount
* @param {number} [maxRetries]
*/
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false;
if (
failure.kind === "SERVER_FAILURE" &&
failure.httpStatus !== undefined &&
![502, 503, 504].includes(failure.httpStatus)
) {
return false;
}
if (
failure.kind === "RATE_LIMITED" &&
typeof failure.retryAfterMs === "number" &&
failure.retryAfterMs > 30_000
) {
return false;
}
return operation.idempotency === "safe" || operation.idempotency === "keyed";
}
/**
* @param {{ kind: string, retryAfterMs?: number, retryAfter?: string }} failure
* @param {number} retryIndex
* @param {() => number} [random]
* @param {number} [now]
*/
export function retryDelay(failure, retryIndex, random = Math.random, now = Date.now()) {
const localBackoff = calculateBackoff(retryIndex, random);
if (failure.kind !== "RATE_LIMITED") return localBackoff;
const retryAfterMs =
typeof failure.retryAfterMs === "number"
? failure.retryAfterMs
: parseRetryAfter(failure.retryAfter, now);
return retryAfterMs === null ? localBackoff : Math.max(localBackoff, retryAfterMs);
}
+115
View File
@@ -0,0 +1,115 @@
import { z } from "zod";
const metaSchema = z
.object({
requestId: z.string().min(1),
traceId: z.string().min(1),
correlationId: z.string().min(1).optional(),
})
.passthrough();
export const successEnvelopeSchema = z
.object({
success: z.literal(true),
data: z.unknown(),
meta: metaSchema,
})
.strict();
export const failureEnvelopeSchema = z
.object({
success: z.literal(false),
error: z
.object({
code: z.string().min(1),
category: z.string().min(1).optional(),
message: z.string().optional(),
retryable: z.boolean().optional(),
details: z.unknown().optional(),
})
.strict(),
meta: metaSchema,
})
.strict();
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
successEnvelopeSchema,
failureEnvelopeSchema,
]);
const sampleResourceSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.string().optional(),
})
.passthrough();
const payloadSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListPayload: z.array(sampleResourceSchema),
SampleResourcePayload: sampleResourceSchema,
}));
const requestSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListQuery: z
.object({
cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20),
})
.strict(),
CreateSampleResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
})
.strict(),
}));
/** @param {unknown} value */
export function validateEnvelope(value) {
return projectResult(responseEnvelopeSchema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationPayload(schemaId, value) {
const schema = payloadSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationRequest(schemaId, value) {
const schema = requestSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId */
function missingSchema(schemaId) {
return {
success: /** @type {false} */ (false),
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
};
}
/**
* @param {{ success: true, data: unknown } |
* { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result
*/
function projectResult(result) {
if (result.success) {
return {
success: /** @type {true} */ (true),
data: structuredClone(result.data),
};
}
return {
success: /** @type {false} */ (false),
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
@@ -0,0 +1,72 @@
import { QueryClient } from "@tanstack/react-query";
import { createFailure } from "../../contracts/errors.js";
export const QUERY_CACHE_DEFAULTS = Object.freeze({
staleTime: 30_000,
gcTime: 300_000,
refetchOnWindowFocus: true,
retry: false,
mutationRetry: false,
persistence: false,
});
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: QUERY_CACHE_DEFAULTS.staleTime,
gcTime: QUERY_CACHE_DEFAULTS.gcTime,
refetchOnWindowFocus: QUERY_CACHE_DEFAULTS.refetchOnWindowFocus,
retry: QUERY_CACHE_DEFAULTS.retry,
},
mutations: {
retry: QUERY_CACHE_DEFAULTS.mutationRetry,
},
},
});
}
/**
* @param {QueryClient} queryClient
* @returns {import("../../application/ports/query-cache-port.js").QueryCachePort}
*/
export function createQueryCacheAdapter(queryClient) {
return Object.freeze({
read(key) {
try {
return { ok: true, value: queryClient.getQueryData(key) };
} catch {
return cacheFailure("read", key);
}
},
write(key, value) {
try {
queryClient.setQueryData(key, structuredClone(value));
return { ok: true };
} catch {
return cacheFailure("write", key);
}
},
async invalidate(namespace) {
try {
await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
return { ok: true };
} catch {
return cacheFailure("invalidate", namespace);
}
},
});
}
/** @param {string} phase @param {readonly unknown[]} key */
function cacheFailure(phase, key) {
const namespace = typeof key[0] === "string" ? key[0] : "unknown";
return {
ok: /** @type {false} */ (false),
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`,
causeClass: `namespace:${namespace}`,
}),
};
}
@@ -0,0 +1,137 @@
import { createFailure } from "../../contracts/errors.js";
import { getStorageDefinition } from "../../contracts/storage-keys.js";
/**
* @param {{
* localStorage?: Storage,
* sessionStorage?: Storage,
* now?: () => number
* }} [dependencies]
* @returns {import("../../application/ports/storage-port.js").StoragePort}
*/
export function createBrowserStorageAdapter(dependencies = {}) {
const memory = new Map();
const now = dependencies.now ?? Date.now;
/** @param {string} name */
function backendFor(name) {
if (name === "localStorage") return dependencies.localStorage;
if (name === "sessionStorage") return dependencies.sessionStorage;
return undefined;
}
return Object.freeze({
read(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("read", logicalName);
}
const backend = backendFor(definition.backend);
try {
const raw = backend?.getItem(definition.physicalKey);
if (raw === null || raw === undefined) {
return { ok: true, value: memory.get(definition.physicalKey) };
}
const envelope = JSON.parse(raw);
if (
!envelope ||
typeof envelope !== "object" ||
envelope.schemaVersion !== definition.schemaVersion
) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
return { ok: true, value: structuredClone(envelope.value) };
} catch {
return unavailable("read", logicalName);
}
},
write(logicalName, value) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("write", logicalName);
}
const expiresAt =
typeof definition.ttl === "number" ? now() + definition.ttl : null;
const envelope = {
schemaVersion: definition.schemaVersion,
expiresAt,
value: structuredClone(value),
};
const backend = backendFor(definition.backend);
try {
if (!backend) throw new DOMException("Storage unavailable", "SecurityError");
backend.setItem(definition.physicalKey, JSON.stringify(envelope));
return { ok: true };
} catch (error) {
const quota =
error instanceof DOMException &&
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name);
if (definition.quotaFallback === "memory") {
memory.set(definition.physicalKey, structuredClone(value));
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: "memory",
};
}
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: definition.quotaFallback,
};
}
},
remove(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("remove", logicalName);
}
try {
backendFor(definition.backend)?.removeItem(definition.physicalKey);
memory.delete(definition.physicalKey);
return { ok: true };
} catch {
return unavailable("remove", logicalName);
}
},
});
}
/** @param {boolean} quota @param {string} phase @param {string} logicalName */
function storageFailure(quota, phase, logicalName) {
return createFailure(
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
"STORAGE",
0,
{
code: `${logicalName}_${phase.toUpperCase()}_${
quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE"
}`,
},
);
}
/** @param {string} phase @param {string} logicalName */
function unavailable(phase, logicalName) {
return {
ok: /** @type {false} */ (false),
error: storageFailure(false, phase, logicalName),
};
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Application facade factory. Concrete dependencies are supplied by bootstrap.
*
* @param {{
* resources: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* },
* cache: import("./ports/query-cache-port.js").QueryCachePort,
* storage: import("./ports/storage-port.js").StoragePort,
* telemetry: import("./ports/telemetry-port.js").TelemetryPort
* }} ports
*/
export function createApplication(ports) {
/**
* @param {unknown} query
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function queryResources(query, context) {
return ports.resources.query.execute(query, context);
}
/**
* @param {unknown} command
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function commandResources(command, context) {
return ports.resources.command.execute(command, context);
}
return Object.freeze({
resources: Object.freeze({
query: queryResources,
command: commandResources,
}),
cache: ports.cache,
storage: ports.storage,
telemetry: ports.telemetry,
});
}
@@ -0,0 +1,16 @@
/**
* @typedef {"authenticated" | "unauthenticated" | "recovery-pending" | "integration-failed"} SessionState
*/
/**
* The session is opaque: credentials are attached without exposing tokens.
*
* @typedef {{
* getState(): SessionState,
* attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void
* }} AuthSessionPort
*/
export {};
+29
View File
@@ -0,0 +1,29 @@
/**
* @typedef {{
* now(): number,
* sleep(milliseconds: number, signal?: AbortSignal): Promise<void>
* }} ClockPort
*/
/** @type {ClockPort} */
export const systemClock = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, milliseconds);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
},
});
+12
View File
@@ -0,0 +1,12 @@
/**
* @typedef {{
* read(key: readonly unknown[]): { ok: true, value: unknown } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure },
* write(key: readonly unknown[], value: unknown): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure },
* invalidate(namespace: readonly unknown[]): Promise<{ ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }>
* }} QueryCachePort
*/
export {};
@@ -0,0 +1,13 @@
/**
* @typedef {{
* getCurrent(): Promise<{
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* }>
* }} ReleaseInfoPort
*/
export {};
+28
View File
@@ -0,0 +1,28 @@
/**
* @template Query
* @template Model
* @typedef {{ execute(query: Query, context?: RequestContext): Promise<Result<Model>> }} ResourceQueryPort
*/
/**
* @template Command
* @template Model
* @typedef {{ execute(command: Command, context?: RequestContext): Promise<Result<Model>> }} ResourceCommandPort
*/
/**
* @typedef {{
* operationId: string,
* routeId: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} RequestContext
*/
/**
* @template Value
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result
*/
export {};
+13
View File
@@ -0,0 +1,13 @@
/**
* @typedef {{
* read(logicalName: string): { ok: true, value: unknown } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure },
* write(logicalName: string, value: unknown): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure,
* fallback?: string },
* remove(logicalName: string): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }
* }} StoragePort
*/
export {};
+5
View File
@@ -0,0 +1,5 @@
/**
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
*/
export {};
+23
View File
@@ -0,0 +1,23 @@
import { createApplication } from "../application/create-application.js";
/**
* This is the only module allowed to join concrete adapters to application
* ports. Boot phases are explicit so failures can stop before product mount.
*
* @param {{
* loadConfig(): Promise<Record<string, unknown>>,
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>,
* createAdapters(context: {
* config: Record<string, unknown>,
* release: Record<string, unknown>
* }): Promise<Parameters<typeof createApplication>[0]>
* }} factories
*/
export async function createCompositionRoot(factories) {
const config = await factories.loadConfig();
const release = await factories.loadRelease(config);
const ports = await factories.createAdapters({ config, release });
const application = createApplication(ports);
return Object.freeze({ config, release, ports, application });
}
+104
View File
@@ -0,0 +1,104 @@
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.js";
import { validateRuntimeConfig } from "./runtime-config-schema.js";
export class BootConfigError extends Error {
/**
* @param {string} code
* @param {{ buildId: string, configSchemaVersion?: string, releaseId?: string }} safe
*/
constructor(code, safe) {
super("Runtime configuration could not be loaded");
this.name = "BootConfigError";
this.kind = "BOOT_CONFIG_FAILURE";
this.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
configSchemaVersion: safe.configSchemaVersion,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* @param {{
* fetcher?: typeof fetch,
* buildConfig?: ReturnType<typeof getBuildConfig>,
* now?: () => number
* }} [options]
*/
export async function loadRuntimeConfig(options = {}) {
const fetcher = options.fetcher ?? fetch;
const buildConfig = options.buildConfig ?? getBuildConfig();
const now = options.now ?? performance.now.bind(performance);
const startedAt = now();
let 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;
try {
rawConfig = await response.json();
} catch {
throw new BootConfigError("CONFIG_JSON_INVALID", {
buildId: buildConfig.buildId,
});
}
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
throw new BootConfigError("CONFIG_SHAPE_INVALID", {
buildId: buildConfig.buildId,
});
}
try {
assertSafeConfigNames(rawConfig);
} catch {
throw new BootConfigError("CONFIG_SECRET_NAME_REJECTED", {
buildId: buildConfig.buildId,
});
}
const validated = validateRuntimeConfig(rawConfig);
if (!validated.success) {
throw new BootConfigError("CONFIG_SCHEMA_INVALID", {
buildId: buildConfig.buildId,
configSchemaVersion:
typeof rawConfig.CONFIG_SCHEMA_VERSION === "string"
? rawConfig.CONFIG_SCHEMA_VERSION
: undefined,
releaseId: typeof rawConfig.RELEASE_ID === "string" ? rawConfig.RELEASE_ID : undefined,
});
}
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,
releaseId: validated.data.RELEASE_ID,
});
}
return Object.freeze({
config: validated.data,
build: buildConfig,
validationDurationMs: now() - startedAt,
});
}
+52
View File
@@ -0,0 +1,52 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
/** @param {{ environment: string }} props */
function BootstrapShell({ environment }) {
return (
<main>
<h1>Clean Architecture Frontend</h1>
<p>{environment} 런타임 계약이 검증되었습니다.</p>
</main>
);
}
/** @param {{ supportReference: string }} props */
function BootErrorShell({ supportReference }) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<p>지원 참조: {supportReference}</p>
</main>
);
}
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing #root mount element");
}
const root = createRoot(rootElement);
async function boot() {
try {
const runtime = await loadRuntimeConfig();
root.render(
<StrictMode>
<BootstrapShell environment={runtime.config.APP_ENV} />
</StrictMode>,
);
} catch (error) {
const supportReference =
error instanceof BootConfigError
? error.safe.supportReference
: "boot:unknown";
root.render(<BootErrorShell supportReference={supportReference} />);
}
}
void boot();
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
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.literal("external"),
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",
});
}
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
const endpointEntries =
/** @type {Array<[string, string | undefined]>} */ ([
["API_BASE_URL", config.API_BASE_URL],
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
]);
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",
});
}
}
});
/** @param {unknown} value */
export function validateRuntimeConfig(value) {
const result = runtimeConfigSchema.safeParse(value);
if (!result.success) {
return {
success: /** @type {false} */ (false),
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
return {
success: /** @type {true} */ (true),
data: structuredClone(result.data),
};
}
+51
View File
@@ -0,0 +1,51 @@
/**
* @typedef {{
* method: string,
* path: string,
* operationId: string,
* auth: "none" | "external-session",
* timeoutMs: number,
* idempotency: "safe" | "keyed" | "none",
* requestSchema: string,
* responseSchema: string,
* owner: string
* }} ApiOperation
*/
/** @param {ApiOperation} definition */
const operation = (definition) => Object.freeze(definition);
export const API_OPERATIONS = Object.freeze({
LIST_SAMPLE_RESOURCES: operation({
method: "GET",
path: "/api/sample/resources",
operationId: "LIST_SAMPLE_RESOURCES",
auth: "external-session",
timeoutMs: 10_000,
idempotency: "safe",
requestSchema: "SampleResourceListQuery",
responseSchema: "SampleResourceListPayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
CREATE_SAMPLE_RESOURCE: operation({
method: "POST",
path: "/api/sample/resources",
operationId: "CREATE_SAMPLE_RESOURCE",
auth: "external-session",
timeoutMs: 10_000,
idempotency: "keyed",
requestSchema: "CreateSampleResourceCommand",
responseSchema: "SampleResourcePayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
});
/** @param {string} operationId */
export function getApiOperation(operationId) {
const registry = /** @type {Record<string, ApiOperation>} */ (API_OPERATIONS);
const selected = registry[operationId];
if (!selected) {
throw new Error(`Unregistered API operation: ${operationId}`);
}
return selected;
}
+54
View File
@@ -0,0 +1,54 @@
const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i;
export const ENV_REGISTRY = Object.freeze({
VITE_BUILD_ID: build("public-metadata", true, null),
VITE_COMMIT_SHA: build("public-metadata", false, "local"),
VITE_ROUTER_BASE_PATH: build("compile-time", true, "/"),
VITE_RUNTIME_CONFIG_URL: build("compile-time", true, "/config.json"),
APP_ENV: runtime("public", true, null),
API_BASE_URL: runtime("public-sensitive", true, null),
REQUEST_TIMEOUT_MS: runtime("public", false, 10_000),
MAX_RETRY_ATTEMPTS: runtime("public", false, 2),
TELEMETRY_ENABLED: runtime("public", true, false),
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"),
});
/**
* @param {string} classification
* @param {boolean} required
* @param {unknown} defaultValue
*/
function build(classification, required, defaultValue) {
return Object.freeze({ phase: "build", classification, required, defaultValue });
}
/**
* @param {string} classification
* @param {boolean} required
* @param {unknown} defaultValue
*/
function runtime(classification, required, defaultValue) {
return Object.freeze({ phase: "runtime", classification, required, defaultValue });
}
/** @param {Record<string, unknown>} config */
export function assertSafeConfigNames(config) {
for (const name of Object.keys(config)) {
if (forbiddenConfigName.test(name)) {
throw new Error(`Forbidden client configuration key: ${name}`);
}
}
}
export function getBuildConfig(environment = import.meta.env) {
const buildId = environment.VITE_BUILD_ID || "local-build";
const commitSha = environment.VITE_COMMIT_SHA || "local";
const routerBasePath = environment.VITE_ROUTER_BASE_PATH || "/";
const runtimeConfigUrl = environment.VITE_RUNTIME_CONFIG_URL || "/config.json";
return Object.freeze({ buildId, commitSha, routerBasePath, runtimeConfigUrl });
}
+240
View File
@@ -0,0 +1,240 @@
const DROP_SENSITIVE = Object.freeze([
"cause",
"body",
"headers",
"authorization",
"url",
"query",
"stack",
"storageValue",
]);
/**
* @typedef {{
* kind: string,
* defaultRetryable: boolean,
* severity: string,
* userMessageKey: string,
* action: string,
* telemetryEvent: string,
* redaction: readonly string[]
* }} ErrorDefinition
*/
/**
* @param {string} kind
* @param {boolean} defaultRetryable
* @param {string} severity
* @param {string} action
* @param {string} [telemetryEvent]
* @returns {Readonly<ErrorDefinition>}
*/
const row = (
kind,
defaultRetryable,
severity,
action,
telemetryEvent = "api.request.failed",
) =>
Object.freeze({
kind,
defaultRetryable,
severity,
userMessageKey: `error.${kind.toLowerCase()}`,
action,
telemetryEvent,
redaction: DROP_SENSITIVE,
});
export const ERROR_REGISTRY = Object.freeze({
NETWORK_UNREACHABLE: row("NETWORK_UNREACHABLE", true, "warning", "retry"),
REQUEST_TIMEOUT: row("REQUEST_TIMEOUT", true, "warning", "retry"),
REQUEST_ABORTED: row("REQUEST_ABORTED", false, "info", "none"),
CONTENT_TYPE_MISMATCH: row(
"CONTENT_TYPE_MISMATCH",
false,
"error",
"contact-support",
),
MALFORMED_JSON: row("MALFORMED_JSON", false, "error", "contact-support"),
ENVELOPE_MISMATCH: row("ENVELOPE_MISMATCH", false, "error", "contact-support"),
SCHEMA_MISMATCH: row("SCHEMA_MISMATCH", false, "error", "contact-support"),
AUTH_REQUIRED: row("AUTH_REQUIRED", false, "info", "reauth"),
AUTH_INTEGRATION_FAILURE: row(
"AUTH_INTEGRATION_FAILURE",
false,
"error",
"contact-support",
),
FORBIDDEN: row("FORBIDDEN", false, "warning", "navigate"),
NOT_FOUND: row("NOT_FOUND", false, "info", "navigate"),
CONFLICT: row("CONFLICT", false, "warning", "retry"),
VALIDATION_REJECTED: row("VALIDATION_REJECTED", false, "info", "none"),
UNKNOWN_CLIENT_FAILURE: row(
"UNKNOWN_CLIENT_FAILURE",
false,
"warning",
"contact-support",
),
RATE_LIMITED: row("RATE_LIMITED", true, "warning", "retry"),
SERVER_FAILURE: row("SERVER_FAILURE", true, "error", "retry"),
CHUNK_LOAD_FAILURE: row(
"CHUNK_LOAD_FAILURE",
false,
"error",
"reload-once",
"release.mismatch.detected",
),
BOOT_CONFIG_FAILURE: row(
"BOOT_CONFIG_FAILURE",
false,
"error",
"contact-support",
"app.boot.failed",
),
RELEASE_MANIFEST_FAILURE: row(
"RELEASE_MANIFEST_FAILURE",
false,
"error",
"contact-support",
"app.boot.failed",
),
DEPLOY_MISMATCH: row(
"DEPLOY_MISMATCH",
false,
"error",
"reload-once",
"release.mismatch.detected",
),
STORAGE_UNAVAILABLE: row(
"STORAGE_UNAVAILABLE",
false,
"warning",
"none",
"storage.operation.failed",
),
STORAGE_QUOTA_EXCEEDED: row(
"STORAGE_QUOTA_EXCEEDED",
false,
"warning",
"none",
"storage.operation.failed",
),
RENDER_FAILURE: row(
"RENDER_FAILURE",
false,
"error",
"reload-once",
"ui.render.failed",
),
TELEMETRY_FAILURE: row(
"TELEMETRY_FAILURE",
false,
"info",
"none",
"telemetry.delivery.dropped",
),
QUERY_CACHE_FAILURE: row(
"QUERY_CACHE_FAILURE",
false,
"error",
"retry",
"query.cache.failed",
),
UNKNOWN_FAILURE: row("UNKNOWN_FAILURE", false, "error", "contact-support"),
});
/**
* @typedef {{
* kind: string,
* code: string,
* httpStatus?: number,
* retryable: boolean,
* operationId: string,
* attemptCount: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: string,
* causeClass?: string
* }} ApiFailure
*/
/**
* @param {string} kind
* @param {string} operationId
* @param {number} attempt
* @param {{
* code?: string,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* causeClass?: string
* }} [details]
* @returns {ApiFailure}
*/
export function createFailure(kind, operationId, attempt, details = {}) {
const registry =
/** @type {Readonly<Record<string, Readonly<ErrorDefinition>>>} */ (
ERROR_REGISTRY
);
const definition =
registry[kind] ?? ERROR_REGISTRY.UNKNOWN_FAILURE;
return Object.freeze({
kind: definition.kind,
code: typeof details.code === "string" ? details.code : definition.kind,
retryable: definition.defaultRetryable,
operationId,
attemptCount: Math.max(1, attempt + 1),
...(Number.isInteger(details.httpStatus)
? { httpStatus: details.httpStatus }
: {}),
...(typeof details.requestId === "string" ? { requestId: details.requestId } : {}),
...(typeof details.traceId === "string" ? { traceId: details.traceId } : {}),
...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs }
: {}),
...(typeof details.causeClass === "string"
? { causeClass: details.causeClass }
: {}),
userMessageKey: definition.userMessageKey,
action: definition.action,
});
}
/** @param {number} status */
export function kindForStatus(status) {
if (status === 401) return "AUTH_REQUIRED";
if (status === 403) return "FORBIDDEN";
if (status === 404) return "NOT_FOUND";
if (status === 409) return "CONFLICT";
if (status === 422) return "VALIDATION_REJECTED";
if (status === 429) return "RATE_LIMITED";
if (status >= 500) return "SERVER_FAILURE";
if (status >= 400) return "UNKNOWN_CLIENT_FAILURE";
return "ENVELOPE_MISMATCH";
}
/**
* Total catch-all that intentionally discards the thrown value.
*
* @param {unknown} value
* @param {{ operationId?: string, attempt?: number }} [context]
*/
export function normalizeUnknownFailure(value, context = {}) {
const causeClass =
value instanceof Error
? value.name
: value === null
? "null"
: typeof value;
return createFailure(
"UNKNOWN_FAILURE",
context.operationId ?? "UNKNOWN_OPERATION",
context.attempt ?? 0,
{ code: "UNKNOWN_FAILURE", causeClass },
);
}
+36
View File
@@ -0,0 +1,36 @@
const RESOURCE_NAMESPACE = Object.freeze(["resource", 1]);
export const queryKeys = Object.freeze({
resource: Object.freeze({
all: () => RESOURCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...RESOURCE_NAMESPACE, "list", canonicalize(filters)]),
/** @param {string} resourceId */
detail: (resourceId) =>
Object.freeze([...RESOURCE_NAMESPACE, "detail", String(resourceId)]),
}),
});
export const QUERY_REGISTRY = Object.freeze({
RESOURCE: Object.freeze({
namespace: RESOURCE_NAMESPACE,
serialization: "canonical-object-order",
identity: "no-pii-token-or-raw-url",
invalidation: "resource namespace after successful mutation",
version: 1,
persistence: "disabled",
}),
});
/** @param {unknown} value @returns {unknown} */
export function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)]),
);
}
return value;
}
+102
View File
@@ -0,0 +1,102 @@
const APP_NAMESPACE = "ca-frontend";
export const STORAGE_REGISTRY = Object.freeze({
COLOR_SCHEME: defineStorageKey({
logicalName: "COLOR_SCHEME",
scope: "preference",
name: "color-scheme",
backend: "localStorage",
classification: "public-preference",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "memory",
}),
CHUNK_RELOAD_GUARD: defineStorageKey({
logicalName: "CHUNK_RELOAD_GUARD",
scope: "release",
name: "chunk-reload-guard",
backend: "sessionStorage",
classification: "opaque-cache",
schemaVersion: 1,
ttl: "session",
migration: "discard",
quotaFallback: "no-persist",
}),
QUERY_PERSISTENCE: defineStorageKey({
logicalName: "QUERY_PERSISTENCE",
scope: "cache",
name: "query-persistence",
backend: "disabled",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
AUTH_TOKEN: defineStorageKey({
logicalName: "AUTH_TOKEN",
scope: "auth",
name: "auth-token",
backend: "forbidden",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
});
/**
* @typedef {{
* logicalName: string,
* scope: string,
* name: string,
* backend: "memory" | "sessionStorage" | "localStorage" | "indexedDB" |
* "disabled" | "forbidden",
* classification: "public-preference" | "opaque-cache" | "sensitive-forbidden",
* schemaVersion: number,
* ttl: number | "session" | null,
* migration: "discard" | ((value: unknown) => unknown),
* quotaFallback: "memory" | "no-persist" | "feature-disable"
* }} StorageKeyInput
*/
/** @param {StorageKeyInput} definition */
export function defineStorageKey(definition) {
if (definition.classification === "sensitive-forbidden") {
if (!["disabled", "forbidden"].includes(definition.backend)) {
throw new Error("Sensitive client storage registration is forbidden");
}
}
if (!Number.isInteger(definition.schemaVersion) || definition.schemaVersion < 1) {
throw new Error("Storage schemaVersion must be a positive integer");
}
return Object.freeze({
...definition,
physicalKey: buildPhysicalKey(
definition.scope,
definition.schemaVersion,
definition.name,
),
});
}
/** @param {string} scope @param {number} schemaVersion @param {string} name */
export function buildPhysicalKey(scope, schemaVersion, name) {
return `${APP_NAMESPACE}:${scope}:v${schemaVersion}:${name}`;
}
/** @param {string} logicalName */
export function getStorageDefinition(logicalName) {
const registry = /** @type {Record<string, ReturnType<typeof defineStorageKey>>} */ (
STORAGE_REGISTRY
);
const definition = registry[logicalName];
if (!definition) throw new Error(`Unregistered storage key: ${logicalName}`);
if (definition.classification === "sensitive-forbidden") {
throw new Error(`Forbidden storage key: ${logicalName}`);
}
return definition;
}
+17
View File
@@ -0,0 +1,17 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
function TestShell() {
return <main aria-label="application shell">ready</main>;
}
describe("component test level", () => {
it("renders an accessible application shell", () => {
render(<TestShell />);
expect(screen.getByRole("main", { name: "application shell" })).toHaveTextContent(
"ready",
);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { expect, test } from "@playwright/test";
test("boots the public app shell", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Clean Architecture Frontend",
);
});
@@ -0,0 +1,3 @@
import { createApplication } from "../../../../src/application/create-application.js";
export const applicationFactory = createApplication;
@@ -0,0 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
export const invalidClient = new QueryClient();
@@ -0,0 +1,3 @@
import React from "react";
export const invalidDomainValue = React.createElement("div");
@@ -0,0 +1,3 @@
import "../../../../src/adapters/http/client.js";
export const invalidEdge = true;
+8
View File
@@ -0,0 +1,8 @@
// This deliberately failing fixture proves that checkJs rejects a wrong shape.
/** @param {{ operationId: string }} request */
function execute(request) {
return request.operationId;
}
execute({ operationId: 42 });
+103
View File
@@ -0,0 +1,103 @@
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../../src/adapters/http/client.js";
let responseStatuses = [];
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
const status = responseStatuses.shift() ?? 200;
if (status === 401) {
return HttpResponse.json(
{
success: false,
error: { code: "UNAUTHENTICATED" },
meta: { requestId: "request-1", traceId: "trace-1" },
},
{ status },
);
}
return HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
meta: { requestId: "request-2", traceId: "trace-1" },
});
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
responseStatuses = [];
server.resetHandlers();
});
afterAll(() => server.close());
const clock = { now: () => 0, sleep: async () => {} };
describe("bounded 401 session recovery", () => {
it("calls recovery once and replays a safe request once", async () => {
responseStatuses = [401, 200];
const recoverSession = vi.fn(async () => "restored");
const authSession = createExternalAuthSessionAdapter({
readState: () => "authenticated",
attachCredential: async (request) => request,
recoverSession,
notifyUnauthenticated: vi.fn(),
});
const client = createHttpClient({
baseUrl: "https://api.test",
authSession,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
ok: true,
});
expect(recoverSession).toHaveBeenCalledTimes(1);
});
it("stops after a second 401 and notifies unauthenticated once", async () => {
responseStatuses = [401, 401];
const notifyUnauthenticated = vi.fn();
const authSession = createExternalAuthSessionAdapter({
readState: () => "authenticated",
attachCredential: async (request) => request,
recoverSession: async () => "restored",
notifyUnauthenticated,
});
const client = createHttpClient({
baseUrl: "https://api.test",
authSession,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
ok: false,
error: { kind: "AUTH_REQUIRED" },
});
expect(notifyUnauthenticated).toHaveBeenCalledTimes(1);
});
it("normalizes attach and invalid recovery failures", async () => {
const attachFailure = createExternalAuthSessionAdapter({
readState: () => "authenticated",
attachCredential: async () => {
throw new Error("credential detail");
},
recoverSession: async () => "restored",
notifyUnauthenticated: vi.fn(),
});
const client = createHttpClient({
baseUrl: "https://api.test",
authSession: attachFailure,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
ok: false,
error: { kind: "AUTH_INTEGRATION_FAILURE" },
});
});
});
+23
View File
@@ -0,0 +1,23 @@
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
const server = setupServer(
http.get("https://example.test/health", () =>
HttpResponse.json({ success: true, data: { status: "ok" } }),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("integration test level", () => {
it("uses MSW to isolate the HTTP boundary", async () => {
const response = await fetch("https://example.test/health");
await expect(response.json()).resolves.toEqual({
success: true,
data: { status: "ok" },
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
let attempts = 0;
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
attempts += 1;
if (attempts < 3) {
return HttpResponse.json(
{ success: false, error: { code: "TEMPORARY" } },
{ status: 503 },
);
}
return HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
meta: { requestId: "request-1", traceId: "trace-1" },
});
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
attempts = 0;
server.resetHandlers();
});
afterAll(() => server.close());
const clock = {
now: () => 0,
sleep: async () => {},
};
describe("shared HTTP client", () => {
it("retries a safe request at most twice and returns validated data", async () => {
const client = createHttpClient({
baseUrl: "https://api.test",
clock,
random: () => 0,
});
await expect(
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
).resolves.toMatchObject({
ok: true,
value: [{ id: "resource-1" }],
meta: { requestId: "request-1" },
});
expect(attempts).toBe(3);
});
it("rejects a non-JSON response without exposing its body", async () => {
server.use(
http.get(
"https://api.test/api/sample/resources",
() => new HttpResponse("<secret>raw body</secret>", { status: 502 }),
),
);
const client = createHttpClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("LIST_SAMPLE_RESOURCES");
expect(result).toMatchObject({
ok: false,
error: { kind: "CONTENT_TYPE_MISMATCH" },
});
expect(JSON.stringify(result)).not.toContain("raw body");
});
it("classifies malformed JSON and invalid payloads at the boundary", async () => {
server.use(
http.get(
"https://api.test/api/sample/resources",
() =>
new HttpResponse("{", {
headers: { "Content-Type": "application/json" },
}),
),
);
const client = createHttpClient({ baseUrl: "https://api.test", clock });
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
ok: false,
error: { kind: "MALFORMED_JSON" },
});
server.use(
http.get("https://api.test/api/sample/resources", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: 42 }],
meta: { requestId: "request-1", traceId: "trace-1" },
}),
),
);
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
ok: false,
error: { kind: "SCHEMA_MISMATCH" },
});
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "../../src/adapters/http/schema-registry.js";
describe("HTTP runtime schema boundary", () => {
it("rejects an invalid top-level envelope", () => {
expect(validateEnvelope({ success: true }).success).toBe(false);
});
it("rejects an invalid operation payload with safe issue metadata", () => {
const result = validateOperationPayload("SampleResourceListPayload", [
{ id: "resource-1", name: 42 },
]);
expect(result).toMatchObject({
success: false,
issues: [{ path: "0.name" }],
});
expect(JSON.stringify(result)).not.toContain("resource-1");
});
it("returns a deep-cloned additive-tolerant payload", () => {
const source = [{ id: "resource-1", name: "Example", additive: "accepted" }];
const result = validateOperationPayload("SampleResourceListPayload", source);
expect(result).toMatchObject({
success: true,
data: [{ id: "resource-1", additive: "accepted" }],
});
expect(result.data).not.toBe(source);
});
it("validates outbound commands before transport", () => {
expect(
validateOperationRequest("CreateSampleResourceCommand", { name: "" }).success,
).toBe(false);
expect(
validateOperationRequest("CreateSampleResourceCommand", { name: "Example" })
.success,
).toBe(true);
});
it("fails closed for an unregistered schema", () => {
expect(validateOperationPayload("UnknownPayload", {})).toMatchObject({
success: false,
issues: [{ code: "SCHEMA_NOT_REGISTERED" }],
});
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { loadRuntimeConfig } from "../../src/bootstrap/load-runtime-config.js";
import { validateRuntimeConfig } from "../../src/bootstrap/runtime-config-schema.js";
import { assertSafeConfigNames } from "../../src/contracts/env.js";
const validConfig = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "external",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "build-a",
};
describe("runtime configuration boundary", () => {
it.each([
[{ ...validConfig, API_BASE_URL: undefined }, "required key"],
[{ ...validConfig, REQUEST_TIMEOUT_MS: 0 }, "integer range"],
[{ ...validConfig, MAX_RETRY_ATTEMPTS: 3 }, "retry cap"],
[{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"],
[{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"],
[{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"],
])("rejects invalid config: %s (%s)", (candidate) => {
expect(validateRuntimeConfig(candidate).success).toBe(false);
});
it("rejects secret-like names before schema validation", () => {
expect(() => assertSafeConfigNames({ CLIENT_SECRET: "not-safe" })).toThrow(
"Forbidden client configuration key",
);
});
it("validates a fetched config under the 500ms budget excluding network", async () => {
let current = 100;
const result = await loadRuntimeConfig({
buildConfig: {
buildId: "build-a",
commitSha: "local",
routerBasePath: "/",
runtimeConfigUrl: "/config.json",
},
fetcher: async () => new Response(JSON.stringify(validConfig)),
now: () => (current += 2),
});
expect(result.validationDurationMs).toBeLessThanOrEqual(500);
expect(result.config.API_BASE_URL).toBe("http://localhost:8080");
});
it("returns only safe boot fields on failure", async () => {
await expect(
loadRuntimeConfig({
buildConfig: {
buildId: "build-a",
commitSha: "local",
routerBasePath: "/",
runtimeConfigUrl: "/config.json",
},
fetcher: async () => new Response("{"),
}),
).rejects.toMatchObject({
safe: {
kind: "BOOT_CONFIG_FAILURE",
buildId: "build-a",
},
});
});
});
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import {
createAnonymousSessionAdapter,
createExternalAuthSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js";
describe("external AuthSessionPort adapter", () => {
it("attaches opaque credentials without exposing a token-shaped session", async () => {
const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated",
attachCredential: async (request) => {
const headers = new Headers(request.headers);
headers.set("X-Session-Attached", "true");
return new Request(request, { headers });
},
recoverSession: async () => "restored",
notifyUnauthenticated: vi.fn(),
});
const request = await adapter.attach(new Request("https://api.test/resource"));
expect(request.headers.get("X-Session-Attached")).toBe("true");
expect(adapter.getState()).toBe("authenticated");
expect(adapter).not.toHaveProperty("accessToken");
expect(adapter).not.toHaveProperty("refreshToken");
});
it("fails invalid recovery states closed", async () => {
const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated",
attachCredential: async (request) => request,
recoverSession: async () => "unexpected",
notifyUnauthenticated: vi.fn(),
});
await expect(adapter.recover()).rejects.toThrow("invalid recovery state");
});
it("provides a safe anonymous adapter", async () => {
const adapter = createAnonymousSessionAdapter();
expect(adapter.getState()).toBe("unauthenticated");
await expect(adapter.recover()).resolves.toBe("no-session");
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
ERROR_REGISTRY,
createFailure,
kindForStatus,
normalizeUnknownFailure,
} from "../../src/contracts/errors.js";
describe("frontend failure classification", () => {
it("defines all 26 stable error kinds with the seven contract fields", () => {
expect(Object.keys(ERROR_REGISTRY)).toHaveLength(26);
for (const definition of Object.values(ERROR_REGISTRY)) {
expect(definition).toEqual(
expect.objectContaining({
kind: expect.any(String),
defaultRetryable: expect.any(Boolean),
severity: expect.any(String),
userMessageKey: expect.any(String),
action: expect.any(String),
telemetryEvent: expect.any(String),
redaction: expect.any(Array),
}),
);
}
});
it.each([
[401, "AUTH_REQUIRED"],
[403, "FORBIDDEN"],
[404, "NOT_FOUND"],
[409, "CONFLICT"],
[422, "VALIDATION_REJECTED"],
[418, "UNKNOWN_CLIENT_FAILURE"],
[429, "RATE_LIMITED"],
[503, "SERVER_FAILURE"],
])("maps HTTP %i to %s", (status, kind) => {
expect(kindForStatus(status)).toBe(kind);
});
it("projects only allowlisted safe fields", () => {
const result = createFailure("SERVER_FAILURE", "LIST_SAMPLE_RESOURCES", 0, {
code: "TEMPORARY",
httpStatus: 503,
requestId: "request-1",
stack: "must not leak",
body: "must not leak",
authorization: "Bearer secret",
});
expect(result).toMatchObject({
kind: "SERVER_FAILURE",
code: "TEMPORARY",
httpStatus: 503,
requestId: "request-1",
});
expect(JSON.stringify(result)).not.toMatch(/stack|body|Bearer|secret/);
});
it("normalizes any thrown value without leaking it", () => {
const secret = { token: "sensitive", nested: { rawBody: "private" } };
const result = normalizeUnknownFailure(secret);
expect(result).toMatchObject({
kind: "UNKNOWN_FAILURE",
causeClass: "object",
});
expect(JSON.stringify(result)).not.toMatch(/sensitive|private|token|rawBody/);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
describe("query key registry", () => {
it("canonicalizes filter order into the same stable key", () => {
expect(queryKeys.resource.list({ page: 1, status: "open" })).toEqual(
queryKeys.resource.list({ status: "open", page: 1 }),
);
});
it("contains no raw URL or token material", () => {
expect(JSON.stringify(queryKeys.resource.detail("resource-1"))).toBe(
'["resource",1,"detail","resource-1"]',
);
});
});
describe("TanStack QueryCachePort adapter", () => {
it("reads, writes, and invalidates only the declared namespace", async () => {
const client = createQueryClient();
const adapter = createQueryCacheAdapter(client);
const listKey = queryKeys.resource.list({ page: 1 });
const otherKey = ["other", 1];
expect(adapter.write(listKey, [{ id: "resource-1" }])).toEqual({ ok: true });
expect(adapter.write(otherKey, "preserved")).toEqual({ ok: true });
expect(adapter.read(listKey)).toMatchObject({
ok: true,
value: [{ id: "resource-1" }],
});
await adapter.invalidate(queryKeys.resource.all());
expect(client.getQueryState(listKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false);
});
it("normalizes adapter exceptions without raw key data", async () => {
const client = new QueryClient();
vi.spyOn(client, "invalidateQueries").mockRejectedValue(new Error("secret-key"));
const adapter = createQueryCacheAdapter(client);
const result = await adapter.invalidate(["resource", "sensitive-filter"]);
expect(result).toMatchObject({
ok: false,
error: { kind: "QUERY_CACHE_FAILURE" },
});
expect(JSON.stringify(result)).not.toContain("sensitive-filter");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
calculateBackoff,
parseRetryAfter,
retryDelay,
shouldRetry,
} from "../../src/adapters/http/retry-policy.js";
describe("HTTP retry policy", () => {
it("uses capped exponential full jitter", () => {
expect(calculateBackoff(0, () => 0.5)).toBe(125);
expect(calculateBackoff(8, () => 1)).toBe(2_000);
});
it("parses retry-after and chooses the longer bounded delay", () => {
expect(parseRetryAfter("2", 0)).toBe(2_000);
expect(retryDelay({ kind: "RATE_LIMITED", retryAfterMs: 500 }, 0, () => 0)).toBe(
500,
);
});
it("allows at most two retries for safe or keyed requests", () => {
const failure = { kind: "SERVER_FAILURE" };
expect(shouldRetry({ idempotency: "safe" }, failure, 0)).toBe(true);
expect(shouldRetry({ idempotency: "keyed" }, failure, 1)).toBe(true);
expect(shouldRetry({ idempotency: "safe" }, failure, 2)).toBe(false);
expect(shouldRetry({ idempotency: "none" }, failure, 0)).toBe(false);
expect(
shouldRetry(
{ idempotency: "safe" },
{ kind: "SERVER_FAILURE", httpStatus: 500 },
0,
),
).toBe(false);
});
it("does not automatically wait beyond 30 seconds", () => {
expect(
shouldRetry(
{ idempotency: "safe" },
{ kind: "RATE_LIMITED", retryAfterMs: 31_000 },
0,
),
).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { createBrowserStorageAdapter } from "../../src/adapters/storage/browser-storage-adapter.js";
import {
STORAGE_REGISTRY,
buildPhysicalKey,
defineStorageKey,
} from "../../src/contracts/storage-keys.js";
function createStorage({ quota = false } = {}) {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => {
if (quota) throw new DOMException("full", "QuotaExceededError");
values.set(key, value);
},
removeItem: (key) => values.delete(key),
clear: () => values.clear(),
key: () => null,
get length() {
return values.size;
},
};
}
describe("storage registry", () => {
it("builds namespace and schema-versioned physical keys", () => {
expect(buildPhysicalKey("preference", 2, "theme")).toBe(
"ca-frontend:preference:v2:theme",
);
expect(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey).toContain(":v1:");
});
it("rejects token or secret persistence registrations", () => {
expect(() =>
defineStorageKey({
logicalName: "TOKEN",
scope: "auth",
name: "token",
backend: "localStorage",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
).toThrow("Sensitive client storage registration is forbidden");
});
it("round-trips public preferences through the adapter", () => {
const localStorage = createStorage();
const adapter = createBrowserStorageAdapter({ localStorage });
expect(adapter.write("COLOR_SCHEME", "dark")).toEqual({ ok: true });
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
});
it("falls back to memory when preference storage quota is exceeded", () => {
const localStorage = createStorage({ quota: true });
const adapter = createBrowserStorageAdapter({ localStorage });
expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({
ok: false,
fallback: "memory",
error: { kind: "STORAGE_QUOTA_EXCEEDED" },
});
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
});
it("discards data from a previous schema version", () => {
const localStorage = createStorage();
localStorage.setItem(
STORAGE_REGISTRY.COLOR_SCHEME.physicalKey,
JSON.stringify({ schemaVersion: 0, value: "dark" }),
);
const adapter = createBrowserStorageAdapter({ localStorage });
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined });
});
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { systemClock } from "../../src/application/ports/clock-port.js";
describe("systemClock", () => {
it("resolves after the requested duration", async () => {
vi.useFakeTimers();
const sleeper = systemClock.sleep(250);
await vi.advanceTimersByTimeAsync(250);
await expect(sleeper).resolves.toBeUndefined();
vi.useRealTimers();
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["src", "scripts", "vite.config.js"],
"exclude": ["dist", "node_modules", "tests/fixtures"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
manifest: true,
sourcemap: false,
},
});
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
setupFiles: ["./tests/setup.js"],
restoreMocks: true,
clearMocks: true,
mockReset: true,
testTimeout: 10_000,
coverage: {
reporter: ["text", "json-summary"],
},
},
});