Skip to content

About

Reusable plugins for fastify

Topics

Resources

Code of conduct

Contributing

Stars

7 stars

Watchers

16 watching

Forks

Repository files navigation

fastify-extras 🧩

Reusable plugins for Fastify.

Dependency Management

The following needs to be taken into consideration when adding new runtime dependency for the fastify-extras package:

  • If dependency is an implementation detail, and end consumer is not expected to import and use the dependency directly, it should be a dependency;
  • If dependency needs to be imported and used by consumer directly for it to function properly, it should be a peerDependency.

Dependencies

  • @lokalise/error-utils;
  • @scalar/fastify-api-reference;
  • @splitsoftware/splitio;
  • fastify-metrics;
  • fastify-plugin;
  • tslib.

@scalar/fastify-api-reference renders the references the API Documentation Plugin serves. A consumer never imports it, which is what makes it a dependency rather than a peer, and it is imported only when that plugin is registered.

Peer Dependencies

  • @fastify/jwt;
  • @fastify/swagger;
  • @opentelemetry/api;
  • dd-trace;
  • fastify;
  • pino;
  • bullmq;
  • ioredis;

@fastify/swagger is loaded at runtime only by the API Documentation Plugin, but the published typings reference its types from the package root, so every consumer needs it installed.

Plugins

RequestContext Provider Plugin

Plugin to:

  • extend existing FastifyRequest with request context by setting the following:
    • logger, a child logger of app.log, with prepopulated properties:
      • x-request-id: the request ID
      • api-endpoint: the route URL pattern (e.g., /, /:userId)
      • api-method: the HTTP method (e.g., GET, POST)
      • api-endpoint-param-{paramName}: for each route parameter (e.g., api-endpoint-param-userId for :userId parameter)
    • reqId, the request-id

No options are required to register the plugin.

The getRequestIdFastifyAppConfig() method is exported and returns:

  • genReqId, a function for generating the request-id;
  • requestIdHeader, the header name used to set the request-id.

Which can be passed to Fastify during instantiation.

The getFastifyAppLoggingConfig(appLogLevel, requestLoggingLevels?) method is exported and returns Fastify configuration for request logging. It accepts:

  • appLogLevel, the application log level from your app configuration;
  • requestLoggingLevels (optional), an array of log levels that should enable request logging. Defaults to ['debug', 'trace', 'info'].

This method returns a logController configuration that:

  • Enables request logging only when the app log level is in requestLoggingLevels;
  • Automatically skips logging for service utility endpoints (/, /health, /ready, /live, /metrics).

Example usage:

import { getFastifyAppLoggingConfig } from '@lokalise/fastify-extras'

const app = fastify({
  ...getFastifyAppLoggingConfig(appConfig.logLevel),
})

Public Healthcheck Plugin

Plugin to monitor app status through public healthcheck.

Add the plugin to your Fastify instance by registering it with the following options:

  • healthChecks, a list of promises with healthcheck in the callback;
  • responsePayload (optional), the response payload that the public healthcheck should return. If no response payload is provided, the default response is:
    { "heartbeat": "HEALTHY" }

Your Fastify app will reply with the status of the app when hitting the GET / route.

Common Healthcheck Plugin

Plugin to monitor app status through public and private healthchecks using asynchronous checks.

Add the plugin to your Fastify instance by registering it with the following options:

  • healthChecks, a list of promises with healthcheck in the callback;
  • responsePayload (optional), the response payload that the healthcheck should return. If no response payload is provided, the default response is:
    { "heartbeat": "HEALTHY", "checks": {} }

Your Fastify app will reply with the status of the app when hitting the GET / public route with aggregated heartbeat from healthchecks provided, example:

{
  "heartbeat": "HEALTHY"
}

Your Fastify app will reply with the status of the app when hitting the GET /health private route with detailed results from healthchecks provided, example:

{
  "heartbeat": "PARTIALLY_HEALTHY",
  "checks": {
    "check1": "HEALTHY",
    "check2": "HEALTHY",
    "check3": "FAIL"
  }
}

Common Sync Healthcheck Plugin

Plugin to monitor app status through public and private healthchecks using synchronous checks. This plugin is recommended when you have healthchecks that run synchronously or are executed in the background, as it provides better performance for such use cases.

Add the plugin to your Fastify instance by registering it with the following options:

  • healthChecks, an array of synchronous healthcheck objects, each containing:
    • name, the identifier for the healthcheck;
    • isMandatory, boolean indicating if this healthcheck is critical for service health;
    • checker, a synchronous function that returns null on success or an Error on failure;
  • responsePayload (optional), the response payload that the healthcheck should return;
  • logLevel (optional), the log level for the healthcheck routes ('fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'), defaults to 'info';
  • infoProviders (optional), an array of info providers to include additional metadata in the /health response;
  • isRootRouteEnabled (optional), whether to enable the public / route, defaults to true.

Example usage:

import { commonSyncHealthcheckPlugin } from '@lokalise/fastify-extras'

app.register(commonSyncHealthcheckPlugin, {
  healthChecks: [
    {
      name: 'database',
      isMandatory: true,
      checker: (app) => {
        // Synchronous check - returns null if healthy, Error if not
        return isDatabaseConnected() ? null : new Error('Database disconnected')
      }
    },
    {
      name: 'cache',
      isMandatory: false,  // Optional dependency
      checker: (app) => {
        return isCacheAvailable() ? null : new Error('Cache unavailable')
      }
    }
  ]
})

