Skip to content

Spec-driven plugin loading#476

Merged
RhysSullivan merged 8 commits intomainfrom
spec-driven-plugin-loading
May 3, 2026
Merged

Spec-driven plugin loading#476
RhysSullivan merged 8 commits intomainfrom
spec-driven-plugin-loading

Conversation

@RhysSullivan
Copy link
Copy Markdown
Owner

Summary

Plugins now contribute their HTTP routes, request handlers, and frontend pages through fields on definePlugin / defineClientPlugin. Adding a plugin is a single edit to executor.config.ts (server) — the host's frontend picks it up automatically through virtual:executor/plugins-client and a React context provider.

  • Single source of truth. apps/local/executor.config.ts and apps/cloud/executor.config.ts are the only places that import server plugin packages. Schema-gen CLI, runtime, test harness, and Vite plugin all read from there.
  • Late-binding handlers. The plugin spec carries an extensionService Service tag; local satisfies it at boot via composePluginHandlers, cloud satisfies it per request via providePluginExtensions. One spec shape supports both wiring patterns.
  • Frontend through context. New @executor-js/sdk/client exports <ExecutorPluginsProvider> + useSourcePlugins() / useSecretProviderPlugins() / useClientPlugins(). Routes/shells/pages stop importing per-app aggregators or */react source plugins.
  • Two strings per plugin. id (runtime ergonomics — executor.openapi.foo()) + packageName (build-time resolution — ${packageName}/client). No conventions, no transforms, no fallback magic. Vite plugin warns loudly when packageName is set but unresolvable.
  • Six existing plugins migrated (openapi, mcp, graphql, google-discovery, onepassword, workos-vault) plus a new canary @executor-js/plugin-example exercising 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 preexisting aBody/bBody errors in apps/cloud/src/api.request-scope.node.test.ts remain (predates this PR on main).
  • 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/greet returns { "message": "hello X", "count": N }.
  • All previously-working routes continue to respond (/api/scope, /api/scopes/*/openapi/*, /api/scopes/*/mcp/*, /api/scopes/*/graphql/*, /api/scopes/*/onepassword/*).
  • Cloud apps/cloud builds and dev server boots with the new spec-driven wiring.
  • Verify nothing in the workspace still imports the deleted @executor-js/react/plugins/source-plugin or secret-provider-plugin shims (types now live in @executor-js/sdk/client).

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.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 2, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud e46f221 May 03 2026, 12:52 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 2, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

Comment thread apps/cloud/src/api/protected-layers.ts Outdated
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";
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boo!

// 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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

booo

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";
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boooooo

Comment thread apps/cloud/executor.config.ts Outdated
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;
}
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

booooooo

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] }),
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont delete

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 2, 2026

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@476

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@476

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@476

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@476

@executor-js/storage-core

npm i https://pkg.pr.new/@executor-js/storage-core@476

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@476

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@476

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@476

@executor-js/plugin-google-discovery

npm i https://pkg.pr.new/@executor-js/plugin-google-discovery@476

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@476

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@476

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@476

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@476

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@476

executor

npm i https://pkg.pr.new/executor@476

commit: e46f221

api: GraphqlApi,
httpClient: FetchHttpClient.layer,
export const GraphqlClient = createPluginAtomClient(GraphqlGroup, {
pluginId: "graphql",
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplication of plugin id?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread packages/core/sdk/src/plugin.ts Outdated

// 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>;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

Comment thread packages/core/sdk/src/client.ts Outdated
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,
},
);
};
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do a quick test to using a react testing framework to make sure this isnt gonna fuck up caching / invalidation

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({}));
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugly!

- 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.
@RhysSullivan RhysSullivan merged commit 48b1c7d into main May 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant