Skip to content

txstate-etc/fastify-txstate

Repository files navigation

fastify-txstate

A small wrapper for fastify providing a set of common conventions & utility functions for presenting an HTTP server.

v4 upgrades to fastify 5 and drops CommonJS, among other things. See the changelog for upgrade notes.

Basic Usage

import Server from 'fastify-txstate'
const server = new Server()
server.app.get('/yourpath', async (req, res) => {
  return { hello: 'world' }
})
server.start().then(() => {
  console.log('started!')
}).catch(e => console.error(e))
  • If you need special configuration for fastify, pass it to new Server({ /* any valid fastify config */ }).
  • server.app is the fastify instance.

Error Handling

Some resources are available to make error handling easy.

HttpError

This class is available to throw simple errors while processing a request:

import { HttpError } from 'fastify-txstate'
server.app.get('/yourpath', async (req, res) => {
  if (!req.params.id) throw new HttpError(400, 'Please provide an id.')
  /* ... */
})

This will result in a 400 error being returned to the client, with a plain text body: Please provide an id.

You may skip the message string and a default will be used, e.g. throw new HttpError(401) sends a plain text body: Authentication is required.

ValidationErrors

This class helps an API communicate with its client about errors that occured during a validation or writing operation. The constructor takes three arguments: a message to be displayed to the user, a dot-separated path to the property of the input object that the message is related to, and a message type, which could be 'error', 'info', 'warning', 'success', or 'system' (system is for errors that the user is not responsible for like a database being offline).

import { ValidationErrors } from 'fastify-txstate'
import { hasFatalErrors } from '@txstate-mws/fastify-shared'
server.app.post('/saveathing', async (req, res) => {
  const thing = req.body
  const messages = []
  if (!thing.title) messages.push({ message: 'Title is required.', path: 'title', type: 'error' })
  if (!thing?.address?.zip) messages.push({ message: 'Zip code is required.', path: 'address.zip', type: 'error' })
  if (hasFatalErrors(messages)) throw new ValidationErrors(messages)
  /* continue processing request */
})

The client will receive HTTP status 422 and a JSON body that looks like this:

{
  "success": false,
  "messages": [
    { "type": "error", "message": "Zip code is required.", "path": "address.zip" }
  ]
}

This format is well supported by our @txstate-mws/svelte-forms library, so it should be easy to pass the errors into your form.

ValidationError

ValidationErrors is preferred since it will show multiple errors at once, instead of making the user fix errors one at a time and not know how far they are from being done. If you just need to throw a quick single error, throw new ValidationError('Wrong!', 'answer') is also available.

Route Schemas

We configure the @fastify/type-provider-json-schema-to-ts type provider, so when you define a schema with as const, TypeScript infers request and response types automatically:

const createUserBody = {
  type: 'object',
  properties: {
    name: { type: 'string' },
    email: { type: 'string' },
    age: { type: 'integer', minimum: 0 }
  },
  additionalProperties: false
} as const

server.app.post('/users', {
  schema: {
    body: createUserBody,
    response: { 200: validatedResponse, 422: validatedResponse }
  }
}, async (req, res) => {
  // req.body is fully typed — name, email, age are all inferred
})

When a route has a body schema, we automatically add 400 and 422 response schemas for validation errors, so those don't need to be specified manually.

Schema Validation vs. Business Validation

We configure Ajv with coerceTypes, allErrors, ajv-formats (for date-time, email, uri, etc.), and ajv-errors (for custom errorMessage strings). strictSchema is off, so OpenAPI properties like example and description won't cause errors.

An important design point: schema validation is for catching client bugs, not user mistakes. When the schema rejects a request, the user gets a generic 400 — not a friendly inline message on a form field. If you want the user to see "Name is required" next to the name input, don't put required: ['name'] in the schema. Instead, make it optional in the schema and check it in your route handler with a ValidationMessage (see Error Handling). Reserve schema-level required, pattern, and format for things the client is responsible for, like ensuring dates are in ISO format.

SchemaObject Type

If you define schemas as standalone objects, you can take advantage of the SchemaObject exported by @txstate-mws/fastify-shared. It extends JSON Schema with OpenAPI properties (example, description) and ajv-errors support (errorMessage). Add as const satisfies SchemaObject to the end of your object before you start filling it with properties and you'll get autocomplete support in your IDE and confidence that your schema is compliant.