The plugin exposes the same routes as the async Common Healthcheck Plugin:

  • GET / - Public route returning aggregated health status
  • GET /health - Private route with detailed healthcheck results

The key differences from the async version:

  • Uses synchronous healthcheck functions instead of promises
  • Better suited for checks that are already running in the background or are inherently synchronous
  • Supports mandatory vs optional healthchecks (optional failures result in PARTIALLY_HEALTHY status)

Startup Healthcheck Plugin

Plugin to monitor app startup status, doing potentially more expensive checks than what is reasonable through periodic healthchecks.

Add the plugin to your Fastify instance by registering it with the following options:

  • healthChecks, a list of asynchronous healthchecks to run at the app startup;
  • resultsLogLevel, at what log level to report healthcheck results - default is INFO;

This is the structure of the log:

{
  "heartbeat": "PARTIALLY_HEALTHY",
  "checks": {
    "check1": "HEALTHY",
    "check2": "HEALTHY",
    "check3": "FAIL"
  }
}

In case a non-optional healthcheck fails, an application startup will throw an error. In order to ensure that the error is thrown correctly, make sure to await the app startup:

  const app = fastify()
  await app.register(startupHealthcheckPlugin, opts)
  await app.ready()

Split IO Plugin

Plugin to handle feature flags in Split IO.

Add the plugin to your Fastify instance by registering it with the following options:

  • isEnabled, if true the plugin will connect to Split IO using the provided apiKey and store data in memory with background syncing;
  • apiKey, your SDK key;
  • debugMode;
  • localhostFilePath (optional), used to utilize the SDK in localhost mode. It corresponds to the full path to a file with the mapping of feature flag name to treatment. apiKey will be automatically replaced with localhost if localhostFilePath is provided.

The plugin decorates your Fastify instance with a SplitIOFeatureManager, which you can inject and use to leverage the following methods:

  • init(), returns a promise that resolves once the SDK has finished loading. It's called automatically when registering the plugin;

  • getTreatment(), returns the proper treatment based on the feature flag name and the key in input. Expected parameters are:

    • key, the ID of the user/account/etc. you're trying to evaluate a treatment for;
    • splitName, the Split IO feature flag name;
    • attributes (optional), a set of Attributes used in evaluation to further decide whether to show the on or off treatment;

    NOTE: If isEnabled is false, getTreatement() will return control to signal disabled treatment.

  • getTreatmentWithConfig(), used to leverage dynamic configurations with your treatment. It accepts the same parameters as getTreatment(), but the response structure is as follows:

    type TreatmentResult = {
      treatment: string
      config: string | null
    }

    NOTE: If isEnabled is false, getTreatementWithConfig() will return control as treatment and null as config to signal disabled treatment.

  • track(), used to record any actions your customers perform. Returns a boolean to indicate whether or not the SDK was able to successfully queue the event. Expected parameters are:

    • key, the ID of the user/account/etc. you're trying to evaluate a treatment for;
    • trafficType, the traffic type of the key;
    • eventType, the event type that this event should correspond to;
    • value (optional), the value to be used in creating the metric;
    • properties(optional), an object of key value pairs that represent the properties to be used to filter your metrics;
  • shutdown(), gracefully shuts down the client.

More info about Split IO can be checked here.

BugSnag Plugin

Plugin to report errors to BugSnag.

Add the plugin to your Fastify instance by registering it with the following options:

Once the plugin has been added to your Fastify instance and loaded, errors will be reported to BugSnag.

Metrics Plugin

Plugin to expose Prometheus metrics.

Add the plugin to your Fastify instance by registering it with the following options:

  • loggerOptions, used to configure the internal logger instance. It can be a boolean or a set of Pino options. By default it is set to false and the logger is disabled;
  • disablePrometheusRequestLogging (optional). By default Fastify will issue an info level log message when a request is received and when the response for that request has been sent. By setting this option to true, these log messages will be disabled. Defaults to true;
  • bindAddress (optional). By default, the server will listen on the address(es) resolved by localhost when no specific host is provided. See the possible values for host when targeting localhost here;
  • errorObjectResolver, a resolver method that, given an err and optionally a correlationID, it will log the error if something goes wrong.

The plugin exposes a GET /metrics route in your Fastify app to retrieve Prometheus metrics. If something goes wrong while starting the Prometheus metrics server, an Error is thrown. Otherwise, a success message is displayed when the plugin has been loaded.

PrometheusCounterTransactionManager

PrometheusCounterTransactionManager is an implementation of TransactionObservabilityManager that uses Prometheus counters to track the number of started, failed, and successful transactions. The results are automatically added to the /metrics endpoint exposed by the metrics plugin.

BullMQ Metrics Plugin

Plugin to auto-discover BullMQ queues which can regularly collect metrics for them and expose via fastify-metrics global Prometheus registry. If used together with metricsPlugin, it will show these metrics on GET /metrics route.

This plugin depends on the following peer-installed packages:

  • bullmq (^5.19.0 or ^6.0.0)
  • ioredis (^5.7.0 or ^6.0.0)

Note that bullmq v6 no longer bundles ioredis, so it always has to be installed explicitly. It also dropped the paused job state: pausing a queue leaves its jobs in waiting rather than moving them onto a separate list, so for a queue paused under v6 the backlog shows up as bullmq_jobs_count{status="waiting"} and status="paused" reads 0.

The plugin still reports status="paused" on both majors, because a queue paused under v5 leaves a paused list behind that v6 only drains once the queue is resumed — until then those jobs are real and stay visible under that label. The two counts read different Redis keys, so no job is counted twice.

