@girardmedia/bootspring 3.3.2 → 3.4.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/assets/agents/accessibility-auditor.md +39 -0
- package/assets/agents/api-designer.md +40 -0
- package/assets/agents/auth-implementer.md +64 -0
- package/assets/agents/bug-hunter.md +42 -0
- package/assets/agents/bundle-analyzer.md +40 -0
- package/assets/agents/cache-optimizer.md +55 -0
- package/assets/agents/changelog-writer.md +55 -0
- package/assets/agents/ci-cd-builder.md +40 -0
- package/assets/agents/code-explainer.md +39 -0
- package/assets/agents/code-reviewer.md +39 -0
- package/assets/agents/cost-optimizer.md +57 -0
- package/assets/agents/cron-scheduler.md +51 -0
- package/assets/agents/data-seeder.md +56 -0
- package/assets/agents/database-architect.md +40 -0
- package/assets/agents/dependency-updater.md +40 -0
- package/assets/agents/deploy-checker.md +40 -0
- package/assets/agents/docker-optimizer.md +40 -0
- package/assets/agents/documentation-writer.md +40 -0
- package/assets/agents/email-builder.md +55 -0
- package/assets/agents/env-setup.md +40 -0
- package/assets/agents/error-handler.md +40 -0
- package/assets/agents/eslint-fixer.md +46 -0
- package/assets/agents/feature-flagger.md +69 -0
- package/assets/agents/git-detective.md +39 -0
- package/assets/agents/graphql-builder.md +60 -0
- package/assets/agents/incident-responder.md +59 -0
- package/assets/agents/log-analyzer.md +39 -0
- package/assets/agents/migration-planner.md +41 -0
- package/assets/agents/monorepo-navigator.md +39 -0
- package/assets/agents/nextjs-expert.md +57 -0
- package/assets/agents/notification-builder.md +56 -0
- package/assets/agents/onboarding-guide.md +39 -0
- package/assets/agents/performance-profiler.md +40 -0
- package/assets/agents/prisma-expert.md +57 -0
- package/assets/agents/rate-limiter.md +58 -0
- package/assets/agents/react-expert.md +58 -0
- package/assets/agents/refactorer.md +42 -0
- package/assets/agents/regex-builder.md +46 -0
- package/assets/agents/release-manager.md +40 -0
- package/assets/agents/s3-manager.md +58 -0
- package/assets/agents/schema-validator.md +40 -0
- package/assets/agents/search-builder.md +62 -0
- package/assets/agents/security-auditor.md +39 -0
- package/assets/agents/sitemap-generator.md +53 -0
- package/assets/agents/stripe-integrator.md +59 -0
- package/assets/agents/tailwind-expert.md +55 -0
- package/assets/agents/tech-debt-tracker.md +39 -0
- package/assets/agents/test-writer.md +42 -0
- package/assets/agents/type-fixer.md +45 -0
- package/assets/agents/webhook-builder.md +54 -0
- package/assets/rules/cpp.md +53 -0
- package/assets/rules/css.md +52 -0
- package/assets/rules/go.md +50 -0
- package/assets/rules/html.md +52 -0
- package/assets/rules/java.md +51 -0
- package/assets/rules/kotlin.md +50 -0
- package/assets/rules/php.md +51 -0
- package/assets/rules/python.md +51 -0
- package/assets/rules/ruby.md +51 -0
- package/assets/rules/rust.md +49 -0
- package/assets/rules/shell.md +52 -0
- package/assets/rules/sql.md +49 -0
- package/assets/rules/swift.md +50 -0
- package/assets/rules/typescript.md +52 -0
- package/assets/rules/yaml-json.md +51 -0
- package/assets/skills/accessibility.md +210 -0
- package/assets/skills/agent-patterns.md +387 -0
- package/assets/skills/ai-integration.md +263 -0
- package/assets/skills/animation-patterns.md +224 -0
- package/assets/skills/api-design.md +218 -0
- package/assets/skills/api-gateway.md +341 -0
- package/assets/skills/api-versioning.md +226 -0
- package/assets/skills/astro-patterns.md +233 -0
- package/assets/skills/auth-patterns.md +248 -0
- package/assets/skills/aws-patterns.md +171 -0
- package/assets/skills/background-jobs.md +162 -0
- package/assets/skills/browser-extensions.md +309 -0
- package/assets/skills/caching-patterns.md +253 -0
- package/assets/skills/ci-cd.md +251 -0
- package/assets/skills/cli-development.md +296 -0
- package/assets/skills/code-review.md +185 -0
- package/assets/skills/cron-patterns.md +327 -0
- package/assets/skills/data-fetching.md +231 -0
- package/assets/skills/database-migrations.md +346 -0
- package/assets/skills/database-patterns.md +219 -0
- package/assets/skills/debugging.md +281 -0
- package/assets/skills/design-system.md +289 -0
- package/assets/skills/django-patterns.md +182 -0
- package/assets/skills/docker-patterns.md +235 -0
- package/assets/skills/e2e-testing.md +287 -0
- package/assets/skills/edge-computing.md +268 -0
- package/assets/skills/electron-patterns.md +266 -0
- package/assets/skills/email-templates.md +206 -0
- package/assets/skills/error-handling.md +265 -0
- package/assets/skills/event-driven.md +232 -0
- package/assets/skills/express-patterns.md +239 -0
- package/assets/skills/fastapi-patterns.md +198 -0
- package/assets/skills/feature-flags.md +212 -0
- package/assets/skills/figma-to-code.md +298 -0
- package/assets/skills/file-upload.md +228 -0
- package/assets/skills/forms-patterns.md +264 -0
- package/assets/skills/gcp-patterns.md +189 -0
- package/assets/skills/git-workflow.md +187 -0
- package/assets/skills/golang-patterns.md +185 -0
- package/assets/skills/graphql-patterns.md +244 -0
- package/assets/skills/i18n-patterns.md +172 -0
- package/assets/skills/image-processing.md +350 -0
- package/assets/skills/java-springboot.md +226 -0
- package/assets/skills/kotlin-patterns.md +207 -0
- package/assets/skills/kubernetes-patterns.md +326 -0
- package/assets/skills/laravel-patterns.md +261 -0
- package/assets/skills/llm-fine-tuning.md +335 -0
- package/assets/skills/load-testing.md +303 -0
- package/assets/skills/logging-observability.md +228 -0
- package/assets/skills/markdown-processing.md +318 -0
- package/assets/skills/mcp-server-patterns.md +292 -0
- package/assets/skills/microservices.md +272 -0
- package/assets/skills/migration-patterns.md +239 -0
- package/assets/skills/mongodb-patterns.md +189 -0
- package/assets/skills/monorepo-patterns.md +287 -0
- package/assets/skills/nextjs-app-router.md +237 -0
- package/assets/skills/notification-patterns.md +348 -0
- package/assets/skills/oauth-patterns.md +246 -0
- package/assets/skills/payment-integration.md +222 -0
- package/assets/skills/pdf-generation.md +307 -0
- package/assets/skills/performance-optimization.md +277 -0
- package/assets/skills/php-patterns.md +210 -0
- package/assets/skills/prisma-patterns.md +241 -0
- package/assets/skills/prompt-engineering.md +193 -0
- package/assets/skills/pwa-patterns.md +247 -0
- package/assets/skills/python-patterns.md +158 -0
- package/assets/skills/python-testing.md +172 -0
- package/assets/skills/queue-patterns.md +295 -0
- package/assets/skills/rag-patterns.md +159 -0
- package/assets/skills/rate-limiting.md +319 -0
- package/assets/skills/react-components.md +201 -0
- package/assets/skills/react-native-patterns.md +299 -0
- package/assets/skills/real-time-patterns.md +181 -0
- package/assets/skills/redis-patterns.md +188 -0
- package/assets/skills/refactoring.md +218 -0
- package/assets/skills/regex-patterns.md +191 -0
- package/assets/skills/remix-patterns.md +262 -0
- package/assets/skills/responsive-design.md +199 -0
- package/assets/skills/ruby-rails-patterns.md +178 -0
- package/assets/skills/rust-patterns.md +211 -0
- package/assets/skills/search-patterns.md +227 -0
- package/assets/skills/security-hardening.md +237 -0
- package/assets/skills/seo-patterns.md +179 -0
- package/assets/skills/serverless-patterns.md +223 -0
- package/assets/skills/sql-optimization.md +154 -0
- package/assets/skills/state-management.md +254 -0
- package/assets/skills/storybook-patterns.md +330 -0
- package/assets/skills/svelte-patterns.md +258 -0
- package/assets/skills/swift-patterns.md +227 -0
- package/assets/skills/tailwind-patterns.md +272 -0
- package/assets/skills/tdd-workflow.md +199 -0
- package/assets/skills/terraform-patterns.md +270 -0
- package/assets/skills/testing-react.md +240 -0
- package/assets/skills/testing-vitest.md +232 -0
- package/assets/skills/typescript-strict.md +159 -0
- package/assets/skills/video-processing.md +340 -0
- package/assets/skills/vue-patterns.md +247 -0
- package/assets/skills/web-workers.md +327 -0
- package/assets/skills/webhooks-patterns.md +283 -0
- package/assets/skills/websocket-patterns.md +306 -0
- package/dist/cli/index.js +941 -958
- package/dist/core/index.d.ts +341 -11
- package/dist/core.js +58 -95
- package/dist/mcp/index.d.ts +33 -1
- package/dist/mcp-server.js +177 -255
- package/package.json +4 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: email-templates
|
|
3
|
+
description: Email template patterns with MJML, inline styles, responsive emails, preview text, and testing across clients.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Email Templates
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
Apply when building transactional or marketing emails. Email HTML is stuck in 2005 -- Outlook uses Word's rendering engine, Gmail strips `<style>` tags, and every client has different quirks. Use MJML to write maintainable markup that compiles to battle-tested HTML. Test in Litmus or Email on Acid before sending.
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
### MJML -- Write Emails Without Suffering
|
|
14
|
+
|
|
15
|
+
MJML compiles to responsive, cross-client HTML:
|
|
16
|
+
|
|
17
|
+
```xml
|
|
18
|
+
<mjml>
|
|
19
|
+
<mj-head>
|
|
20
|
+
<mj-attributes>
|
|
21
|
+
<mj-all font-family="Helvetica, Arial, sans-serif" />
|
|
22
|
+
<mj-text font-size="16px" line-height="1.5" color="#374151" />
|
|
23
|
+
</mj-attributes>
|
|
24
|
+
<mj-preview>Your order #1234 has shipped</mj-preview>
|
|
25
|
+
</mj-head>
|
|
26
|
+
<mj-body background-color="#f9fafb">
|
|
27
|
+
<mj-section background-color="#ffffff" border-radius="8px" padding="32px">
|
|
28
|
+
<mj-column>
|
|
29
|
+
<mj-image src="https://example.com/logo.png" width="120px" alt="Logo" />
|
|
30
|
+
<mj-text font-size="24px" font-weight="700" padding-top="24px">
|
|
31
|
+
Your order has shipped!
|
|
32
|
+
</mj-text>
|
|
33
|
+
<mj-text>
|
|
34
|
+
Hi {{name}}, your order #{{orderId}} is on its way.
|
|
35
|
+
Expected delivery: {{deliveryDate}}.
|
|
36
|
+
</mj-text>
|
|
37
|
+
<mj-button background-color="#2563eb" color="#ffffff"
|
|
38
|
+
href="{{trackingUrl}}" border-radius="6px" font-size="16px">
|
|
39
|
+
Track Your Package
|
|
40
|
+
</mj-button>
|
|
41
|
+
</mj-column>
|
|
42
|
+
</mj-section>
|
|
43
|
+
<mj-section padding="16px">
|
|
44
|
+
<mj-column>
|
|
45
|
+
<mj-text font-size="12px" color="#9ca3af" align="center">
|
|
46
|
+
You received this because you placed an order at Example Store.
|
|
47
|
+
<a href="{{unsubscribeUrl}}">Unsubscribe</a>
|
|
48
|
+
</mj-text>
|
|
49
|
+
</mj-column>
|
|
50
|
+
</mj-section>
|
|
51
|
+
</mj-body>
|
|
52
|
+
</mjml>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Compile with: `npx mjml input.mjml -o output.html`
|
|
56
|
+
|
|
57
|
+
### React Email -- Component-Based Templates
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import {
|
|
61
|
+
Body, Container, Head, Heading, Html,
|
|
62
|
+
Preview, Section, Text, Button, Img,
|
|
63
|
+
} from "@react-email/components";
|
|
64
|
+
|
|
65
|
+
interface OrderShippedProps {
|
|
66
|
+
name: string;
|
|
67
|
+
orderId: string;
|
|
68
|
+
trackingUrl: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function OrderShipped({ name, orderId, trackingUrl }: OrderShippedProps) {
|
|
72
|
+
return (
|
|
73
|
+
<Html>
|
|
74
|
+
<Head />
|
|
75
|
+
<Preview>Your order #{orderId} has shipped</Preview>
|
|
76
|
+
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "Helvetica, Arial, sans-serif" }}>
|
|
77
|
+
<Container style={{ maxWidth: 600, margin: "0 auto", padding: 32 }}>
|
|
78
|
+
<Img src="https://example.com/logo.png" width={120} alt="Logo" />
|
|
79
|
+
<Heading style={{ fontSize: 24, marginTop: 24 }}>
|
|
80
|
+
Your order has shipped!
|
|
81
|
+
</Heading>
|
|
82
|
+
<Text style={{ fontSize: 16, lineHeight: 1.5, color: "#374151" }}>
|
|
83
|
+
Hi {name}, your order #{orderId} is on its way.
|
|
84
|
+
</Text>
|
|
85
|
+
<Section style={{ textAlign: "center", marginTop: 24 }}>
|
|
86
|
+
<Button
|
|
87
|
+
href={trackingUrl}
|
|
88
|
+
style={{
|
|
89
|
+
backgroundColor: "#2563eb",
|
|
90
|
+
color: "#ffffff",
|
|
91
|
+
padding: "12px 24px",
|
|
92
|
+
borderRadius: 6,
|
|
93
|
+
fontSize: 16,
|
|
94
|
+
textDecoration: "none",
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
Track Your Package
|
|
98
|
+
</Button>
|
|
99
|
+
</Section>
|
|
100
|
+
</Container>
|
|
101
|
+
</Body>
|
|
102
|
+
</Html>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Sending with Nodemailer + Template Rendering
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import nodemailer from "nodemailer";
|
|
111
|
+
import mjml2html from "mjml";
|
|
112
|
+
import Handlebars from "handlebars";
|
|
113
|
+
import { readFileSync } from "fs";
|
|
114
|
+
|
|
115
|
+
const transporter = nodemailer.createTransport({
|
|
116
|
+
host: process.env.SMTP_HOST,
|
|
117
|
+
port: 587,
|
|
118
|
+
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
async function sendOrderShipped(to: string, data: OrderData) {
|
|
122
|
+
const mjmlTemplate = readFileSync("templates/order-shipped.mjml", "utf-8");
|
|
123
|
+
const compiled = Handlebars.compile(mjmlTemplate);
|
|
124
|
+
const mjmlOutput = compiled(data);
|
|
125
|
+
const { html } = mjml2html(mjmlOutput);
|
|
126
|
+
|
|
127
|
+
await transporter.sendMail({
|
|
128
|
+
from: '"Example Store" <orders@example.com>',
|
|
129
|
+
to,
|
|
130
|
+
subject: `Your order #${data.orderId} has shipped`,
|
|
131
|
+
html,
|
|
132
|
+
text: `Hi ${data.name}, your order #${data.orderId} has shipped. Track it: ${data.trackingUrl}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Preview Text Hack
|
|
138
|
+
|
|
139
|
+
The preview text appears in the inbox list. Control it, or clients will grab the first visible text:
|
|
140
|
+
|
|
141
|
+
```html
|
|
142
|
+
<!-- Preview text visible in inbox, hidden in email body -->
|
|
143
|
+
<div style="display: none; max-height: 0; overflow: hidden;">
|
|
144
|
+
Your order #1234 has shipped -- arriving Thursday.
|
|
145
|
+
<!-- Pad with whitespace to prevent body text from appearing -->
|
|
146
|
+
‌ ‌ ‌ ‌ ‌
|
|
147
|
+
</div>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Dark Mode Support
|
|
151
|
+
|
|
152
|
+
```html
|
|
153
|
+
<style>
|
|
154
|
+
@media (prefers-color-scheme: dark) {
|
|
155
|
+
.email-body { background-color: #1f2937 !important; }
|
|
156
|
+
.email-text { color: #f3f4f6 !important; }
|
|
157
|
+
.email-card { background-color: #374151 !important; }
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
160
|
+
<!-- Fallback for clients that strip style tags -->
|
|
161
|
+
<meta name="color-scheme" content="light dark" />
|
|
162
|
+
<meta name="supported-color-schemes" content="light dark" />
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Testing Strategy
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { render } from "@react-email/render";
|
|
169
|
+
|
|
170
|
+
describe("OrderShipped email", () => {
|
|
171
|
+
it("renders with required props", () => {
|
|
172
|
+
const html = render(
|
|
173
|
+
<OrderShipped name="Alice" orderId="1234" trackingUrl="https://track.example.com/1234" />
|
|
174
|
+
);
|
|
175
|
+
expect(html).toContain("Your order has shipped");
|
|
176
|
+
expect(html).toContain("Alice");
|
|
177
|
+
expect(html).toContain("track.example.com/1234");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("includes unsubscribe link", () => {
|
|
181
|
+
const html = render(<OrderShipped {...defaultProps} />);
|
|
182
|
+
expect(html).toContain("Unsubscribe");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Examples
|
|
188
|
+
|
|
189
|
+
| Pattern | When | Result |
|
|
190
|
+
|---------|------|--------|
|
|
191
|
+
| MJML templates | Marketing emails, newsletters | Cross-client responsive |
|
|
192
|
+
| React Email | Transactional, typed props | Component reuse, TypeScript |
|
|
193
|
+
| Preview text | All emails | Control inbox preview line |
|
|
194
|
+
| Dark mode meta | Modern clients | Respects user preference |
|
|
195
|
+
| Plain text fallback | Accessibility, spam filters | Better deliverability |
|
|
196
|
+
| Litmus/Email on Acid | Pre-send QA | Catch rendering bugs |
|
|
197
|
+
|
|
198
|
+
## Checklist
|
|
199
|
+
- [ ] Templates use MJML or React Email, not hand-written table HTML
|
|
200
|
+
- [ ] Every email has a plain text fallback
|
|
201
|
+
- [ ] Preview text is explicitly set, not auto-generated from body
|
|
202
|
+
- [ ] CTA buttons use bulletproof button technique (not just `<a>`)
|
|
203
|
+
- [ ] Images have alt text and explicit width/height
|
|
204
|
+
- [ ] Unsubscribe link present in every marketing email (CAN-SPAM)
|
|
205
|
+
- [ ] Dark mode handled with `prefers-color-scheme` and meta tags
|
|
206
|
+
- [ ] Tested in Gmail, Outlook, Apple Mail, and Yahoo before launch
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: error-handling
|
|
3
|
+
description: Handle errors gracefully with Result types, retry logic, circuit breakers, and structured logging.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Error Handling Patterns
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
Apply these patterns in every service, API, and CLI tool. The default — throwing
|
|
11
|
+
exceptions and hoping a catch block somewhere handles them — leads to crashed
|
|
12
|
+
processes, lost data, and unreadable logs. Explicit error handling is not
|
|
13
|
+
overhead; it's how reliable software works.
|
|
14
|
+
|
|
15
|
+
## How It Works
|
|
16
|
+
|
|
17
|
+
### 1. Result Types — Errors as Values
|
|
18
|
+
|
|
19
|
+
Make errors part of the return type so callers can't ignore them.
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
type Result<T, E = Error> =
|
|
23
|
+
| { ok: true; value: T }
|
|
24
|
+
| { ok: false; error: E };
|
|
25
|
+
|
|
26
|
+
function ok<T>(value: T): Result<T, never> {
|
|
27
|
+
return { ok: true, value };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function err<E>(error: E): Result<never, E> {
|
|
31
|
+
return { ok: false, error };
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
function parseConfig(raw: string): Result<Config, ParseError> {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(raw);
|
|
41
|
+
if (!data.apiUrl) return err(new ParseError('Missing apiUrl'));
|
|
42
|
+
return ok(data as Config);
|
|
43
|
+
} catch {
|
|
44
|
+
return err(new ParseError('Invalid JSON'));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = parseConfig(input);
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
logger.error('Config parse failed', { error: result.error.message });
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
// result.value is narrowed to Config here
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Custom Error Classes
|
|
57
|
+
|
|
58
|
+
Categorize errors so handlers can react differently to different failures.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
class AppError extends Error {
|
|
62
|
+
constructor(
|
|
63
|
+
message: string,
|
|
64
|
+
public readonly code: string,
|
|
65
|
+
public readonly statusCode: number = 500,
|
|
66
|
+
public readonly isOperational: boolean = true,
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = this.constructor.name;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class NotFoundError extends AppError {
|
|
74
|
+
constructor(resource: string, id: string) {
|
|
75
|
+
super(`${resource} ${id} not found`, 'NOT_FOUND', 404);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class ValidationError extends AppError {
|
|
80
|
+
constructor(public readonly fields: Record<string, string>) {
|
|
81
|
+
super('Validation failed', 'VALIDATION_ERROR', 400);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class ExternalServiceError extends AppError {
|
|
86
|
+
constructor(service: string, cause?: Error) {
|
|
87
|
+
super(`${service} unavailable`, 'EXTERNAL_SERVICE_ERROR', 502, true);
|
|
88
|
+
this.cause = cause;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 3. Error Boundaries in Express
|
|
94
|
+
|
|
95
|
+
Centralize error handling in one middleware.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// Must be registered last, with 4 parameters
|
|
99
|
+
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
|
100
|
+
if (err instanceof AppError && err.isOperational) {
|
|
101
|
+
logger.warn('Operational error', {
|
|
102
|
+
code: err.code,
|
|
103
|
+
message: err.message,
|
|
104
|
+
path: req.path,
|
|
105
|
+
});
|
|
106
|
+
return res.status(err.statusCode).json({
|
|
107
|
+
error: { code: err.code, message: err.message },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Unexpected errors — log full stack, return generic message
|
|
112
|
+
logger.error('Unhandled error', {
|
|
113
|
+
error: err.message,
|
|
114
|
+
stack: err.stack,
|
|
115
|
+
path: req.path,
|
|
116
|
+
});
|
|
117
|
+
res.status(500).json({
|
|
118
|
+
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 4. Retry with Exponential Backoff
|
|
124
|
+
|
|
125
|
+
Retry transient failures (network timeouts, rate limits) with increasing delays.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
async function retry<T>(
|
|
129
|
+
fn: () => Promise<T>,
|
|
130
|
+
options: { maxAttempts?: number; baseDelayMs?: number; maxDelayMs?: number } = {},
|
|
131
|
+
): Promise<T> {
|
|
132
|
+
const { maxAttempts = 3, baseDelayMs = 200, maxDelayMs = 10000 } = options;
|
|
133
|
+
|
|
134
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
135
|
+
try {
|
|
136
|
+
return await fn();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (attempt === maxAttempts) throw error;
|
|
139
|
+
|
|
140
|
+
const delay = Math.min(
|
|
141
|
+
baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 100,
|
|
142
|
+
maxDelayMs,
|
|
143
|
+
);
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error('Unreachable');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Usage
|
|
151
|
+
const data = await retry(() => fetchFromAPI('/users'), { maxAttempts: 3 });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Add jitter (the `Math.random()` part) to prevent thundering herds.
|
|
155
|
+
|
|
156
|
+
### 5. Circuit Breaker
|
|
157
|
+
|
|
158
|
+
Stop calling a failing service to let it recover.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
class CircuitBreaker {
|
|
162
|
+
private failures = 0;
|
|
163
|
+
private lastFailure = 0;
|
|
164
|
+
private state: 'closed' | 'open' | 'half-open' = 'closed';
|
|
165
|
+
|
|
166
|
+
constructor(
|
|
167
|
+
private readonly threshold: number = 5,
|
|
168
|
+
private readonly resetTimeMs: number = 30000,
|
|
169
|
+
) {}
|
|
170
|
+
|
|
171
|
+
async call<T>(fn: () => Promise<T>): Promise<T> {
|
|
172
|
+
if (this.state === 'open') {
|
|
173
|
+
if (Date.now() - this.lastFailure > this.resetTimeMs) {
|
|
174
|
+
this.state = 'half-open';
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error('Circuit breaker is open');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const result = await fn();
|
|
182
|
+
this.onSuccess();
|
|
183
|
+
return result;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.onFailure();
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private onSuccess() {
|
|
191
|
+
this.failures = 0;
|
|
192
|
+
this.state = 'closed';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private onFailure() {
|
|
196
|
+
this.failures++;
|
|
197
|
+
this.lastFailure = Date.now();
|
|
198
|
+
if (this.failures >= this.threshold) {
|
|
199
|
+
this.state = 'open';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const paymentBreaker = new CircuitBreaker(5, 30000);
|
|
205
|
+
const charge = await paymentBreaker.call(() => stripe.charges.create(params));
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 6. Structured Logging
|
|
209
|
+
|
|
210
|
+
Log machine-parseable JSON, not random strings.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// Bad
|
|
214
|
+
console.log(`User ${userId} failed to login: ${error.message}`);
|
|
215
|
+
|
|
216
|
+
// Good
|
|
217
|
+
logger.warn('Login failed', {
|
|
218
|
+
userId,
|
|
219
|
+
error: error.message,
|
|
220
|
+
code: error.code,
|
|
221
|
+
ip: req.ip,
|
|
222
|
+
timestamp: new Date().toISOString(),
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Use log levels consistently:
|
|
227
|
+
| Level | Use |
|
|
228
|
+
|-------|-----|
|
|
229
|
+
| `error` | Unexpected failures requiring investigation |
|
|
230
|
+
| `warn` | Expected failures (auth denied, validation) |
|
|
231
|
+
| `info` | Significant business events (order created, deploy started) |
|
|
232
|
+
| `debug` | Developer troubleshooting details (only in dev) |
|
|
233
|
+
|
|
234
|
+
### 7. Graceful Shutdown
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
process.on('SIGTERM', async () => {
|
|
238
|
+
logger.info('SIGTERM received, shutting down gracefully');
|
|
239
|
+
server.close();
|
|
240
|
+
await db.$disconnect();
|
|
241
|
+
await cache.quit();
|
|
242
|
+
process.exit(0);
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Examples
|
|
247
|
+
|
|
248
|
+
| Failure type | Pattern | Recovery |
|
|
249
|
+
|-------------|---------|----------|
|
|
250
|
+
| Invalid input | Validation error + 400 | Immediate feedback to caller |
|
|
251
|
+
| Missing resource | Not found error + 404 | Caller adjusts request |
|
|
252
|
+
| Network timeout | Retry with backoff | Auto-recover after delay |
|
|
253
|
+
| Persistent outage | Circuit breaker | Fail fast, serve cached/degraded |
|
|
254
|
+
| Unknown crash | Global error handler | Log, alert, restart |
|
|
255
|
+
|
|
256
|
+
## Checklist
|
|
257
|
+
|
|
258
|
+
- [ ] Functions that can fail return `Result<T, E>` or throw typed errors, never raw strings
|
|
259
|
+
- [ ] Custom error classes include `code`, `statusCode`, and `isOperational`
|
|
260
|
+
- [ ] Express has a centralized error middleware registered last
|
|
261
|
+
- [ ] External calls use retry with exponential backoff and jitter
|
|
262
|
+
- [ ] Critical dependencies have circuit breakers
|
|
263
|
+
- [ ] All log output is structured JSON with consistent fields
|
|
264
|
+
- [ ] Graceful shutdown handlers close connections before exiting
|
|
265
|
+
- [ ] Production errors never leak stack traces or internal details to clients
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: event-driven
|
|
3
|
+
description: Event-driven architecture with pub/sub, event bus, CQRS, event sourcing, and dead letter queues.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Event-Driven Architecture
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
Apply when services need to communicate without tight coupling, when you need audit trails of every state change, or when read and write workloads have different scaling needs. Event-driven architecture decouples producers from consumers, enabling independent scaling, temporal decoupling, and easier system evolution.
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
### In-Process Event Bus
|
|
14
|
+
|
|
15
|
+
Start simple with a typed event emitter before reaching for message brokers:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
type EventHandler<T = unknown> = (payload: T) => void | Promise<void>;
|
|
19
|
+
|
|
20
|
+
interface AppEvents {
|
|
21
|
+
"order.created": { orderId: string; userId: string; total: number };
|
|
22
|
+
"order.shipped": { orderId: string; trackingNumber: string };
|
|
23
|
+
"user.registered": { userId: string; email: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class TypedEventBus {
|
|
27
|
+
private handlers = new Map<string, EventHandler[]>();
|
|
28
|
+
|
|
29
|
+
on<K extends keyof AppEvents>(event: K, handler: EventHandler<AppEvents[K]>): void {
|
|
30
|
+
const list = this.handlers.get(event as string) ?? [];
|
|
31
|
+
list.push(handler as EventHandler);
|
|
32
|
+
this.handlers.set(event as string, list);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async emit<K extends keyof AppEvents>(event: K, payload: AppEvents[K]): Promise<void> {
|
|
36
|
+
const handlers = this.handlers.get(event as string) ?? [];
|
|
37
|
+
await Promise.allSettled(handlers.map((h) => h(payload)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const bus = new TypedEventBus();
|
|
42
|
+
bus.on("order.created", async (data) => sendConfirmationEmail(data.userId, data.orderId));
|
|
43
|
+
bus.on("order.created", async (data) => updateInventory(data.orderId));
|
|
44
|
+
bus.on("order.created", async (data) => trackAnalytics("order_created", data));
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Distributed Pub/Sub with Redis Streams
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { Redis } from "ioredis";
|
|
51
|
+
|
|
52
|
+
const redis = new Redis();
|
|
53
|
+
|
|
54
|
+
// Producer
|
|
55
|
+
async function publishEvent(stream: string, event: Record<string, string>) {
|
|
56
|
+
await redis.xadd(stream, "*", ...Object.entries(event).flat());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await publishEvent("orders", {
|
|
60
|
+
type: "order.created",
|
|
61
|
+
orderId: "o_123",
|
|
62
|
+
payload: JSON.stringify({ userId: "u_456", total: 99.99 }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Consumer group -- each service gets every event exactly once
|
|
66
|
+
await redis.xgroup("CREATE", "orders", "email-service", "0", "MKSTREAM").catch(() => {});
|
|
67
|
+
|
|
68
|
+
async function consumeEvents(group: string, consumer: string) {
|
|
69
|
+
while (true) {
|
|
70
|
+
const results = await redis.xreadgroup(
|
|
71
|
+
"GROUP", group, consumer, "COUNT", 10, "BLOCK", 5000, "STREAMS", "orders", ">"
|
|
72
|
+
);
|
|
73
|
+
if (!results) continue;
|
|
74
|
+
for (const [, messages] of results) {
|
|
75
|
+
for (const [id, fields] of messages) {
|
|
76
|
+
await processEvent(Object.fromEntries(chunked(fields, 2)));
|
|
77
|
+
await redis.xack("orders", group, id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### CQRS -- Command Query Responsibility Segregation
|
|
85
|
+
|
|
86
|
+
Separate write models from read models:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// Command side -- handles writes, enforces business rules
|
|
90
|
+
class OrderCommandHandler {
|
|
91
|
+
async createOrder(cmd: CreateOrderCommand): Promise<string> {
|
|
92
|
+
const order = Order.create(cmd.userId, cmd.items);
|
|
93
|
+
await this.orderRepo.save(order);
|
|
94
|
+
await this.eventBus.emit("order.created", {
|
|
95
|
+
orderId: order.id,
|
|
96
|
+
userId: cmd.userId,
|
|
97
|
+
total: order.total,
|
|
98
|
+
});
|
|
99
|
+
return order.id;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Query side -- denormalized read model updated by events
|
|
104
|
+
class OrderReadModel {
|
|
105
|
+
async onOrderCreated(event: OrderCreatedEvent) {
|
|
106
|
+
await this.readDb.insert("order_summaries", {
|
|
107
|
+
orderId: event.orderId,
|
|
108
|
+
userId: event.userId,
|
|
109
|
+
total: event.total,
|
|
110
|
+
status: "pending",
|
|
111
|
+
createdAt: event.timestamp,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getOrderSummaries(userId: string) {
|
|
116
|
+
return this.readDb.query(
|
|
117
|
+
"SELECT * FROM order_summaries WHERE user_id = $1 ORDER BY created_at DESC",
|
|
118
|
+
[userId]
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Event Sourcing
|
|
125
|
+
|
|
126
|
+
Store events as the source of truth, derive state by replaying:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
type OrderEvent =
|
|
130
|
+
| { type: "OrderCreated"; orderId: string; items: OrderItem[]; timestamp: string }
|
|
131
|
+
| { type: "PaymentReceived"; orderId: string; amount: number; timestamp: string }
|
|
132
|
+
| { type: "OrderShipped"; orderId: string; trackingId: string; timestamp: string };
|
|
133
|
+
|
|
134
|
+
function buildOrderState(events: OrderEvent[]): OrderState {
|
|
135
|
+
return events.reduce((state, event) => {
|
|
136
|
+
switch (event.type) {
|
|
137
|
+
case "OrderCreated":
|
|
138
|
+
return { ...state, id: event.orderId, items: event.items, status: "pending" };
|
|
139
|
+
case "PaymentReceived":
|
|
140
|
+
return { ...state, status: "paid", paidAmount: event.amount };
|
|
141
|
+
case "OrderShipped":
|
|
142
|
+
return { ...state, status: "shipped", trackingId: event.trackingId };
|
|
143
|
+
}
|
|
144
|
+
}, {} as OrderState);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Append-only event store
|
|
148
|
+
async function appendEvent(aggregateId: string, event: OrderEvent): Promise<void> {
|
|
149
|
+
await db.query(
|
|
150
|
+
"INSERT INTO events (id, aggregate_id, type, data, timestamp) VALUES ($1, $2, $3, $4, $5)",
|
|
151
|
+
[crypto.randomUUID(), aggregateId, event.type, JSON.stringify(event), event.timestamp]
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Dead Letter Queue
|
|
157
|
+
|
|
158
|
+
Handle failed messages without losing them:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
async function processWithDLQ(
|
|
162
|
+
message: QueueMessage,
|
|
163
|
+
handler: (msg: QueueMessage) => Promise<void>,
|
|
164
|
+
maxRetries = 3
|
|
165
|
+
) {
|
|
166
|
+
const retryCount = message.attributes?.retryCount ?? 0;
|
|
167
|
+
try {
|
|
168
|
+
await handler(message);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (retryCount < maxRetries) {
|
|
171
|
+
await queue.send({
|
|
172
|
+
...message,
|
|
173
|
+
attributes: { retryCount: retryCount + 1 },
|
|
174
|
+
delaySeconds: Math.pow(2, retryCount) * 10,
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
await dlq.send({
|
|
178
|
+
originalMessage: message,
|
|
179
|
+
error: (err as Error).message,
|
|
180
|
+
failedAt: new Date().toISOString(),
|
|
181
|
+
});
|
|
182
|
+
logger.error({ messageId: message.id }, "Sent to DLQ after max retries");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Idempotent Event Handlers
|
|
189
|
+
|
|
190
|
+
Events may be delivered more than once. Make handlers safe to re-run:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
async function idempotentHandler(
|
|
194
|
+
eventId: string,
|
|
195
|
+
handler: () => Promise<void>
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const acquired = await redis.set(`processed:${eventId}`, "1", "NX", "EX", 86400);
|
|
198
|
+
if (!acquired) {
|
|
199
|
+
logger.info({ eventId }, "Already processed, skipping");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
await handler();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Or use database constraints
|
|
206
|
+
await db.query(
|
|
207
|
+
`INSERT INTO shipment_notifications (order_id, sent_at)
|
|
208
|
+
VALUES ($1, NOW()) ON CONFLICT (order_id) DO NOTHING`,
|
|
209
|
+
[orderId]
|
|
210
|
+
);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Examples
|
|
214
|
+
|
|
215
|
+
| Pattern | When | Result |
|
|
216
|
+
|---------|------|--------|
|
|
217
|
+
| In-process event bus | Monolith | Decoupled handlers, zero latency |
|
|
218
|
+
| Pub/Sub (Redis/RabbitMQ) | Microservices | Cross-service communication |
|
|
219
|
+
| CQRS | Different read/write scaling | Optimized read models |
|
|
220
|
+
| Event sourcing | Audit trail, financial systems | Complete history, replay |
|
|
221
|
+
| Dead letter queue | Failed message handling | No lost messages |
|
|
222
|
+
| Idempotency | At-least-once delivery | Exactly-once processing |
|
|
223
|
+
|
|
224
|
+
## Checklist
|
|
225
|
+
- [ ] Events named in past tense (`order.created`, not `create.order`)
|
|
226
|
+
- [ ] Every event has ID, timestamp, and aggregate ID
|
|
227
|
+
- [ ] Consumers are idempotent (safe to process same event twice)
|
|
228
|
+
- [ ] Dead letter queues configured for all subscriptions
|
|
229
|
+
- [ ] Event schemas versioned for backward compatibility
|
|
230
|
+
- [ ] CQRS read models documented as eventually consistent
|
|
231
|
+
- [ ] Event store indexed on aggregate_id + version
|
|
232
|
+
- [ ] Failed events trigger alerts, not silent data loss
|