import type { SchemaObject } from '@txstate-mws/fastify-shared'

const addressSchema = {
  type: 'object',
  properties: {
    street: { type: 'string', example: '123 Main St' },
    city: { type: 'string', example: 'San Marcos' },
    zip: { type: 'string', description: '5-digit US zip code' }
  },
  additionalProperties: false
} as const satisfies SchemaObject

Response Serialization

Responses are validated through Ajv (not fast-json-stringify), so input and output validation behave identically. If a response doesn't match its schema, the server throws an error rather than sending malformed data. Null values are converted to undefined before validation, and Date objects are automatically stringified to ISO format.

Swagger / OpenAPI

Call server.swagger() before registering routes to enable auto-generated documentation:

const server = new Server()
await server.swagger({ path: '/docs' })
// register routes after this

Route schemas are exposed in the OpenAPI spec automatically. The example and description properties appear in the Swagger UI. If authentication is configured, security schemes are added automatically.

Custom Error Handling

If you would like special treatment for certain errors, addErrorHandler provides an easy way:

server.addErrorHandler(async (err, req, res) => {
  if (err instanceof MyCustomErrorClass) {
    res.status(500).send('You done messed up.')
  }
})

In this case we only need custom error handling for a specific class of Error. Calling res.send() in your handler is how you signal that the error has been intercepted. If you do not call res.send(), the default error handling will kick in, so you can still throw HttpError or ValidationErrors and have them handled properly.

You may call addErrorHandler multiple times; they will be executed in order and bail out when one calls res.send().

Opt-Out of Error Handling

If you want all the error handling to yourself, you may use fastify's setErrorHandler method to override all of fastify-txstate's behavior:

server.app.setErrorHandler((err, req, res) => {
  /* whatever you want */
})

SSL

SSL and HTTP2 support is enabled automatically if you provide a key and cert at /securekeys/private.key and /securekeys/cert.pem, respectively.

  • If you do not provide a custom port, and SSL key and cert are not present, port 80 will be used.
  • If you do not provide a custom port, and SSL key and cert are present, port 443 will be used and http traffic on port 80 will be redirected to https.
  • You can set a custom port with e.g. server.start(8080), or you can set the PORT environment variable. If the SSL key and cert are present, your custom port will expect https.

Health checks / load-balanced restart

A health check is automatically available at /health. You may use server.setUnhealthy('your message') and server.setHealthy() to alter the response. When a SIGINT or SIGTERM is issued (e.g. during an intentional restart), you have the option of delaying for a few seconds to allow the load-balancer to see that you are down. Do this by setting the LOAD_BALANCE_TIMEOUT environment variable in seconds.

During this period, /health will return HTTP 503, but all other requests will process normally. After the period, the service shuts down as requested. This gives load balancers time to switch all incoming traffic to another service, ensuring no clients see an error during the restart.

Origin Checking

To help prevent XSRF attacks, we automatically reject requests that send an origin header that doesn't match the host (sub)domain. Only domain is compared, not protocol or port. This is especially helpful in large organizations where untrusted web sites run under different subdomains. SameSite cookies can help with attacks from other domains, but attacks on the same subdomain can still succeed.

There are several ways to allow additional origins. Each is available as a constructor config option and an environment variable (comma-separated).

