update skills

This commit is contained in:
2026-03-17 16:53:22 -07:00
parent 0b0783ef8e
commit f9a530667e
389 changed files with 54512 additions and 1 deletions

View File

@@ -0,0 +1,89 @@
# Cloudflare Workers for Platforms
Multi-tenant platform with isolated customer code execution at scale.
## Use Cases
- Multi-tenant SaaS running customer code
- AI-generated code execution in secure sandboxes
- Programmable platforms with isolated compute
- Edge functions/serverless platforms
- Website builders with static + dynamic content
- Unlimited app deployment at scale
**NOT for general Workers** - only for Workers for Platforms architecture.
## Quick Start
**One-click deploy:** [Platform Starter Kit](https://github.com/cloudflare/workers-for-platforms-example) deploys complete WfP setup with dispatch namespace, dispatch worker, and user worker example.
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/workers-for-platforms-example)
**Manual setup:** See [configuration.md](./configuration.md) for namespace creation and dispatch worker configuration.
## Key Features
- Unlimited Workers per namespace (no script limits)
- Automatic tenant isolation
- Custom CPU/subrequest limits per customer
- Hostname routing (subdomains/vanity domains)
- Egress/ingress control
- Static assets support
- Tags for bulk operations
## Architecture
**4 Components:**
1. **Dispatch Namespace** - Container for unlimited customer Workers, automatic isolation (untrusted mode by default - no request.cf access, no shared cache)
2. **Dynamic Dispatch Worker** - Entry point, routes requests, enforces platform logic (auth, limits, validation)
3. **User Workers** - Customer code in isolated sandboxes, API-deployed, optional bindings (KV/D1/R2/DO)
4. **Outbound Worker** (optional) - Intercepts external fetch, controls egress, logs subrequests (blocks TCP socket connect() API)
**Request Flow:**
```
Request → Dispatch Worker → Determines user Worker → env.DISPATCHER.get("customer")
→ User Worker executes (Outbound Worker for external fetch) → Response → Dispatch Worker → Client
```
## Decision Trees
### When to Use Workers for Platforms
```
Need to run code?
├─ Your code only → Regular Workers
├─ Customer/AI code → Workers for Platforms
└─ Untrusted code in sandbox → Workers for Platforms OR Sandbox API
```
### Routing Strategy Selection
```
Hostname routing needed?
├─ Subdomains only (*.saas.com) → `*.saas.com/*` route + subdomain extraction
├─ Custom domains → `*/*` wildcard + Cloudflare for SaaS + KV/metadata routing
└─ Path-based (/customer/app) → Any route + path parsing
```
### Isolation Mode Selection
```
Worker mode?
├─ Running customer code → Untrusted (default)
├─ Need request.cf geolocation → Trusted mode
├─ Internal platform, controlled code → Trusted mode with cache key prefixes
└─ Maximum isolation → Untrusted + unique resources per customer
```
## In This Reference
| File | Purpose | When to Read |
|------|---------|--------------|
| [configuration.md](./configuration.md) | Namespace setup, dispatch worker config | First-time setup, changing limits |
| [api.md](./api.md) | User worker API, dispatch API, outbound worker | Deploying workers, SDK integration |
| [patterns.md](./patterns.md) | Multi-tenancy, routing, egress control | Planning architecture, scaling |
| [gotchas.md](./gotchas.md) | Limits, isolation issues, best practices | Debugging, production prep |
## See Also
- [workers](../workers/) - Core Workers runtime documentation
- [durable-objects](../durable-objects/) - Stateful multi-tenant patterns
- [sandbox](../sandbox/) - Alternative for untrusted code execution
- [Reference Architecture: Programmable Platforms](https://developers.cloudflare.com/reference-architecture/diagrams/serverless/programmable-platforms/)
- [Reference Architecture: AI Vibe Coding Platform](https://developers.cloudflare.com/reference-architecture/diagrams/ai/ai-vibe-coding-platform/)

View File

@@ -0,0 +1,196 @@
# API Operations
## Deploy User Worker
```bash
curl -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/workers/dispatch/namespaces/$NAMESPACE/scripts/$SCRIPT_NAME" \
-H "Authorization: Bearer $API_TOKEN" \
-F 'metadata={"main_module": "worker.mjs"};type=application/json' \
-F 'worker.mjs=@worker.mjs;type=application/javascript+module'
```
### TypeScript SDK
```typescript
import Cloudflare from "cloudflare";
const client = new Cloudflare({ apiToken: process.env.API_TOKEN });
const scriptFile = new File([scriptContent], `${scriptName}.mjs`, {
type: "application/javascript+module",
});
await client.workersForPlatforms.dispatch.namespaces.scripts.update(
namespace, scriptName,
{
account_id: accountId,
metadata: { main_module: `${scriptName}.mjs` },
files: [scriptFile],
}
);
```
## TypeScript Types
```typescript
import type { DispatchNamespace } from '@cloudflare/workers-types';
interface DispatchNamespace {
get(name: string, options?: Record<string, unknown>, dispatchOptions?: DynamicDispatchOptions): Fetcher;
}
interface DynamicDispatchOptions {
limits?: DynamicDispatchLimits;
outbound?: Record<string, unknown>;
}
interface DynamicDispatchLimits {
cpuMs?: number; // Max CPU milliseconds
subRequests?: number; // Max fetch() calls
}
// Usage
const userWorker = env.DISPATCHER.get('customer-123', {}, {
limits: { cpuMs: 50, subRequests: 20 },
outbound: { customerId: '123', url: request.url }
});
```
## Deploy with Bindings
```bash
curl -X PUT ".../scripts/$SCRIPT_NAME" \
-F 'metadata={
"main_module": "worker.mjs",
"bindings": [
{"type": "kv_namespace", "name": "MY_KV", "namespace_id": "'$KV_ID'"}
],
"tags": ["customer-123", "production"],
"compatibility_date": "2026-01-01" // Use current date for new projects
};type=application/json' \
-F 'worker.mjs=@worker.mjs;type=application/javascript+module'
```
## List/Delete Workers
```bash
# List
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/workers/dispatch/namespaces/$NAMESPACE/scripts" \
-H "Authorization: Bearer $API_TOKEN"
# Delete by name
curl -X DELETE ".../scripts/$SCRIPT_NAME" -H "Authorization: Bearer $API_TOKEN"
# Delete by tag
curl -X DELETE ".../scripts?tags=customer-123%3Ayes" -H "Authorization: Bearer $API_TOKEN"
```
**Pagination:** SDK supports async iteration. Manual: add `?per_page=100&page=1` query params.
## Static Assets
**3-step process:** Create session → Upload files → Deploy Worker
### 1. Create Upload Session
```bash
curl -X POST ".../scripts/$SCRIPT_NAME/assets-upload-session" \
-H "Authorization: Bearer $API_TOKEN" \
-d '{
"manifest": {
"/index.html": {"hash": "08f1dfda4574284ab3c21666d1ee8c7d4", "size": 1234}
}
}'
# Returns: jwt, buckets
```
**Hash:** SHA-256 truncated to first 16 bytes (32 hex characters)
### 2. Upload Files
```bash
curl -X POST ".../workers/assets/upload?base64=true" \
-H "Authorization: Bearer $UPLOAD_JWT" \
-F '08f1dfda4574284ab3c21666d1ee8c7d4=<BASE64_CONTENT>'
# Returns: completion jwt
```
**Multiple buckets:** Upload to all returned bucket URLs (typically 2 for redundancy) using same JWT and hash.
### 3. Deploy with Assets
```bash
curl -X PUT ".../scripts/$SCRIPT_NAME" \
-F 'metadata={
"main_module": "index.js",
"assets": {"jwt": "<COMPLETION_TOKEN>"},
"bindings": [{"type": "assets", "name": "ASSETS"}]
};type=application/json' \
-F 'index.js=export default {...};type=application/javascript+module'
```
**Asset Isolation:** Assets shared across namespace by default. For customer isolation, salt hash: `sha256(customerId + fileContents).slice(0, 32)`
## Dispatch Workers
### Subdomain Routing
```typescript
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const userWorkerName = new URL(request.url).hostname.split(".")[0];
const userWorker = env.DISPATCHER.get(userWorkerName);
return await userWorker.fetch(request);
},
};
```
### Path Routing
```typescript
const pathParts = new URL(request.url).pathname.split("/").filter(Boolean);
const userWorker = env.DISPATCHER.get(pathParts[0]);
return await userWorker.fetch(request);
```
### KV Routing
```typescript
const hostname = new URL(request.url).hostname;
const userWorkerName = await env.ROUTING_KV.get(hostname);
const userWorker = env.DISPATCHER.get(userWorkerName);
return await userWorker.fetch(request);
```
## Outbound Workers
Control external fetch from user Workers:
### Configure
```typescript
const userWorker = env.DISPATCHER.get(
workerName, {},
{ outbound: { customer_context: { customer_name: workerName, url: request.url } } }
);
```
### Implement
```typescript
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const customerName = env.customer_name;
const url = new URL(request.url);
// Block domains
if (["malicious.com"].some(d => url.hostname.includes(d))) {
return new Response("Blocked", { status: 403 });
}
// Inject auth
if (url.hostname === "api.example.com") {
const headers = new Headers(request.headers);
headers.set("Authorization", `Bearer ${generateJWT(customerName)}`);
return fetch(new Request(request, { headers }));
}
return fetch(request);
},
};
```
**Note:** Doesn't intercept DO/mTLS fetch.
See [README.md](./README.md), [configuration.md](./configuration.md), [patterns.md](./patterns.md), [gotchas.md](./gotchas.md)

View File

@@ -0,0 +1,167 @@
# Configuration
## Dispatch Namespace Binding
### wrangler.jsonc
```jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"dispatch_namespaces": [{
"binding": "DISPATCHER",
"namespace": "production"
}]
}
```
## Worker Isolation Mode
Workers in a namespace run in **untrusted mode** by default for security:
- No access to `request.cf` object
- Isolated cache per Worker (no shared cache)
- `caches.default` disabled
### Enable Trusted Mode
For internal platforms where you control all code:
```bash
curl -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/workers/dispatch/namespaces/$NAMESPACE" \
-H "Authorization: Bearer $API_TOKEN" \
-d '{"name": "'$NAMESPACE'", "trusted_workers": true}'
```
**Caveats:**
- Workers share cache within namespace (use cache key prefixes: `customer-${id}:${key}`)
- `request.cf` object accessible
- Redeploy existing Workers after enabling trusted mode
**When to use:** Internal platforms, A/B testing platforms, need geolocation data
### With Outbound Worker
```jsonc
{
"dispatch_namespaces": [{
"binding": "DISPATCHER",
"namespace": "production",
"outbound": {
"service": "outbound-worker",
"parameters": ["customer_context"]
}
}]
}
```
## Wrangler Commands
```bash
wrangler dispatch-namespace list
wrangler dispatch-namespace get production
wrangler dispatch-namespace create production
wrangler dispatch-namespace delete staging
wrangler dispatch-namespace rename old new
```
## Custom Limits
Set CPU time and subrequest limits per invocation:
```typescript
const userWorker = env.DISPATCHER.get(
workerName,
{},
{
limits: {
cpuMs: 10, // Max CPU ms
subRequests: 5 // Max fetch() calls
}
}
);
```
Handle limit violations:
```typescript
try {
return await userWorker.fetch(request);
} catch (e) {
if (e.message.includes("CPU time limit")) {
return new Response("CPU limit exceeded", { status: 429 });
}
throw e;
}
```
## Static Assets
Deploy HTML/CSS/images with Workers. See [api.md](./api.md#static-assets) for upload process.
### Wrangler
```jsonc
{
"name": "customer-site",
"main": "./src/index.js",
"assets": {
"directory": "./public",
"binding": "ASSETS"
}
}
```
```bash
npx wrangler deploy --name customer-site --dispatch-namespace production
```
### Dashboard Deployment
Alternative to CLI:
1. Upload Worker file in dashboard
2. Add `--dispatch-namespace` flag: `wrangler deploy --dispatch-namespace production`
3. Or configure in wrangler.jsonc under `dispatch_namespaces`
See [api.md](./api.md) for programmatic deployment via REST API or SDK.
## Tags
Organize/search Workers (max 8/script):
```bash
# Set tags
curl -X PUT ".../tags" -d '["customer-123", "pro", "production"]'
# Filter by tag
curl ".../scripts?tags=production%3Ayes"
# Delete by tag
curl -X DELETE ".../scripts?tags=customer-123%3Ayes"
```
Common patterns: `customer-123`, `free|pro|enterprise`, `production|staging`
## Bindings
**Supported binding types:** 29 total including KV, D1, R2, Durable Objects, Analytics Engine, Service, Assets, Queue, Vectorize, Hyperdrive, Workflow, AI, Browser, and more.
Add via API metadata (see [api.md](./api.md#deploy-with-bindings)):
```json
{
"bindings": [
{"type": "kv_namespace", "name": "USER_KV", "namespace_id": "..."},
{"type": "r2_bucket", "name": "STORAGE", "bucket_name": "..."},
{"type": "d1", "name": "DB", "id": "..."}
]
}
```
Preserve existing bindings:
```json
{
"bindings": [{"type": "r2_bucket", "name": "STORAGE", "bucket_name": "new"}],
"keep_bindings": ["kv_namespace", "d1"] // Preserves existing bindings of these types
}
```
For complete binding type reference, see [bindings](../bindings/) documentation
See [README.md](./README.md), [api.md](./api.md), [patterns.md](./patterns.md), [gotchas.md](./gotchas.md)

View File

@@ -0,0 +1,134 @@
# Gotchas & Limits
## Common Errors
### "Worker not found"
**Cause:** Attempting to get Worker that doesn't exist in namespace
**Solution:** Catch error and return 404:
```typescript
try {
const userWorker = env.DISPATCHER.get(workerName);
return userWorker.fetch(request);
} catch (e) {
if (e.message.startsWith("Worker not found")) {
return new Response("Worker not found", { status: 404 });
}
throw e; // Re-throw unexpected errors
}
```
### "CPU time limit exceeded"
**Cause:** User Worker exceeded configured CPU time limit
**Solution:** Track violations in Analytics Engine and return 429 response; consider adjusting limits per customer tier
### "Hostname Routing Issues"
**Cause:** DNS proxy settings causing routing problems
**Solution:** Use `*/*` wildcard route which works regardless of proxy settings for orange-to-orange routing
### "Bindings Lost on Update"
**Cause:** Not using `keep_bindings` flag when updating Worker
**Solution:** Use `keep_bindings: true` in API requests to preserve existing bindings during updates
### "Tag Filtering Not Working"
**Cause:** Special characters not URL encoded in tag filters
**Solution:** URL encode tags (e.g., `tags=production%3Ayes`) and avoid special chars like `,` and `&`
### "Deploy Failures with ES Modules"
**Cause:** Incorrect upload format for ES modules
**Solution:** Use multipart form upload, specify `main_module` in metadata, and set file type to `application/javascript+module`
### "Static Asset Upload Failed"
**Cause:** Invalid hash format, expired token, or incorrect encoding
**Solution:** Hash must be first 16 bytes (32 hex chars) of SHA-256, upload within 1 hour of session creation, deploy within 1 hour of upload completion, and Base64 encode file contents
### "Outbound Worker Not Intercepting Calls"
**Cause:** Outbound Workers don't intercept Durable Object or mTLS binding fetch
**Solution:** Plan egress control accordingly; not all fetch calls are intercepted
### "TCP Socket Connection Failed"
**Cause:** Outbound Worker enabled blocks `connect()` API for TCP sockets
**Solution:** Outbound Workers only intercept `fetch()` calls; TCP socket connections unavailable when outbound configured. Remove outbound if TCP needed, or use proxy pattern.
### "API Rate Limit Exceeded"
**Cause:** Exceeded Cloudflare API rate limits (1200 requests per 5 minutes per account, 200 requests per second per IP)
**Solution:** Implement exponential backoff:
```typescript
async function deployWithBackoff(deploy: () => Promise<void>, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await deploy();
} catch (e) {
if (e.status === 429 && i < maxRetries - 1) {
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
continue;
}
throw e;
}
}
}
```
### "Gradual Deployment Not Supported"
**Cause:** Attempted to use gradual deployments with user Workers
**Solution:** Gradual deployments not supported for Workers in dispatch namespaces. Use all-at-once deployment with staged rollout via dispatch worker logic (feature flags, percentage-based routing).
### "Asset Session Expired"
**Cause:** Upload JWT expired (1 hour validity) or completion token expired (1 hour after upload)
**Solution:** Complete asset upload within 1 hour of session creation, and deploy Worker within 1 hour of upload completion. For large uploads, batch files or increase upload parallelism.
## Platform Limits
| Limit | Value | Notes |
|-------|-------|-------|
| Workers per namespace | Unlimited | Unlike regular Workers (500 per account) |
| Namespaces per account | Unlimited | Best practice: 1 production + 1 staging |
| Max tags per Worker | 8 | For filtering and organization |
| Worker mode | Untrusted (default) | No `request.cf` access unless trusted mode |
| Cache isolation | Per-Worker (untrusted) | Shared in trusted mode with key prefixes |
| Durable Object namespaces | Unlimited | No per-account limit for WfP |
| Gradual Deployments | Not supported | All-at-once only |
| `caches.default` | Disabled (untrusted) | Use Cache API with custom keys |
## Asset Upload Limits
| Limit | Value | Notes |
|-------|-------|-------|
| Upload session JWT validity | 1 hour | Must complete upload within this time |
| Completion token validity | 1 hour | Must deploy within this time after upload |
| Asset hash format | First 16 bytes SHA-256 | 32 hex characters |
| Base64 encoding | Required | For binary files |
## API Rate Limits
| Limit Type | Value | Scope |
|------------|-------|-------|
| Client API | 1200 requests / 5 min | Per account |
| Client API | 200 requests / sec | Per IP address |
| GraphQL | Varies by query cost | Query complexity |
See [Cloudflare API Rate Limits](https://developers.cloudflare.com/fundamentals/api/reference/limits/) for details.
## Operational Limits
| Operation | Limit | Notes |
|-----------|-------|-------|
| CPU time (custom limits) | Up to Workers plan limit | Set per-invocation in dispatch worker |
| Subrequests (custom limits) | Up to Workers plan limit | Set per-invocation in dispatch worker |
| Outbound Worker subrequests | Not intercepted for DO/mTLS | Only regular fetch() calls |
| TCP sockets with outbound | Disabled | `connect()` API unavailable |
See [README.md](./README.md), [configuration.md](./configuration.md), [api.md](./api.md), [patterns.md](./patterns.md)

View File

@@ -0,0 +1,188 @@
# Multi-Tenant Patterns
## Billing by Plan
```typescript
interface Env {
DISPATCHER: DispatchNamespace;
CUSTOMERS_KV: KVNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const userWorkerName = new URL(request.url).hostname.split(".")[0];
const customerPlan = await env.CUSTOMERS_KV.get(userWorkerName);
const plans = {
enterprise: { cpuMs: 50, subRequests: 50 },
pro: { cpuMs: 20, subRequests: 20 },
free: { cpuMs: 10, subRequests: 5 },
};
const limits = plans[customerPlan as keyof typeof plans] || plans.free;
const userWorker = env.DISPATCHER.get(userWorkerName, {}, { limits });
return await userWorker.fetch(request);
},
};
```
## Resource Isolation
**Complete isolation:** Create unique resources per customer
- KV namespace per customer
- D1 database per customer
- R2 bucket per customer
```typescript
const bindings = [{
type: "kv_namespace",
name: "USER_KV",
namespace_id: `customer-${customerId}-kv`
}];
```
## Hostname Routing
### Wildcard Route (Recommended)
Configure `*/*` route on SaaS domain → dispatch Worker
**Benefits:**
- Supports subdomains + custom vanity domains
- No per-route limits (regular Workers limited to 100 routes)
- Programmatic control
- Works with any DNS proxy settings
**Setup:**
1. Cloudflare for SaaS custom hostnames
2. Fallback origin (dummy `A 192.0.2.0` if Worker is origin)
3. DNS CNAME to SaaS domain
4. `*/*` route → dispatch Worker
5. Routing logic in dispatch Worker
```typescript
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const hostname = new URL(request.url).hostname;
const hostnameData = await env.ROUTING_KV.get(`hostname:${hostname}`, { type: "json" });
if (!hostnameData?.workerName) {
return new Response("Hostname not configured", { status: 404 });
}
const userWorker = env.DISPATCHER.get(hostnameData.workerName);
return await userWorker.fetch(request);
},
};
```
### Subdomain-Only
1. Wildcard DNS: `*.saas.com` → origin
2. Route: `*.saas.com/*` → dispatch Worker
3. Extract subdomain for routing
### Orange-to-Orange (O2O) Behavior
When customers use Cloudflare and CNAME to your Workers domain:
| Scenario | Behavior | Route Pattern |
|----------|----------|---------------|
| Customer not on Cloudflare | Standard routing | `*/*` or `*.domain.com/*` |
| Customer on Cloudflare (proxied CNAME) | Invokes Worker at edge | `*/*` required |
| Customer on Cloudflare (DNS-only CNAME) | Standard routing | Any route works |
**Recommendation:** Always use `*/*` wildcard for consistent O2O behavior.
### Custom Metadata Routing
For Cloudflare for SaaS: Store worker name in custom hostname `custom_metadata`, retrieve in dispatch worker to route requests. Requires custom hostnames as subdomains of your domain.
## Observability
### Logpush
- Enable on dispatch Worker → captures all user Worker logs
- Filter by `Outcome` or `Script Name`
### Tail Workers
- Real-time logs with custom formatting
- Receives HTTP status, `console.log()`, exceptions, diagnostics
### Analytics Engine
```typescript
// Track violations
env.ANALYTICS.writeDataPoint({
indexes: [customerName],
blobs: ["cpu_limit_exceeded"],
});
```
### GraphQL
```graphql
query {
viewer {
accounts(filter: {accountTag: $accountId}) {
workersInvocationsAdaptive(filter: {dispatchNamespaceName: "production"}) {
sum { requests errors cpuTime }
}
}
}
}
```
## Use Case Implementations
### AI Code Execution
```typescript
async function deployGeneratedCode(name: string, code: string) {
const file = new File([code], `${name}.mjs`, { type: "application/javascript+module" });
await client.workersForPlatforms.dispatch.namespaces.scripts.update("production", name, {
account_id: accountId,
metadata: { main_module: `${name}.mjs`, tags: [name, "ai-generated"] },
files: [file],
});
}
// Short limits for untrusted code
const userWorker = env.DISPATCHER.get(sessionId, {}, { limits: { cpuMs: 5, subRequests: 3 } });
```
**VibeSDK:** For AI-powered code generation + deployment platforms, see [VibeSDK](https://github.com/cloudflare/vibesdk) - handles AI generation, sandbox execution, live preview, and deployment.
Reference: [AI Vibe Coding Platform Architecture](https://developers.cloudflare.com/reference-architecture/diagrams/ai/ai-vibe-coding-platform/)
### Edge Functions Platform
```typescript
// Route: /customer-id/function-name
const [customerId, functionName] = new URL(request.url).pathname.split("/").filter(Boolean);
const workerName = `${customerId}-${functionName}`;
const userWorker = env.DISPATCHER.get(workerName);
```
### Website Builder
- Deploy static assets + Worker code
- See [api.md](./api.md#static-assets) for full implementation
- Salt hashes for asset isolation
## Best Practices
### Architecture
- One namespace per environment (production, staging)
- Platform logic in dispatch Worker (auth, rate limiting, validation)
- Isolation automatic (no shared cache, untrusted mode)
### Routing
- Use `*/*` wildcard routes
- Store mappings in KV
- Handle missing Workers gracefully
### Limits & Security
- Set custom limits by plan
- Track violations with Analytics Engine
- Use outbound Workers for egress control
- Sanitize responses
### Tags
- Tag all Workers: customer ID, plan, environment
- Enable bulk operations
- Filter efficiently
See [README.md](./README.md), [configuration.md](./configuration.md), [api.md](./api.md), [gotchas.md](./gotchas.md)