every-plugin 2.9.1 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/index.d.cts +2 -2
  2. package/dist/index.d.mts +2 -2
  3. package/dist/plugin.cjs +2 -2
  4. package/dist/plugin.cjs.map +1 -1
  5. package/dist/plugin.d.cts +22 -10
  6. package/dist/plugin.d.cts.map +1 -1
  7. package/dist/plugin.d.mts +22 -10
  8. package/dist/plugin.d.mts.map +1 -1
  9. package/dist/plugin.mjs +2 -2
  10. package/dist/plugin.mjs.map +1 -1
  11. package/dist/runtime/errors.cjs +2 -1
  12. package/dist/runtime/errors.cjs.map +1 -1
  13. package/dist/runtime/errors.d.cts.map +1 -1
  14. package/dist/runtime/errors.d.mts.map +1 -1
  15. package/dist/runtime/errors.mjs +2 -1
  16. package/dist/runtime/errors.mjs.map +1 -1
  17. package/dist/runtime/index.cjs +12 -4
  18. package/dist/runtime/index.cjs.map +1 -1
  19. package/dist/runtime/index.d.cts +1 -1
  20. package/dist/runtime/index.d.cts.map +1 -1
  21. package/dist/runtime/index.d.mts +1 -1
  22. package/dist/runtime/index.d.mts.map +1 -1
  23. package/dist/runtime/index.mjs +12 -4
  24. package/dist/runtime/index.mjs.map +1 -1
  25. package/dist/runtime/mf-config.cjs +1 -1
  26. package/dist/runtime/mf-config.mjs +1 -1
  27. package/dist/runtime/services/plugin-lifecycle.service.cjs +1 -0
  28. package/dist/runtime/services/plugin-lifecycle.service.cjs.map +1 -1
  29. package/dist/runtime/services/plugin-lifecycle.service.mjs +1 -0
  30. package/dist/runtime/services/plugin-lifecycle.service.mjs.map +1 -1
  31. package/dist/runtime/services/plugin-loader.service.cjs +2 -1
  32. package/dist/runtime/services/plugin-loader.service.cjs.map +1 -1
  33. package/dist/runtime/services/plugin-loader.service.d.cts.map +1 -1
  34. package/dist/runtime/services/plugin-loader.service.d.mts.map +1 -1
  35. package/dist/runtime/services/plugin-loader.service.mjs +3 -2
  36. package/dist/runtime/services/plugin-loader.service.mjs.map +1 -1
  37. package/dist/runtime/services/plugin.service.cjs +1 -0
  38. package/dist/runtime/services/plugin.service.cjs.map +1 -1
  39. package/dist/runtime/services/plugin.service.d.cts +1 -0
  40. package/dist/runtime/services/plugin.service.d.cts.map +1 -1
  41. package/dist/runtime/services/plugin.service.d.mts +1 -0
  42. package/dist/runtime/services/plugin.service.d.mts.map +1 -1
  43. package/dist/runtime/services/plugin.service.mjs +1 -0
  44. package/dist/runtime/services/plugin.service.mjs.map +1 -1
  45. package/package.json +1 -1
  46. package/skills/plugin-development/SKILL.md +53 -7
  47. package/skills/plugin-testing/SKILL.md +55 -0
  48. package/src/plugin.ts +27 -10
  49. package/src/runtime/errors.ts +4 -1
  50. package/src/runtime/index.ts +7 -3
  51. package/src/runtime/services/plugin-lifecycle.service.ts +5 -0
  52. package/src/runtime/services/plugin-loader.service.ts +22 -2
  53. package/src/runtime/services/plugin.service.ts +1 -0
@@ -109,7 +109,7 @@ export default createPlugin({
109
109
  }),
110
110
  contract,
111
111
 
