Skip to content

API

Every package here ships TypeScript declarations, so an editor completes and documents what follows without any setup: see Types.

henri is the running instance (global.henri; under NODE_ENV=test it is @usehenri/testing that sets it). Every module is exposed under its name.

Property Description
henri.config get(key, safe = false) throws on a missing key unless safe; has(key). Keys use dots: stores.default.adapter. See Configuration.
henri.pen The logger: info(name, ...args), warn, error, debug, verbose, silly print one line; fatal(name, summary, full, obj, code) prints the error with its code and returns an Error to throw; line(n) prints blank lines; notify(title, message) prints in development. What a line looks like is config.logs.format; see Logs and error reporting.
henri.user validatePassword(password) ({ valid, errors: [{ code, message }] }, never throws), passwordPolicy, encrypt(password) (rejects what the policy refuses), compare(password, user) (rejects, never resolves false: HENRI_USER_PASSWORD_MISMATCH for a wrong password and for no account, HENRI_USER_PASSWORD_UNVERIFIABLE for a record carrying no hash), findByEmail(email), findById(id) (the public externalId or the primary key), publicUser(user) ({ externalId, email, roles } plus config.user.public), settings (the normalized config.user). See Users.
henri.accounts Registration, the password reset and the address confirmation: register(attributes), requestPasswordReset(email), resetPassword(token, password), requestConfirmation(email), confirm(token), requestEmailChange(user, email), tokenFor(user, purpose), consume(token, purpose), sendReset(user), sendConfirmation(user), allowed(user), identify(user), urlFor(path), checkPassword(password), policy(), drain(), PURPOSE, settings. See Users.
henri.passport The passport instance holding the local and jwt strategies (with a user model).
henri.can(user, action, record, options) May this user take this action on this record? false for everything it cannot answer true for. See Policies.
henri.policies The policies of app/policies: can(), authorize(), scope(user, name), links(), paths(), names(), get(name), rule(name, action), resolve(word), settings. See Policies.
henri.privacy The fields the models marked personal: keys (every one of them, masked in the logs), private (the ones that never leave the server), fields(model), strip(value, include), describe(), subject(who), export(who), plan(who, options), erase(who, options), settings, subjectModel. See Personal data.
henri.versions The history of the models that say options: { versioned: true }: of(record), list(filter), count(filter), get(id), reify(version) (a read: the record as it was, folded backwards from the live one), restore(version, options) (a write: it refuses an inexact reconstruction unless force), acting(who, fn), prune(options), watches(model), enabled, settings. See Model versions.
henri.params(req) .permit(...fields) and .all(), the helper behind req.permit().
henri.mail send(message), transporter, nodemailer. See Mail.
henri.mailers The mailers of app/mailers: <name>.<action>(...) and message(name, action, args) build a message (render(), deliver(), deliverLater()), deliver(name, action, ...args) sends one, names(), actions(name), tree(), preview(name, action), onDeliverLater(fn), drain(). See Mail.
henri.graphql run(query, variables, contextValue), endpoint, active, settings (the normalized config.graphql: the limits and the access rules), the error classes. The module @usehenri/graphql ships: undefined when the application does not depend on it. See GraphQL.
henri.router routes (the expanded table keyed by verb path), pathForRoles(user), handler (the Express router the routes are mounted on).
henri.controllers get('name#action'), hooks('name#action') (the before hooks of an action, as middlewares), accepts('name#action') (what the action declared it accepts), checks('name#action') (the parameter check, as middlewares), all().
henri.server app (the Express application), httpServer, express, url, host, port, draining, drain(), shutdown(signal).
henri.model stores (the adapter instances by store name), ids (the model globals), getStore(name).
henri.view engine (the view engine), renderer, hbs (the Handlebars engine, always available).
henri.jobs perform(name, args, options), performIn(wait, ...), performAt(when, ...), performNow(name, args), get(id), list(filter), stats(), names(), and dead (count, list, get, retry, retryAll, discard, discardAll). The module @usehenri/jobs ships: undefined when the application does not depend on it. See Jobs.
henri.workers workers (the loaded workers by file) and files.
henri.validator validator.js.
henri.utils resolveFrom(name, dir), resolvePackageJson(name, dir), checkPackages(names) (throws with the install command), detectPackageManager(dir), installCommand(names), loadModules(dir), syntax(file), isLoopback(address).
henri.gql A tagged template returning the query string, so editors format GraphQL.
henri.shared The backend config.shared names, which the rate limit, the sign-in lockout and the idempotency keys count in: name, onError, healthy, describe(), ping(), rateLimitStore(feature), keyValueStore(feature). null when the application names none, which means every counter is kept in this process.
henri.cache The cache: fetch(key, [options], fn) (one promise per key, so concurrent misses run the function once), get(key), set(key, value, [options]), delete(key), clear(), scope(name), stats(), settings. Values are JSON plus Date; a model instance is refused. This process’s memory unless config.shared names a backend. See Caching.
henri.reporter The error reporting seam: onError(handler) (one handler, null removes it), report(error, options), enabled. The handler hears the boot failure, every 5xx and every unhandled rejection, with the error, its code, the request id and { method, route, status } — and nothing that came from the client. See Logs and error reporting.
henri.telemetry OpenTelemetry: span(name, [options], fn), inject(carrier), histogram(name, [options]), observe(name, options, callback), on(boundary), enabled, spans. Always there and always safe to call — with no @opentelemetry/api in the application, span() runs the function and nothing else. See Telemetry.