If you rely on the default BackgroundJobsBasedQueueDiscoverer, pairing bullmq v6 with @lokalise/background-jobs-common requires that package to be on >=15.2.0 — earlier versions only accept bullmq v5.

Add the plugin to your Fastify instance by registering it with the following possible options:

  • redisConfigs, Redis configurations used for BullMQ. Plugin uses them to discover the queues.
  • bullMqPrefix (optional, default: bull). The prefix used by BullMQ to store the queues in Redis;
  • metricsPrefix (optional, default: bullmq). The prefix for the metrics in Prometheus;
  • queueDiscoverer (optional, default: BackgroundJobsBasedQueueDiscoverer). The queue discoverer to use. The default one relies on the logic implemented by @lokalise/background-jobs-common where queue names are registered by the background job processors; If you are not using @lokalise/background-jobs-common, you can use your own queue discoverer by instantiating a RedisBasedQueueDiscoverer or implementing a QueueDiscoverer interface;
  • excludedQueues (optional, default: []). An array of queue names to exclude from metrics collection;
  • histogramBuckets (optional, default: [20, 50, 150, 400, 1000, 3000, 8000, 22000, 60000, 150000]). Buckets for the histogram metrics (such as job completion or overall processing time).
  • collectionOptions (optional, default: { type: 'interval', intervalInMs: 5000 }). Allows to configure how metrics are collected. Supports the following properties:
    • type. Can be either interval or manual.
      • With interval type, plugin automatically loops and updates metrics at the specified interval.
      • With manual type, you need to call app.bullMqMetrics.collect() to update the metrics; that allows you to build your own logic for scheduling the updates.
    • intervalInMs (only for type: 'interval'). The interval in milliseconds at which the metrics are collected;

This plugin exposes bullMqMetrics.collect() method on the Fastify instance to manually trigger the metrics collection.

If something goes wrong while starting the BullMQ metrics plugin, an Error is thrown.

OpenTelemetry Transaction Manager Plugin

Plugin to create custom OpenTelemetry spans for background jobs.

Add the plugin to your Fastify instance by registering it with the following options:

  • isEnabled, if true the plugin will create spans using OpenTelemetry;
  • tracerName (optional), the instrumentation scope name for the tracer. This identifies the instrumentation library, not the service. For service identification, configure it via OpenTelemetry SDK resource attributes (e.g., OTEL_SERVICE_NAME environment variable). Defaults to 'opentelemetry-transaction-manager-plugin';
  • tracerVersion (optional), the instrumentation scope version for the tracer. Defaults to '1.0.0';
  • maxConcurrentSpans (optional), maximum number of concurrent spans to track. When this limit is reached, the oldest spans will be evicted and automatically ended to prevent leaks. Defaults to 2000.

The plugin decorates your Fastify instance with an OpenTelemetryTransactionManager, which implements the TransactionObservabilityManager interface from @lokalise/node-core. You can inject and use the following methods:

  • start(transactionName, uniqueTransactionKey), starts a background span with the provided name and stores it by the unique key. Background spans are always started as root spans, so they never inherit whatever context is active on the caller's async stack. If a span is active when the transaction starts (e.g. a producer traceparent propagated together with a queue job), it is recorded as a span link, so the enqueue -> process relation stays navigable while the transaction keeps its own trace id and its own sampling decision;
  • startWithGroup(transactionName, uniqueTransactionKey, transactionGroup), starts a background span with an additional transaction.group attribute;
  • stop(uniqueTransactionKey, wasSuccessful?), ends the span referenced by the unique key. Sets status to OK if successful (default), or ERROR if not;
  • addCustomAttribute(attrName, attrValue), adds a custom attribute to the currently active span. attrValue can be a string, number, or boolean;
  • addCustomAttributes(uniqueTransactionKey, atts), adds multiple custom attributes to the span identified by the unique key;
  • setUserID(userId), sets the enduser.id attribute on the active span;
  • setControllerName(name, action), sets code.namespace and code.function attributes on the active span.

Additional OpenTelemetry-specific methods:

  • getSpan(uniqueTransactionKey), returns the span for advanced manipulation, or null if not found;
  • getTracer(), returns the underlying OpenTelemetry tracer;
  • runInSpanContext(uniqueTransactionKey, fn), executes a function within the context of a specific span, useful for automatic parent-child span linking.

Example usage:

import { openTelemetryTransactionManagerPlugin } from '@lokalise/fastify-extras'

// Register the plugin
await app.register(openTelemetryTransactionManagerPlugin, {
  isEnabled: true,
  tracerName: 'my-instrumentation',
  tracerVersion: '1.0.0',
  // maxConcurrentSpans: 2000, // optional
})

// Use in your application
const manager = app.openTelemetryTransactionManager

// Start a transaction
manager.start('process-email-job', 'job-123')

// Add custom attributes
manager.addCustomAttributes('job-123', {
  jobType: 'email',
  recipient: 'user@example.com',
})

// Execute nested operations within the span context
manager.runInSpanContext('job-123', () => {
  // Child spans created here will be linked to the parent
  doSomeWork()
})

// End the transaction
manager.stop('job-123', true) // true = successful

Note: This plugin requires @opentelemetry/api as a peer dependency. Make sure your application has OpenTelemetry configured with appropriate exporters (e.g., OTLP exporter) to send traces to your observability backend.

Datadog Transaction Manager Plugin

