Adding Custom Routes
The Browserless SDK lets you register your own HTTP and WebSocket routes alongside the built-in ones. Common reasons to do this:
- Expose an unauthenticated healthcheck for Kubernetes liveness and readiness probes (the built-in
/pressureendpoint requires a token). - Add a custom API that wraps Browserless functionality with your own logic.
- Override a built-in route with one that behaves differently for your deployment.
This guide walks through building a child Docker image that ships a compiled TypeScript route on top of the Enterprise image.
How route loading works
At startup, the SDK walks the directory pointed to by the BLESS_ROUTES environment variable. The Enterprise image sets this for you:
BLESS_ROUTES=${APP_DIR}/node_modules/@browserless.io/browserless/build/routes
For every immediate subdirectory, the loader registers anything found under <group>/http/ and <group>/ws/. That means you can drop a new file into a new group (or an existing one) and the runtime will pick it up — no env-var or code changes required.
ROUTES env varSetting ROUTES replaces the routes directory entirely. It is not additive, so pointing it at your own folder removes every built-in route. Instead, layer your route file into the existing directory as shown below.
Building a child image with a custom route
This example adds an unauthenticated /healthz endpoint that returns 204 when the instance has spare capacity and 503 when it is overloaded — suitable for Kubernetes probes that cannot embed an API token.
Scaffold a project
Create a project directory with a TypeScript route source, build config, and Dockerfile:
custom-routes/
├── Dockerfile
├── package.json
├── tsconfig.json
└── src/
└── healthz.get.tsWrite the route
Routes extend
HTTPRoute(orBrowserHTTPRoute,WebSocketRoute,BrowserWebsocketRoute) and export a default class. Settingauth = falsedisables token authentication for this route only.src/healthz.get.tsimport {
APITags,
HTTPRoute,
Methods,
Request,
contentTypes,
writeResponse,
} from '@browserless.io/browserless';
import { ServerResponse } from 'http';
export default class HealthzGetRoute extends HTTPRoute {
name = 'HealthzGetRoute';
accepts = [contentTypes.any];
auth = false;
browser = null;
concurrency = false;
contentTypes = [contentTypes.text];
description = 'Unauthenticated liveness/readiness probe for Kubernetes.';
method = Methods.get;
path = '/healthz';
tags = [APITags.management];
async handler(_req: Request, res: ServerResponse): Promise<void> {
const monitoring = this.monitoring();
const limiter = this.limiter();
const { cpuOverloaded, memoryOverloaded } = await monitoring.overloaded();
const ready = limiter.hasCapacity && !cpuOverloaded && !memoryOverloaded;
return writeResponse(res, ready ? 204 : 503, '');
}
}The naming convention is
{name}.{method}.ts— the loader uses the file suffix (.http.ts,.ws.ts, or a method like.get.ts/.post.ts) to register the route correctly.Add build config
The SDK is only needed at compile time for type resolution; the runtime imports against the copy already installed in the Enterprise image.
package.json{
"name": "browserless-custom-routes",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"@browserless.io/browserless": "2.48.2",
"@types/node": "^24.0.0",
"typescript": "^5.5.0"
}
}tsconfig.json{
"compilerOptions": {
"rootDir": "src",
"outDir": "build",
"module": "es2022",
"moduleResolution": "Bundler",
"target": "es2022",
"lib": ["dom", "es2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}Pin the SDK version to the one shipped in the Enterprise image you are extending so the types match.
Write the Dockerfile
A multi-stage build keeps
node_modulesand the TypeScript compiler out of the final image.Dockerfile# syntax=docker/dockerfile:1.6
# Args used in a FROM must be declared before the first FROM.
ARG ENTERPRISE_IMAGE=registry.browserless.io/browserless/browserless/enterprise:latest
# ---- Stage 1: compile the TS route against the SDK types ----
FROM node:24-alpine AS build
WORKDIR /work
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund --ignore-scripts
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc
# ---- Stage 2: layer the compiled route onto the Enterprise image ----
FROM ${ENTERPRISE_IMAGE}
USER root
# BLESS_ROUTES is set by the Enterprise image and points at the SDK's
# build/routes dir. Dropping a file under a new "custom" group means the
# runtime auto-discovers it without overriding the built-in route tree.
RUN mkdir -p ${BLESS_ROUTES}/custom/http \
&& chown -R blessuser:blessuser ${BLESS_ROUTES}/custom
COPY \
/work/build/healthz.get.js \
${BLESS_ROUTES}/custom/http/healthz.get.js
USER blessuserPin
ENTERPRISE_IMAGEto a specific tag for reproducibility.Build and run
After authenticating to the Browserless registry, build the image:
docker login registry.browserless.io
docker build -t projects/browserless-healthz:latest .Run the image with the same environment variables you use for the base Enterprise image:
docker run -p 3000:3000 \
-e KEY=your-license-key \
-e TOKEN=your-api-token \
projects/browserless-healthz:latestVerify the route responds without a token:
curl -v http://localhost:3000/healthz
# * Host localhost:3000 was resolved.
# ...
# > GET /healthz HTTP/1.1
# > Host: localhost:3000
# ...
# < HTTP/1.1 204 No Content
# < Content-Type: text/plain; charset=UTF-8
# ...Existing built-in routes still require a token and continue to work unchanged.
Consuming REST parameters
Routes can match dynamic path segments and read them inside the handler.
The router uses micromatch glob syntax — not Express-style :id placeholders — so a path like /users/:id is expressed as /users/+([0-9a-zA-Z-_])?(/). The +([...]) group matches one-or-more allowed characters, and the trailing ?(/) makes the optional slash forgiving.
The SDK ships a getFinalPathSegment helper that pulls the last segment off the URL — perfect for single-parameter routes. This example proxies a request to JSONPlaceholder using the ID from the URL:
import {
APITags,
BadRequest,
HTTPRoute,
Methods,
NotFound,
Request,
contentTypes,
getFinalPathSegment,
jsonResponse,
} from '@browserless.io/browserless';
import { ServerResponse } from 'http';
export default class UsersGetRoute extends HTTPRoute {
name = 'UsersGetRoute';
accepts = [contentTypes.any];
auth = true;
browser = null;
concurrency = false;
contentTypes = [contentTypes.json];
description = 'Fetch a user by ID from the upstream JSONPlaceholder API.';
method = Methods.get;
path = '/users/+([0-9a-zA-Z-_])?(/)'; // This is NOT a RegEx, but a micromatch glob pattern.
tags = [APITags.management];
async handler(req: Request, res: ServerResponse): Promise<void> {
const id = getFinalPathSegment(req.parsed.pathname);
if (!id) {
throw new BadRequest('Missing user id in path');
}
const upstream = await fetch(
`https://jsonplaceholder.typicode.com/users/${id}`,
);
if (upstream.status === 404) {
throw new NotFound(`No user found with id "${id}"`);
}
const user = await upstream.json();
return jsonResponse(res, 200, user);
}
}
BadRequest, NotFound, Unauthorized, Timeout, TooManyRequests, and ServerError are all exported from the SDK. Throwing one returns the matching status without manual writeResponse calls
Compile-and-drop is identical to the healthcheck route, just add users.get.ts next to healthz.get.ts, build the image, and the loader picks it up.
Test it against a running container:
curl "http://localhost:3000/users/1?token=your-api-token"
# {
# "id": 1,
# "name": "Leanne Graham",
# "username": "Bret",
# // ...
# }
Adding more route types
The same pattern works for any route primitive exported from the SDK:
| Class | Use for |
|---|---|
HTTPRoute | REST endpoints with no browser dependency |
BrowserHTTPRoute | REST endpoints that launch a browser |
WebSocketRoute | WebSocket endpoints with no browser dependency |
BrowserWebsocketRoute | WebSocket endpoints that connect to a browser |
File suffix conventions: *.http.ts and *.ws.ts, or a more specific {name}.{method}.ts (e.g. metrics.get.ts). Drop the compiled output under ${BLESS_ROUTES}/<group>/http/ or ${BLESS_ROUTES}/<group>/ws/ to register it.