原始内容
fastify-playground
A production-oriented reference REST API built with Fastify, TypeScript, and Node.js 24. It demonstrates OpenAPI documentation, Firebase Authentication, TypeBox validation, structured logging, strict content negotiation, and deterministic shutdown.
TypeScript logo from Wikimedia Commons
Features
- Layered plugin architecture with production HSTS, an exact CORS origin allowlist, and fastify-observability
- Validated request IDs, strict W3C Trace Context, request-scoped Pino fields, and exactly one structured terminal access record
- Firebase Authentication with ID token verification, a
request.userdecorator, and a protected/v1/auth/meexample - TypeBox schema validation with compile-time TypeScript types and runtime JSON Schema validation
- RFC 9457 Problem Details for application-owned error responses with optional field-level validation errors
- Content negotiation supporting JSON (RFC 8259) and CBOR (RFC 8949) formats via
Acceptheader - Cursor-based pagination with RFC 8288 Link headers
- OpenAPI 3.1.0 documentation with Swagger UI, auto-generated from TypeBox route schemas
- Graceful SIGTERM/SIGINT shutdown owned by the executable server boundary; fatal Node.js errors are not treated as recoverable application events
- Health check endpoints (
/healthfor process liveness and/statusfor shutdown/load readiness)
API Design Principles
URI Design
- Lowercase letters with hyphens for multi-word segments:
/api/user-profiles - Plural nouns for collections:
/items,/users - Path parameters for resource identifiers:
/items/{id} - Query parameters for filtering, sorting, and pagination:
/items?category=electronics&limit=20
HTTP Methods
| Method | Purpose | Idempotent | Success Status |
|---|---|---|---|
| GET | Retrieve resource(s) | Yes | 200 OK |
| POST | Create resource | No | 201 Created |
| PUT | Replace resource | Yes | 200 OK / 204 No Content |
| PATCH | Partial update | No | 200 OK |
| DELETE | Remove resource | Yes | 204 No Content |
Error Responses (RFC 9457)
Application-owned errors, including /status failures, use RFC 9457 Problem Details. Documentation routes retain their Fastify plugin-owned error formats.
{
"title": "Validation Error",
"status": 422,
"detail": "One or more fields failed validation",
"errors": [
{ "location": "body.name", "message": "is required" }
]
}
Responses include Link: </schemas/ErrorModel.json>; rel="describedBy" for schema discovery. Error bodies use
application/problem+json by default, or the same fields encoded as registered application/cbor when the client
explicitly prefers CBOR.
Content Negotiation
API responses with a body default to JSON. CBOR is selected only by an explicit, positive-quality
Accept: application/cbor; wildcards and equal quality values keep JSON. RFC 9110 quality values and specificity are
honored, including exact q=0 exclusions. JSON-family ranges may include charset=utf-8; unsupported media
parameters remain non-matching. If an explicit Accept value excludes every supported success representation, the API
returns 406 before parsing the request body or running the handler. A 204 or 205 response has no representation, so
Accept does not gate it.
| Response class | Media types | Policy |
|---|---|---|
/v1 success |
application/json, application/cbor |
Strict negotiation; JSON is the default and tie preference |
/health |
application/json |
Strict JSON-only liveness response |
/status |
application/json |
Strict JSON-only readiness response; unavailable responses use negotiated Problem Details |
/schemas/*.json |
application/schema+json |
Strict JSON Schema representation |
| Problem Details | application/problem+json, application/cbor |
Best effort; explicit CBOR preference wins, otherwise JSON preserves the original error status |
/api-docs assets |
Fastify-owned JSON, YAML, HTML, CSS, or JavaScript | Fixed formats outside application negotiation |
JSON and CBOR request bodies are selected independently with Content-Type. Modeled request bodies accept
application/json and application/cbor, with media-type parameters ignored. Unowned structured suffixes such as
application/vnd.example+cbor and the unregistered application/problem+cbor are rejected with 415. RFC 9290's
registered application/concise-problem-details+cbor uses a different compact data model and is not implemented.
Negotiated responses include Vary: Accept, Origin. Successful modeled responses and Problem Details use an RFC 8288
Link header for schema discovery. Response instances do not contain the JSON Schema $schema keyword; standalone
schema documents include the Draft 2020-12 dialect and local $defs for referenced components.
Pagination
Collections use cursor-based pagination with RFC 8288 Link headers:
GET /items?limit=20&cursor=aXRlbToxMjM
Link: </items?limit=20&cursor=aXRlbToxNTY>; rel="next"
cursor- Canonical, unpadded Base64URL cursor with a 2,048-character limit (do not decode on client)limit- Items per page (1-100, default 20)
Malformed, noncanonical, empty, oversized, invalid UTF-8, wrong-resource, and stale cursors return a client error. A previous-page link returns the exact preceding page; the second page links back to the first page without an empty cursor parameter.
Request Identification
- Client-provided
X-Request-Idis accepted only when it contains 1–128 URI-unreserved ASCII characters (A-Z,a-z,0-9,-,.,_, or~) - Missing, empty, duplicate, oversized, or invalid values are replaced with a generated request ID
- Response includes
X-Request-Idheader request.id,request.observability.requestId, and the Pinorequest_idbinding always agree
Observability
fastify-observability v2 owns the application Pino logger, request-ID generation, W3C trace parsing, request correlation, and access logging. The app explicitly pins Trace Context Level 1, the W3C Recommendation, rather than opting into the draft Level 2 grammar. It emits one terminal request completed record for successful and handled-error responses, unhandled errors, and abnormal outcomes identified as timeout, client_disconnect, body_error, or response_dropped.
Native terminal error capture is disabled, so terminal records retain correlation, route, status, timing, and abnormal-outcome metadata without serializing arbitrary error messages, stacks, causes, or properties. Application code can emit controlled domain diagnostic codes when needed. Concrete request paths, direct peer IPs, and User-Agent values also remain disabled; low-cardinality path_template and operation_id fields are retained. Query strings, request bodies, cookies, authorization, and arbitrary headers are not selected for access records.
The GCP preset maps log levels to Cloud Logging severity and emits the validated bare trace ID in logging.googleapis.com/trace. It does not prepend a project resource, copy the incoming parent ID into a fake current-span field, initialize a cloud SDK, create spans, or project Level 2's draft random flag. Without a valid traceparent, correlation_id falls back to the request ID.
Application code logs through fastify.log, request.log, or reply.log. Request-scoped logs inherit request_id, correlation_id, and validated trace fields. The immutable context is also available as request.observability.
Project Structure
.agents/skills/ Portable coding-agent workflows
app/
src/
app.ts # Pure application factory
server.ts # Executable startup and signal boundary
env.ts # Environment validation (TypeBox)
plugins/ # Application-owned Fastify plugins
routes/ # Route handlers (health, schemas)
modules/ # Feature modules (auth/, github/, hello/, items/)
<name>/ # index.ts, routes.ts, schemas.ts, service.ts
schemas/ # Shared TypeBox schemas (problem-details, pagination)
utils/ # Utility functions
tests/
unit/ # Unit tests (mirror src/ structure)
integration/ # Full-stack integration tests
property/ # Bounded fast-check properties
mocks/ # Test mocks (firebase.ts)
functions/ # Firebase Cloud Functions (placeholder)
Requirements
- Node.js 24.18.0 (pinned in
.node-version), managed with fnm:brew install fnm - pnpm 11.13.0 through Corepack
- just for repository workflows
- actionlint and zizmor 1.27.0 for local workflow checks
- Firebase project with Authentication, or the Authentication emulator, only when exercising
/v1/auth/me
Quick Start
git clone https://github.com/janisto/fastify-playground.git
cd fastify-playground
fnm use && corepack enable
cp .env.example .env
just install
just serve
Access the API at http://localhost:3000 and Swagger UI at http://localhost:3000/api-docs
The root Justfile loads .env for its recipes. Direct runtime commands such as pnpm dev and pnpm start consume variables already exported by your shell.
Tech Stack
| Category | Technology |
|---|---|
| Runtime | Node.js 24.18.0 (ES2024) |
| Framework | Fastify 5.x with TypeScript 7 |
| Observability | fastify-observability 2 with Pino 10 |
| Package manager | pnpm 11.13.0 |
| Authentication | Firebase Admin SDK |
| Schema Validation | TypeBox with @fastify/type-provider-typebox |
| Testing | Vitest with V8 coverage (90% minimum) |
| Code Quality | Biome (formatting, linting, imports) |
| Backend Services | Firebase Authentication |
| Module System | ESM ("type": "module") |
Development Commands
Use the root Justfile for normal development:
just lint # Check Biome formatting and lint rules
just typing # Type-check source and tests
just test # Run unit and integration tests
just cov # Run the full suite with coverage
just qa # Apply safe fixes, then type-check and test
just check # Run all non-mutating quality gates
just workflow-check # Check workflow syntax and security
just fuzz # Run the property suite with FUZZ_RUNS (default 1000)
just audit # Audit production dependencies
just install # Install exactly from the lockfile
just fresh # Reinstall from a clean node_modules and dist
just update # Update dependencies within declared ranges
Direct package scripts run from app/:
corepack pnpm qa # Auto-fix lint/format, type check, and run tests
corepack pnpm dev # Start dev server with hot reload
corepack pnpm test # Run all tests
corepack pnpm test:coverage # Run tests with coverage report
corepack pnpm check # Run format, lint, and import checks
corepack pnpm check:fix # Auto-fix all issues
corepack pnpm typecheck # Type check source and tests
corepack pnpm start # Start the compiled server
Container
just container-build # Build image
just container-up # Run container detached
just container-down # Stop container
Or with Docker/Podman CLI:
docker build -t fastify-playground:latest ./app
docker run --rm -p 8080:8080 --env-file .env fastify-playground:latest
Deployment
Google Cloud Run
# Build and push to Artifact Registry
gcloud builds submit --tag REGION-docker.pkg.dev/PROJECT_ID/REPO/fastify-playground:latest ./app
# Deploy the checked-in complete container image
gcloud run deploy fastify-playground \
--image REGION-docker.pkg.dev/PROJECT_ID/REPO/fastify-playground:latest \
--platform managed \
--region REGION \
--allow-unauthenticated
This repository builds a complete distroless application image. Cloud Run's automatic base-image updates apply to services deployed from a scratch application image or compatible source/buildpacks flow, not to this image. Rebuild and redeploy the checked-in image to pick up Node.js or distroless security updates. The runtime remains non-root and does not enable source maps because the image does not ship source files and no source-map-dependent operational workflow is configured.
Trace correlation requires only a valid incoming W3C traceparent; no Google Cloud project setting is needed for the package's bare trace-ID contract.
API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness probe ({ status: "healthy" }) |
| GET | /status |
Readiness probe ({ status: "ready" }); returns 503 while shutting down or under excessive process load |
| GET | /v1/auth/me |
Minimal identity from a verified Firebase ID token |
| GET | /v1/hello |
Greeting endpoint |
| POST | /v1/hello |
Personalized greeting (201 Created) |
| GET | /v1/items |
Items with cursor-based pagination and category filtering |
| GET | /v1/github/owners/:owner |
GitHub user profile |
| GET | /v1/github/owners/:owner/repos |
List user repositories |
| GET | /v1/github/repos/:owner/:repo |
Repository details |
| GET | /v1/github/repos/:owner/:repo/activity |
Repository activity (paginated) |
| GET | /v1/github/repos/:owner/:repo/languages |
Repository languages |
| GET | /v1/github/repos/:owner/:repo/tags |
Repository tags |
| GET | /schemas/:schemaId |
Schema discovery |
| GET | /api-docs |
Swagger UI |
| GET | /api-docs/json |
OpenAPI 3.1.0 spec (JSON) |
Environment Variables
Copy .env.example to .env and customize as needed:
cp .env.example .env
| Variable | Default | Description |
|---|---|---|
NODE_ENV |
development |
Environment mode (development, production, test) |
PORT |
3000 |
Server port |
HOST |
0.0.0.0 |
Server host |
LOG_LEVEL |
info |
Logging level (trace, debug, info, warn, error, fatal, silent) |
CORS_ORIGINS |
empty | JSON array or comma-separated exact browser origins; empty denies all browser origins |
Firebase Authentication configuration:
| Variable | Description |
|---|---|
GOOGLE_APPLICATION_CREDENTIALS |
Local path used directly by Application Default Credentials; the application does not read the key file |
FIREBASE_AUTH_EMULATOR_HOST |
Auth emulator address (e.g., localhost:9099) |
The API GITHUB_TOKEN in .env.example is test-only. It raises the limit for opt-in tests that instantiate GitHubClient directly; the running API deliberately does not decode or forward it. Prefer a fine-grained token without private-repository access. It is separate from the Merge GITHUB_TOKEN, GitHub Actions' automatic token for repository workflows.
Plugin Architecture
Plugins are registered explicitly in app.ts with layered dependencies:
| Layer | Plugins |
|---|---|
| 1. Observability | fastify-observability |
| 2. Core | sensible, helmet, cors |
| 3. HTTP Lifecycle | vary-header, cbor-parser, content-negotiation |
| 4. Infrastructure | Firebase Auth, lifecycle, Swagger, process-pressure protection |
| 5. Application | auth, error-handler |
| 6. Response Metadata | schema-registry, schema-discovery |
| 7. Routes | health, schemas, v1 modules |
Observability is registered once at the root before every application hook and route. Application-owned plugins use fastify-plugin when decorators or hooks must escape encapsulation.
Firebase Authentication
curl \
--header 'Accept: application/json' \
--header 'Authorization: Bearer FIREBASE_ID_TOKEN' \
http://localhost:3000/v1/auth/me
The response contains only { "userId": "..." }; claims such as email addresses are not reflected. Missing, malformed, invalid, or expired credentials return a controlled 401 Problem Details response, while Firebase configuration or provider failures return 503. The example uses Firebase's default checkRevoked: false; enable revocation checks only when immediate session invalidation is part of the product contract and the extra lookup is acceptable.
GitHub Proxy Boundary
The /v1/github examples proxy public GitHub REST data without a server credential. This is intentional: attaching a broad deployment token to caller-selected owner and repository paths could expose private resources available to that token. Each upstream request has a 10-second overall deadline beneath the application's 15-second handler deadline, propagates request cancellation, validates the successful GitHub payload, and maps upstream failures to controlled Problem Details.
GitHub's unauthenticated primary rate limit is 60 requests per hour per source IP. The example has no cache or distributed inbound rate limiter. A production public proxy that needs more capacity should define a different authorization boundary and add cache and distributed abuse controls rather than putting a private-capable token behind these caller-selected routes.
Testing
- Framework: Vitest with V8 coverage
- Coverage threshold: 90% (lines, functions, branches, statements)
- Coverage scope: Full unit and integration suite across all
src/**/*.tsfiles - Unit tests:
tests/unit/with mocked external dependencies - Integration tests:
tests/integration/; direct real-GitHub client tests require the APIGITHUB_TOKENand otherwise skip - Property tests:
tests/property/; routine runs use 100 successful cases per property
Run a longer property-only campaign with just fuzz. Set FUZZ_RUNS to change the successful-case budget. Reproduce
an exact fast-check failure with:
FUZZ_RUNS=1000 FUZZ_SEED=<seed> FUZZ_PATH=<path> just fuzz
Keep the generated seed and path in failure reports. Promote stable minimized failures to named regression tests; do not commit random corpora.
Troubleshooting
| Symptom | Repository-owned cause | Smallest corrective action |
|---|---|---|
Startup rejects CORS_ORIGINS |
An entry is not an exact HTTP(S) origin, contains credentials/path/query/fragment, or the JSON array contains a non-string | Use exact origins such as https://app.example; explicit default ports normalize to the origin default |
| A request returns 401 without contacting Firebase | The authorization value is not exactly case-insensitive Bearer, one ASCII space, and one non-whitespace token |
Send Authorization: Bearer <token> with no extra fields or alternate whitespace |
| A response is 406 | Accept excludes every representation the route can produce |
Request application/json, or explicitly request application/cbor on routes that produce it |
| A request body is 415 | Content-Type is not owned by the route; arbitrary +cbor and application/problem+cbor are unsupported |
Send the route's declared application/json or exact application/cbor media type |
| A GitHub proxy route returns 502 or 504 | GitHub returned invalid/upstream data, transport failed, or the 10-second upstream deadline elapsed | Retry after checking GitHub availability, the correlated terminal record, and controlled domain diagnostics; do not add a broad server token |
/health is healthy while /status is 503 |
Liveness intentionally bypasses process pressure; readiness reports shutdown or excessive load | Keep probing /health for process liveness and use /status for traffic readiness |
| Requests emit duplicate or uncorrelated terminal records | Fastify was wired outside the canonical fastify-observability constructor and root-plugin setup |
Restore the package-created logger/genReqId wiring and register the observability plugin once before routes |
Code Style Requirements
Biome enforces formatting, imports, project and test rules, and type-aware promise and correctness checks. TypeScript separately checks production and test code with exact optional properties and unchecked indexed access. pnpm check:fix applies safe fixes; just check runs all non-mutating quality gates.
CI/CD
GitHub Actions workflows in .github/workflows/:
| Workflow | Description |
|---|---|
app-ci.yml |
Pinned-runtime build, tests, coverage, production image build, probe smoke, and graceful-shutdown smoke |
app-lint.yml |
Code quality (Biome) |
labeler.yml |
Automatic PR labeling |
labeler-manual.yml |
Manual labeling for historical PRs |
dependabot-auto-merge.yml |
Auto-merge Dependabot minor/patch updates |
workflow-security.yml |
Read-only zizmor workflow security scanning |
Dependabot is configured in .github/dependabot.yml for automated dependency updates. The auto-merge workflow intentionally maps the Merge GITHUB_TOKEN, exposed as ${{ secrets.GITHUB_TOKEN }}, to GH_TOKEN for GitHub CLI authentication; contributors do not need to configure that secret. Workflow actions intentionally use explicit release tags such as actions/checkout@v7.0.0, rather than full commit SHAs, so Dependabot can propose readable version updates.
Contributing
The README is the human-facing project guide. Portable coding-agent workflows live under .agents/skills/, and agent execution rules live in AGENTS.md.
License
MIT