Primate LogoPrimate

Modules

Modules are the extension mechanism for Primate. They let you hook into the application lifecycle — at build time, at startup, and on every request — to add capabilities that are not part of the core framework.

All official @primate/* packages (frontends, databases, backends) are themselves modules, and you can write your own using exactly the same API.

The Module interface

A module is a plain object with two properties.

import type { Module } from "primate";

const myModule: Module = {
  name: "my-module",
  setup(hooks) {
    // register lifecycle hooks here
  },
};
Property Type Description
name string unique identifier used in logs and error messages
setup (hooks: Setup) => void called once at startup; use it to register hooks

In practice, modules are usually exported as factory functions so they can accept configuration:

import type { Module } from "primate";

type Options = {
  verbose?: boolean;
};

export default (options: Options = {}): Module => ({
  name: "my-module",
  setup(hooks) {
    // use options here
  },
});

Register the module in config/app.ts:

import config from "primate/config";
import myModule from "./my-module.ts";

export default config({
  modules: [myModule({ verbose: true })],
});

Lifecycle hooks

The setup function receives a Setup object that exposes lifecycle hooks. Each hook registers a callback that Primate calls at the corresponding point in the application lifecycle.

Hook When it runs Typical use
onInit Before anything else validate config, set up external connections
onBuild During the build step register esbuild plugins, precompute assets
onServe When the HTTP server starts capture server properties like secure
onLog On every log call capture or forward log entries — send to an external service, write to a file
onError On an unexpected request failure record incidents and attach correlation IDs
onHandle On every incoming request middleware — auth, headers, request context
onRoute After routing, before the handler per-route pre/post processing

onInit

Called once during startup, before any other lifecycle phase. Use it to validate options, establish database connections, or perform one-off initialisation that the rest of the module depends on.

setup({ onInit }) {
  onInit(async app => {
    // app.path gives you access to project directories
    const config = await app.path.config.join("my-module.json").json();
    // throw here to abort startup on misconfiguration
  });
}

onBuild

Called during the build step. Use it to register esbuild plugins, precompute static assets, or write files into the build output.

setup({ onBuild }) {
  onBuild(async app => {
    // register a custom esbuild plugin for the client bundle
    app.plugin("client", {
      name: "my-loader",
      setup(build) {
        build.onLoad({ filter: /\.txt$/ }, async args => ({
          contents: await fs.readFile(args.path, "utf8"),
          loader: "text",
        }));
      },
    });

    // precompute a file into the run directory
    const data = await computeSomething();
    await app.runpath("my-module-data.json").writeJSON(data);
  });
}

onServe

Called once when the HTTP server starts, after the build is complete. Use it to capture runtime properties of the server, such as whether it is running over HTTPS.

setup({ onServe }) {
  let secure = false;

  onServe(app => {
    secure = app.secure;
  });
}

onLog

Called every time Primate emits a log entry, regardless of the configured log level. Use it to forward log output to an external service, write structured logs to a file, or feed entries into a monitoring pipeline.

setup({ onLog }) {
  onLog(entry => {
    // entry.level  — "error" | "warn" | "info" | "trace"
    // entry.message — message text
    externalService.log(entry.level, entry.message);
  });
}

The callback is synchronous and receives a LogEntry object.

Property Type Description
level "error" | "warn" | "info" | "trace" severity of the entry
message string message text

onLog fires for every log entry, even those below the application's configured log level. If you are writing to a noisy sink, filter by level inside the callback.

onError

Called when an unexpected failure would produce a 500 response. The hook is awaited before Primate invokes the error route and receives the original thrown value, the request facade, the matched route template when known, and the request-processing phase.

setup({ onError }) {
  onError(async ({ frames, phase, request, route }) => {
    const incident = await report({ frames, phase, route });
    request.set("incident", incident.id);
  });
}
Property Type Description
cause unknown original thrown value
frames readonly RequestErrorFrame[] application source frames with file, line, column, and an optional function
request RequestFacade request associated with the failure
route string | undefined matched route template, never a concrete pathname
phase "match" | "handler" | "layout" | "render" | "error-handler" | "handle" processing phase that failed

The hook remains inside request context. Values attached with request.set() are therefore available to the application's +error route, which can show an opaque incident ID without exposing the cause.

Each error observer runs in registration order. If one observer fails, Primate logs that failure and continues with the remaining observers. Observer return values cannot change the response, and observer failures are not reported back through onError.

Production builds emit a linked private build/server.js.map without embedded source content. Primate uses it to map minified stack positions before invoking onError. Frame paths are relative to the application root, so they remain stable between deployments and do not expose build-machine paths. Server maps are runtime artifacts and are not included in public client assets or served by Primate.

Treat cause and the request as sensitive. Do not send exception messages, headers, bodies, cookies, query values, or path values to an incident service unless your application has explicitly classified them as safe.

onHandle

Called on every incoming request, before routing. This is Primate's middleware hook. Return a Response to short-circuit the request; call next(request) to continue.

setup({ onHandle }) {
  onHandle((request, next) => {
    const token = request.headers.try("Authorization");
    if (token === undefined) {
      return new Response("Unauthorized", { status: 401 });
    }
    // pass a modified request downstream using request context
    return next(request.set("auth.token", token));
  });
}

The callback receives the same RequestFacade available in route handlers. Attach derived values (authenticated user, feature flags, locale) with request.set() so route handlers can read them with request.get().

onHandle runs for every request, including static assets. Keep the callback fast.

onRoute

Called after routing resolves but before the route handler runs. Useful for per-route pre- or post-processing.

setup({ onRoute }) {
  onRoute((request, next) => {
    // add a header to every response the app produces
    return next(request).then(response => {
      response.headers.set("X-Powered-By", "my-module");
      return response;
    });
  });
}

A complete example

Here is a minimal logging module that measures response time and prints it to the console.

import type { Module } from "primate";

export default (): Module => ({
  name: "request-logger",

  setup({ onHandle }) {
    onHandle(async (request, next) => {
      const start = Date.now();
      const response = await next(request);
      const ms = Date.now() - start;
      console.log(`${request.method} ${request.url.pathname} ${response.status} ${ms}ms`);
      return response;
    });
  },
});

Register it in config/app.ts:

import config from "primate/config";
import requestLogger from "./request-logger.ts";

export default config({
  modules: [requestLogger()],
});

Sharing state between hooks

Because a module factory is a regular function, any variable declared inside it is shared across all hooks for the lifetime of the application. This is the standard way to pass data between, say, onServe and onHandle.

export default (): Module => {
  // shared across all hooks
  let apiKey = "";

  return {
    name: "my-module",
    setup({ onInit, onHandle }) {
      onInit(async app => {
        apiKey = await loadApiKey();
      });

      onHandle((request, next) => {
        return next(request.set("apiKey", apiKey));
      });
    },
  };
};
Previous
I18N
Next
Intro