UNPKG

every-plugin

Version:
217 lines (173 loc) 6.82 kB
--- 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