Plugin to create custom Datadog APM spans for background jobs using dd-trace. This is an alternative to the OpenTelemetry Transaction Manager plugin for applications using Datadog for observability.

Important: The Datadog tracer must be pre-initialized before your application starts. Use the --import flag to load the tracer:

node --import dd-trace/initialize.mjs app.js

The plugin does not call tracer.init() — it expects the tracer to already be running when the plugin loads.

Add the plugin to your Fastify instance by registering it with the following options:

  • isEnabled, if true the plugin will create spans using the Datadog tracer.

The plugin decorates your Fastify instance with a DatadogTransactionManager, which implements the TransactionObservabilityManager interface from @lokalise/node-core. You can inject and use the following methods:

  • start(transactionName, uniqueTransactionKey), starts a background span with the provided name and stores it by the unique key;
  • startWithGroup(transactionName, uniqueTransactionKey, transactionGroup), starts a background span with an additional transaction.group tag;
  • stop(uniqueTransactionKey), ends the span referenced by the unique key;
  • addCustomAttribute(attrName, attrValue), adds a custom tag to the currently active span. attrValue can be a string, number, or boolean;
  • addCustomAttributes(uniqueTransactionKey, atts), adds multiple tags to the span identified by the unique key;
  • setUserID(userId), sets the usr.id tag on the active span (Datadog convention);
  • setControllerName(name, action), sets code.namespace and code.function tags on the active span.

Example usage:

import { datadogTransactionManagerPlugin } from '@lokalise/fastify-extras'

// Register the plugin
await app.register(datadogTransactionManagerPlugin, {
  isEnabled: true,
})

// Use in your application
const manager = app.datadogTransactionManager

// Start a transaction
manager.start('process-email-job', 'job-123')

// Add custom attributes
manager.addCustomAttributes('job-123', {
  jobType: 'email',
  recipient: 'user@example.com',
})

// End the transaction
manager.stop('job-123')

Note: This plugin requires dd-trace as a peer dependency (>=5.0.0). The tracer must be initialized externally via node --import dd-trace/initialize.mjs app.js — the plugin only obtains the already-running tracer singleton.

Amplitude Plugin

This plugin facilitates the transmission of events to Amplitude.

To add this plugin to your Fastify instance, register it with the following configurations:

  • isEnabled: A flag utilized to activate or de-activate the plugin.
  • apiKey (optional): This refers to the Amplitude API key which can be procured from your respective Amplitude project.
  • options (optional): To configure Amplitude, please refer to this documentation.
  • apiUsageTracking (optional): You can use this callback to generate an event that will automatically be sent for tracking API usage. Non-specification of this feature will lead to disabling of API tracking.
  • plugins (optional): This feature allows you to expand the plugin's functionality, from altering event properties to relaying to third-party APIs. To learn more, visit this link.

The plugin decorates your Fastify instance with a Amplitude, which you can inject and use the track method on it to send events whenever you need

📘 To ensure optimal functionality with this plugin, you may need to incorporate Amplitude types into your development dependencies.

"@amplitude/analytics-types": "*"

Related utilities:

  • AmplitudeAdapter - A type-safe wrapper that validates events using Zod schemas before sending them to Amplitude
  • FakeAmplitude - A utility class for testing environments that doesn't send events to Amplitude See the Amplitude utilities section for detailed documentation and examples.

Strip Trailing Slash Plugin

This plugin helps with SEO and SSR by ensuring search engines index only one version of a URL, avoiding duplicate content. It redirects URLs with a trailing slash to the version without it, making it easier for search engines to crawl your site consistently.

UnhandledException Plugin

This plugin provides a mechanism for handling uncaught exceptions within your Fastify application, ensuring that such exceptions are logged and reported. It's especially useful for capturing unforeseen exceptions and provides a controlled shutdown of the Fastify server, thereby ensuring no potential data corruption.

Setup & Configuration

To integrate this plugin into your Fastify instance, follow these steps:

  1. First, import the necessary types and the plugin:
import { FastifyInstance } from 'fastify'
import { unhandledExceptionPlugin, ErrorObjectResolver } from '@lokalise/fastify-extras'
  1. Configure the plugin:

Define your own ErrorObjectResolver to dictate how the uncaught exceptions will be structured for logging. Here's an example:

const myErrorResolver: ErrorObjectResolver = (err, correlationID) => {
  return {
    error: err,
    id: correlationID,
  }
}

You'll also need to provide an ErrorReporter instance. This instance should have a report method to handle the error reporting logic. For example:

import { ErrorReporter } from '@lokalise/node-core'

const myErrorReporter = new ErrorReporter(/* initialization params */)
  1. Register the plugin with your Fastify instance:
const fastify = Fastify()

fastify.register(unhandledExceptionPlugin, {
  errorObjectResolver: myErrorResolver,
  errorReporter: myErrorReporter,
})

Options

The plugin accepts the following options:

  • errorObjectResolver (required): This function determines the structure of the error object which will be logged in case of an uncaught exception.

  • errorReporter (required): An instance of the ErrorReporter which will handle reporting of the uncaught exceptions.

Working Principle

When an uncaught exception occurs, the plugin:

  • Logs the exception using the provided errorObjectResolver.

  • Reports the exception using the ErrorReporter.

  • Shuts down the Fastify server gracefully.

  • Exits the process with exit code 1.

Dependencies

  • @lokalise/node-core: For error reporting.

  • fastify: The framework this plugin is designed for.

🚨 It's critical to note that this plugin listens to the process's 'uncaughtException' event. Multiple listeners on this event can introduce unpredictable behavior in your application. Ensure that this is the sole listener for this event or handle interactions between multiple listeners carefully.

