Docs / Plugins

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
    },
  };
}

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.