@horka/app-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# ARCHITECTURE — Layered Nuxt Web App
|
|
2
|
+
|
|
3
|
+
Maps the universal L0–L5 contract (`ARCHITECTURE_PRINCIPLES.md` — read it first) onto
|
|
4
|
+
Nuxt 4 / Vue 3 / TypeScript. One repo, one `app/` source dir. Nuxt has no module-system
|
|
5
|
+
walls, so **the import graph IS the architecture** — every rule below must stay
|
|
6
|
+
grep-visible (Physical mapping rule 2).
|
|
7
|
+
|
|
8
|
+
## 1. Layer model — Nuxt instantiation
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
L5 COMPLETE FEATURES app/pages/ file-based routes — THIN assemblies only
|
|
12
|
+
app/layouts/ shells (default, app, …)
|
|
13
|
+
app/stores/ shared app state (Pinia) — explicit barrel imports
|
|
14
|
+
app/middleware/ route policy (auth from route meta — SEO_AND_ROUTING.md)
|
|
15
|
+
app/plugins/sdk.ts composition root: wires the SDK with runtime config
|
|
16
|
+
app.vue · error.vue · server/ (health endpoint, BFF routes)
|
|
17
|
+
L4 SHARED FEATURES app/features/<Feature>/ domain-AWARE bricks reused by ≥2 pages
|
|
18
|
+
(components/ + composables/ + types/ + index.ts barrel)
|
|
19
|
+
L3 CORE LOGIC app/domain/ pure TS: models, engines, validation —
|
|
20
|
+
no Vue, no Nuxt, no IO, no auto-imports
|
|
21
|
+
CORE UI app/designSystem/<DSModule>/ domain-BLIND components
|
|
22
|
+
(components/ + types/ + composables/ + index.ts barrel)
|
|
23
|
+
L2 DATA the typed SDK (external package — SDK_CONTRACT.md)
|
|
24
|
+
app/composables/api/ repository composables: call SDK,
|
|
25
|
+
map wire types → domain models
|
|
26
|
+
L1 OPS app/plugins/ analytics.client.ts · logger.{client,server}.ts · flags
|
|
27
|
+
app/constants/analytics.ts the typed event catalog (OPS_WEB.md)
|
|
28
|
+
L0 FOUNDATION app/assets/css/tokens.css design tokens (CSS custom properties)
|
|
29
|
+
app/utils/ pure helpers/formatters — no Vue imports
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> **Target shape vs. what ships.** The map above is where each kind of file *goes* — not an
|
|
33
|
+
> inventory of the skeleton. The scaffold ships the always-needed floor only:
|
|
34
|
+
> `app/assets/css/{tokens,main}.css`, `app/utils/`, `app/domain/`, `app/designSystem/DSButton`,
|
|
35
|
+
> `app/features/`, `app/pages/index.vue`, `app/app.vue`, and `server/api/health.get.ts`.
|
|
36
|
+
> Everything else named here — `app/plugins/` (analytics/logger/flags), `app/constants/analytics.ts`,
|
|
37
|
+
> `app/composables/api/`, `app/stores/` (Pinia — add `@pinia/nuxt` the day a store lands),
|
|
38
|
+
> `app/middleware/auth.global.ts`, `app/layouts/`, `error.vue` — is created **the slice it earns
|
|
39
|
+
> its first real file**. Don't pre-scaffold empty directories or add Pinia before a store exists;
|
|
40
|
+
> the rules below say where things belong when they arrive, and the greps in §5 assume the target
|
|
41
|
+
> layout (they no-op on directories that don't exist yet).
|
|
42
|
+
|
|
43
|
+
## 2. Dependency direction rules
|
|
44
|
+
|
|
45
|
+
| Layer | May use | Must NEVER use | Why |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| L0 tokens + utils | nothing | Vue, Nuxt, the DOM | testable in node, reusable anywhere |
|
|
48
|
+
| L1 ops | L0, runtime config | domain types, SDK | plumbing is product-blind |
|
|
49
|
+
| L2 api composables | SDK public surface, L3 domain models, L1, L0 | components, stores | they implement L3 needs (the sanctioned upward arrow: models/contracts only) |
|
|
50
|
+
| L3 `domain/` | L0 utils only | Vue, Nuxt, SDK, fetch | pure — vitest runs it in milliseconds |
|
|
51
|
+
| L3 `designSystem/` | L0 tokens, Vue | domain types, SDK, stores, i18n keys of features | a DS button doesn't know what an "Order" is |
|
|
52
|
+
| L4 `features/` | L3, L2, L1, L0 | sibling features, pages, stores in props | bricks take values + emit events |
|
|
53
|
+
| L5 pages/layouts/stores | everything below | sibling pages | final assembly, throwaway by design |
|
|
54
|
+
|
|
55
|
+
- **L4/L5 communicate by props down / events up** (`@save`, `@delete`) — a brick never reaches
|
|
56
|
+
into a store on its own; the page wires store ↔ brick.
|
|
57
|
+
- **Auto-imports hide the import graph.** A composable used without an import line is still a
|
|
58
|
+
dependency. Compensate with placement + naming discipline (CONVENTIONS.md) and the greps in §5.
|
|
59
|
+
|
|
60
|
+
> ⚠️ **Gotcha:** Symptom — refactoring the API SDK breaks the *design system*. Cause — a
|
|
61
|
+
> production team kept domain row components (`ProjectRow`, `ReleaseRow`…) inside the DS folder,
|
|
62
|
+
> their props typed against deep SDK internals (`sdk/src/types/*`). Fix — `designSystem/` is
|
|
63
|
+
> domain-blind by contract: primitives and UI types in props only. Domain-aware composites are
|
|
64
|
+
> **features (L4)**; SDK types enter through L2 mapping, never through component props' imports.
|
|
65
|
+
|
|
66
|
+
> ⚠️ **Gotcha:** Symptom — a "component" added under `app/pages/` becomes a navigable route.
|
|
67
|
+
> Cause — Nuxt routes every `.vue` file under `pages/`. Fix — components never live in `pages/`;
|
|
68
|
+
> page-specific pieces go to the matching `features/<Feature>/components/`.
|
|
69
|
+
|
|
70
|
+
## 3. Module anatomy + registration
|
|
71
|
+
|
|
72
|
+
DS modules (L3) and feature modules (L4) share one anatomy:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
app/designSystem/DSButton/ app/features/Checkout/
|
|
76
|
+
components/DSButton.vue components/CheckoutSummary.vue
|
|
77
|
+
types/dsButton.ts composables/useCheckout.ts
|
|
78
|
+
index.ts ← barrel types/checkout.ts
|
|
79
|
+
index.ts
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`nuxt.config.ts` registers them once, by glob — adding a module requires zero config:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
components: [
|
|
86
|
+
{ path: '~/designSystem', pattern: '**/components/**', pathPrefix: false },
|
|
87
|
+
{ path: '~/features', pattern: '**/components/**', pathPrefix: false },
|
|
88
|
+
],
|
|
89
|
+
imports: { dirs: ['~/designSystem/**/composables', '~/features/**/composables'] },
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> ⚠️ **Gotcha:** Symptom — every DS component renders as an empty comment node, no error
|
|
93
|
+
> anywhere. Cause — the glob was put in `path` (`path: '~/designSystem/**/components'`);
|
|
94
|
+
> the scanner ignores glob-in-path **silently** and registers nothing. Fix — directory in
|
|
95
|
+
> `path`, glob in `pattern` (as above). Verify after config changes:
|
|
96
|
+
> `grep DSButton .nuxt/components.d.ts`.
|
|
97
|
+
|
|
98
|
+
- Components auto-register by file name (`<DSButton />` in any template).
|
|
99
|
+
- **Everything else crosses module boundaries through the barrel**:
|
|
100
|
+
`import { DS_BUTTON_VARIANTS } from '~/designSystem/DSButton'` — never a deep file path.
|
|
101
|
+
The barrel is the module's public surface; internals are refactorable.
|
|
102
|
+
|
|
103
|
+
## 4. Layer contracts
|
|
104
|
+
|
|
105
|
+
### L3 `domain/` — the pure heart
|
|
106
|
+
Plain TS modules: entities, engines (pure functions, injected `now`/randomness), validation,
|
|
107
|
+
repository *contracts* (interfaces) when the app owns orchestration. Zero imports from Vue/Nuxt.
|
|
108
|
+
This is where DELIVERY.md's "domain heart" slice lives; every rule ships with a vitest test that
|
|
109
|
+
runs without booting Nuxt.
|
|
110
|
+
|
|
111
|
+
### L2 — the SDK and its composables
|
|
112
|
+
The SDK is wired ONCE in `app/plugins/sdk.ts` (runtime config → client instance → `provide`).
|
|
113
|
+
Repository composables (`app/composables/api/useProjectsApi.ts`) are the only callers; they map
|
|
114
|
+
wire DTOs to `domain/` models. **No component or store calls `$fetch` to the backend directly**
|
|
115
|
+
(MULTI_REPO_CONTRACT.md: never bypass the SDK).
|
|
116
|
+
|
|
117
|
+
### L1 — ops plugins
|
|
118
|
+
Analytics catalog + `trackEvent`, logger plugins (client/server variants), feature-flag reader
|
|
119
|
+
(fail closed). All specified in OPS_WEB.md. Nothing here knows the domain.
|
|
120
|
+
|
|
121
|
+
### L5 — pages, layouts, stores, middleware
|
|
122
|
+
Pages assemble bricks, declare their own route policy (`definePageMeta({ auth: false })`), own
|
|
123
|
+
SEO meta, and fetch via `useAsyncData` in setup (CONVENTIONS.md §4). Stores hold cross-page
|
|
124
|
+
state only; module-local state belongs in the module's composable.
|
|
125
|
+
|
|
126
|
+
## 5. Enforcement greps — run before claiming a slice done
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
grep -rn "from '~/designSystem" app/domain app/utils # L3-pure importing UI → bug
|
|
130
|
+
grep -rn "sdk/src\|/dist/" app --include='*.ts' --include='*.vue' # deep SDK imports → bug
|
|
131
|
+
grep -rnE "#[0-9a-fA-F]{3,8}\b" app --include='*.vue' # hex color outside tokens → bug
|
|
132
|
+
grep -rn "\$fetch(" app/pages app/features app/stores # raw API calls bypassing L2
|
|
133
|
+
grep -rn "onMounted" app/pages # each hit needs a CLIENT-ONLY rationale
|
|
134
|
+
awk 'END{ if (NR>300) exit 1 }' <component>.vue # size cap (CONVENTIONS.md §3)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## 6. Where does a new file go?
|
|
138
|
+
|
|
139
|
+
| You are adding… | It lives in… |
|
|
140
|
+
|---|---|
|
|
141
|
+
| A color/spacing/font/radius value | `app/assets/css/tokens.css` (L0) |
|
|
142
|
+
| A pure formatter/helper | `app/utils/` (L0) |
|
|
143
|
+
| An analytics event, logger, feature flag | `app/constants/` + `app/plugins/` (L1) |
|
|
144
|
+
| An API call / DTO mapping | `app/composables/api/` (L2) |
|
|
145
|
+
| A business rule, entity, engine | `app/domain/` (L3) — with its test |
|
|
146
|
+
| A domain-blind component (button, card, modal) | `app/designSystem/<DSModule>/` (L3) |
|
|
147
|
+
| A domain-aware brick used by ≥2 pages | `app/features/<Feature>/` (L4) |
|
|
148
|
+
| A route, layout, cross-page state, route policy | `app/pages/`, `app/layouts/`, `app/stores/`, `app/middleware/` (L5) |
|
|
149
|
+
| A server-only endpoint (health, BFF) | `server/api/` (L5) |
|
|
150
|
+
| A user-facing string | `i18n/<locale>/<feature>.json` (I18N.md) |
|
|
151
|
+
|
|
152
|
+
When in doubt between L4 and L5: start in the page, promote to `features/` on second use.
|
|
153
|
+
|
|
154
|
+
## 7. Why this works with AI agents
|
|
155
|
+
|
|
156
|
+
- **Fast ground truth without the browser**: `npm run test` exercises `domain/`, `utils/`,
|
|
157
|
+
DS components and composables in seconds — agents iterate there before paying for
|
|
158
|
+
`nuxt build` or a browser session.
|
|
159
|
+
- **Grep-visible violations** (§5): every architectural rule is one search away.
|
|
160
|
+
- **Deterministic components**: DS modules mount standalone in vitest (no Nuxt context),
|
|
161
|
+
so UI bricks are provable without e2e.
|
|
162
|
+
- **Predictable placement** (§6): an agent knows the 3–5 files a feature needs; diffs stay small.
|
|
163
|
+
|
|
164
|
+
Build matrix before claiming "done":
|
|
165
|
+
```bash
|
|
166
|
+
npm run test # unit layer — seconds
|
|
167
|
+
npm run build # full SSR build
|
|
168
|
+
npm run dev # then eyes-on: open the page, check empty/error states
|
|
169
|
+
```
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# CONVENTIONS — Vue 3 / Nuxt 4 / TypeScript
|
|
2
|
+
|
|
3
|
+
Prescriptive — follow as written. Layer placement is ARCHITECTURE.md; this file is how the
|
|
4
|
+
code inside each brick looks.
|
|
5
|
+
|
|
6
|
+
## 1. DS modules (L3 Core UI)
|
|
7
|
+
|
|
8
|
+
One folder per component family, fixed anatomy, one barrel:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
app/designSystem/DSButton/
|
|
12
|
+
components/DSButton.vue # auto-registered globally (glob in nuxt.config)
|
|
13
|
+
types/dsButton.ts # variant consts + prop types — plain .ts, NEVER .d.ts
|
|
14
|
+
composables/ # optional: UI-only logic (focus trap, dismiss…)
|
|
15
|
+
tests/DSButton.spec.ts # vitest, mounts standalone — colocated with the module
|
|
16
|
+
index.ts # barrel = the module's ONLY public surface
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Rules:
|
|
20
|
+
- Name = `DS<Thing>` (folder, component, file). The prefix prevents collisions with feature
|
|
21
|
+
components and makes DS usage grep-able.
|
|
22
|
+
- **Domain-blind**: props are primitives, UI enums, slots, callbacks. A domain model in a DS
|
|
23
|
+
prop means the component belongs in `features/` (L4) — move it.
|
|
24
|
+
- Variants are a `const` array + derived union type (`DS_BUTTON_VARIANTS` →
|
|
25
|
+
`DSButtonVariant`), exported from `types/`, so tests can iterate every variant.
|
|
26
|
+
- Cross-module imports go through the barrel: `import { DSButton } from '~/designSystem/DSButton'`.
|
|
27
|
+
|
|
28
|
+
> ⚠️ **Gotcha:** Symptom — `import { MY_CONST } from './types/foo'` is `undefined` at runtime.
|
|
29
|
+
> Cause — consts were authored in a `.d.ts` file; declaration files emit no JavaScript and most
|
|
30
|
+
> pipelines silently skip them. Fix — module types live in **plain `.ts`** files; reserve `.d.ts`
|
|
31
|
+
> for ambient declarations only (e.g. `window.yourProvider` — OPS_WEB.md §1).
|
|
32
|
+
|
|
33
|
+
## 2. Naming
|
|
34
|
+
|
|
35
|
+
| Thing | Convention | Example |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| DS component | `DS<Thing>.vue` | `DSButton.vue`, `DSModal.vue` |
|
|
38
|
+
| Feature component | `<Feature><Role>.vue` | `CheckoutSummary.vue` |
|
|
39
|
+
| Composable | `use<Thing>.ts`, one main export | `useApiErrorHandler.ts` |
|
|
40
|
+
| Domain module | noun, intent not tech | `order.ts`, `pricingEngine.ts` |
|
|
41
|
+
| Store | `use<Thing>Store` in `app/stores/` | `useSessionStore` |
|
|
42
|
+
| Types file | camelCase, plain `.ts` | `dsButton.ts` |
|
|
43
|
+
| Events emitted | past/imperative verb | `@save`, `@dismiss`, `@page-change` |
|
|
44
|
+
| Booleans | assertions | `isLoading`, `hasErrors`, `canSubmit` |
|
|
45
|
+
|
|
46
|
+
Comments explain **why** (decision, trap, side effect) — never what the code already says.
|
|
47
|
+
|
|
48
|
+
## 3. Component size cap — 200 target, 300 hard
|
|
49
|
+
|
|
50
|
+
A `.vue` file (template + script + style) over **300 lines is a review blocker**; over 200,
|
|
51
|
+
plan the split. Splitting recipe, in order:
|
|
52
|
+
1. **Pure logic out** → `app/domain/` (testable rules) or a module composable (UI orchestration).
|
|
53
|
+
2. **Repeated/markable template chunks out** → child components in the same module's
|
|
54
|
+
`components/`, or a DS module if domain-blind.
|
|
55
|
+
3. **Types and consts out** → the module's `types/` file.
|
|
56
|
+
|
|
57
|
+
> 📖 **War story:** a production homepage hero grew to 1,353 lines — animation logic, three
|
|
58
|
+
> sub-layouts, inline SVG and tracking calls in one SFC. Every change risked every behavior, and
|
|
59
|
+
> no part was testable alone. The cap exists so the split happens at 250 lines, not at 1,353.
|
|
60
|
+
|
|
61
|
+
## 4. Data fetching — the SSR stance (read before any fetch)
|
|
62
|
+
|
|
63
|
+
This app runs `ssr: true`. **Default: fetch in setup** with `useAsyncData`/`useFetch` —
|
|
64
|
+
runs on the server, renders real content, hydrates without refetch:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const { data: projects, error, pending } = await useAsyncData(
|
|
68
|
+
'projects', // explicit, stable key
|
|
69
|
+
() => useProjectsApi().list(), // L2 repository composable — never raw $fetch
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Client-only fetching is the exception** and requires a written rationale at the call site:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// CLIENT-ONLY: depends on viewport size measured after mount; below the fold,
|
|
77
|
+
// not SEO-relevant, gated behind user interaction.
|
|
78
|
+
onMounted(async () => { … })
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`grep -rn onMounted app/pages` with no adjacent `CLIENT-ONLY:` comment = violation.
|
|
82
|
+
|
|
83
|
+
> 📖 **War story:** a production app ran `ssr: true` while ~45 pages fetched everything in
|
|
84
|
+
> `onMounted`. The server rendered empty shells, crawlers indexed spinners, users got a content
|
|
85
|
+
> flash on every navigation — all of SSR's cost, none of its value. The stance above is the fix.
|
|
86
|
+
|
|
87
|
+
SSR safety rules:
|
|
88
|
+
- No `window`/`document` at setup top level — guard with `import.meta.client` or move to
|
|
89
|
+
lifecycle hooks. `if (import.meta.server) return` is the idiom for client-only ops code.
|
|
90
|
+
- State shared between server render and client must use `useState`, not module-level `ref`s
|
|
91
|
+
(module state leaks **between requests** on the server).
|
|
92
|
+
- Never patch globals (`window.fetch`, `console`) — see OPS_WEB.md §Logging.
|
|
93
|
+
|
|
94
|
+
## 5. Props / emits / boundaries
|
|
95
|
+
|
|
96
|
+
- `defineProps<{…}>()` + `withDefaults`, `defineEmits<{ save: [item: Item] }>()` — always typed.
|
|
97
|
+
- Bricks receive **values**, emit **events**. They never import a store, never navigate, never
|
|
98
|
+
call the SDK. The page (L5) wires store ↔ brick and owns navigation.
|
|
99
|
+
- Optional callbacks/slots change the UI (e.g. no `@delete` listener → hide the delete button):
|
|
100
|
+
use `defineSlots`/optional emits deliberately, document on the brick.
|
|
101
|
+
|
|
102
|
+
## 6. SDK consumption (L2)
|
|
103
|
+
|
|
104
|
+
- Wired ONCE in `app/plugins/sdk.ts` from `runtimeConfig` — components never instantiate it.
|
|
105
|
+
- All calls go through repository composables in `app/composables/api/`, which map wire DTOs to
|
|
106
|
+
`domain/` models. Pages and stores call composables, never the SDK directly.
|
|
107
|
+
- **Import the SDK from its package root (or `…/types` subpath) only.** Deep `…/src/…` imports
|
|
108
|
+
bypass the public contract and break on any internal refactor (ANTI_PATTERNS.md #3 — found in
|
|
109
|
+
22 files of one consumer). Enforce with `no-restricted-imports` once ESLint lands.
|
|
110
|
+
- Every `catch` around an SDK call delegates to `useApiErrorHandler` (OPS_WEB.md §2).
|
|
111
|
+
|
|
112
|
+
## 7. Stores (L5)
|
|
113
|
+
|
|
114
|
+
- Pinia, only for state shared across pages (session, cart…). Module-local state lives in the
|
|
115
|
+
module's composable instead.
|
|
116
|
+
- Stores are imported **explicitly from the `~/stores` barrel** — no directory auto-scan magic;
|
|
117
|
+
the import graph must stay visible.
|
|
118
|
+
- Stores call L2 composables and `domain/` engines; they never touch components or routes.
|
|
119
|
+
|
|
120
|
+
## 8. Strings, styles, sizes
|
|
121
|
+
|
|
122
|
+
- **Every user-facing string goes through i18n** — including `aria-label`, `placeholder`,
|
|
123
|
+
`title`. Hardcoded copy in a template is a violation (I18N.md has the detector script spec).
|
|
124
|
+
- **Visual values come from tokens**: `var(--ds-color-*)`, `var(--ds-space-*)`,
|
|
125
|
+
`var(--ds-radius-*)`, `var(--ds-font-*)`. A hex color or magic px in a component is a bug;
|
|
126
|
+
add a token instead.
|
|
127
|
+
- Scoped styles per component; global styles only in `app/assets/css/` base files.
|
|
128
|
+
|
|
129
|
+
## 9. Testing — real or absent
|
|
130
|
+
|
|
131
|
+
- **Unit (vitest)**: `domain/` engines (exhaustive, table-driven), DS components (mounted with
|
|
132
|
+
`@vue/test-utils`, standalone — no Nuxt context), composables with logic. Colocated in each
|
|
133
|
+
module's `tests/`. Must pass from a clean checkout: `npm install && npm run test`.
|
|
134
|
+
- **E2E (playwright)**: user journeys only, in `tests/e2e/`. `@playwright/test` is a real
|
|
135
|
+
devDependency with a real config; specs boot the dev server themselves.
|
|
136
|
+
- A `package.json` script that cannot run from a clean checkout **is testing theater**
|
|
137
|
+
(ANTI_PATTERNS.md #10): either install the dependency and keep the test green, or delete the
|
|
138
|
+
script. Generated reports (`playwright-report/`, `coverage/`) are gitignored — a committed
|
|
139
|
+
report is a claim, a green run is proof (DELIVERY.md).
|
|
140
|
+
- Every new domain rule ships with its test in the same change. No test, no rule.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# I18N — module-per-feature translations, enforced parity
|
|
2
|
+
|
|
3
|
+
Discipline for `@nuxtjs/i18n` (add the module the day the first user-facing string appears —
|
|
4
|
+
which is usually day one). The unit of organization is the **feature module**, mirroring the
|
|
5
|
+
code layout: a feature ships its strings the way it ships its components.
|
|
6
|
+
|
|
7
|
+
## 1. Layout
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
i18n/
|
|
11
|
+
i18n.config.ts # the ONE registration point: LOCALES + MODULES + static imports
|
|
12
|
+
GLOSSARY.md # canonical translation of domain terms per locale
|
|
13
|
+
en/
|
|
14
|
+
common.json # genuinely shared strings ONLY (generic buttons, generic errors)
|
|
15
|
+
errors.json # API error names → messages (OPS_WEB.md §2 reads these)
|
|
16
|
+
<feature>.json # one file per feature/page module (checkout.json, settings.json…)
|
|
17
|
+
fr/
|
|
18
|
+
…same files, same keys
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
- **One JSON module per feature, created WITH the feature.** A new L4/L5 module's definition of
|
|
23
|
+
done includes its `<feature>.json` in every locale.
|
|
24
|
+
- A key used by exactly one feature lives in that feature's file. `common.json` is for strings
|
|
25
|
+
shared by ≥2 features — moving a key there is a deliberate promotion, like L5→L4 for code.
|
|
26
|
+
- Keys are namespaced inside the module: `checkout.summary.title`, max 3 nesting levels.
|
|
27
|
+
Never reuse another feature's keys — duplication across features is correct here (copy
|
|
28
|
+
diverges per context; shared keys create accidental coupling).
|
|
29
|
+
- Components consume strings only via `t('feature.…')` — including `aria-label`, `placeholder`,
|
|
30
|
+
`title` attributes and toast messages.
|
|
31
|
+
|
|
32
|
+
## 2. Registration — static imports, one config
|
|
33
|
+
|
|
34
|
+
Lazy per-file loading does not compose with module-per-feature splitting in a reliable way;
|
|
35
|
+
register statically and let the bundler tree-shake:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// i18n/i18n.config.ts
|
|
39
|
+
const LOCALES = ['en', 'fr'] as const
|
|
40
|
+
const MODULES = ['common', 'errors', 'checkout'] as const // ← add new modules HERE
|
|
41
|
+
|
|
42
|
+
import en_common from './en/common.json'
|
|
43
|
+
import en_errors from './en/errors.json'
|
|
44
|
+
// … one import per (locale × module); the analyzer (§3) fails CI if one is missing
|
|
45
|
+
|
|
46
|
+
export default defineI18nConfig(() => ({
|
|
47
|
+
legacy: false,
|
|
48
|
+
fallbackLocale: 'en',
|
|
49
|
+
messages: { en: { …merge en_* }, fr: { …merge fr_* } },
|
|
50
|
+
}))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Adding a feature module = 1 file per locale + 1 entry in `MODULES` + 1 import line per locale.
|
|
54
|
+
Adding a locale = 1 folder + 1 entry in `LOCALES` (copy `en/` as the starting point so the
|
|
55
|
+
parity check drives the translation work).
|
|
56
|
+
|
|
57
|
+
## 3. Parity enforcement — tooling, not vigilance
|
|
58
|
+
|
|
59
|
+
Translations drift silently; humans don't notice a missing key until a user does. A small
|
|
60
|
+
analyzer script (`scripts/check-i18n.*`, any language) runs locally and in CI with three checks:
|
|
61
|
+
|
|
62
|
+
1. **Key parity (CI gate — fails the build):** every key present in one locale exists in all
|
|
63
|
+
locales, per module file. Missing file = every key missing.
|
|
64
|
+
2. **Suspected untranslated (report):** keys whose values are byte-identical across locales.
|
|
65
|
+
Usually a pasted-but-never-translated copy.
|
|
66
|
+
3. **Hardcoded strings (report → gate once clean):** scan `.vue` templates for literal
|
|
67
|
+
user-facing text outside `t()` calls (text nodes, `aria-label`, `placeholder`, `alt`).
|
|
68
|
+
|
|
69
|
+
Wire it as `npm run i18n:check` and run it in the same CI job as the unit tests — translation
|
|
70
|
+
breakage is a build failure, not a backlog ticket.
|
|
71
|
+
|
|
72
|
+
> ⚠️ **Gotcha:** Symptom — the identical-values check drowns in false positives. Cause — brand
|
|
73
|
+
> names, locale-invariant terms ("Premium", "API", product name) are *legitimately* identical.
|
|
74
|
+
> Fix — the script maintains an explicit ignore list of keys/values; every addition to the list
|
|
75
|
+
> is reviewed, so the report stays actionable instead of muted.
|
|
76
|
+
|
|
77
|
+
> ⚠️ **Gotcha:** Symptom — a locale renders raw `{name}` braces to the user. Cause —
|
|
78
|
+
> interpolation parameters present in one locale's string but missing/renamed in another;
|
|
79
|
+
> key parity can't see it. Fix — the analyzer also compares the **set of `{param}` placeholders**
|
|
80
|
+
> per key across locales and fails on mismatch.
|
|
81
|
+
|
|
82
|
+
> ⚠️ **Gotcha:** Symptom — translated rich text renders as escaped HTML or, worse, injects
|
|
83
|
+
> markup. Cause — HTML inside translation strings rendered with `v-html`. Fix — prefer
|
|
84
|
+
> component interpolation (`<i18n-t>`); if HTML in translations is unavoidable, sanitize at
|
|
85
|
+
> render time and say so next to the call site.
|
|
86
|
+
|
|
87
|
+
## 4. GLOSSARY.md — domain terms decided once
|
|
88
|
+
|
|
89
|
+
Every domain term gets one canonical translation per locale, recorded in `i18n/GLOSSARY.md`
|
|
90
|
+
(term, per-locale translation, one-line definition, terms that must NOT be translated).
|
|
91
|
+
Translators — human or agent — consult it before inventing a synonym. Inconsistent domain
|
|
92
|
+
vocabulary across pages reads as three different products.
|
|
93
|
+
|
|
94
|
+
## 5. Locale strategy
|
|
95
|
+
|
|
96
|
+
- Detection: cookie-persisted choice + browser-language first visit. Expose a visible switcher.
|
|
97
|
+
- URL strategy is an SEO decision, not an i18n one: `no_prefix` (locale invisible in URLs) is
|
|
98
|
+
fine for an app behind auth, but **indexable multilingual marketing pages need per-locale
|
|
99
|
+
URLs** (`prefix_except_default`) + `hreflang` — see SEO_AND_ROUTING.md. Decide deliberately
|
|
100
|
+
and record it in DECISIONS.md.
|
|
101
|
+
- Dates, numbers and currencies go through `Intl` formatters in `app/utils/` (L0) — never
|
|
102
|
+
hand-formatted strings in templates.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# OPS_WEB — analytics, errors, logging, flags, deploy
|
|
2
|
+
|
|
3
|
+
The L1 ops layer plus the ship pipeline. Everything here is product-blind plumbing; if a
|
|
4
|
+
snippet needs a domain type, it's in the wrong file.
|
|
5
|
+
|
|
6
|
+
## 1. Analytics as code — the typed event catalog
|
|
7
|
+
|
|
8
|
+
Event names scattered as inline strings make the tracking plan unauditable and typo-prone.
|
|
9
|
+
The catalog **is** the tracking plan:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// app/constants/analytics.ts (L1)
|
|
13
|
+
export const ANALYTICS_EVENTS = {
|
|
14
|
+
// Naming: [Context] [Action] [Element] — document expected props inline
|
|
15
|
+
SCROLL_DEPTH: 'Scroll Depth', // props: { percent: 25|50|75|100, page }
|
|
16
|
+
CTA_HERO_PRIMARY: 'CTA Hero Primary', // props: { page }
|
|
17
|
+
SIGNUP_COMPLETED: 'Signup Completed',
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]
|
|
21
|
+
|
|
22
|
+
export function trackEvent(
|
|
23
|
+
name: AnalyticsEvent,
|
|
24
|
+
props?: Record<string, string | number | boolean>,
|
|
25
|
+
): void {
|
|
26
|
+
if (import.meta.server) return // SSR-safe by construction
|
|
27
|
+
window.yourProvider?.(name, props ? { props } : undefined)
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- **If an event name isn't in the catalog, the event doesn't exist.** A provider call with an
|
|
33
|
+
inline string anywhere else is a violation — grep for the provider symbol outside this file.
|
|
34
|
+
- Props are documented next to each event; changing an event's name or props is reviewed like
|
|
35
|
+
an API change (dashboards are consumers).
|
|
36
|
+
- Renamed events keep the old entry in a `// LEGACY` section until dashboards migrate, then die.
|
|
37
|
+
- Behavioral tracking (scroll depth, section visibility) lives in dedicated composables that
|
|
38
|
+
import the catalog — components call `useScrollDepthTracking('pricing')`, never the provider.
|
|
39
|
+
- Use a privacy-respecting, cookieless provider when possible; anything cookie-based goes
|
|
40
|
+
behind the consent manager.
|
|
41
|
+
|
|
42
|
+
## 2. API errors — one handler, statusCode → severity
|
|
43
|
+
|
|
44
|
+
UI code never inspects response bodies or invents error copy. The SDK normalizes every non-2xx
|
|
45
|
+
into a typed `ApiError` (SDK_CONTRACT.md §2); the app maps it to UX exactly once:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// app/composables/useApiErrorHandler.ts
|
|
49
|
+
const SEVERITY: Record<number, 'warning' | 'error'> = {
|
|
50
|
+
409: 'warning', // conflict: "already exists" — recoverable, don't alarm
|
|
51
|
+
423: 'warning', // locked / temporarily unavailable
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useApiErrorHandler() {
|
|
55
|
+
const { t, te } = useI18n()
|
|
56
|
+
const toast = useToast()
|
|
57
|
+
|
|
58
|
+
function handleError(error: unknown, overrides?: Record<number, string>): void {
|
|
59
|
+
if (isApiError(error)) { // type guard exported by the SDK
|
|
60
|
+
const key = `errors.api.${error.errorName}` // wire contract → i18n/errors.json
|
|
61
|
+
const message = overrides?.[error.statusCode] ?? (te(key) ? t(key) : t('errors.generic'))
|
|
62
|
+
toast[SEVERITY[error.statusCode] ?? 'error'](message)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
toast.error(t('errors.generic')) // never leak raw Error.message to users
|
|
66
|
+
}
|
|
67
|
+
return { handleError }
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Usage — per-call overrides for context-specific copy:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
try { await useTeamsApi().create(input) }
|
|
75
|
+
catch (e) { handleError(e, { 409: t('teams.alreadyExists') }) }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Rules: every `catch` around an L2 call delegates here · 401 is NOT handled here (the SDK's
|
|
79
|
+
session layer owns refresh/logout — SDK_CONTRACT.md §3) · raw `error.message` never reaches
|
|
80
|
+
a toast (it's developer text, often English-only, sometimes sensitive).
|
|
81
|
+
|
|
82
|
+
## 3. Logging — injected, never monkey-patched
|
|
83
|
+
|
|
84
|
+
One logger module in `app/utils/logger.ts` (level from runtime config, structured meta), exposed
|
|
85
|
+
to app code via plugins (`logger.client.ts`, `logger.server.ts`). Production builds drop stray
|
|
86
|
+
`console.*` (esbuild `drop`), so the logger is the only voice that survives.
|
|
87
|
+
|
|
88
|
+
> ⚠️ **Gotcha:** Symptom — every HTTP request in the app gets logged twice, aborts and streamed
|
|
89
|
+
> responses behave oddly, and nobody can find *where* the logging comes from. Cause — a plugin
|
|
90
|
+
> reassigned `window.fetch` to a logging wrapper. Patched globals are invisible at call sites,
|
|
91
|
+
> stack with every other patcher, double-instrument SDK traffic that already has its own logging
|
|
92
|
+
> port, and subtly break fetch semantics. Fix — instrument the boundaries you own: the SDK's
|
|
93
|
+
> injected logger (SDK_CONTRACT.md §5) and, for app-local calls, a created client —
|
|
94
|
+
> `$fetch.create({ onRequest, onResponse, onResponseError })` — that callers import knowingly.
|
|
95
|
+
|
|
96
|
+
## 4. Feature flags — fail closed
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// app/utils/flags.ts (L0 pure) — read via useRuntimeConfig in a composable (L1)
|
|
100
|
+
const TRUTHY = new Set(['true', '1'])
|
|
101
|
+
export function flagEnabled(value: string | boolean | undefined | null): boolean {
|
|
102
|
+
if (typeof value === 'boolean') return value
|
|
103
|
+
if (value == null) return false // missing means OFF
|
|
104
|
+
return TRUTHY.has(String(value).trim().toLowerCase()) // unknown means OFF
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Misconfiguration must disable the feature, never enable it. Parsers like `value !== 'false'`
|
|
109
|
+
turn every typo into a silent activation (ANTI_PATTERNS.md #8 — found in production). When a
|
|
110
|
+
flag evaluates to off because the value was unrecognized, log a warning naming the variable.
|
|
111
|
+
|
|
112
|
+
## 5. Runtime config — one shape, runtime overrides
|
|
113
|
+
|
|
114
|
+
- Declare every key in `runtimeConfig` (server) / `runtimeConfig.public` (client-visible) with
|
|
115
|
+
a safe default. Override at **runtime** via `NUXT_<KEY>` / `NUXT_PUBLIC_<KEY>` env vars.
|
|
116
|
+
- Secrets live server-side only. A secret in `public` ships to every browser.
|
|
117
|
+
- Because public values are runtime-overridable, **one image serves all environments** — don't
|
|
118
|
+
bake per-environment URLs at build time (see §6).
|
|
119
|
+
|
|
120
|
+
## 6. Docker — multi-stage, BuildKit secrets, tiny runtime
|
|
121
|
+
|
|
122
|
+
```dockerfile
|
|
123
|
+
# syntax=docker/dockerfile:1
|
|
124
|
+
FROM node:22-alpine AS builder
|
|
125
|
+
WORKDIR /app
|
|
126
|
+
COPY package.json package-lock.json ./
|
|
127
|
+
# Private registry/git auth: BuildKit secret — NEVER a build ARG.
|
|
128
|
+
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
|
|
129
|
+
COPY . .
|
|
130
|
+
RUN npm run build # → .output/ (self-contained Nitro server)
|
|
131
|
+
|
|
132
|
+
FROM node:22-alpine AS production
|
|
133
|
+
WORKDIR /app
|
|
134
|
+
ENV NODE_ENV=production
|
|
135
|
+
COPY --from=builder /app/.output ./.output
|
|
136
|
+
EXPOSE 3000
|
|
137
|
+
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
|
138
|
+
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
|
139
|
+
CMD ["node", ".output/server/index.mjs"]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Build: `docker build --secret id=npmrc,src=$HOME/.npmrc -t app .`
|
|
143
|
+
Ship a `.dockerignore` (node_modules, .nuxt, .output, .env*, test artifacts) or the builder
|
|
144
|
+
context uploads your laptop.
|
|
145
|
+
|
|
146
|
+
> ⚠️ **Gotcha:** Symptom — a repo access token readable by anyone who can pull the image.
|
|
147
|
+
> Cause — the token entered the build as an `ARG` and was written into git config in a `RUN`
|
|
148
|
+
> layer; `docker history` and layer caches preserve both. Fix — BuildKit secret mounts: the
|
|
149
|
+
> secret exists only during the single `RUN` it's mounted into and never lands in a layer.
|
|
150
|
+
|
|
151
|
+
> ⚠️ **Gotcha:** Symptom — every new config variable must be edited in four places (workflow
|
|
152
|
+
> secrets, Dockerfile ARGs, Dockerfile ENVs, compose file) and someone always misses one.
|
|
153
|
+
> Cause — public config baked at build time forces the whole env through build args. Fix — §5:
|
|
154
|
+
> runtime `NUXT_*` overrides; build args only for values that genuinely change the build output.
|
|
155
|
+
|
|
156
|
+
## 7. Health & deploy gate — one endpoint, every consumer
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// server/api/health.get.ts — already in the skeleton
|
|
160
|
+
export default defineEventHandler(() => ({ status: 'ok', service: '…', time: … }))
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- **Exactly one health endpoint.** The Docker HEALTHCHECK, the orchestrator/reverse-proxy
|
|
164
|
+
check, the deploy gate and uptime monitoring all hit `/api/health`. Two "health" URLs WILL
|
|
165
|
+
diverge, and the one your alerting watches will be the stale one.
|
|
166
|
+
- The deploy pipeline's last step curls it on the live host and fails the deploy on non-200 —
|
|
167
|
+
proof over claims (DELIVERY.md), applied to infrastructure.
|
|
168
|
+
- Keep it dependency-free (no DB call) unless a dependency genuinely gates "alive"; if you add
|
|
169
|
+
readiness checks later, extend the same endpoint's payload, don't add a second URL.
|
|
170
|
+
|
|
171
|
+
## 8. Error monitoring
|
|
172
|
+
|
|
173
|
+
Add a crash/error reporter (client + server) before the first real user, not after the first
|
|
174
|
+
real incident. Requirements: source maps uploaded at build (hidden from the public bundle),
|
|
175
|
+
environment tag from runtime config, and the `useApiErrorHandler` path reports unexpected
|
|
176
|
+
(5xx/unknown) errors — expected 4xx noise stays out of the alert channel.
|