0Z4C0
// navigation
// @ozaco/std

Plugin

An effect-based plugin/protocol system with scoped contexts and an around/before/after/error hook pipeline.

import from@ozaco/std/plugin

A Protocol declares a contract — a context type, setup arguments, and a set of action signatures — tagged by an optional subtype symbol. A Plugin provides the concrete behavior: either standalone (definePlugin(...).build(actions)) or as an implementation of a protocol (Protocol.implement(...).build(actions)). Both are the same underlying shape, produced by the shared createHookable factory.

Plugins are activated with install, which runs setup inside the current effect scope and stores the resulting context so downstream operations can read it. Calling SomeProtocol.actions.* dispatches to whichever implementation is installed; a protocol's exec strategy decides how — the default runs the last-installed impl, while overrides can run the highest-priority one (codec) or fan out to every impl (logger). A direct SomePlugin.actions.* call always targets that plugin's own impl.

Every action flows through a hook pipeline. The Hookable methods — around, before, after, error — layer typed handlers keyed by action name onto a plugin or protocol, letting callers wrap, short-circuit, transform, or observe calls without touching the implementation. getService exposes the underlying action descriptor for advanced interop.

01

Definition

Declare protocols and build plugins.

fn

defineProtocol

Declare a protocol: a tagged contract that one or more plugins implement.

signature
function defineProtocol<
  TContext,
  TArgs extends unknown[] = [],
  TActions extends {} = {},
  TCustomActions extends {} = {},
>(options: {
  subtype?: symbol
  cloneable?: boolean
  name: string
  version: string
  description?: string
  handlers?: TCustomActions
  defaultActions?: Partial<TActions>
  exec?: Hookable.Exec
}): Protocol<TContext, TArgs, TActions, TCustomActions>

The type parameters fix the shared context, the setup argument tuple, the action surface implementations must provide, and any protocol-level handlers (registry plumbing that runs once, not per-impl). Set cloneable: true to allow multiple implementations to coexist, and pass exec to override how actions dispatch across them (default: the last-installed impl wins).

example
import { defineProtocol } from '@ozaco/std/plugin'

const Cache = defineProtocol<
  { store: Map<string, unknown> },
  [options?: { max?: number }],
  { get(key: string): Future<unknown>; set(key: string, value: unknown): Future<void> }
>({ name: 'cache', version: '0.0.1' })
fn

definePlugin

Define a standalone plugin, returning a definition you finish with `.build(actions)`.

signature
function definePlugin<TContext, TArgs extends unknown[] = []>(options: {
  subtype?: symbol
  name: string
  version: string
  description?: string
  setup(...args: TArgs): Operation<TContext>
}): Plugin.Definition<TContext, TArgs>

// Plugin.Definition:
interface Definition<TContext, TArgs> {
  context: Context<TContext>
  build(): Plugin<TContext, TArgs>
  build<TActions>(actions: TActions): Plugin<TContext, TArgs, TActions>
}

setup receives the install-time arguments and returns the plugin’s context via an effect operation. .build(actions) attaches the action implementations and yields the frozen Plugin, whose context can be read anywhere the plugin is installed.

example
import { definePlugin } from '@ozaco/std/plugin'
import { operation } from '@ozaco/std/effect'

const Clock = definePlugin({
  name: 'clock',
  version: '0.0.1',
  *setup() {
    return { now: Date.now }
  },
}).build({
  time: operation(function* () {
    return Date.now()
  }),
})
02

Lifecycle

Install plugins and reach into the action machinery.

fn

install

Run a plugin’s `setup` and store its context in the current effect scope.

signature
function* install<TContext, TArgs extends unknown[]>(
  plugin: Plugin<TContext, TArgs>,
  ...args: TArgs
): Operation<TContext>

Calls plugin.setup(...args), sets the resulting value on the plugin’s context in the active scope, and returns it. After installation the plugin’s actions are dispatchable, and its context is visible to any operation running in that scope chain.

example
import { install } from '@ozaco/std/plugin'

function* main() {
  const ctx = yield* install(Cache, { max: 100 })
  // Cache.actions.* are now available in this scope.
}
fn

getService

Extract the raw action descriptor behind a bound plugin action handler.

signature
const getService: (
  handler: (...args: any[]) => Operation<any>,
) => Operation<Hookable.RawAction>

// Hookable.RawAction:
interface RawAction {
  self?: (...args: any[]) => Operation<unknown>
  context: Context<unknown>
  options: { name: string; version: string; subtype?: symbol }
  key: string
  meta?: Record<string, any>
}

