Skip to content

everything-dev

A consolidated product package for building Module Federation apps with oRPC APIs.

plugin-development

417 linesSource

Build, register, and deploy plugins within everything.dev. Covers the _template scaffold, contract/service/index pattern, database setup with Drizzle, authored-config registration, plugin UI/sidebar, and CLI workflow. Use when creating new plugins, adding database-backed routes, or deploying plugins to production.

Config form: the authored config is bos.app.ts (canonical, preferred when both exist). A legacy bos.config.json is still supported for older children. Where this doc says bos.config.json for the local authored file, read "the authored config". The published artifact on FastKV keeps the key name bos.config.json.

Plugin Development

Plugin Structure

plaintext
plugins/your-plugin/
├── src/
│   ├── contract.ts          # oRPC route definitions + Zod schemas
│   ├── service.ts           # Business logic (class or plain functions)
│   ├── index.ts             # createPlugin() wiring
│   ├── db/                  # Optional: database schema + migrations
│   │   ├── schema.ts
│   │   ├── layer.ts
│   │   ├── migrator.ts
│   │   └── migrations/
│   ├── plugins-client.gen.ts  # Generated — in-process plugin client types
│   └── __tests__/            # Tests
├── package.json
├── rspack.config.js          # Build config
├── plugin.dev.ts             # Dev server (port, variables, secrets)
└── tsconfig.json

Step 1: Copy from _template

The quickest start is to copy the template:

shell
cp -r plugins/_template plugins/your-plugin

Then rename in package.json, plugin.dev.ts, and rspack.config.js.

Or use the CLI (no automated command yet — copy template manually).

Step 2: Define the Contract (src/contract.ts)

Use oc.router() to define typed routes. Each route has a method, path, optional input/output schemas, and optional error declarations:

ts
import { eventIterator, oc } from "@orpc/contract";
import { z } from "zod";

// Define errors this plugin throws
const Errors = {
  NOT_FOUND: { status: 404, message: "Item not found" },
  UNAUTHORIZED: { status: 401, message: "User ID required" },
};

export const ItemSchema = z.object({
  id: z.string(),
  title: z.string(),
  createdAt: z.string().datetime(),
});

export const contract = oc.router({
  getById: oc
    .route({ method: "GET", path: "/items/{id}" })
    .input(z.object({ id: z.string().min(1) }))
    .output(z.object({ item: ItemSchema, userId: z.string() }))
    .errors(Errors),

  ping: oc
    .route({ method: "GET", path: "/ping" })
    .output(z.object({ status: z.literal("ok"), timestamp: z.string().datetime() })),

  search: oc
    .route({ method: "GET", path: "/search" })
    .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
    .output(eventIterator(SearchResultSchema)),  // SSE streaming

  enqueueBackground: oc
    .route({ method: "POST", path: "/background/events" })
    .input(z.object({ id: z.string().optional() }))
    .output(z.object({ ok: z.boolean() })),
});

export type ContractType = typeof contract;

Key rules:

  • Import z from zod, oc/eventIterator from @orpc/contract, and Effect/Layer from effect directly — the every-plugin/zod, every-plugin/orpc, and every-plugin/effect facade barrels were deleted. Use every-plugin/errors for the shared error shapes
  • Path params ({id}) automatically bind to Zod input keys
  • .output(eventIterator(Schema)) enables SSE streaming routes
  • .errors(Errors) enables typed error handling in generated clients

Step 3: Build Services (src/service.ts)

Services are plain classes or functions, optionally using Effect:

ts
import { Effect } from "effect";

export class TemplateService {
  constructor(
    private baseUrl: URL,
    private apiKey: string,
    private timeout: number,
  ) {}

  ping(): Effect.Effect<{ status: string; timestamp: string }> {
    return Effect.succeed({ status: "ok", timestamp: new Date().toISOString() });
  }

  getById(id: string): Effect.Effect<{ id: string; title: string; createdAt: string }> {
    return Effect.tryPromise(async () => {
      const res = await fetch(`${this.baseUrl}/items/${id}`, {
        headers: { Authorization: `Bearer ${this.apiKey}` },
        signal: AbortSignal.timeout(this.timeout),
      });
      if (!res.ok) throw new Error("Item not found");
      return res.json();
    });
  }
}

Use Effect.runPromise(service.method()) to bridge Effect and async handlers in createRouter.

Step 4: Wire with createPlugin (src/index.ts)

ts
import { ORPCError } from "@orpc/server";
import { Effect } from "effect";
import { createPlugin } from "every-plugin";
import { z } from "zod";
import { contract } from "./contract";
import type { PluginsClient } from "./plugins-client.gen";
import { TemplateService } from "./service";

export default createPlugin.withPlugins<PluginsClient>()({
  variables: z.object({
    baseUrl: z.url().default("https://api.example.com"),
    timeout: z.number().min(1000).max(60000).default(10000),
  }),

  secrets: z.object({
    apiKey: z.string().min(1).default("dev-key"),
  }),

  context: z.object({
    userId: z.string().optional(),
    user: z.object({
      id: z.string(),
      role: z.string().optional(),
      email: z.string().optional(),
      name: z.string().optional(),
    }).optional(),
    reqHeaders: z.custom<Headers>().optional(),
    getRawBody: z.custom<() => Promise<string>>().optional(),
  }),

  contract,

  initialize: (config, _plugins, tools) =>
    Effect.gen(function* () {
      const service = new TemplateService(config.variables.baseUrl, config.secrets.apiKey, config.variables.timeout);
      yield* service.ping(); // health check on startup

      const publisher = new MemoryPublisher({ resumeRetentionSeconds: 120 });

      // For scoped resources (DB pools, caches, repositories):
      // const repo = yield* buildScoped(MyRepoTag, MyRepoLive.pipe(...))

      return { service, publisher };
    }),

  shutdown: () => Effect.void,

  createRouter: (deps, builder) => {
    const { service, publisher } = deps;
    const requireAuth = builder.middleware(async ({ context, next }) => {
      if (!context.userId) throw new ORPCError("UNAUTHORIZED", { message: "User ID required" });
      return next({ context: { ...context, userId: context.userId } });
    });

    return {
      getById: builder.getById.use(requireAuth).handler(async ({ input, context }) => {
        const item = await Effect.runPromise(service.getById(input.id));
        return { item, userId: context.userId };
      }),

      ping: builder.ping.handler(async () => {
        return await Effect.runPromise(service.ping());
      }),

      search: builder.search.handler(async function* ({ input }) {
        const generator = await Effect.runPromise(service.search(input.query, input.limit));
        for await (const result of generator) yield result;
      }),
    };
  },
});

Thing Provider Pattern

When the API owns the registry but the plugin owns the payload, add a small provider surface to your contract:

ts
createThing: oc
  .route({ method: "POST", path: "/things" })
  .input(z.object({ thingId: z.string(), payload: z.unknown() }))
  .output(z.object({ type: z.string(), payload: z.unknown(), action: z.string().optional() })),

getThing: oc
  .route({ method: "GET", path: "/things/{thingId}" })
  .input(z.object({ thingId: z.string() }))
  .output(z.object({ type: z.string(), payload: z.unknown() })),

In service.ts, keep the plugin-specific type derivation close to the data source:

ts
createThing(thingId: string, payload: unknown) {
  return Effect.sync(() => {
    const type = this.resolveThingType(payload);
    this.things.set(thingId, { thingId, type, payload, createdAt: now, updatedAt: now });
    return { type, payload, action: `${type}.created` };
  });
}

Good practice:

  • Let the API store only thingId, pluginId, and timestamps.
  • Let the plugin decide the public type string and payload shape.
  • Use pluginId as the API routing key, not as a UI concern.
  • Keep plugins-client.gen.ts local to the plugin scaffold so createPlugin.withPlugins<PluginsClient>() stays typed.

Key Patterns

variables — Public config exposed in the authored config (typed, with defaults). secrets — Private values from process.env (typed, dev defaults). context — Per-request context injected by the host. See "Request Context Reference" below for all available fields. initialize — Effect-based startup. Return an Effect Layer; the runtime builds it against the plugin's lifecycle scope. Use buildScoped(tag, layer) (or buildScopedContext(layer) for multi-service layers) from "every-plugin" to build scoped resources (DB pools, caches, repositories) that live for the plugin's lifetime. createRouter — Maps contract procedures to handlers. Receives the value returned by initialize plus a pre-configured builder.

Request Context

The host injects a per-request context object into every plugin. The plugin must declare the fields it uses in its context zod schema — the schema is a filter; the host passes all fields, but your plugin only sees what you declare.

Available fields: userId, user (id, role, email, name), organizationId / organization (active org envelope, member.role, metadata.daoAccountId), near (primaryAccountId, linkedAccounts), walletAddress, apiKey (id, name, permissions), reqHeaders, getRawBody.

See request-context for the full schema and field table. For pre-built auth/organization middleware, see the api-and-auth skill.

Step 5: Register in the authored config

Add a plugin entry:

json
{
  "plugins": {
    "your-plugin": {
      "name": "your-plugin",
      "development": "local:plugins/your-plugin",
      "production": "https://cdn.example.com/your-plugin/remoteEntry.js",
      "ssr": "https://cdn.example.com/your-plugin/ssr/remoteEntry.js",
      "variables": {
        "baseUrl": "https://api.example.com",
        "timeout": 5000
      },
      "secrets": ["YOURPLUGIN_API_KEY"],
      "routes": ["ui/src/routes/_layout/_authenticated/your-plugin/**"],
      "shared": {
        "react": { "version": "^19.0.0", "singleton": true },
        "react-dom": { "version": "^19.0.0", "singleton": true }
      }
    }
  }
}

The CLI updates the authored config (bos.app.ts) automatically when you run bos plugin add / bos plugin remove.

Remote-only plugins: The development key is optional. A plugin can be remote-only (just a production URL) — the host/API consume it via pluginsClient and HTTP, and types resolve from the deployed manifest. Plugin source does not need to live in the consuming repo. This is how plugins maintained in other repos are mounted.

Step 6: plugin.dev.ts

Dev server config for local development:

ts
import { defineConfig } from "every-plugin/dev";

export default defineConfig({
  port: 3015,
  variables: {
    baseUrl: "https://api.example.com",
    timeout: 10000,
  },
  secrets: {
    apiKey: "dev-key",
  },
});

Step 7: Build Config (rspack.config.js)

js
const { createRspackConfig } = require("every-plugin/rspack");

module.exports = createRspackConfig({
  name: "your-plugin",
  exposes: {
    ".": "./src/index.ts",
    "./contract": "./src/contract.ts",
  },
  plugins: [],
});

Key exports:

  • "." — the plugin entry point (createPlugin export)
  • "./contract" — the contract module (for type generation from remote plugins)

Database Setup

Schema (src/db/schema.ts)

ts
import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";

export const items = pgTable("items", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
  titleIdx: uniqueIndex("items_title_idx").on(table.title),
}));

The database seam (src/db/layer.ts)

One ~15-line file, scaffolded once and owned by the plugin (ADR 0033): the plugin-owned DatabaseTag (id "<pluginId>/Database"), the typed Database alias, and DatabaseLive calling databaseLayer from everything-dev/db:

ts
// everything-dev/db's databaseLayer supports:
//   "pglite:.bos/your-plugin/:memory:" — in-memory PGlite (dev)
//   "pglite:.bos/your-plugin/data"      — persistent PGlite (dev)
//   "<postgres connection string>"      — real PostgreSQL (prod)
// namespace: true (default) scopes into plugin_<slug>; false keeps the
// public schema (api shell) or the dedicated database (auth)

Layer Pattern

DatabaseLive(config) is a Layer built by databaseLayer: it creates the driver with acquireRelease, runs migrations on boot (journal-scoped advisory lock — the pool stays open for migration queries), detects drift, and returns the typed db on the tag. The driver closes when the layer's scope closes.

Migration Generation

  1. Create drizzle.config.ts in your plugin directory
  2. Run drizzle-kit generate to produce SQL migration files
  3. Store migrations in src/db/migrations/ with an explicit storage resolved from the workspace (the no-arg fallback reads npm_package_name, unreliable under rspack/Module Federation bundling)

Migrations run inside DatabaseLive's scoped layer. The critical rule is to build the DB-backed service via buildScoped so the scope (and the pool) lives for the plugin's lifetime. Do NOT call DatabaseLive directly in initialize and extract the driver — that creates a transient scope that releases the pool immediately. See database for correct/wrong examples. To swap the storage situation, provide any other Layer for DatabaseTag — the tag is the seam.

Per-Plugin Isolation

Each plugin manages its own database or schema. Connection strings are passed via secrets (YOURPLUGIN_DATABASE_URL). The convention:

plaintext
YOURPLUGIN_DATABASE_URL=pglite:.bos/your-plugin/:memory:

In production, set this to a real PostgreSQL connection string in the host's environment.

Calling Other Plugins

Use createPlugin.withPlugins<PluginsClient>() to get typed access to other plugin factories. The plugins-client.gen.ts file provides the PluginsClient type. Regenerate with bos types gen.

Plugin UI

Add a UI remote via the ui field in the authored config. Sidebar items are defined manually in ui/src/components/layout/nav-items.ts.

Deploy

shell
bos plugin publish          # Build and publish just this plugin
bos publish --deploy        # Build ALL packages + deploy + publish config

Builds the plugin, pins the deterministic image-native production URL into the published config (https://<domain>/bundles/<account>/<gateway>/<key>/), and publishes to FastKV registry. Restart the host after publishing.

CLI Lifecycle

  1. cp -r plugins/_template plugins/your-plugin then edit names
  2. Register in the authored config (or bos plugin add)
  3. bos types gen
  4. bos dev to develop
  5. bos plugin publish to deploy

The tasks you will actually be given

"Add a DB-backed route." Schema in src/db/schema.ts → drizzle-kit generate → inside initialize, build the repo with buildScoped(RepoTag, RepoLive.pipe(Layer.provide(DatabaseLive(config.secrets.YOURPLUGIN_DATABASE_URL)))) → handler is .effect(function* ...) and does yield* RepoTag.

"Register and deploy a new plugin." bos plugin add local:plugins/your-plugin (writes the authored config + regenerates types) → pnpm run typecheck → bos plugin publish your-plugin → restart the host.

"Expose a service to the host, not just oRPC." Set servicesTag on the plugin definition — handlers keep accessing it with yield* Tag as normal.

What comes back when it fails

worddo
pool dead / connection released right after startupa transient scope — you extracted the driver instead of building the service via buildScoped(...) inside initialize; rebuild it that way (see database)
ModuleFederationError / __webpack_modules__[e].call in the browserthe deployed mf-manifest.json is older than the host's federation runtime — bos mf check, then bos plugin publish <key>, restart the host
migration never runsmigrations execute inside DatabaseLive's scoped layer at plugin boot — confirm the plugin booted and the <YOU>_DATABASE_URL secret resolves
handler can't see context.userIdthe context zod schema is a filter — declare every field you use or the host's value is stripped
stale types in plugins-client.gen.tsbos types gen (or pnpm run typecheck) after editing any contract