Plugins
Extend Hopak with custom field types, middleware, and boot hooks — one explicit use() call.
Added in 1.0
A plugin is a named object with a setup function. Register it with one explicit call — nothing activates by import side effect:
// main.ts
import { hopak } from '@hopak/core';
import uuid from '@hopak/uuid';
await hopak().use(uuid()).listen();
setup runs once during boot, before models are scanned — so a field type registered by a plugin is available to every model file.
The contract
import type { HopakPlugin } from '@hopak/core';
export default function myPlugin(): HopakPlugin {
return {
name: 'my-plugin',
setup(ctx) {
// ctx.config — resolved HopakConfig (read-only)
// ctx.log — the app logger
// ctx.registerField(type, adapter)
// ctx.before(...) / ctx.after(...) / ctx.wrap(...)
// ctx.onBoot(hook) — runs after every plugin's setup, before routes load
},
};
}
- Plugins run in registration order.
use()is idempotent byname— registering the same plugin twice is a no-op.use()afterlisten()throws.- Two different plugins claiming the same field type throw a
PluginErrornaming the first owner.
Custom field types
A field plugin ships two things: a FieldBuilder subclass (for typed models) and an adapter (for DDL + validation):
// hopak-plugin-uuid/index.ts
import * as v from 'valibot';
import { FieldBuilder, type HopakPlugin } from '@hopak/core';
class UuidField extends FieldBuilder<string, false> {
constructor() {
super('uuid');
}
required() {
return this.markAs<UuidField & { __required: true }>(true);
}
}
export const uuid = () => new UuidField();
export default function uuidPlugin(): HopakPlugin {
return {
name: 'hopak-plugin-uuid',
setup(ctx) {
ctx.registerField('uuid', {
storage: 'text',
schema: () => v.pipe(v.string(), v.uuid()),
});
},
};
}
storage says how the value is persisted; Hopak maps it to the right column and DDL on all three dialects. Available: text, integer, real, boolean, timestamp, json. schema returns the Valibot schema used for request validation.
Models then use it like any built-in field, with full inference:
import { model, text } from '@hopak/core';
import { uuid } from 'hopak-plugin-uuid';
export default model('device', {
name: text().required(),
externalId: uuid().required().unique(),
});
Plugin middleware
Middleware added by a plugin runs before middleware added via hopak().before() — plugins are framework-level:
setup(ctx) {
ctx.before((reqCtx) => {
reqCtx.setHeader('X-Powered-By', 'Hopak');
});
}
Testing a project that uses plugins
createTestServer needs the same plugins the app registers — the field registry is populated during plugin setup:
const env = await createTestServer({ rootDir: '.', plugins: [uuidPlugin()] });
Naming convention
Official plugins live under @hopak/*; community plugins use the hopak-plugin-* prefix (like eslint-plugin-*).
What is never a plugin
model(), defineRoute(), crud.*, the built-in field types, relations, validation, and serialization are Hopak’s identity. Plugins extend around them, not replace them.