Write a framework
A framework people install: config, core plugins, default plugins, exports, error handling and tests.
On this page
What you ship
A framework is a package with one job: give its users createApp and createPlugin, with the right
plugins already in. This guide builds a small one, notifier, that queues and sends messages. Each
file below is real and compiled on every test run of this site.
notifier/
src/
config.ts createCoreConfig: the contract and the core plugins
plugins/
transport.ts a plugin that talks to the outside
outbox.ts a plugin with state, events and a dependency
index.ts createCore: the defaults and the public exports
tests/
outbox.test.ts one app per testThe build order
- Write the contract: global config, global events, core plugins.
- Write one plugin per concern.
- Fix the defaults and handle hook errors.
- Export the surface.
- Test through a real app.
1. The contract
The contract is everything a plugin can rely on. Keep the global config small: a value one plugin
owns belongs in that plugin's config. Global events are for names several plugins share.
import { envPlugin, logPlugin, processEnv } from "@moku-labs/common";
import { createCoreConfig } from "@moku-labs/core";
export type Config = { stage: "production" | "development" | "test" };
export type Events = { "message:sent": { id: string; to: string } };
export const coreConfig = createCoreConfig<Config, Events, [typeof logPlugin, typeof envPlugin]>("notifier", {
config: { stage: "production" },
plugins: [logPlugin, envPlugin],
pluginConfigs: {
env: { providers: [processEnv()] }
}
});
export const { createPlugin, createCore } = coreConfig;ctx.logandctx.envon every plugin. The tuple in the generics makes them typed.- A sensible default. An app can replace it through
pluginConfigs.env.
You do not have to write log and env yourself: @moku-labs/common has them.
For your own infrastructure, use createCorePlugin.
2. The plugins
A plugin's name is its key on app, so pick the word your users should type. helpers run before
createApp, so they suit values that end up in config.
import { createPlugin } from "../config";
export type Message = { id: string; to: string; body: string };
export const transportPlugin = createPlugin("transport", {
config: { endpoint: "https://mail.example.com/send", tokenVariable: "MAIL_TOKEN" },
helpers: {
message: (to: string, body: string): Message => ({ id: crypto.randomUUID(), to, body })
},
api: ctx => ({
send: async (message: Message) => {
const response = await fetch(ctx.config.endpoint, {
method: "POST",
headers: { authorization: `Bearer ${ctx.env.require(ctx.config.tokenVariable)}` },
body: JSON.stringify(message)
});
if (!response.ok) throw new Error(`Send failed with ${response.status}.`);
ctx.emit("message:sent", { id: message.id, to: message.to });
}
})
});transportPlugin.message(...), with no app.
outbox depends on transport. It owns the queue, opens a timer in onStart and closes it in
onStop, where it gets only its own state.
import { createPlugin } from "../config";
import { type Message, transportPlugin } from "./transport";
type OutboxState = { queue: Message[]; timer?: ReturnType<typeof setInterval> };
export const outboxPlugin = createPlugin("outbox", {
depends: [transportPlugin],
config: { flushEveryMs: 1000 },
events: register => ({
"outbox:drained": register<{ count: number }>("The queue became empty")
}),
createState: (): OutboxState => ({ queue: [] }),
api: ctx => ({
add: (message: Message) => {
ctx.state.queue.push(message);
ctx.log.debug("outbox:added", { id: message.id });
},
flush: async () => {
const batch = ctx.state.queue.splice(0);
const transport = ctx.require(transportPlugin);
for (const message of batch) await transport.send(message);
ctx.emit("outbox:drained", { count: batch.length });
},
size: () => ctx.state.queue.length
}),
hooks: ctx => ({
"message:sent": ({ id }) => ctx.log.info("outbox:sent", { id })
}),
onStart: ctx => {
const { flush } = ctx.require(outboxPlugin);
ctx.state.timer = setInterval(() => void flush(), ctx.config.flushEveryMs);
},
onStop: ctx => {
clearInterval(ctx.state.timer);
}
});- Own events. Visible to plugins that depend on
outbox. - Call
requireinside the method, not in the factory body.
3. The defaults
createCore fixes the plugin list. Dependencies go first: the kernel checks the order and never
sorts. onError is not optional in practice: without one, a failing hook handler vanishes.
import { coreConfig, createCore } from "./config";
import { outboxPlugin } from "./plugins/outbox";
import { transportPlugin } from "./plugins/transport";
export const { createApp, createPlugin } = createCore(coreConfig, {
plugins: [transportPlugin, outboxPlugin],
pluginConfigs: {
outbox: { flushEveryMs: 5000 }
},
onError: (error, core) => core.log.error("hook:failed", undefined, error)
});
export { outboxPlugin, transportPlugin };
export type { Message } from "./plugins/transport";- Overrides the spec. An app's
pluginConfigsoverrides this. - Only core plugin APIs here. They are ready before any hook can fire.
4. The exports
Users import from the package root, nothing deeper.
| Export | Why |
|---|---|
createApp | The one call an app makes. |
createPlugin | So an app can add its own plugins, typed against your config and events. |
| The plugins | So another plugin can write depends: [outboxPlugin] and ctx.require(outboxPlugin). |
| The types of your API | So a user can name them. |
Declare the core as a peer dependency, along with @moku-labs/common if you use it. A second copy
in node_modules makes the types disagree.
{
"name": "notifier",
"type": "module",
"exports": { ".": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" } },
"peerDependencies": {
"@moku-labs/common": "*",
"@moku-labs/core": "*"
}
}If part of your framework runs in the browser, add a second entry with its own createCore and only
browser-safe plugins. @moku-labs/web and @moku-labs/web/browser are built this way.
5. Test through a real app
Create a new app per test: an app holds state and starts once. log in test mode prints nothing
and still records, so expect() can read what happened.
import { expect, it, vi } from "vitest";
import { createApp, transportPlugin } from "../src";
it("sends the queue and reports the batch", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("ok")));
const app = createApp({
config: { stage: "test" },
pluginConfigs: {
log: { mode: "test" },
env: { providers: [{ name: "fixture", load: () => ({ MAIL_TOKEN: "t" }) }] }
}
});
app.outbox.add(transportPlugin.message("[email protected]", "Hi"));
await app.outbox.flush();
expect(app.outbox.size()).toBe(0);
await vi.waitFor(() => app.log.expect().toHaveEvent("outbox:sent"));
});- A provider is two fields, so a fixture is one line.
emitdoes not wait for handlers, so wait for the trace.
- A wrong order is an error, not a sort. List dependencies first. The kernel reports it with both plugin names.
- Core plugins go in
createCoreConfig.createCore({ plugins })takes only plugins fromcreatePlugin. createCore({ pluginConfigs })is not typed by plugin. A typo there is silently ignored.createCoreConfigandcreateAppcheck theirs.- Keep names off the reserved list.
start,stop,emit,require,has,config,globalandstatefail increateApp, because they collide with the app's own members. - Global events are for every plugin. A plugin's own event belongs in its
events, so it shows up only wheredependssays.
Related
- Three layers: what a framework may and may not do.
- Plugins: every spec field.
- Lifecycle: the order of
onInit,onStart,onStop. - @moku-labs/common: the
logandenvcore plugins.