112
- initialize: (config) =>
112
+ initialize: (config, _plugins, tools) =>
113
113
  Effect.gen(function* () {
114
114
  const service = new MyService(config.variables.baseUrl, config.secrets.apiKey);
115
115
  yield* service.ping();
@@ -118,8 +118,8 @@ export default createPlugin({
118
118
 
119
119
  shutdown: () => Effect.void,
120
120
 
121
- createRouter: (context, builder) => {
122
- const { service } = context;
121
+ createRouter: (deps, builder) => {
122
+ const { service } = deps;
123
123
 
124
124
  const requireAuth = builder.middleware(async ({ context, next }) => {
125
125
  if (!context.userId) throw new ORPCError("UNAUTHORIZED", { message: "Auth required" });
@@ -148,12 +148,12 @@ import type { PluginsClient } from "./lib/plugins-types.gen";
148
148
  export default createPlugin.withPlugins<PluginsClient>()({
149
149
  variables: z.object({ demoMessage: z.string().optional() }),
150
150
  contract,
151
- initialize: (config, plugins) =>
151
+ initialize: (config, plugins, _tools) =>
152
152
  Effect.sync(() => ({ plugins, demoMessage: config.variables.demoMessage ?? "not configured" })),
153
- createRouter: (services, builder) => ({
153
+ createRouter: (deps, builder) => ({
154
154
  pluginDemo: builder.pluginDemo.handler(async () => {
155
- const status = await services.plugins.registry().getRegistryStatus();
156
- return { apiVariable: services.demoMessage, registryStatus: status };
155
+ const status = await deps.plugins.registry().getRegistryStatus();
156
+ return { apiVariable: deps.demoMessage, registryStatus: status };
157
157
  }),
158
158
  }),
159
159
  });
@@ -163,6 +163,51 @@ export default createPlugin.withPlugins<PluginsClient>()({
163
163
  - Call `services.plugins.{key}()` to execute plugin routers in-process — no HTTP roundtrip
164
164
  - The host loads non-API plugins first (Phase 1), then loads the API with `pluginsClient` injected (Phase 2)
165
165
 
166
+ ## Long-Lived Scoped Resources
167
+
168
+ For database pools, caches, publisher channels, or any resource that should live for the plugin's lifetime, use `tools.buildService(tag, layer)` inside `initialize`:
169
+
170
+ ```typescript
171
+ import { createPlugin } from "every-plugin";
172
+ import { Effect, Layer } from "every-plugin/effect";
173
+
174
+ export default createPlugin({
175
+ // ...
176
+ initialize: (config, _plugins, tools) =>
177
+ Effect.gen(function* () {
178
+ const repo = yield* tools.buildService(
179
+ MyRepoTag,
180
+ MyRepoLive.pipe(Layer.provide(DatabaseLive(config.secrets.MY_DATABASE_URL))),
181
+ );
182
+
183
+ const publisher = new MemoryPublisher({ resumeRetentionSeconds: 120 });
184
+
185
+ return { repo, publisher };
186
+ }),
187
+ // ...
188
+ });
189
+ ```
190
+
191
+ `tools.buildService(tag, layer)` binds the layer's resources to the plugin's lifecycle scope.
192
+ Resources persist until the plugin shuts down and are automatically cleaned up during `runtime.shutdown()`.
193
+
194
+ **Bad — creates a transient scope that closes immediately:**
195
+ ```typescript
196
+ const svc = yield* Effect.provide(MyTag, MyLive.pipe(Layer.provide(DatabaseLive(url))))
197
+ ```
198
+
199
+ **Good — resources persist for the plugin's lifetime:**
200
+ ```typescript
201
+ const svc = yield* tools.buildService(MyTag, MyLive.pipe(Layer.provide(DatabaseLive(url))))
202
+ ```
203
+
204
+ Key rules:
205
+ - Use `tools.buildService(...)` for any `Layer.scoped(...)` resource that should survive initialization
206
+ - Plain class construction (new Service(...)) is still fine directly in `initialize`
207
+ - `createRouter(deps)` receives whatever `initialize` returns — same mental model as before
208
+ - Do not use `Effect.provide(Tag, Layer.scoped(...))` for persistent dependencies inside `initialize`
209
+ - `tools` is provided by the framework and does not need to be imported
210
+
166
211
  ## Dev Server Config (plugin.dev.ts)
167
212
 
168
213
  ```typescript
@@ -214,3 +259,4 @@ export default {
214
259
  - Forgetting `.errors(Errors)` on routes that can throw ORPCError — untyped errors
215
260
  - Using `Effect.runPromise` inside `Effect.gen` — use `yield*` instead for proper error channel
216
261
  - Putting business logic in `createRouter` — keep it in the service class, router is just glue
262
+ - Using `Effect.provide(Tag, Layer.scoped(...))` inside `initialize` for long-lived resources — creates a transient scope that releases the resource immediately after initialization. Use `tools.buildService(Tag, Layer.scoped(...))` instead
@@ -124,6 +124,61 @@ describe("API with mock registry", () => {
124
124
  });
125
125
  ```
126
126
 
127
+ ## Testing Scope Lifecycle
128
+
129
+ When a plugin uses `tools.buildService(...)` inside `initialize`, verify that scoped resources persist after initialization and are released during shutdown:
130
+
131
+ ```typescript
132
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
133
+ import { createPlugin, createPluginRuntime } from "every-plugin";
134
+ import { Context, Effect, Layer } from "every-plugin/effect";
135
+
136
+ let released = false;
137
+
138
+ class TestTag extends Context.Tag("TestTag")<TestTag, { value: string }>() {}
139
+
140
+ describe("scope lifecycle", () => {
141
+ let runtime: ReturnType<typeof createPluginRuntime>;
142
+
143
+ beforeAll(async () => {
144
+ const plugin = createPlugin({
145
+ // ... variables, secrets, contract ...
146
+ initialize: (_config, _plugins, tools) =>
147
+ Effect.gen(function* () {
148
+ const svc = yield* tools.buildService(TestTag, TestLive);
149
+ return { svc };
150
+ }),
151
+ createRouter: (deps) => ({
152
+ // ... routes ...
153
+ }),
154
+ });
155
+
156
+ runtime = createPluginRuntime({
157
+ registry: { "lifecycle-test": { module: plugin } },
158
+ });
159
+
160
+ await runtime.usePlugin("lifecycle-test", {
161
+ variables: {}, secrets: {},
162
+ });
163
+ });
164
+
165
+ it("resource persists after initialization", () => {
166
+ expect(released).toBe(false);
167
+ });
168
+
169
+ afterAll(async () => {
170
+ await runtime.shutdown();
171
+ expect(released).toBe(true);
172
+ });
173
+ });
174
+ ```
175
+
176
+ Key assertions:
177
+ - Resource should NOT be released after `usePlugin()` completes
178
+ - Resource SHOULD be released after `runtime.shutdown()`
179
+ - Multiple independent plugins have independent scope lifetimes
180
+ - `runtime.shutdown()` calls cleanup for all registered plugins
181
+
127
182
  ## Testing Streaming (eventIterator)
128
183
 
129
184
  Use `for await` to collect streaming results:
package/src/plugin.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { AnyContractRouter, AnySchema, InferSchemaOutput } from "@orpc/contract";
2
2
  import { ORPCError } from "@orpc/contract";
3
- import type { Context, Implementer, Router } from "@orpc/server";
3
+ import type { Implementer, Router } from "@orpc/server";
4
4
  import { implement, onError } from "@orpc/server";
5
- import { Effect, type Scope } from "effect";
5
+ import { Effect, type Context as EffectContext, type Layer, type Scope } from "effect";
6
6
  import { extractFromFiberFailure, formatORPCError } from "./runtime/errors";
7
7
 
8
8
  type ContextOutput<T> = T extends AnySchema ? InferSchemaOutput<T> : Record<string, never>;
@@ -22,21 +22,36 @@ type PluginInitializeInput<V extends AnySchema, S extends AnySchema> = {
22
22
  secrets: InferSchemaOutput<S>;
23
23
  };
24
24
 
25
+ /**
26
+ * Tools provided to plugin initialize/services for building long-lived scoped resources.
27
+ */
28
+ type ServiceOf<T> = T extends EffectContext.Tag<any, infer S> ? S : never;
29
+
30
+ export type PluginServicesTools = {
31
+ buildService: <T>(tag: T, layer: Layer.Layer<any, any, any>) => Effect.Effect<ServiceOf<T>>;
32
+ };
33
+
25
34
  type PluginDefinition<
26
35
  V extends AnySchema,
27
36
  S extends AnySchema,
28
37
  TContract extends AnyContractRouter,
29
38
  TRequestContext extends AnySchema | undefined,
30
- TDeps extends Context,
39
+ TDeps extends Record<string, any>,
31
40
  P extends Record<string, unknown>,
32
41
  > = {
33
42
  variables: V;
34
43
  secrets: S;
35
44
  contract: TContract;
36
45
  context?: TRequestContext;
46
+ /**
47
+ * Initialize the plugin and build dependencies.
48
+ * A third argument `tools` is provided for building long-lived scoped resources
49
+ * (e.g. database pools) via `tools.buildService(tag, layer)`.
50
+ */
37
51
  initialize?: (
38
52
  config: PluginInitializeInput<V, S>,
39
53
  plugins: P,
54
+ tools?: PluginServicesTools,
40
55
  ) => Effect.Effect<TDeps, Error, Scope.Scope>;
41
56
  createRouter: (
42
57
  deps: TDeps,
@@ -53,7 +68,7 @@ export interface LoadedPluginWithBinding<
53
68
  TVariables extends AnySchema,
54
69
  TSecrets extends AnySchema,
55
70
  TRequestContext extends AnySchema | undefined,
56
- TDeps extends Context = Record<never, never>,
71
+ TDeps extends Record<string, any> = Record<never, never>,
57
72
  > {
58
73
  new (): Plugin<TContract, TVariables, TSecrets, TRequestContext, TDeps>;
59
74
  binding: {
@@ -72,7 +87,7 @@ export interface Plugin<
72
87
  TVariables extends AnySchema,
73
88
  TSecrets extends AnySchema,
74
89
  TRequestContext extends AnySchema | undefined,
75
- TDeps extends Context = Record<never, never>,
90
+ TDeps extends Record<string, any> = Record<never, never>,
76
91
  > {
77
92
  readonly id: string;
78
93
  readonly contract: TContract;
@@ -81,6 +96,7 @@ export interface Plugin<
81
96
  initialize(
82
97
  config: PluginInitializeInput<TVariables, TSecrets>,
83
98
  plugins: Record<string, unknown>,
99
+ tools?: PluginServicesTools,
84
100
  ): Effect.Effect<TDeps, unknown, Scope.Scope>;
85
101
 
86
102
  shutdown(): Effect.Effect<void, never>;
@@ -100,7 +116,7 @@ export interface CreatePluginFn {
100
116
  S extends AnySchema,
101
117
  TContract extends AnyContractRouter,
102
118
  TRequestContext extends AnySchema | undefined = undefined,
103
- TDeps extends Context = Record<never, never>,
119
+ TDeps extends Record<string, any> = Record<never, never>,
104
120
  P extends Record<string, unknown> = Record<string, never>,
105
121
  >(
106
122
  config: PluginDefinition<V, S, TContract, TRequestContext, TDeps, P>,
@@ -114,7 +130,7 @@ export const createPlugin: CreatePluginFn = function createPlugin<
114
130
  S extends AnySchema,
115
131
  TContract extends AnyContractRouter,
116
132
  TRequestContext extends AnySchema | undefined = undefined,
117
- TDeps extends Context = Record<never, never>,
133
+ TDeps extends Record<string, any> = Record<never, never>,
118
134
  P extends Record<string, unknown> = Record<string, never>,
119
135
  >(config: PluginDefinition<V, S, TContract, TRequestContext, TDeps, P>) {
120
136
  const configSchema: PluginConfigFor<V, S, TRequestContext> = {
@@ -134,10 +150,11 @@ export const createPlugin: CreatePluginFn = function createPlugin<
134
150
  initialize(
135
151
  pluginConfig: PluginInitializeInput<V, S>,
136
152
  plugins: Record<string, unknown> = {},
153
+ tools?: PluginServicesTools,
137
154
  ): Effect.Effect<TDeps, unknown, Scope.Scope> {
138
155
  const init = config.initialize ?? (() => Effect.succeed({} as TDeps));
139
156
 
140
- return init(pluginConfig, plugins as P).pipe(
157
+ return init(pluginConfig, plugins as P, tools).pipe(
141
158
  Effect.tap((deps) =>
142
159
  Effect.sync(() => {
143
160
  this._deps = deps;
@@ -209,7 +226,7 @@ export type CreatePluginWithPlugins<P extends Record<string, unknown>> = <
209
226
  S extends AnySchema,
210
227
  TContract extends AnyContractRouter,
211
228
  TRequestContext extends AnySchema | undefined = undefined,
212
- TDeps extends Context = Record<never, never>,
229
+ TDeps extends Record<string, any> = Record<never, never>,
213
230
  >(
214
231
  config: PluginDefinition<V, S, TContract, TRequestContext, TDeps, P>,
215
232
  ) => LoadedPluginWithBinding<TContract, V, S, TRequestContext, TDeps>;
@@ -220,7 +237,7 @@ export function withPlugins<P extends Record<string, unknown>>(): CreatePluginWi
220
237
  S extends AnySchema,
221
238
  TContract extends AnyContractRouter,
222
239
  TRequestContext extends AnySchema | undefined = undefined,
223
- TDeps extends Context = Record<never, never>,
240
+ TDeps extends Record<string, any> = Record<never, never>,
224
241
  >(
225
242
  config: PluginDefinition<V, S, TContract, TRequestContext, TDeps, P>,
226
243
  ) => createPlugin<V, S, TContract, TRequestContext, TDeps, P>(config as any);
@@ -132,7 +132,10 @@ export const formatORPCError = (error: any): string | null => {
132
132
  const message = error.message || "An error occurred";
133
133
 
134
134
  lines.push(`\n╭─ oRPC Error ${"─".repeat(40)}`);
135
- lines.push(`│ ${message}`);
135
+ const messageLines = message.split("\n");
136
+ for (const line of messageLines) {
137
+ lines.push(`│ ${line}`);
138
+ }
136
139
  lines.push(`│ Code: ${code} (${status})`);
137
140
  lines.push(`│`);
138
141
 
@@ -78,10 +78,11 @@ export class PluginRuntime<R = RegisteredPlugins> {
78
78
  const pluginService = yield* PluginService;
79
79
  const validatedId = yield* this.validatePluginId(pluginId);
80
80
 
81
- // Load → Instantiate → Initialize
81
+ // Load → Instantiate → Initialize → Register
82
82
  const ctor = yield* pluginService.loadPlugin(validatedId);
83
83
  const instance = yield* pluginService.instantiatePlugin(pluginId, ctor);
84
84
  const initialized = yield* pluginService.initializePlugin(instance, config, plugins);
85
+ yield* pluginService.registerPlugin(initialized);
85
86
 
86
87
  return initialized;
87
88
  }).pipe(Effect.annotateLogs({ plugin: pluginId }), Effect.provide(this.runtime));
@@ -134,7 +135,9 @@ export class PluginRuntime<R = RegisteredPlugins> {
134
135
  ): Promise<InitializedPlugin<T>> {
135
136
  const effect = Effect.gen(function* () {
136
137
  const pluginService = yield* PluginService;
137
- return yield* pluginService.initializePlugin(instance, config, plugins);
138
+ const initialized = yield* pluginService.initializePlugin(instance, config, plugins);
139
+ yield* pluginService.registerPlugin(initialized);
140
+ return initialized;
138
141
  }).pipe(Effect.annotateLogs({ plugin: instance.plugin.id }));
139
142
  return this.runPromise(effect);
140
143
  }
@@ -151,8 +154,9 @@ export class PluginRuntime<R = RegisteredPlugins> {
151
154
  async evictPlugin<K extends keyof R & string>(
152
155
  pluginId: K,
153
156
  config: PluginConfigInput<R[K]>,
157
+ plugins?: Record<string, unknown>,
154
158
  ): Promise<void> {
155
- const cacheKey = this.generateCacheKey(pluginId, config);
159
+ const cacheKey = this.generateCacheKey(pluginId, { ...config, __plugins: plugins ?? {} });
156
160
 
157
161
  const effect = Effect.gen(this, function* () {
158
162
  const pluginService = yield* PluginService;
@@ -49,6 +49,11 @@ export class PluginLifecycleService extends Effect.Service<PluginLifecycleServic
49
49
  cleanup: () =>
50
50
  Effect.gen(function* () {
51
51
  const plugins = yield* Ref.get(activePlugins);
52
+ if (typeof process !== "undefined" && process.env?.EVERY_PLUGIN_DEBUG_SCOPES === "1") {
53
+ yield* Effect.logWarning(
54
+ `[PluginLifecycle] cleaning up ${plugins.size} active plugin(s)`,
55
+ );
56
+ }
52
57
 
53
58
  yield* Effect.forEach(
54
59
  plugins,
@@ -1,6 +1,7 @@
1
1
  import type { InferSchemaInput, InferSchemaOutput } from "@orpc/contract";
2
- import { Context, Effect, Scope } from "effect";
2
+ import { Context, Effect, Context as EffectContext, Layer, Scope } from "effect";
3
3
  import type { z } from "zod";
4
+ import type { PluginServicesTools } from "../../plugin";
4
5
  import type {
5
6
  AnyPlugin,
6
7
  AnyPluginConstructor,
@@ -237,9 +238,28 @@ export class PluginLoaderService extends Effect.Service<PluginLoaderService>()(
237
238
  // Create a long-lived scope for this plugin instance
238
239
  const scope = yield* Scope.make();
239
240
 
241
+ // Create a per-plugin MemoMap so tool-built layers are memoized
242
+ const memoMap = yield* Layer.makeMemoMap;
243
+
244
+ const tools: PluginServicesTools = {
245
+ buildService: (tag: any, layer: any) =>
246
+ (Layer.buildWithMemoMap(layer, memoMap, scope) as any).pipe(
247
+ Effect.map((ctx: any) => EffectContext.get(ctx, tag)) as any,
248
+ ),
249
+ };
250
+
240
251
  // Initialize plugin within the scope
252
+ // The plugin's initialize method handles:
253
+ // 1. Building services (DB pools, caches) via tools.buildService
254
+ // 2. Shaping final deps via initialize
255
+ // Any Layer.scoped resources built through tools.buildService are
256
+ // bound to the plugin scope and persist until the plugin is shutdown.
241
257
  const context = yield* plugin
242
- .initialize({ variables: _variables, secrets: hydratedConfig.secrets }, plugins ?? {})
258
+ .initialize(
259
+ { variables: _variables, secrets: hydratedConfig.secrets },
260
+ plugins ?? {},
261
+ tools,
262
+ )
243
263
  .pipe(
244
264
  Effect.provideService(Scope.Scope, scope),
245
265
  Effect.tapError((_error) =>
@@ -25,6 +25,7 @@ export class PluginService extends Effect.Service<PluginService>()("PluginServic
25
25
  loadPlugin: loader.loadPlugin,
26
26
  instantiatePlugin: loader.instantiatePlugin,
27
27
  initializePlugin: loader.initializePlugin,
28
+ registerPlugin: (plugin: InitializedPlugin<AnyPlugin>) => lifecycle.register(plugin),
28
29
  shutdownPlugin: (plugin: InitializedPlugin<AnyPlugin>) =>
29
30
  Effect.gen(function* () {
30
31
  yield* plugin.plugin