Spec-driven plugin loading#476
Merged
RhysSullivan merged 8 commits intomainfrom May 3, 2026
Merged
Conversation
Single source of truth for the plugin list lives in `executor.config.ts`
(local + cloud). Each plugin's spec contributes its own HttpApiGroup,
late-binding handlers Layer, and a Service tag the host satisfies — at
boot for local, per request for cloud. New SDK helpers compose them:
composePluginApi(plugins)
composePluginHandlers(plugins, executor) // local: eager binding
composePluginHandlerLayer(plugins) // cloud: leave Tag unbound
providePluginExtensions(plugins)(executor) // cloud: per-request
Frontend mirrors the same shape: `defineClientPlugin` declares pages,
widgets, slot components, plus optional `sourcePlugin` /
`secretProviderPlugin` contributions. The new `@executor-js/vite-plugin`
reads `executor.config.ts` and emits a `virtual:executor/plugins-client`
module from each plugin's `./client` export. The host wraps once with
`<ExecutorPluginsProvider>` at the route root; pages call
`useSourcePlugins()` / `useSecretProviderPlugins()` /
`useClientPlugins()` instead of importing from any per-app aggregator.
The SDK ships re-exports of Effect, Schema, HttpApi*, plus reactive
primitives on the client side, so plugin authors can stay within
`@executor-js/sdk` and `@executor-js/sdk/client` without reaching for
`effect/*` directly.
Each plugin declares two strings: `id` for runtime ergonomics
(`executor.openapi.foo()`) and `packageName` for build-time resolution
(`${packageName}/client`). No transforms, no scope conventions, no
fallback magic. The Vite plugin warns loudly if `packageName` is set
but unresolvable so typos fail visibly.
Six existing plugins migrated (openapi, mcp, graphql, google-discovery,
onepassword, workos-vault). New plugin-example serves as a canary —
shared HttpApiGroup, server with extension/routes/handlers, client
contributing a navbar page through the catch-all
`/plugins/$pluginId/$` route.
Local + cloud servers no longer manually wire each plugin's
`*Group` / `*Handlers` / `*ExtensionService`; cloud retains a few
type-only `*ExtensionService` imports for the per-request middleware's
`provides` clause (framework requires static Tag identifiers there).
Routes/shells/pages have zero plugin imports. Adding a plugin is a
single edit to `executor.config.ts` plus a Vite restart.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | e46f221 | May 03 2026, 12:52 AM |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-marketing | e46f221 | Commit Preview URL Branch Preview URL |
May 03 2026, 12:52 AM |
RhysSullivan
commented
May 2, 2026
Comment on lines
+11
to
+23
| import { | ||
| CoreExecutorApi, | ||
| observabilityMiddleware, | ||
| } from "@executor-js/api"; | ||
| import { CoreHandlers } from "@executor-js/api/server"; | ||
| import { OpenApiGroup, OpenApiHandlers } from "@executor-js/plugin-openapi/api"; | ||
| import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; | ||
| import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api"; | ||
| CoreHandlers, | ||
| composePluginApi, | ||
| composePluginHandlerLayer, | ||
| } from "@executor-js/api/server"; | ||
| // Type-only Group imports — needed for `HttpApiClient.ForApi<typeof | ||
| // ProtectedCloudApi>` to type test clients precisely. Runtime | ||
| // composition is data-driven via `composePluginApi(cloudPlugins)`. | ||
| // These imports erase at build time and are NOT a fanout of plugin | ||
| // runtime wiring. | ||
| import type { OpenApiGroup } from "@executor-js/plugin-openapi/api"; | ||
| import type { McpGroup } from "@executor-js/plugin-mcp/api"; | ||
| import type { GraphqlGroup } from "@executor-js/plugin-graphql/api"; |
RhysSullivan
commented
May 2, 2026
| // 5. provides `AuthContext` + the execution-stack services to the handler. | ||
| // | ||
| // Replaces both the old outer `Effect.gen` in this file (which did its own | ||
| // WorkOS lookup) and the per-route `OrgAuth` HttpApiMiddleware (which did |
RhysSullivan
commented
May 2, 2026
Comment on lines
+31
to
+34
| // Type-only imports — see `protected.ts` for the same pattern. | ||
| import type { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; | ||
| import type { McpExtensionService } from "@executor-js/plugin-mcp/api"; | ||
| import type { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; |
RhysSullivan
commented
May 2, 2026
Comment on lines
+22
to
+39
| declare module "@executor-js/sdk" { | ||
| interface ConfigPluginDeps { | ||
| /** WorkOS vault credentials. Provided per-request from `env.WORKOS_*` | ||
| * in production; the test harness leaves this undefined and uses | ||
| * `workosVaultClient` to inject an in-memory fake instead. */ | ||
| readonly workosCredentials?: { | ||
| readonly apiKey: string; | ||
| readonly clientId: string; | ||
| }; | ||
| /** Pluggable WorkOS Vault HTTP client — set by the test harness to | ||
| * bypass the real WorkOS API. Production leaves this undefined and | ||
| * falls back to the credential-driven default. */ | ||
| readonly workosVaultClient?: import( | ||
| "@executor-js/plugin-workos-vault" | ||
| ).WorkOSVaultClient; | ||
| } | ||
| } | ||
|
|
RhysSullivan
commented
May 2, 2026
Comment on lines
-231
to
-236
| export const blob = sqliteTable("blob", { | ||
| namespace: text('namespace').notNull(), | ||
| key: text('key').notNull(), | ||
| value: text('value').notNull() | ||
| }, (table) => [ | ||
| primaryKey({ columns: [table.namespace, table.key] }), |
@executor-js/cli
@executor-js/config
@executor-js/execution
@executor-js/sdk
@executor-js/storage-core
@executor-js/codemode-core
@executor-js/runtime-quickjs
@executor-js/plugin-file-secrets
@executor-js/plugin-google-discovery
@executor-js/plugin-graphql
@executor-js/plugin-keychain
@executor-js/plugin-mcp
@executor-js/plugin-onepassword
@executor-js/plugin-openapi
executor
commit: |
RhysSullivan
commented
May 2, 2026
| api: GraphqlApi, | ||
| httpClient: FetchHttpClient.layer, | ||
| export const GraphqlClient = createPluginAtomClient(GraphqlGroup, { | ||
| pluginId: "graphql", |
Owner
Author
There was a problem hiding this comment.
duplication of plugin id?
Owner
Author
There was a problem hiding this comment.
its fine to duplicate it but you can do some ts magic to import the sdk type and ensure the constatns match up via providing it as a generic?
RhysSullivan
commented
May 2, 2026
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| export type AnyPlugin = Plugin<string, any, any, any>; | ||
| export type AnyPlugin = Plugin<string, any, any, any, any, any>; |
RhysSullivan
commented
May 2, 2026
Comment on lines
+206
to
+220
| export const createPluginAtomClient = <G extends HttpApiGroup.Any>( | ||
| group: G, | ||
| options: CreatePluginAtomClientOptions, | ||
| ) => { | ||
| const { pluginId, baseUrl = "/api" } = options; | ||
| const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); | ||
| return AtomHttpApi.Service<`Plugin_${string}Client`>()( | ||
| `Plugin_${pluginId}Client`, | ||
| { | ||
| api: bundle, | ||
| httpClient: FetchHttpClient.layer, | ||
| baseUrl, | ||
| }, | ||
| ); | ||
| }; |
Owner
Author
There was a problem hiding this comment.
do a quick test to using a react testing framework to make sure this isnt gonna fuck up caching / invalidation
RhysSullivan
commented
May 2, 2026
Comment on lines
+29
to
+32
| // The CLI never reaches plugin runtime — only `plugin.schema` is read. | ||
| // Pass an empty deps object; plugins that need host deps must tolerate | ||
| // missing values for schema-only inspection. | ||
| const schema = collectSchemas(config.plugins({})); |
- composePluginApi<TPlugins> now returns precisely typed
HttpApi<"executor", CoreGroups | PluginGroups<TPlugins>> via a new
TGroup generic on PluginSpec — cloud no longer imports OpenApiGroup,
McpGroup, GraphqlGroup as types just to recover the API shape.
- Cloud middleware provides clauses use PluginExtensionServices<typeof
cloudPlugins> instead of importing each plugin's *ExtensionService
type directly. Single tuple drives both runtime and types.
- createPluginAtomClient reads plugin id from group.identifier — no
more pluginId duplication at every call site.
- defineExecutorConfig infers TDeps from the factory parameter, so
apps annotate their deps inline (interface CloudPluginDeps {...} on
the factory) instead of declare-module-augmenting a global
ConfigPluginDeps interface.
- cloud-plugins.ts holds the single executorConfig.plugins({}) call
shared by protected-layers, protected, and the test harness.
- Restore the apps/local blob table (accidental delete).
- ExecutorPluginsFactory takes `deps?` so the schema-gen CLI and
Vite plugin call `config.plugins()` (not `config.plugins({})`).
Apps annotate their deps inline with `({…}: Deps = {})`.
- PluginSpec / Plugin defaults are now `any` for the wide slots so
`AnyPlugin = Plugin<string>` covers any concrete plugin without a
cascade of `any, any, any, …` at every callsite. PluginExtensions
drops the same cascade.
- Add `client.test.ts` covering the pluginId-from-group.identifier
change: Service Tag key ("Plugin_<id>Client"), distinct keys per
group, atom factories reachable.
- Add `passWithNoTests: true` vitest configs for the new vite-plugin and example plugin packages — they have no tests yet and were exiting non-zero on `vitest run`. - Add `bun-types` devDep to plugin-example so its tsconfig (`types: ["bun-types", "node"]`) resolves on CI. - Fix `aBody`/`bBody` `unknown` typecheck error in cloud/api.request-scope.node.test.ts (pre-existing on main). - Annotate the three `Layer.empty as unknown as …` widenings in plugin-routes.ts with `oxlint-disable-next-line executor/no-double-cast` + a one-line reason — TS can't witness that `Layer.empty` matches the computed merged-handler type through Layer's variance markers. - Suppress react/forbid-elements on the example plugin's raw `<input>` / `<button>` (the example demonstrates the SDK without depending on `@executor-js/react`'s component lib) and pass an explicit empty `reactivityKeys: []` on the demo mutation to satisfy the require-reactivity-keys rule.
Cloudflare's `vite build` runs through Node, which externalizes the workspace `@executor-js/vite-plugin` dep and then can't load its `./src/index.ts` source (Node ESM rejects `.ts`). Two coordinated fixes: - vite-plugin's exports are now condition-aware: `bun` keeps the TS source path (zero-build dev/test loop in Bun), `default` falls back to `./dist/index.js` for any non-Bun resolver (Node, the Cloudflare build). - cloud's `build` script now runs `turbo run build --filter @executor-js/vite-plugin && vite build` so the dist/ exists by the time vite resolves the import. Turbo caches the build, so repeated invocations are no-ops.
The CI's Build-preview-binary job runs `bunx --bun vite build` on `apps/local`. With vite-plugin's new condition-aware exports, Vite falls back to `default → ./dist/index.js`, which doesn't exist without a build. Mirror the cloud app's fix: prefix the build with `turbo run build --filter @executor-js/vite-plugin`. Turbo caches the artifact, so re-runs are no-ops.
The plugin package itself stays in the workspace as a reference implementation; just unwiring it from `apps/local/executor.config.ts` + dropping the workspace dep so it's no longer bundled into the local app's frontend.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Plugins now contribute their HTTP routes, request handlers, and frontend pages through fields on
definePlugin/defineClientPlugin. Adding a plugin is a single edit toexecutor.config.ts(server) — the host's frontend picks it up automatically throughvirtual:executor/plugins-clientand a React context provider.apps/local/executor.config.tsandapps/cloud/executor.config.tsare the only places that import server plugin packages. Schema-gen CLI, runtime, test harness, and Vite plugin all read from there.extensionServiceService tag; local satisfies it at boot viacomposePluginHandlers, cloud satisfies it per request viaprovidePluginExtensions. One spec shape supports both wiring patterns.@executor-js/sdk/clientexports<ExecutorPluginsProvider>+useSourcePlugins()/useSecretProviderPlugins()/useClientPlugins(). Routes/shells/pages stop importing per-app aggregators or*/reactsource plugins.id(runtime ergonomics —executor.openapi.foo()) +packageName(build-time resolution —${packageName}/client). No conventions, no transforms, no fallback magic. Vite plugin warns loudly whenpackageNameis set but unresolvable.@executor-js/plugin-exampleexercising every surface end-to-end.Design notes:
notes/dynamic-plugin-loading-v1.md(refreshed to match what shipped).Test plan
bun run typecheck— only the preexistingaBody/bBodyerrors inapps/cloud/src/api.request-scope.node.test.tsremain (predates this PR onmain).cd apps/local && bun run db:schema— schema generation still works through the new factory shape.cd apps/local && bun run dev— boots; example plugin appears in sidebar under "Example";POST /api/greetreturns{ "message": "hello X", "count": N }./api/scope,/api/scopes/*/openapi/*,/api/scopes/*/mcp/*,/api/scopes/*/graphql/*,/api/scopes/*/onepassword/*).apps/cloudbuilds and dev server boots with the new spec-driven wiring.@executor-js/react/plugins/source-pluginorsecret-provider-pluginshims (types now live in@executor-js/sdk/client).