Skip to main content

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 /pressure endpoint 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.

Don't use the ROUTES env var

Setting 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.

  1. 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.ts
  2. Write the route

    Routes extend HTTPRoute (or BrowserHTTPRoute, WebSocketRoute, BrowserWebsocketRoute) and export a default class. Setting auth = false disables token authentication for this route only.

    src/healthz.get.ts
    import {
    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.

  3. 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.

  4. Write the Dockerfile

    A multi-stage build keeps node_modules and 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 --from=build --chown=blessuser:blessuser \
    /work/build/healthz.get.js \
    ${BLESS_ROUTES}/custom/http/healthz.get.js

    USER blessuser

    Pin ENTERPRISE_IMAGE to a specific tag for reproducibility.

  5. 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:latest

    Verify 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.

Use Globs for matching

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:

src/users.get.ts
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);
}
}
Error classes throw the right HTTP code

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:

ClassUse for
HTTPRouteREST endpoints with no browser dependency
BrowserHTTPRouteREST endpoints that launch a browser
WebSocketRouteWebSocket endpoints with no browser dependency
BrowserWebsocketRouteWebSocket 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.