On the instance itself: henri.env, isProduction, isDev, isTest, release (the core version), cwd(), init(), reload(), stop() (resolves with the errors of the modules that failed to stop), addMiddleware(name, fn) (fn(router) runs before the routes are mounted; register it before the router starts), modules.add(module) (before init(), see modules), analyze() (what the boot did) and forceMail (use the configured mail transport under NODE_ENV=test).

Member Description
req.permit(...fields) The listed fields from the query string, body and path parameters (later sources win); missing fields are omitted. With no field: everything the action declared it accepts, checked and coerced. See Controllers.
req.user, req.isAuthenticated() Passport, with a user model: the user instance (without its password) and whether someone is logged in. req.logIn(user, cb) and req.logout(cb) take callbacks.
req.session The express-session object, with a user model.
req.flash(type, message) Queues a flash message; req.flash(type) reads and clears one type, req.flash() the whole bag. Stored in the session, so a no-op without a user model. See Controllers.
req.csrfToken The CSRF token of the request (a string), with a user model.
req._henri What the view engine reads: { csrf, flash, localUrl, paths, query, user } for every request, plus data, errors, graphql and the paths filtered by role and policy after res.render() on the React renderer. Pages read it through withHenri. Reading flash is what consumes the messages.
req.inertia { request, errors } with the Inertia renderer: whether the Inertia client made the request, and the errors set for the next render.
res.render(route, options) Render a page with { data } or { graphql }, or answer the view options as JSON when the client asks for it. include names the fields marked personal: { expose: false } this page may carry. An action that returns without answering renders /<controller>/<action> with what it returned. See Controllers.
res.hbs(route, options) Same, through the Handlebars engine whatever the renderer.
res.boom.<name>(message, data, code) A JSON error { statusCode, error, message, data }, plus a code when one of henri’s error codes is given. See Controllers.
res.inertia.errors(obj), res.inertia.location(url) With the Inertia renderer: validation errors for the next render, and an external redirect.
req.id The request id (X-Request-Id, accepted or generated), echoed on the answer and written in the log lines of the request.
req.can(action, record, options) The policy question, with the user of the request filled in. See Policies.
req.authorize(action, record, options) The same, resolving with the record and rejecting with a POLICY_DENIED error (404 by default, 401 and the login page for an anonymous visitor) when the answer is no.
req.scope(name, context) What a list of these records is filtered by, from the policy’s scope. The name defaults to what the route is about.
req.pagination() { page, perPage, skip, limit, offset } from ?page= and ?per_page=, bounded by config.api.
req.apiVersion 'v1' when the client accepts application/vnd.henri.v1+json, else null.
res.resource(record, options), res.collection(records, options) HAL answers with _links from the route helpers, filtered by roles and then by the policy of the record; Location on 201, _embedded, paging links and Link/X-Total-Count on collections. subject names what the policies are asked about when the answer is a presentation of the record, and include the personal fields this answer may carry. See JSON API.
res.negotiate({ html, json }) Runs html for browsers and json for API clients.
res.format(handlers) Express content negotiation; put json before html.

Every entry point on this page checks what it is called with. JavaScript is not strongly typed and TypeScript erases at runtime, so henri validates exhaustively at its boundaries and trusts what is inside — the configuration at boot, the request a controller answers, and the calls an application makes.

