API
Every package here ships TypeScript declarations, so an editor completes and documents what follows without any setup: see Types.
The henri global
Section titled “The henri global”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).
Request and response
Section titled “Request and response”| 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. |
Wrong calls
Section titled “Wrong calls”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 42req.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:
nullis not the same as absent.res.resource(record, null)is refused:options = {}only fills in forundefined, so anullused 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 noincludeat 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.
The controller file
Section titled “The controller file”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.
The model file
Section titled “The model file”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.
Store adapters
Section titled “Store adapters”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.
View engines
Section titled “View engines”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(). |
Client packages
Section titled “Client packages”@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 staticbuild({ cwd, config }). See Views.@usehenri/testing:setup,teardown,request,agent,henri,supertest;@usehenri/testing/setup-fileand@usehenri/testing/global-setupfor Vitest. See Testing.