API Documentation Plugin

Serves two API references off one route table: the customer-facing one and the internal one, both rendered by @scalar/fastify-api-reference. The plugin registers @fastify/swagger twice, once per audience, and points a Scalar instance at each document.

@scalar/fastify-api-reference is a dependency of this package, so there is nothing to install for it. @fastify/swagger is a peer dependency and has to be installed alongside:

npm i @fastify/swagger

It is a peer because it decorates the service's own app and its types appear in this plugin's published options, so the two have to agree on one copy of it. Registering the plugin without it fails with an error naming it.

Neither package is imported until the plugin is registered, so a service using any other plugin from this package loads neither.

What separates the two documents

schema.hide. Route builders that resolve contract visibility (@lokalise/api-contracts, and the opinionated-machine builders on top of it) already set it on everything that is not customer-facing, so the audience decision is made before this plugin sees the route.

Route Public reference Internal reference
no hide, or hide: false documented documented
hide: true hidden documented, marked x-internal-endpoint: true
matched by hiddenRoutes hidden hidden

The internal reference is a superset: it documents the public endpoints too, and marks the ones that are internal.

Routes hidden by default

hiddenRoutes defaults to the exported DEFAULT_HIDDEN_ROUTES. Each entry is one url a sibling plugin in this package registers, and string matchers are exact, so nothing on this list has an opinion about the url space beneath it.

Url Registered by Why it needs excluding
/ commonHealthcheckPlugin, commonSyncHealthcheckPlugin (unless isRootRouteEnabled: false) registered with hide: true
/health commonHealthcheckPlugin, commonSyncHealthcheckPlugin, publicHealthcheckPlugin registered with hide: true
/metrics a service exposing a scrape endpoint on its main app not this package's metricsPlugin, see below

The healthcheck routes all set schema.hide: true, and that is exactly why they have to be listed. This plugin reads hide: true as "internal, put it in the internal document", so without the exclusion the healthchecks would be kept out of the public document and then documented in the internal one. Listing them keeps them out of both.

/metrics is not for this package's metricsPlugin, which passes endpoint: null to fastify-metrics and serves the scrape endpoint from a separate server on port 9080. That route is never in the documented app's route table, so nothing needs to hide it. The entry covers a service that exposes /metrics on its main app by other means.

startupHealthcheckPlugin registers no routes and needs nothing here.

The reference's own routes are excluded separately, from the set of urls the two Scalar scopes registered. Scalar registers them with hide: true too, which is indistinguishable from a contract-derived hide, so without the exclusion the internal document would document the documentation. That set is the reason /documentation is not in the table: matching registered urls covers a reference mounted at /, where a prefix cannot help, and leaves a service's own /documentation/guides/:slug documented.

When a default does not match your setup

Matching is exact, so a default only ever hides the url it names. Two cases need you to say something:

  • You moved a route. publicHealthcheckPlugin takes a url, so url: '/healthz' leaves /healthz documented in the internal document while /health in the defaults matches nothing. Replace the list with the urls you configured.
  • You own a route that shares a name. /metrics as a real analytics endpoint, say. Drop the entry rather than working around it.
// healthcheck moved, no scrape endpoint on this app
hiddenRoutes: ['/', '/healthz']

Nothing nested is affected either way: /health/ready, /health/tips, /metrics/daily and /documentation/guides are documented with the defaults untouched. Use a regular expression when a subtree really is the target.

Usage

import { apiDocumentationPlugin } from '@lokalise/fastify-extras'
import { jsonSchemaTransform, jsonSchemaTransformObject } from 'fastify-type-provider-zod'

await app.register(apiDocumentationPlugin, {
  openapi: { info: { title: 'Users API', version: '1.0.0' } },
  exposeInternalDocumentation: true,
  transform: jsonSchemaTransform,
  transformObject: jsonSchemaTransformObject,
  internalHooks: { onRequest: requireInternalNetwork },
})

Register it before the routes it should document. @fastify/swagger collects routes through an onRoute hook, and a hook only sees what is registered after it.

That serves:

  • /documentation/, the public reference, with /documentation/openapi.json and /documentation/openapi.yaml for the document itself;
  • /documentation/internal/, the internal reference, with the same two document endpoints under it.

The internal reference is opt-in. exposeInternalDocumentation defaults to false, which builds no internal document and registers none of its routes, so there is nothing to reach until a service asks for it. That document lists every endpoint the public one hides, along with their schemas, and the plugin puts no authentication in front of it, so internalHooks is where an authentication or network check goes, and exposeInternalDocumentation: !isProduction is the shape for a service that wants it only outside production.

hooks covers both references. internalHooks merges over it per hook name, the way internalScalarConfiguration merges over scalarConfiguration, so a check written for the public reference guards the internal one too until something replaces it. The internal document is a superset of the public one, so a service that guards the smaller surface never ends up with the larger one open.

openapi is required. @fastify/swagger reads the presence of that key as the choice between OpenAPI 3 and Swagger 2.0, and a Swagger 2.0 document cannot carry the component references the transforms produce.

Taking a route out of both documents

hiddenRoutes is the only override, and it only subtracts. A route on the list is in neither document, whatever its hide flag says. It takes a list of matchers: a string matches one url and nothing else, a regular expression is tested against the url, and a function receives { url, method, schema } for anything else. Urls are the Fastify ones, with :param placeholders rather than the {param} form the document uses.

