Reusable plugins for Fastify.
-
- RequestContext Provider Plugin
- Public Healthcheck Plugin
- Common Healthcheck Plugin
- Common Sync Healthcheck Plugin
- Split IO Plugin
- BugSnag Plugin
- Metrics Plugin
- Bull MQ Metrics Plugin
- OpenTelemetry Transaction Manager Plugin
- Datadog Transaction Manager Plugin
- UnhandledException Plugin
- API Documentation Plugin
- API Visibility Plugin
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.
@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.
@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.
Plugin to:
- extend existing
FastifyRequestwith request context by setting the following:logger, a child logger of app.log, with prepopulated properties:x-request-id: the request IDapi-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-userIdfor:userIdparameter)
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),
})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.
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"
}
}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 returnsnullon success or anErroron 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/healthresponse;isRootRouteEnabled(optional), whether to enable the public/route, defaults totrue.
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 statusGET /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_HEALTHYstatus)
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()Plugin to handle feature flags in Split IO.
Add the plugin to your Fastify instance by registering it with the following options:
isEnabled, iftruethe plugin will connect to Split IO using the providedapiKeyand 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.apiKeywill be automatically replaced withlocalhostiflocalhostFilePathis 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
isEnabledis false,getTreatement()will returncontrolto signal disabled treatment. -
getTreatmentWithConfig(), used to leverage dynamic configurations with your treatment. It accepts the same parameters asgetTreatment(), but the response structure is as follows:type TreatmentResult = { treatment: string config: string | null }
NOTE: If
isEnabledis false,getTreatementWithConfig()will returncontrolastreatmentandnullasconfigto 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.
Plugin to report errors to BugSnag.
Add the plugin to your Fastify instance by registering it with the following options:
isEnabled;bugsnag, a set of customizable xonfiguration options.
Once the plugin has been added to your Fastify instance and loaded, errors will be reported to BugSnag.
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 tofalseand the logger is disabled;disablePrometheusRequestLogging(optional). By default Fastify will issue aninfolevel log message when a request is received and when the response for that request has been sent. By setting this option totrue, these log messages will be disabled. Defaults totrue;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 anerrand optionally acorrelationID, 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 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.
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.0or^6.0.0)ioredis(^5.7.0or^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-commonwhere 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 aRedisBasedQueueDiscovereror implementing aQueueDiscovererinterface;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 eitherintervalormanual.- With
intervaltype, plugin automatically loops and updates metrics at the specified interval. - With
manualtype, you need to callapp.bullMqMetrics.collect()to update the metrics; that allows you to build your own logic for scheduling the updates.
- With
intervalInMs(only fortype: '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.
Plugin to create custom OpenTelemetry spans for background jobs.
Add the plugin to your Fastify instance by registering it with the following options:
isEnabled, iftruethe 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_NAMEenvironment 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 to2000.
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 additionaltransaction.groupattribute;stop(uniqueTransactionKey, wasSuccessful?), ends the span referenced by the unique key. Sets status toOKif successful (default), orERRORif not;addCustomAttribute(attrName, attrValue), adds a custom attribute to the currently active span.attrValuecan be a string, number, or boolean;addCustomAttributes(uniqueTransactionKey, atts), adds multiple custom attributes to the span identified by the unique key;setUserID(userId), sets theenduser.idattribute on the active span;setControllerName(name, action), setscode.namespaceandcode.functionattributes on the active span.
Additional OpenTelemetry-specific methods:
getSpan(uniqueTransactionKey), returns the span for advanced manipulation, ornullif 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 = successfulNote: This plugin requires
@opentelemetry/apias a peer dependency. Make sure your application has OpenTelemetry configured with appropriate exporters (e.g., OTLP exporter) to send traces to your observability backend.
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.jsThe 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, iftruethe 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 additionaltransaction.grouptag;stop(uniqueTransactionKey), ends the span referenced by the unique key;addCustomAttribute(attrName, attrValue), adds a custom tag to the currently active span.attrValuecan be a string, number, or boolean;addCustomAttributes(uniqueTransactionKey, atts), adds multiple tags to the span identified by the unique key;setUserID(userId), sets theusr.idtag on the active span (Datadog convention);setControllerName(name, action), setscode.namespaceandcode.functiontags 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-traceas a peer dependency (>=5.0.0). The tracer must be initialized externally vianode --import dd-trace/initialize.mjs app.js— the plugin only obtains the already-running tracer singleton.
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 AmplitudeFakeAmplitude- A utility class for testing environments that doesn't send events to Amplitude See the Amplitude utilities section for detailed documentation and examples.
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.
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.
To integrate this plugin into your Fastify instance, follow these steps:
- First, import the necessary types and the plugin:
import { FastifyInstance } from 'fastify'
import { unhandledExceptionPlugin, ErrorObjectResolver } from '@lokalise/fastify-extras'- 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 */)- Register the plugin with your Fastify instance:
const fastify = Fastify()
fastify.register(unhandledExceptionPlugin, {
errorObjectResolver: myErrorResolver,
errorReporter: myErrorReporter,
})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.
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.
-
@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.
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/swaggerIt 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.
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.
Matching is exact, so a default only ever hides the url it names. Two cases need you to say something:
- You moved a route.
publicHealthcheckPlugintakes aurl, sourl: '/healthz'leaves/healthzdocumented in the internal document while/healthin the defaults matches nothing. Replace the list with the urls you configured. - You own a route that shares a name.
/metricsas 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.
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.jsonand/documentation/openapi.yamlfor 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.
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.
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
Fooand aFooInputper registered schema. Pruning keeps only the direction actually used, so a response-only schema loses itsInputtwin, 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.securitySchemesis never pruned, since security schemes are referenced by name fromsecurityrequirements 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.
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.
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.
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.
| 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.
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:
- 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. - Route gating. A public caller hitting a route marked
internalgets a404, 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-contractsexposes it onconfig.apiContract) or, for a non-contract (legacy) route, a directconfig.visibilitymarker. Route resolution is fail-closed: a route without a validpublicmarker resolves tointernal, so an unmarked or misconfigured route is never accidentally exposed. Every public route therefore needs an explicitvisibility: 'public'. - 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.
| 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.) |
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 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 eventuser_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.
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; returnundefinedto fall back to the default mapping. The returned object may includeheaders, 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; returnundefinedto 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:
PublicErrorinstances respond witherror.httpStatusCodeanderror.toPayload()as the body, i.e.{ message, code, errorCode, details? }(detailsis present only when the definition declares adetailsSchema). Like any other error, they are reported and logged only when the status code is 5xx (e.g.ErrorType.UNAVAILABLE).InternalErrorinstances are not client-facing: they are masked as500 INTERNAL_SERVER_ERROR, reported, and logged as{ msg, code, error }, witherrorserialized viapino.stdSerializers.errWithCausesodetailsand thecausechain 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.
createStaticTokenAuthPreHandler- creates pre handler tha expects a static token in theAuthorizationheader. If value is different from the expected token, it will return a 401 response.