A call henri cannot honour raises HENRI_ARGUMENT_INVALID naming the method, the argument, what was expected and what arrived:

henri.cache.fetch(fn) must be a function, but it is the number 42
req.pagination(overrides.perPage) must be a whole number above zero, but it is the string "abc"
res.render(options) must be an object, but it is the string "oops"

Every problem is reported, not the first one. An async method rejects with it, which is what its caller is already handling; a synchronous one throws, and inside a controller that reaches the client as the 500 the code names.

Four rules are worth knowing, because they are what people bump into:

  • null is not the same as absent. res.resource(record, null) is refused: options = {} only fills in for undefined, so a null used to go straight through to the line that broke.
  • Inside an options bag, a selector or a switch does take null — a caller that computes an option and comes up with nothing should not have to delete the key — and a key whose absence has a default does not, because { include: null } and no include at all are genuinely different there.
  • A misspelled option is refused, and named. henri.privacy.erase(who, { stratgy: 'delete' }) says did you mean “options.strategy”?. An option henri does not know and that is nothing like one it does is left alone, the way the configuration leaves an application’s own keys alone.
  • A selector that names nothing is refused too, with HENRI_ARGUMENT_UNKNOWN_TARGET: henri.retention.sweep({ only: 'Propsal' }) used to report a clean, successful, empty run, which is exactly what somebody reads as the work being done.

On an authentication path there is a fifth, because two of these rules pull against each other: a refusal must never become an account-enumeration oracle, and a programming mistake must still be loud. The line is whose mistake it is. A value you chose — the record a hash is for, the purpose of a token, the path of a link — is a coded refusal, because the alternative is what these used to do: henri.accounts.urlFor(42) built https://host42 and mailed it, tokenFor(user, 'reset') minted a link nothing could ever spend, and henri.user.encrypt(password, { identiy: user }) wrote a hash bound to nothing. A value a visitor sent — a password, a token out of a url, an address typed into a form — is not checked at all and keeps the answer it always had, so henri.user.compare() checks its second argument and not its first, and null there means no account and answers the mismatch a wrong password answers, in the same words and at the same cost. See Users.

Some entry points refuse on their own terms instead, and keep doing so: a bad cache key is HENRI_CACHE_KEY_INVALID, a value the cache cannot store is HENRI_CACHE_VALUE_UNSUPPORTED, a trail entry with no action is HENRI_TRAIL_INVALID_EVENT, an unknown mailer is HENRI_MAIL_UNKNOWN_MAILER. Some are deliberately total and answer null or false rather than throwing: henri.model.errors(), henri.policies.get(), henri.config.has(), henri.reporter.onError(). And one is deliberately lenient: henri.reporter.report() runs on a failure path, so refusing a wrong call there would lose the failure it was called about.

Where a check goes follows one rule: never inside a loop of henri’s own. res.collection(records) checks that it was given a list and stops there; the rows are the serializer’s business, and one assertion per row to catch a mistake the call itself announces is the wrong trade. The checks run in production too — the cost is a typeof per argument on entry points that are followed by I/O, and a check that only ran in development would be missing from the one place a wrong call is expensive.

The signatures live in @usehenri/core’s src/base/arguments.js, as data, and src/__tests__/arguments.spec.js calls every one of them with garbage: a public method that forgets its check fails that test.

module.exports = {
before: { all: [], 'show,edit': loadTask }, // or [fn, { only, except, run }]
index: async (req, res) => {},
};