Strings carry no pattern syntax at all. /admin matches /admin, not /admin/users; /admin* matches a url that literally contains an asterisk. Reach for a regular expression when you mean a subtree, which keeps the reach visible at the call site instead of implied by a string.

await app.register(apiDocumentationPlugin, {
  openapi: { info: { title: 'Users API', version: '1.0.0' } },
  // adds to the defaults rather than replacing them
  hiddenRoutes: [...DEFAULT_HIDDEN_ROUTES, '/internal-metrics', /^\/debug\//],
})

hiddenRoutes replaces DEFAULT_HIDDEN_ROUTES rather than adding to it, hence the spread.

Which of the two documents a route appears in is not configurable, in either direction. schema.hide is the route builder's own answer to "is this endpoint customer-facing", and a route needing to contradict it wants its contract changed rather than the documentation configured around it. So the only question left for a service is whether a route is API surface at all, which is what hiddenRoutes answers.

Models

Hiding an operation does not remove the schemas behind it. fastify-type-provider-zod's jsonSchemaTransformObject writes the entire Zod registry into components.schemas in one pass over the finished document, and never sees which operations the route-level transform hid. app.addSchema shared schemas land there the same way. Both Scalar and @fastify/swagger-ui render components.schemas as their models panel, so an internal-only response shape in the public document is not merely present in the JSON, a reader sees its name and its fields on screen.

The plugin therefore prunes every components entry the document's own operations do not reference, transitively, before serving it. Three parts of that are worth expecting rather than being surprised by:

  • The provider emits a Foo and a FooInput per registered schema. Pruning keeps only the direction actually used, so a response-only schema loses its Input twin, and the panel is shorter than the registry even for a document with nothing internal in it.
  • Self-referential models (the Zod 4 getter pattern) survive: the walk is cycle-safe.
  • components.securitySchemes is never pruned, since security schemes are referenced by name from security requirements rather than by $ref.

Set pruneUnreferencedComponents: false for a document that deliberately publishes a schema catalogue beyond what its operations reference. pruneUnreachableComponents(document) is also exported on its own, for services that assemble their documents by hand.

Scalar's configuration: { hideModels: true } hides the panel but leaves the schemas in the document. Prune for the leak, hide for the noise.

Tags

Services that share a tag catalogue across documents define each tag once — name and description in a single place — so a tag never carries divergent descriptions across services, then register that whole catalogue on every document. @fastify/swagger copies it through verbatim, so a document otherwise advertises tags for operations it does not serve: a translations-only tag in the export service's reference, or, in the internal-vs-public split, a tag only a hidden operation uses lingering in the public document. Both UIs render those as empty groups.

The plugin therefore prunes every top-level tags entry the document's own operations do not reference, using the tag names the operations already carry. A tag object with no readable name is kept, since reachability cannot speak to a tag it cannot identify. Set pruneUnreferencedTags: false for a document that deliberately advertises tags beyond what its operations use. pruneUnreferencedTags(document) is also exported on its own, for services that assemble their documents by hand.

Marking internal operations

Operations that are internal are marked x-internal-endpoint: true in the internal document. Deliberately not x-internal: Scalar reads both x-internal and x-scalar-ignore as a request to leave the operation out of the reference, which would hide every internal endpoint from the document that exists to show them. Both keys are rejected when passed as internalMarkerKey, along with any key without the x- prefix, which @fastify/swagger drops before it reaches the document. internalMarkerKey: false turns the marking off.

Field-level visibility

Whole operations split by audience through schema.hide; individual response and request fields split through a Zod .meta({ visibility: 'internal' }) marker. A field carrying it is dropped from the public document and kept in the internal one. It covers response bodies, request bodies and the query, path and header parameters, and recurses through nested objects, arrays and components.schemas, so a field behind a shared $ref is stripped too.

const USER_SCHEMA = z.object({
  id: z.string(),
  internalNote: z.string().meta({ visibility: 'internal' }), // absent from the public document
})

On by default. Set stripInternalFields: false to publish internal fields verbatim.

The marker must live in the registry fastify-type-provider-zod (ftpz) reads. Using the global registry is recommended, since that is where Zod's .meta() writes and ftpz reads from it by default. With a custom registry, add the visibility metadata to that registry yourself, or the field goes unstripped. See the Zod metadata docs.

Options

Option Default Description
openapi required Document metadata (info, servers, security, tags) for both documents
internalOpenapi openapi, with (internal) added to the title Metadata for the internal document. Replaces openapi, not merged into it
publicRoutePrefix /documentation Where the public reference is served
internalRoutePrefix /documentation/internal Where the internal reference is served
exposeInternalDocumentation false Whether the internal document is built and served at all
hiddenRoutes DEFAULT_HIDDEN_ROUTES Routes kept out of both documents
internalMarkerKey x-internal-endpoint Key marking internal operations, or false
transform - Route-level transform, typically jsonSchemaTransform
transformObject - Document-level transform, typically jsonSchemaTransformObject
pruneUnreferencedComponents true Drop components entries no operation of the document references
stripInternalFields true Drop response/request fields marked visibility: 'internal', audience-aware
pruneUnreferencedTags true Drop top-level tags no operation of the document references
scalarConfiguration - Passed through to Scalar for both references
internalScalarConfiguration - Scalar configuration for the internal reference only
hooks - onRequest / preHandler hooks for both references
internalHooks hooks Hooks for the internal reference, merged over hooks per hook name
logLevel - Log level for the routes both references register
documentDecorator swagger Decorator holding the public document
internalDocumentDecorator internalSwagger Decorator holding the internal document

Both documents stay available programmatically, as app.swagger() and app.internalSwagger(). The internalSwagger decorator only exists where exposeInternalDocumentation is on.

API Visibility Plugin

The runtime counterpart to the API Documentation Plugin's field-level visibility: that plugin hides internal fields and routes from the published document, this one enforces the same visibility markers on live traffic. A single marker is the source of truth for both.

The caller's audience comes from a gateway-stamped request header (audienceHeader, default x-api-audience) and is fail-closed: only a value that exactly matches one of internalAudienceValues (default ['internal']) is treated as internal; anything else (missing, unknown, malformed) is public. So a caller whose header does not exactly match a configured value is treated as public: it receives a 404 from every internal endpoint and a stripped response from public ones. The gateway must own this header (strip or overwrite any client-provided value), otherwise an external caller could claim to be internal.

Driven by that audience, it does three things:

  1. Response field stripping. For a public caller, response properties marked .meta({ visibility: 'internal' }) are removed before serialization, encoded against a derived public schema, so even required internal-only fields never leak. Internal callers get the response untouched, and routes with no internal fields cost nothing.
  2. Route gating. A public caller hitting a route marked internal gets a 404, indistinguishable from a route that does not exist so its existence is not leaked. A route's audience comes from its contract (@lokalise/fastify-api-contracts exposes it on config.apiContract) or, for a non-contract (legacy) route, a direct config.visibility marker. Route resolution is fail-closed: a route without a valid public marker resolves to internal, so an unmarked or misconfigured route is never accidentally exposed. Every public route therefore needs an explicit visibility: 'public'.
  3. Zod compiler registration. The plugin works entirely in terms of Zod schemas: it derives the public schema and encodes public responses with Zod, so it needs fastify-type-provider-zod's validator and serializer compilers to be the active ones. Zod schemas are a hard requirement of this plugin, not a per-route choice, so it registers those compilers for you instead of making every consumer wire them up by hand. If you need a custom compiler, set it after this plugin; it logs a warning if it finds one already installed when it loads (register this plugin first).

Register it before the routes it should protect. Its hooks only see routes registered later in the same encapsulation scope, so a route registered before it bypasses both gate and stripping and would leak. Fastify exposes no scope-local way to detect this at boot, so the ordering is yours to get right: register the plugin first in whatever scope holds the routes it must protect.

Routes you do not register carry no marker, so they gate to internal. Because resolution is fail-closed, routes registered by other plugins in the same scope have no config.visibility and return a 404 to public callers. This includes the API Documentation Plugin's Scalar and @fastify/swagger routes, healthchecks and /metrics. List their path prefixes in alwaysPublicPathPrefixes to exempt them from the gate (field stripping still applies), or, where you control the route options, mark them config: { visibility: 'public' }.

await app.register(apiVisibilityPlugin, {
  alwaysPublicPathPrefixes: ['/documentation', '/health', '/metrics'],
})
import { apiVisibilityPlugin } from '@lokalise/fastify-extras'

await app.register(apiVisibilityPlugin) // before your routes

// A contract route carries its visibility already; a legacy route opts in directly:
app.get('/internal-only', { config: { visibility: 'internal' } }, handler)

The .meta({ visibility }) marker must live in the registry ftpz reads, the same caveat as the document-level Field-level visibility above. Note: @lokalise/fastify-api-contracts currently types config.apiContract as required, so a bare config on a non-contract route may need a cast until that is relaxed.

Options

Option Default Description
audienceHeader x-api-audience Request header carrying the caller's audience. Only a value in internalAudienceValues is internal
internalAudienceValues ['internal'] audienceHeader value(s) identifying an internal caller: a string or an array (exact match)
alwaysPublicPathPrefixes [] Path prefixes exempt from the gate, always reachable by public callers (docs, health, etc.)

Utilities

amplitude

FakeAmplitude

FakeAmplitude is a utility class that extends Amplitude but doesn't send any events to Amplitude. This is useful for testing environments or when you want to disable event tracking without changing your application code.

Example usage:

import { FakeAmplitude } from '@lokalise/fastify-extras'

// In your test or development environment
const amplitude = new FakeAmplitude()

// track() will not send any events
amplitude.track({
  event_type: 'button_clicked',
  user_id: 'user-123',
})

The track() method returns a promise that resolves to null immediately, maintaining the same interface as the real Amplitude class without actually sending data.

AmplitudeAdapter

AmplitudeAdapter is a type-safe wrapper around Amplitude that uses Zod schemas to validate events before sending them. This ensures that all events sent to Amplitude conform to predefined schemas, catching errors at compile-time and runtime.

Key features:

  • Type-safe event tracking with TypeScript
  • Automatic validation using Zod schemas
  • Prevents sending malformed events to Amplitude

Example usage:

import { z } from 'zod'
import { AmplitudeAdapter, AMPLITUDE_BASE_MESSAGE_SCHEMA, Amplitude } from '@lokalise/fastify-extras'
import type { AmplitudeMessage } from '@lokalise/fastify-extras'

// Define your event schemas
const eventSchemas = {
  buttonClicked: {
    schema: AMPLITUDE_BASE_MESSAGE_SCHEMA.extend({
      event_type: z.literal('button_clicked'),
      event_properties: z.object({
        button_id: z.string(),
        page: z.string(),
      }),
    }),
  },
  userSignedUp: {
    schema: AMPLITUDE_BASE_MESSAGE_SCHEMA.extend({
      event_type: z.literal('user_signed_up'),
      event_properties: z.object({
        plan: z.enum(['free', 'premium']),
      }),
    }),
  },
} as const satisfies Record<string, AmplitudeMessage>

const eventSchemaValues = Object.values(eventSchemas)
type SupportedEvents = typeof eventSchemaValues

// Create the adapter
const amplitude = new Amplitude(true)
const amplitudeAdapter = new AmplitudeAdapter<SupportedEvents>({ amplitude })

// Track events with type safety
amplitudeAdapter.track(eventSchemas.buttonClicked, {
  user_id: 'user-123',
  event_properties: {
    button_id: 'submit-btn',
    page: '/checkout',
  },
})

// With groups
amplitudeAdapter.track(eventSchemas.userSignedUp, {
  user_id: 'user-456',
  groups: { company: 'acme-corp' },
  event_properties: {
    plan: 'premium',
  },
})

Schema validation:

The AMPLITUDE_BASE_MESSAGE_SCHEMA provides the base schema that all events must extend. It requires:

  • event_type: A literal string identifying the event
  • user_id: A non-empty string or the literal 'SYSTEM'
  • groups (optional): A record of group names to values

When defining custom events, extend this base schema with your specific event_type and event_properties:

const myEventSchema = AMPLITUDE_BASE_MESSAGE_SCHEMA.extend({
  event_type: z.literal('my_custom_event'),
  event_properties: z.object({
    // Your custom properties with validation
    count: z.number().int().positive(),
    status: z.enum(['active', 'inactive']),
  }),
})

If validation fails, a ZodError will be thrown, preventing invalid data from being sent to Amplitude.

errors

createErrorHandler

createErrorHandler creates a shared error handler to be passed to fastify.setErrorHandler. It resolves any thrown error to a standardized { message, code, errorCode?, details? } payload, reports 5xx errors via the provided errorReporter and logs them. code identifies the error; errorCode is a deprecated alias of code kept for @lokalise/node-core compatibility. The built-in mapping always emits both, but custom resolveResponseObject overrides only have to return code. Because overrides may omit it, errorCode is typed as optional on ErrorResponseObject: code that reads payload.errorCode (e.g. from defaultResolveResponseObject) must handle undefined or, preferably, read code instead. errorCode will be dropped in a future major version.

Example usage:

import { createErrorHandler } from '@lokalise/fastify-extras'

app.setErrorHandler(
  createErrorHandler({
    errorReporter,
    // optional overrides:
    // resolveResponseObject: (error) => ({ statusCode, headers?, payload }) | undefined,
    // resolveLogObject: (error) => logObject | undefined,
  }),
)

Optional hooks:

  • resolveResponseObject — maps an error to the response; return undefined to fall back to the default mapping. The returned object may include headers, which are applied to the response, e.g. resolveResponseObject: (error) => error instanceof CapacityError ? { statusCode: 503, headers: { 'retry-after': '30' }, payload } : undefined.
  • resolveLogObject — builds the object logged for 5xx errors; return undefined to fall back to the default log object.

The default error-to-response mapping is exported as defaultResolveResponseObject, so services can reuse it outside the fastify handler and only prepend their own branches, e.g. const responseObject = myBranches(error) ?? defaultResolveResponseObject(error).

@lokalise/errors support:

Errors from @lokalise/errors are handled natively, detected with the package's isInstance() guards so the check works across realms and duplicated package copies:

  • PublicError instances respond with error.httpStatusCode and error.toPayload() as the body, i.e. { message, code, errorCode, details? } (details is present only when the definition declares a detailsSchema). Like any other error, they are reported and logged only when the status code is 5xx (e.g. ErrorType.UNAVAILABLE).
  • InternalError instances are not client-facing: they are masked as 500 INTERNAL_SERVER_ERROR, reported, and logged as { msg, code, error }, with error serialized via pino.stdSerializers.errWithCause so details and the cause chain are preserved.

@lokalise/node-core errors (PublicNonRecoverableError, InternalError) remain supported with unchanged behaviour, so both error families can coexist during a migration. A custom resolveResponseObject / resolveLogObject still takes precedence over the built-in mapping for both.

Server-Sent Events support:

The error handler is aware of routes streaming Server-Sent Events via @fastify/sse (including SSE contract routes built with @lokalise/fastify-api-contracts >= 6). When an error reaches the handler while an SSE stream is live — detected as reply.sse?.isConnected && reply.raw.headersSent — the status line and headers are already committed, so a regular error response is impossible. Instead, the resolved error payload (the status code is ignored) is sent as a terminal event before closing the stream:

event: error
data: {"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","errorCode":"INTERNAL_SERVER_ERROR"}

Error reporting and logging happen for stream errors too. Both detection conditions are required: @fastify/sse sets isConnected in its context constructor, before the route handler runs, so the flag alone is true on every SSE-capable request — including ones that fail before any stream started (e.g. a response-serialization failure on the JSON representation of a dual JSON/SSE route). Those are still answered as regular error responses; only headersSent distinguishes an actually started stream.

@fastify/sse is not a dependency of this package — apps without the plugin registered are completely unaffected.

route-utilities

authPreHandlers

  • createStaticTokenAuthPreHandler - creates pre handler tha expects a static token in the Authorization header. If value is different from the expected token, it will return a 401 response.

About

Reusable plugins for fastify

Topics

Resources

Code of conduct

Contributing

Stars

7 stars

Watchers

16 watching

Forks

Releases

Packages

Used by

Contributors

Languages