Invokes handler with an internal RAW_ACTION sentinel so it returns its descriptor (the underlying self implementation, owning context, plugin options, action key, and meta) instead of executing. Fails with an unexpected tag when the handler is not part of a plugin.

example
import { getService } from '@ozaco/std/plugin'

const service = yield* getService(Cache.actions.get)
service.key //=> 'get'
fn

createHookable

Low-level factory powering `definePlugin`/`defineProtocol`: builds the context, action proxy, hooks and plugin builder.

signature
function createHookable(options: {
  name: string
  version: string
  handlers?: Record<string, any>
  defaultActions?: Record<string, any>
  subtype?: symbol
  cloneable?: boolean
  exec?: Hookable.Exec
}): {
  context: Context<unknown>
  actions: unknown
  hooks: {
    useHook(): Operation<...>
    around(handlers): Operation<void>
    before(handlers): Operation<void>
    after(handlers): Operation<void>
    error(handlers): Operation<void>
  }
  buildPlugin(buildOptions, buildActions?): Plugin
}

Creates the scope-local Context, hook-store and chain contexts, the action Proxy that resolves each call through the before/around/after/error pipeline, and buildPlugin which wires an implementation’s setup and actions into a frozen Plugin. You rarely call this directly — prefer definePlugin/defineProtocol — but it is exported for building custom protocol machinery.

03

Guards

Narrow unknown values to plugins and protocols.

fn

isPlugin

Narrow a value to a `Plugin` (tagged with the `PLUGIN` symbol).

signature
function isPlugin(value: unknown): value is Plugin
fn

isProtocol

Narrow a value to a `Protocol` (tagged with the `PROTOCOL` symbol).

signature
function isProtocol(value: unknown): value is Protocol
04

Types

iface

Plugin

A built plugin: a hookable bundle of `setup`, `context`, `actions` and metadata.

signature
interface Plugin<TContext = unknown, TArgs extends unknown[] = unknown[], TActions = unknown>
  extends Hookable<TActions & {}> {
  _t: typeof PLUGIN
  _st?: symbol
  name: string
  version: string
  description: string
  setup(...args: TArgs): Operation<TContext>
  context: Context<TContext>
  actions: TActions
  getKeys(): string[]
  getMeta(key: string): Record<string, any> | undefined
}

The Plugin namespace also exposes:

  • InferContext<T> — extract the context type from a plugin.
  • Definition<TContext, TArgs> — the intermediate result of definePlugin, with context and build(actions?).
iface

Protocol

A contract that plugins implement; carries shared `context`, routed `actions`, and `implement`.

signature
interface Protocol<
  TContext = unknown,
  TArgs extends unknown[] = unknown[],
  TActions extends {} = {},
  TSelfActions extends {} = {},
> extends Hookable<TActions> {
  _t: typeof PROTOCOL
  _st?: symbol
  name: string
  version: string
  context: Context<TContext>
  actions: TActions & TSelfActions
  implement<TIContext extends TContext, TIArgs extends TArgs>(options: {
    name: string
    version: string
    description?: string
    setup(...args: TIArgs): Operation<TIContext>
  }): Protocol.Implementation<TIContext, TIArgs, TActions>
}

The Protocol namespace also exposes:

  • InferContext<T> — extract the context type from a protocol.
  • Implementation<TContext, TArgs, TActions> — the result of .implement(...), with context and build(actions).
iface

Hookable

The hook surface shared by plugins and protocols: `around`/`before`/`after`/`error` plus `useHook`.

signature
interface Hookable<TActions> {
  useHook(): Operation<Map<string, unknown>>
  around(handlers: Hookable.Around<TActions>): Operation<void>
  before(handlers: Hookable.Before<TActions>): Operation<void>
  after(handlers: Hookable.After<TActions>): Operation<void>
  error(handlers: Hookable.OnError<TActions>): Operation<void>
}

Handler maps mirror the action tree, so before({ get: (args) => ... }) hooks the get action. The Hookable namespace also exposes the per-kind handler shapes and function types (Around/Before/After/OnError, AroundFn/BeforeFn/AfterFn/ErrorFn, AnyAction) and internal wiring types (RawAction, Exec, Call, HookStore, HookSelfEntry).

  • around — wrap a call, deciding whether/how to invoke next.
  • before — run before the action, e.g. validation.
  • after — inspect or replace the result.
  • error — observe a thrown/failed call.
  • Exec — the dispatch strategy defineProtocol accepts via exec.
example
import { install } from '@ozaco/std/plugin'

// Layer a hook onto an installed protocol.
yield* Cache.before({
  get: function* ([key]) {
    yield* Logger.actions.debug('cache get', { key })
  },
})