Skip to content
Moku LabsDocs
Concepts pages

Plugins

Every PluginSpec field, context tiers, core plugins.

plugins.mdllms.txt

PluginSpec

Every field is optional. A plugin with only api works; a plugin with only hooks works.

createPlugin("name", {
  config:      { /* defaults, type C */ },
  events:      register => ({ "name:event": register<Payload>("description") }),
  depends:     [otherPlugin],
  createState: ctx => ({ /* mutable state, type S */ }),
  api:         ctx => ({ /* public methods, mounted on app.name */ }),
  hooks:       ctx => ({ "event:name": payload => { /* handler */ } }),
  onInit:      ctx => { /* all plugins exist; sync only */ },
  onStart:     async ctx => { /* app is running */ },
  onStop:      async ctx => { /* teardown, reverse order */ },
  helpers:     { /* static pure functions, no ctx */ }
});
FieldRule
configDefaults. Shallow-merged with framework and app overrides, then frozen.
eventsDeclared with the register callback. Compile-time only.
dependsPlugins that must appear earlier in the list. Validation and typing only, no sorting.
createStateCalled once with { global, config }. Returns the only mutable store.
apiSynchronous, no side effects. Mounted at app.<name>.
hooksEvent handlers with the full ctx. Payloads are typed from the merged event map.
onInit, onStart, onStopLifecycle. See Lifecycle.
helpersPure functions on the instance, usable before createApp.

Types are inferred from the spec. createPlugin never takes explicit generics.

A plugin with state and a dependency

src/plugins/counter.ts
import { createApp, createPlugin } from "@moku-labs/web";

const clockPlugin = createPlugin("clock", {
  api: () => ({ now: () => Date.now() })
});

const counterPlugin = createPlugin("counter", {
  depends: [clockPlugin],
  config: { step: 1 },
  createState: () => ({ count: 0, lastAt: 0 }),
  api: ctx => ({
    increment: () => {
      ctx.state.count += ctx.config.step;
      ctx.state.lastAt = ctx.require(clockPlugin).now();
    },
    value: () => ctx.state.count
  })
});

const app = createApp({
  plugins: [clockPlugin, counterPlugin],
  pluginConfigs: { site: { name: "Demo", url: "https://example.com" }, counter: { step: 2 } }
});

app.counter.increment();
console.log(app.counter.value()); // 2
  1. Must come earlier in the plugins list.
  2. require takes the instance, not a string.
  3. A closure over state, never the state object itself.

Context tiers

The kernel hands each factory a context sized to its phase.

Phasectx has
createState{ global, config }
api, hooks, onInit, onStart{ global, config, state, emit, require, has } plus core APIs such as ctx.log
onStop{ global, config, state }: own data only, peers may already be stopped

The type system enforces every restriction. Calling ctx.emit in createState is a compile error, not a runtime surprise.

Talking to other plugins

ctx.require(plugin) takes an instance and returns its typed API, or throws. ctx.has(name) is a string check that never throws. Use require for request and response: events do not return values.

Core plugins

Infrastructure such as log and env. Their context is { config, state } only. They cannot have depends, events or hooks, and their APIs land flat on every regular plugin's ctx, not on app. They init and start first, and stop last. In @moku-labs/web they are log and env, so every plugin can call ctx.log.info(...).

Anti-patterns

  • Leaking state. Return value: () => ctx.state.count, never { state: ctx.state }.
  • God plugins. One plugin per domain concern. Keep index.ts near 30 lines; logic goes in api.ts and its siblings.
  • emit for request and response. Use require(plugin) to get a return value.
  • Inventing primitives. No class XService, no app.registerService(). Write a plugin.