Every exported function is an action (tasks#index); before is the only reserved key. Hooks run once the route is allowed, in declaration order, and one that answers ends the request. An action that returns without answering renders /<controller>/<action> (/<controller> for index) with what it returned. See Controllers.

module.exports = {
store: 'default',
name: 'tasks',
options: {
personal: { subject: 'ownerId', onErase: 'anonymize' },
versioned: true,
},
schema: {
title: { type: 'string' },
name: { type: 'string', personal: true },
},
graphql: { types: '', resolvers: {} },
associate(models) {},
};

Core adds identity (the lowercased file name) and globalId (the file name) before handing the file to the adapter, and exposes the ORM model as global[globalId]. See Models.

Core loads @usehenri/<adapter> from the application for each store (adapter is one of disk, drizzle, mariadb, mongoose, mssql, mysql, postgresql), builds new Adapter(name, config, henri), calls addModel() for every model file of that store, then start(). Adapters implement:

Member Description
adapterName, name The adapter kind (drizzle, postgresql, disk, …) and the store name.
addModel(model, userModelName) Registers a model file and returns the ORM model. Every model gets externalId (a uuid, unique and not null in the database, unless options.externalId is false), createdAt/updatedAt (unless options.timestamps is false), paginate({ page, perPage }) answering { records, page, perPage, total, pages } and, with options.paranoid, soft deletes. The model whose identity equals userModelName is the user model: it gets email (unique, lowercased, trimmed, validated), password (hashed, not selected by default) and roles (only writable through setRoles() or with { unsafe: true }).
getModels() The ORM models by global id.
start(), stop() start() connects, calls the associate(models) export of each model once every model exists, and brings the schema up on SQL: a drizzle store pushes it in development and applies its migrations in production with migrate: true, an mssql store runs sequelize.sync() in development. stop() disconnects, and start() may be called again.
getSessionConnector(session) Async; resolves with a ready express-session Store.
findUserByEmail(email) The user with its password hash, or null.
findUserById(id) The user without its password, or null (also for a malformed id). Takes the public externalId as well as the primary key.
userId(user), toPlain(user) The primary key as a string (what the session stores); the user as a plain object without its password and without its primary key.
ping(), transaction(fn) Resolves true when the database answers; runs fn in a transaction.
query(sql, params, options) SQL adapters only: a raw query with the driver’s placeholders (? on sqlite and mysql, $1 on postgres, ? or :name on mssql).

@usehenri/drizzle exports the Drizzle class, which is henri’s SQL data layer. Its constructor takes a fourth argument, options, which is how a dialect package is built: adapterName (the name in the logs and the errors), dialect, which wins over the store configuration, and driverPaths, where the driver is looked for before the application. @usehenri/postgresql and @usehenri/mysql are that class with all three fixed, so they carry their driver (pg, mysql2) and a store that names one needs no dialect key; "adapter": "mariadb" is @usehenri/mysql too.

@usehenri/sequelize exports the SQL base class (with Sequelize, DataTypes and normalizeSchema as statics) and @usehenri/mssql extends it with the tedious driver. That is the whole of the Sequelize story: Drizzle has no SQL Server dialect, so an mssql store is the one store without migrations, and the base class adds drift(), what the database and the models disagree about, which henri db:status prints. @usehenri/mongoose exports the Mongoose class and @usehenri/disk extends it with a managed local server. Core falls back to Mongoose or Sequelize calls when an adapter lacks one of the four user methods.

henri.model.errors(error) normalizes what any of them throws on an invalid write into { field: message }, and answers null when the error is not a validation failure. See Validation errors.

@usehenri/drizzle, @usehenri/mongoose and @usehenri/sequelize, the three base packages, each export their uuid generator and the helpers around the public identifier from @usehenri/<name>/external-id: uuidv7(), isUuid(value), normalizeExternalId(value) and withoutInternalIds(record). See Identifiers.

Core loads the engine named by renderer: Handlebars is built in, react resolves @usehenri/react/engine and inertia @usehenri/inertia/engine from the application. An engine is new Engine(henri) with:

Method Description
init() Level 3: check the dependencies and the layout, create the missing files.
prepare() Level 5, before the server listens: build in production, start the dev server.
fallback(router) Register the catch-all that serves pages no route claimed (GET and HEAD only) and the static assets.
render(req, res, route, opts) Used by res.render(). opts holds data, user, paths, query, csrf, localUrl, flash, errors and graphql.
reload() Optional, called on every application reload.
close() Optional, called by henri.stop().
  • @usehenri/react: withHenri (default), useHenri, HenriContext, request, RequestError. @usehenri/react/forms: Form, Input, Select, Radio, Editor, Button, FormError, useForm, FormContext, Validation, messageFor, sanitize. @usehenri/react/engine: ReactEngine, build({ cwd, config }), createNextConfig(cwd). See Views.
  • @usehenri/inertia: useHenri, Form, useForm, Link, Head, router, usePage, pathFor, getRoute, request, resolvePage. @usehenri/inertia/vite: henriViteConfig({ views, entry, react }). @usehenri/inertia/engine: the engine, with a static build({ cwd, config }). See Views.
  • @usehenri/testing: setup, teardown, request, agent, henri, supertest; @usehenri/testing/setup-file and @usehenri/testing/global-setup for Vitest. See Testing.