Config Env Var Match behavior
validOrigins VALID_ORIGINS Exact origin match including scheme and port (e.g. https://app.example.com)
validOriginHosts VALID_ORIGIN_HOSTS Hostname match, ignoring scheme and port (e.g. app.example.com)
validOriginSuffixes VALID_ORIGIN_SUFFIXES Domain suffix match — allows any subdomain (e.g. example.com allows foo.example.com, bar.baz.example.com)
checkOrigin Custom function (req) => boolean for arbitrary logic. Runs after the other checks; return true to allow.

All methods are additive — an origin is allowed if it passes any check.

You can disable origin checks entirely with the skipOriginCheck configuration or SKIP_ORIGIN_CHECK environment variable.

Reverse Proxy

If your application is behind a reverse proxy, you'll want to set the trustProxy configuration to true so that variables like request.protocol get set correctly. You can also set the TRUST_PROXY environment variable. true or 1 will translate to { trustProxy: true }; anything else will be passed unchanged as a string.

Logging

We try to set up logging well by default, including things like the HTTP traceparent header, and putting the url in both the incoming and outgoing access log entries so that it's easy to grep for certain routes/params.

Development and production logs are different, based on the NODE_ENV environment variable. The development logger is designed to be extremely brief and not in JSON format, so that you can see errors clearly.

If you want to provide your own logger, you can pass a pino instance via the loggerInstance option in the server constructor configuration. The devLogger and prodLogger are also exported if you'd like to use them as a starting point.

You can also simply add information to the reply.extraLogInfo object and it will automatically appear in the outgoing access log in production.

Authentication

Pass an authenticate function to the Server constructor to enable authentication. It runs as an onRequest hook on all standard HTTP methods (GET, POST, PUT, PATCH, DELETE), except /health and swagger endpoints. The return value is available at req.auth in your route handlers and is included in production logs (minus token, accessToken, and issuerConfig).

import Server from 'fastify-txstate'
const server = new Server({
  authenticate: async (req) => {
    // extract and verify credentials from the request
    // return a FastifyTxStateAuthInfo object, or undefined if unauthenticated
    // throw to reject with 401
  }
})

The authenticate function should return a FastifyTxStateAuthInfo object with at least username, sessionId, and token. This gives us a predictable interface, since raw JWT claims may vary by provider. Returning undefined means the request is unauthenticated (but allowed). Throwing an error sends a 401 response.

We provide a built-in jwtAuthenticate that validates JWT tokens from any combination of issuer types — OAuth/OIDC providers (with auto-discovery), TxState Unified Auth, raw JWKS endpoints, symmetric secrets, and asymmetric public keys. You can also write your own authenticate for any authentication scheme — API keys, session lookups, custom token formats, etc.

JWT Authentication

The jwtAuthenticate function validates JWTs from the Authorization: Bearer header or a session cookie. It supports any mix of these issuer types:

  • OAuth/OIDC — auto-discovers the provider's JWKS via .well-known/openid-configuration or .well-known/oauth-authorization-server from the issuer's iss claim.
  • TxState Unified Auth — JWKS + a /validateToken poll for centralized deauth.
  • JWKS endpoint — a direct JWKS URL (no discovery).
  • Asymmetric public key — a PEM-encoded RSA/EC public key.
  • Symmetric secret — an HMAC shared secret.

For providers like Google that issue opaque access tokens, have the client send the ID token instead — it's a standard JWT that proves the user's identity without requiring a round-trip to the provider on every request.

import Server, { jwtAuthenticate } from 'fastify-txstate'
const server = new Server({
  authenticate: jwtAuthenticate({ authenticateAll: true })
})

Environment Variables

At least one issuer must be configured. Use any combination of the env-var shortcuts below, or the JSON-based JWT_TRUSTED_ISSUERS for full control. Configuration from both sources is merged.

Variable Description
UA_URL URL of a TxState Unified Auth service. Creates a unified-auth issuer with iss: 'unified-auth'.
UA_URL_INTERNAL Internal URL of the UA service for server-to-server requests in split-horizon DNS scenarios.
OAUTH_URLS Comma-separated OAuth/OIDC issuer URLs (e.g. https://accounts.google.com,https://login.microsoftonline.com/{tenant}/v2.0). Each becomes an issuer with iss equal to the URL.
OAUTH_INTERNAL_URLS Map external OAuth issuer URLs to internal URLs for docker-compose / split-horizon DNS. Format: external=internal,external=internal (e.g. https://auth.example.com=http://keycloak:8080). Rewrites server-to-server requests (discovery, JWKS, token exchange) but not browser redirects.
JWT_SECRET Symmetric HMAC secret for verifying JWTs. Tokens must have iss: 'jwt-secret'.
JWT_PUBLIC_KEY PEM-encoded asymmetric public key for verifying JWTs. Tokens must have iss: 'jwt-public-key' (use JWT_TRUSTED_ISSUERS instead for another name). Literal \n is converted to real newlines so PEMs survive env-var encoding.
JWT_TRUSTED_AUDIENCES Comma-separated list of accepted aud values, unioned into every issuer's audience list. See Audience Validation.
JWT_TRUSTED_CLIENTIDS Comma-separated list of accepted client_id values, unioned into every issuer's client-id list.
JWT_TRUSTED_ISSUERS JSON array of issuer configs for advanced setups. See Issuer JSON Config.

Issuer JSON Config

JWT_TRUSTED_ISSUERS accepts a JSON array of objects. Each object describes one issuer:

Field Required Description
iss Yes The iss claim that tokens from this issuer must carry.
type No One of oauth, jwks, unified-auth, publicKey, secret. Inferred from other fields if omitted: iss === 'unified-auth' → unified-auth; secret → secret; publicKey → publicKey; url → jwks. Set explicitly to oauth to enable .well-known discovery.
url Conditional Required for oauth, jwks, and unified-auth. For oauth it's the issuer URL (discovery is performed relative to it); for jwks it's the JWKS endpoint directly.
publicKey Conditional PEM-encoded public key (publicKey type).
secret Conditional Symmetric HMAC secret (secret type).
internalUrl No Server-to-server URL prefix override for split-horizon DNS.
audiences No Array of accepted aud values for this issuer. Unioned with JWT_TRUSTED_AUDIENCES.
clientIds No Array of accepted client_id values for this issuer. Unioned with JWT_TRUSTED_CLIENTIDS.
validateUrl No (unified-auth only) Override URL for the deauth poll. Resolved relative to url. Defaults to <url>/validateToken.
logoutUrl No End-session URL surfaced as req.auth.issuerConfig.logoutUrl. For OAuth issuers this is auto-discovered; set only to override. Resolved relative to url.

Options

Option Description
authenticateAll If true, all requests require authentication except routes in exceptRoutes or optionalRoutes.
exceptRoutes Set<string> of route URLs that skip authentication entirely and do not receive an auth object.
optionalRoutes Set<string> of route URLs that do not require authentication but populate req.auth if a session is available.
extraClaims A function that receives the full JWT payload and returns extra properties to merge into the auth object (e.g. payload => ({ roles: payload.roles })). If you use this, you should also set JWT_TRUSTED_AUDIENCES or per-issuer audiences. See Audience Validation.

Calling registerOAuthCookieRoutes or registerUaCookieRoutes automatically excludes their callback/redirect routes from authentication and marks their logout routes as optional, so you do not need to list them here.

Cookie Endpoints

For server-rendered applications or SPAs that need cookie-based sessions, registerOAuthCookieRoutes implements the full OAuth authorization code flow with PKCE (S256), storing the ID token in an HttpOnly cookie. The access token and refresh token are stored in separate cookies (optionally encrypted via OAUTH_COOKIE_SECRET). Expired ID tokens are transparently refreshed using the refresh token cookie.

import Server, { jwtAuthenticate, registerOAuthCookieRoutes } from 'fastify-txstate'
const server = new Server({
  authenticate: jwtAuthenticate({ authenticateAll: true })
})
registerOAuthCookieRoutes(server.app)

The access token is available at req.auth.accessToken for making requests to the provider's APIs on behalf of the user (e.g. Google Drive, Microsoft Graph). registerOAuthCookieRoutes accepts an optional second argument with:

Option Description
scopes Array of scopes to always include in the authorization request, merged with any scopes the client passes via the scope query parameter.
loginPage A function for rendering a login selection page when multiple issuers are configured. See Multiple Issuers.

Multiple Issuers

When multiple OAuth issuers are configured (via OAUTH_URLS or JWT_TRUSTED_ISSUERS), you can provide a loginPage function to let the user choose which provider to sign in with. The function receives an array of { issuerUrl, redirectHref } and should return an HTML string.

registerOAuthCookieRoutes(server.app, {
  loginPage: issuers => `<!DOCTYPE html>
    <html><body>
      <h1>Sign in with</h1>
      ${issuers.map(i => `<a href="${i.redirectHref}">${new URL(i.issuerUrl).hostname}</a>`).join('<br>')}
    </body></html>`
})

When a user hits /.oauthRedirect without specifying an issuer query parameter, they see this page. Each link redirects back to /.oauthRedirect with the chosen issuer pre-filled. If no loginPage is provided, the first trusted issuer is used. Clients can also bypass the selection by passing issuer directly: /.oauthRedirect?requestedUrl=...&issuer=https://accounts.google.com.

Additional Environment Variables

Variable Required Description
OAUTH_COOKIE_CLIENT_ID Yes OAuth client ID for the authorization code flow.
OAUTH_COOKIE_CLIENT_SECRET No OAuth client secret. PKCE secures the code exchange, but some providers require a secret even with PKCE.
OAUTH_COOKIE_SECRET No If set, the refresh token and access token cookies are encrypted with AES-256-GCM. If not, they are stored as plaintext (still HttpOnly and Secure).
OAUTH_COOKIE_NAME No Name for the session cookie. Defaults to a random hex string.
PUBLIC_URL No Base URL for the API, used to generate callback URIs (e.g. https://myapp.example.com/api). Derived from the request hostname if not set.
UI_URL No Base URL for the UI (e.g. https://myapp.example.com). Used as the default redirect destination after login/logout. If not set, guessed by removing the last path segment from PUBLIC_URL or the request URL.

Routes

  • GET /.oauthRedirect?requestedUrl=...&scope=...&issuer=... — Redirects to the OAuth provider's login page. requestedUrl is required and specifies where to redirect after login. scope is optional and defaults to openid offline_access. issuer is optional and selects which trusted issuer to use; if omitted with multiple issuers and a loginPage configured, a selection page is shown.
  • GET /.oauthCallback — Handles the provider's redirect. Exchanges the authorization code for tokens (using PKCE), sets the ID token, access token, and refresh token as cookies, and redirects to the original requestedUrl. If no ID token is returned, falls back to the access token if it is a JWT.
  • GET /.oauthLogout — Clears all OAuth cookies and redirects to the provider's end_session_endpoint if available, with the ID token as a hint for single sign-out.

Server-Rendered Login Redirects

For server-rendered routes where you want to redirect unauthenticated users straight into the login flow (rather than returning a 401 and letting client code react), call requireCookieAuthOAuth(req, res) at the top of your handler. If req.auth is empty it redirects the browser through /.oauthRedirect (preserving the current URL as requestedUrl) and returns true; otherwise it returns false and your handler proceeds.

import { requireCookieAuthOAuth } from 'fastify-txstate'
server.app.get('/dashboard', async (req, res) => {
  if (await requireCookieAuthOAuth(req, res)) return
  // ...render the page using req.auth
})

If you are using registerUaCookieRoutes for TxState Unified Auth instead of OAuth, the equivalent helper is requireCookieAuthUa(req, res). (The old name requireCookieAuth still works but is deprecated.)

Client-Side Authentication (without cookie endpoints)

If you are implementing the OAuth flow in your client application instead of using the cookie endpoints above, send the token to the API as an Authorization: Bearer <token> header. Here is what you need to know:

Which token to send

The API validates tokens locally by verifying the JWT signature against the provider's JWKS. This means the token must be a JWT. Choose accordingly:

  • Microsoft, Okta, Auth0, Keycloak: Access tokens are JWTs. Send the access token.
  • Google: Access tokens are opaque and cannot be verified locally. Send the ID token instead — it is a standard JWT containing the user's identity.
  • General rule: If your provider's access token is a JWT (three base64url segments separated by dots), send it. If it's opaque, send the ID token.

Scopes

Scopes control what the user sees on the consent screen and what permissions the token carries. Each provider has its own conventions:

  • Google: openid for basic sign-in, add email or profile for more claims. Scopes like https://www.googleapis.com/auth/drive.readonly authorize access to Google APIs — only request these if the client needs them.
  • Microsoft: openid for sign-in, User.Read for Microsoft Graph, api://{resource-id}/.default for custom APIs.
  • Okta / Auth0: openid for sign-in, custom scopes as configured in your authorization server.
  • For all providers, openid is the minimum needed to get an ID token.

Refresh tokens

The client is responsible for refreshing tokens before they expire and sending a fresh token with each request. How to obtain a refresh token varies by provider:

  • Most OIDC providers (Microsoft, Okta, Auth0, Keycloak): Request the offline_access scope.
  • Google: Pass access_type=offline&prompt=consent as query parameters on the authorization request. The offline_access scope is ignored.
  • Apple, AWS Cognito: Return refresh tokens automatically based on app configuration — no special scope needed.

PKCE

Use PKCE (S256) for the authorization code exchange even if your provider doesn't require it. Generate a code_verifier, send the code_challenge in the authorization request, and include the code_verifier when exchanging the code for tokens. This protects against authorization code interception and is supported by all major providers.

Streaming File Proxy with postFormData

When your API receives a file upload and needs to forward it to another service, you typically have to buffer the entire file in memory or write it to disk first. The postFormData helper avoids this by constructing a multipart/form-data request from streams, allowing you to pipe an incoming upload directly to a remote API with no intermediate storage.

For example, proxying an uploaded file to S3-compatible storage:

import Server, { postFormData } from 'fastify-txstate'
const server = new Server()
server.app.post('/upload', async (req, res) => {
  const results = []
  for await (const part of req.parts()) {
    if (part.type === 'file') {
      // forward each file stream directly to S3 with no intermediate storage
      const resp = await postFormData(
        `https://s3.amazonaws.com/${BUCKET_NAME}`,
        [
          { name: 'key', value: `uploads/${part.filename}` },
          { name: 'Content-Type', value: part.mimetype },
          { name: 'file', value: part.file, filename: part.filename, filetype: part.mimetype }
        ],
        { Authorization: `AWS ${AWS_ACCESS_KEY}:${signature}` }
      )
      results.push({ filename: part.filename, status: resp.status })
    }
  }
  return results
})

Each field is either a text field ({ name, value: string }) or a file field ({ name, value: ReadableStream | Readable, filename?, filetype?, filesize? }). If all file fields include filesize, a Content-Length header is calculated automatically; otherwise the request is sent as chunked.

You can also pass custom headers as a third argument: postFormData(url, fields, { Authorization: 'Bearer ...' }).

File Storage with FileSystemHandler

FileSystemHandler provides an opinionated way to stream uploaded files into the local filesystem, named by their SHA-256 checksum. Since identical files produce the same checksum, duplicates are automatically deduplicated — uploading the same file twice stores it only once.

Files are organized into a two-level directory structure based on the checksum (a/b/cdef...) to avoid overwhelming a single directory with too many entries.

import Server, { FileSystemHandler } from 'fastify-txstate'
const storage = new FileSystemHandler({ tmpdir: '/files/tmp', permdir: '/files/storage' })
await storage.init() // ensures tmpdir and permdir exist

const server = new Server()
server.app.post('/upload', async (req, res) => {
  const results = []
  for await (const part of req.parts()) {
    if (part.type === 'file') {
      const { checksum, size } = await storage.put(part.file)
      // save the checksum in your database alongside whatever record it was uploaded against
    }
  }
})

server.app.get('/download/:checksum', async (req, res) => {
  const stream = storage.get(req.params.checksum)
  return res.send(stream)
})

The put method streams the file to a temporary location while computing its SHA-256 hash, then re-reads the file to verify it was written correctly before moving it to its permanent checksum-based path. It returns the checksum (base64url-encoded) and size in bytes.

Method Description
init() Creates tmpdir and permdir if they don't exist. Call this before using the handler.
put(stream) Streams a Readable to storage. Returns { checksum, size }.
get(checksum) Returns a Readable stream for the file.
remove(checksum) Deletes the file. No-op if already gone.
exists(checksum) Returns true if the file exists.
fileSize(checksum) Returns the file size in bytes.

Both tmpdir and permdir default to /files/tmp/ and /files/storage/ respectively. A default instance is also exported as fileHandler if the defaults work for your setup.

The FileHandler interface is also exported, so you can write your own storage backend with the same API. The idea is that your application accepts a FileHandler as configuration rather than depending on a concrete implementation. In development or simple deployments you use FileSystemHandler; in production a different instance of the same service could provide an S3-backed implementation — the route handlers don't change. The postFormData helper is useful for building cloud implementations, since it can stream files to a remote API without buffering.

import { FileSystemHandler, type FileHandler } from 'fastify-txstate'
import { S3FileHandler } from './s3filehandler.js'

const storage: FileHandler = process.env.FILE_STORAGE === 's3'
  ? new S3FileHandler({ bucket: process.env.S3_BUCKET })
  : new FileSystemHandler()

Analytics

The analyticsPlugin registers a POST /analytics endpoint that accepts an array of interaction events from your frontend, enriches them with server-side context (user agent, IP, authentication, timestamp), and flushes them in batches to a storage backend every 5 seconds.

import Server, { analyticsPlugin } from 'fastify-txstate'
const server = new Server()
server.app.register(analyticsPlugin, { appName: 'my-app' })

The client sends events shaped like:

[{
  "eventType": "ActionPanel.svelte",
  "screen": "/pages/[id]",
  "action": "Edit Page",
  "target": "/sites/5/pages/12"
}]

eventType, screen, and action are required. target and additionalProperties are optional.

Options

Option Description
appName Required. Identifies the application in stored events.
analyticsClient An AnalyticsClient instance for storing events. See below for defaults.
authorize A function (req) => boolean to restrict access to the endpoint. If it returns false, a 401 is thrown.

Storage Clients

By default, the plugin picks a client automatically:

  • If ELASTICSEARCH_URL is set, events are bulk-indexed into Elasticsearch using ElasticAnalyticsClient.
  • Otherwise, in development (NODE_ENV=development), events are logged to the console.
  • Otherwise, events are logged via the fastify logger (LoggingAnalyticsClient).

You can override this by passing your own analyticsClient. Extend the AnalyticsClient class and implement the push method:

import { AnalyticsClient, type StoredInteractionEvent } from 'fastify-txstate'

class BigQueryAnalyticsClient extends AnalyticsClient {
  async push (events: StoredInteractionEvent[]) {
    // write events to BigQuery, ClickHouse, etc.
  }
}

server.app.register(analyticsPlugin, {
  appName: 'my-app',
  analyticsClient: new BigQueryAnalyticsClient()
})

Elasticsearch Environment Variables

Variable Required Description
ELASTICSEARCH_URL Yes Elasticsearch node URL.
ELASTICSEARCH_USER No Defaults to elastic.
ELASTICSEARCH_PASS No Elasticsearch password.
ELASTICSEARCH_USEREVENTS_INDEX No Index name. Defaults to interaction-analytics.

Audience Validation

Audience validation is a way to ensure that tokens you accept were generated with your API in mind. This helps when the token's claims include authorization like role memberships specific to your app. An attacker could register their own app with identical role names and use their token for your API, unless you specify your API as the only valid audience via JWT_TRUSTED_AUDIENCES (or per-issuer audiences in JWT_TRUSTED_ISSUERS).

fastify-txstate is somewhat opinionated about storing authorization information in your authentication tokens. It's generally not a good idea - you'll end up with people staying in roles until their token expires, and be vulnerable to attacks like this. Let the authentication layer identify the user, and let your API match the user's identity with any authorization roles. To this end, FastifyTxStateAuthInfo doesn't have any spec for authorization-related claims.

Audience validation only becomes necessary if you use the extraClaims option to pull authorization claims from the token into your auth object.

AI Agent Skills

If you use AI coding agents (Claude Code, Cursor, Copilot, etc.) to help build your APIs, this repo includes skill files that teach them how to use fastify-txstate. Copy the ones relevant to your project into your agent configuration (e.g. .claude/ or .cursor/rules/):

Skill Description
server-basics.md Teaches the agent how to set up the server's error handling, SSL, health checks, logging
validation.md Teaches the agent how to create POST/PUT endpoints that cooperate with svelte-forms to show validation feedback to users.
authentication.md Teaches the agent how to configure authentication for the server.
file-handling.md Teaches the agent how to use our tools for streaming files to disk or swappable backends
endpoint-schemas.md Teaches the agent how to define route schemas for type inference, validation, and Swagger documentation
analytics.md Teaches the agent how to configure the server for interaction event tracking in Elasticsearch or a custom storage client

About

A small wrapper for fastify providing a set of common conventions & utility functions we use.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors