every-plugin
Version:
217 lines (173 loc) • 6.82 kB
Markdown
name: plugin-development
description: Build every-plugin modules with oRPC contracts, Effect services, and Module Federation. Use when creating or modifying plugins under plugins/ or the _template scaffold.
metadata:
sources: "src/plugin.ts,src/orpc.ts,src/errors.ts,src/zod.ts,src/types.ts"
# every-plugin Development
## Plugin Structure
Every plugin has three core files:
```
plugins/your-plugin/
├── src/
│ ├── contract.ts # oRPC route definitions + Zod schemas
│ ├── service.ts # Business logic (plain class, Effect error handling)
│ ├── index.ts # createPlugin() wiring
│ └── __tests__/ # Integration & unit tests
├── package.json
├── rspack.config.js # Build config (every-plugin provides defaults)
├── plugin.dev.ts # Dev server config (port, variables, secrets)
└── tsconfig.json
```
## Step 1: Define the Contract
Import from `every-plugin` subpath exports — not from `@orpc/*` or `zod` directly:
```typescript
import { eventIterator, oc } from "every-plugin/orpc";
import { z } from "every-plugin/zod";
const Errors = {
UNAUTHORIZED: { status: 401, message: "Auth required" },
NOT_FOUND: { status: 404, message: "Not found" },
};
export const contract = oc.router({
getById: oc
.route({ method: "GET", path: "/items/{id}" })
.input(z.object({ id: z.string() }))
.output(z.object({ item: ItemSchema }))
.errors(Errors),
search: oc
.route({ method: "GET", path: "/search" })
.input(z.object({ query: z.string(), limit: z.number().default(10) }))
.output(eventIterator(SearchResultSchema)),
ping: oc
.route({ method: "GET", path: "/ping" })
.output(z.object({ status: z.literal("ok"), timestamp: z.string().datetime() })),
});
export type ContractType = typeof contract;
```
Key points:
- Always import `oc` from `every-plugin/orpc`, `z` from `every-plugin/zod`, `Effect` from `every-plugin/effect`
- Use `eventIterator(schema)` for streaming responses
- Define error objects with `status` + `message` and pass via `.errors()`
- Use `CommonPluginErrors` from `every-plugin/errors` for standard UNAUTHORIZED/FORBIDDEN/NOT_FOUND/BAD_REQUEST
## Step 2: Create the Service
Plain TypeScript class with Effect error handling:
```typescript
import { Effect } from "every-plugin/effect";
export class MyService {
constructor(private baseUrl: string, private apiKey: string) {}
getById(id: string) {
return Effect.tryPromise({
try: async () => {
const res = await fetch(`${this.baseUrl}/items/${id}`);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
return res.json();
},
catch: (error: unknown) => new Error(`Failed to fetch item: ${error}`),
});
}
ping() {
return Effect.succeed({ status: "ok" as const, timestamp: new Date().toISOString() });
}
}
```
## Step 3: Wire with createPlugin
```typescript
import { createPlugin } from "every-plugin";
import { Effect } from "every-plugin/effect";
import { ORPCError } from "every-plugin/orpc";
import { z } from "every-plugin/zod";
import { contract } from "./contract";
import { MyService } from "./service";
export default createPlugin({
variables: z.object({
baseUrl: z.url().default("https://api.example.com"),
}),
secrets: z.object({
apiKey: z.string().min(1),
}),
contract,
initialize: (config) =>
Effect.gen(function* () {
const service = new MyService(config.variables.baseUrl, config.secrets.apiKey);
yield* service.ping();
return { service };
}),
shutdown: () => Effect.void,
createRouter: (context, builder) => {
const { service } = context;
const requireAuth = builder.middleware(async ({ context, next }) => {
if (!context.userId) throw new ORPCError("UNAUTHORIZED", { message: "Auth required" });
return next({ context: { ...context, userId: context.userId } });
});
return {
getById: builder.getById.use(requireAuth).handler(async ({ input, context }) => {
return await Effect.runPromise(service.getById(input.id));
}),
ping: builder.ping.handler(async () => {
return await Effect.runPromise(service.ping());
}),
};
},
});
```
## Plugin Composition (withPlugins)
When an API plugin needs to call other plugins in-process:
```typescript
import type { PluginsClient } from "./lib/plugins-types.gen";
export default createPlugin.withPlugins<PluginsClient>()({
variables: z.object({ demoMessage: z.string().optional() }),
contract,
initialize: (config, plugins) =>
Effect.sync(() => ({ plugins, demoMessage: config.variables.demoMessage ?? "not configured" })),
createRouter: (services, builder) => ({
pluginDemo: builder.pluginDemo.handler(async () => {
const status = await services.plugins.registry().getRegistryStatus();
return { apiVariable: services.demoMessage, registryStatus: status };
}),
}),
});
```
- `pluginsClient` is a map of `createClient` factories, typed by the generated `PluginsClient`
- Call `services.plugins.{key}()` to execute plugin routers in-process — no HTTP roundtrip
- The host loads non-API plugins first (Phase 1), then loads the API with `pluginsClient` injected (Phase 2)
## Dev Server Config (plugin.dev.ts)
```typescript
import type { PluginConfigInput } from "every-plugin";
import Plugin from "./src/index";
export default {
pluginId: "my-plugin",
port: 3010,
config: {
variables: {
baseUrl: "https://api.example.com",
},
secrets: {
apiKey: "dev-only-key",
},
} satisfies PluginConfigInput<typeof Plugin>,
};
```
Port assignments: host=3000, api=3001, auth=3002, ui=3003, ui-ssr=3004, plugins=3010+.
## Build Config (rspack.config.js)
every-plugin provides rspack helpers as plugins:
```javascript
import {
EmitPluginManifest,
EveryPluginDevServer,
FixMfDataUriPlugin,
} from "every-plugin/build/rspack";
export default {
plugins: [
new EmitPluginManifest(),
new EveryPluginDevServer({ dts: false }),
new FixMfDataUriPlugin(),
],
};
```
`EveryPluginDevServer` configures the Module Federation dev server defaults. Add the manifest/fix plugins the same way the package templates do.
## Common Mistakes
- Importing `oc` from `@orpc/contract` instead of `every-plugin/orpc` — will cause version mismatches in Module Federation
- Importing `z` from `zod` instead of `every-plugin/zod` — may cause Vitest CJS/ESM interop issues; always use `every-plugin/zod`
- Forgetting `.errors(Errors)` on routes that can throw ORPCError — untyped errors
- Using `Effect.runPromise` inside `Effect.gen` — use `yield*` instead for proper error channel
- Putting business logic in `createRouter` — keep it in the service class, router is just glue