From ac699eca8f94f50b5794e0ab6d0408b10163aae3 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 4 Aug 2025 09:12:39 -0700 Subject: [PATCH 01/11] Initial AI generated framework for plugins --- PLUGIN_FEATURE.md | 328 ++++++++++++++++++++++++++ packages/client/src/client.ts | 32 ++- packages/client/src/index.ts | 1 + packages/client/src/plugin.ts | 129 ++++++++++ packages/test/src/example-plugin.ts | 175 ++++++++++++++ packages/worker/src/index.ts | 1 + packages/worker/src/plugin.ts | 140 +++++++++++ packages/worker/src/worker-options.ts | 12 + 8 files changed, 816 insertions(+), 2 deletions(-) create mode 100644 PLUGIN_FEATURE.md create mode 100644 packages/client/src/plugin.ts create mode 100644 packages/test/src/example-plugin.ts create mode 100644 packages/worker/src/plugin.ts diff --git a/PLUGIN_FEATURE.md b/PLUGIN_FEATURE.md new file mode 100644 index 000000000..b796fcd36 --- /dev/null +++ b/PLUGIN_FEATURE.md @@ -0,0 +1,328 @@ +# Temporal TypeScript SDK Plugin Support + +This implements a Plugin system similar to the Python SDK's Plugin feature, allowing you to extend and customize the behavior of Temporal clients and workers through a chain of responsibility pattern. + +## Overview + +Plugins provide a way to intercept and modify: +- Client creation and configuration +- Service connections +- Worker configuration and execution +- Activities, workflows, and interceptors + +## Architecture + +The plugin system uses a **chain of responsibility pattern** where each plugin can: +1. Modify configuration +2. Pass control to the next plugin in the chain +3. Perform custom logic before/after delegation + +## Client Plugin Support + +### ClientOptions Extension + +```typescript +export interface ClientOptions extends BaseClientOptions { + // ... existing options ... + + /** + * List of plugins to register with the client. + */ + plugins?: Plugin[]; +} +``` + +### Plugin Base Class + +```typescript +export abstract class Plugin { + /** + * Gets the fully qualified name of this plugin. + */ + get name(): string; + + /** + * Initialize this plugin in the plugin chain. + */ + initClientPlugin(next: Plugin): Plugin; + + /** + * Hook called when creating a client to allow modification of configuration. + */ + configureClient(config: ClientConfig): ClientConfig; + + /** + * Hook called when connecting to the Temporal service. + */ + async connectServiceClient(config: ConnectionOptions): Promise; +} +``` + +## Worker Plugin Support + +### WorkerOptions Extension + +```typescript +export interface WorkerOptions { + // ... existing options ... + + /** + * List of plugins to register with the worker. + */ + plugins?: Plugin[]; +} +``` + +### Worker Plugin Methods + +```typescript +export abstract class Plugin extends ClientPlugin { + /** + * Initialize this plugin in the worker plugin chain. + */ + initWorkerPlugin(next: Plugin): Plugin; + + /** + * Hook called when creating a worker to allow modification of configuration. + */ + configureWorker(config: WorkerConfig): WorkerConfig; +} +``` + +## Usage Examples + +### Basic Client Plugin + +```typescript +import { Plugin, Client } from '@temporalio/client'; + +class CustomClientPlugin extends Plugin { + configureClient(config: ClientConfig): ClientConfig { + // Add custom metadata + console.log('Configuring client with custom settings'); + + // Modify configuration + const modifiedConfig = { + ...config, + // Add custom properties + }; + + // Chain to next plugin + return super.configureClient(modifiedConfig); + } +} + +// Use with client +const client = new Client({ + plugins: [new CustomClientPlugin()], + namespace: 'default', +}); +``` + +### Basic Worker Plugin + +```typescript +import { Plugin, Worker } from '@temporalio/worker'; + +class CustomWorkerPlugin extends Plugin { + configureWorker(config: WorkerConfig): WorkerConfig { + // Modify task queue name + const taskQueue = config.taskQueue ? `custom-${config.taskQueue}` : config.taskQueue; + + const modifiedConfig = { + ...config, + taskQueue, + identity: `${config.identity}-with-plugin`, + }; + + console.log(`Modified task queue to: ${taskQueue}`); + return super.configureWorker(modifiedConfig); + } +} + +// Use with worker +const worker = await Worker.create({ + plugins: [new CustomWorkerPlugin()], + taskQueue: 'my-task-queue', + workflowsPath: './workflows', +}); +``` + +### Activity Plugin Example + +```typescript +class ActivityPlugin extends Plugin { + private activities: Record; + + constructor(activities: Record) { + super(); + this.activities = activities; + } + + configureWorker(config: WorkerConfig): WorkerConfig { + // Merge custom activities with existing ones + const existingActivities = config.activities || {}; + const mergedActivities = { + ...existingActivities, + ...this.activities, + }; + + return super.configureWorker({ + ...config, + activities: mergedActivities, + }); + } +} + +// Custom activities +const customActivities = { + async logMessage(message: string): Promise { + console.log(`Custom activity: ${message}`); + }, + + async processData(data: any): Promise { + return { processed: true, data }; + }, +}; + +// Use the plugin +const worker = await Worker.create({ + plugins: [new ActivityPlugin(customActivities)], + taskQueue: 'my-task-queue', + workflowsPath: './workflows', +}); +``` + +### Multiple Plugin Chain + +```typescript +class LoggingPlugin extends Plugin { + configureClient(config: ClientConfig): ClientConfig { + console.log('LoggingPlugin: Client configuration'); + return super.configureClient(config); + } + + configureWorker(config: WorkerConfig): WorkerConfig { + console.log('LoggingPlugin: Worker configuration'); + return super.configureWorker(config); + } +} + +class MetricsPlugin extends Plugin { + configureClient(config: ClientConfig): ClientConfig { + console.log('MetricsPlugin: Adding metrics interceptors'); + // Add metrics interceptors + return super.configureClient(config); + } +} + +// Chain multiple plugins +const client = new Client({ + plugins: [ + new LoggingPlugin(), + new MetricsPlugin(), + new CustomClientPlugin(), + ], + namespace: 'default', +}); +``` + +## Implementation Details + +### Plugin Chain Building + +The `buildPluginChain()` function creates a chain of responsibility: + +```typescript +export function buildPluginChain(plugins: Plugin[]): Plugin { + if (plugins.length === 0) { + return new _RootPlugin(); + } + + // Start with the root plugin at the end + let chain: Plugin = new _RootPlugin(); + + // Build the chain in reverse order + for (let i = plugins.length - 1; i >= 0; i--) { + const plugin = plugins[i]; + plugin.initClientPlugin(chain); + chain = plugin; + } + + return chain; +} +``` + +### Client Integration + +The Client constructor processes plugins before initialization: + +```typescript +constructor(options?: ClientOptions) { + // Process plugins first to allow them to modify configuration + const processedOptions = Client.applyPlugins(options); + + super(processedOptions); + // ... rest of constructor +} + +private static applyPlugins(options?: ClientOptions): ClientOptions { + if (!options?.plugins?.length) { + return options ?? {}; + } + + const pluginChain = buildPluginChain(options.plugins); + const clientConfig: ClientConfig = { ...options }; + const processedConfig = pluginChain.configureClient(clientConfig); + + return { ...processedConfig }; +} +``` + +### Worker Integration + +Similarly, the Worker.create() method would process plugins: + +```typescript +public static async create(options: WorkerOptions): Promise { + // Apply plugins to modify configuration + const processedOptions = Worker.applyPlugins(options); + + // ... rest of worker creation with processed options +} +``` + +## Files Modified/Added + +### Client Package (`packages/client/`) +- **NEW**: `src/plugin.ts` - Base Plugin class and client plugin support +- **MODIFIED**: `src/client.ts` - Added plugins field to ClientOptions and plugin processing +- **MODIFIED**: `src/index.ts` - Export Plugin and related types + +### Worker Package (`packages/worker/`) +- **NEW**: `src/plugin.ts` - Worker plugin extension and chain building +- **MODIFIED**: `src/worker-options.ts` - Added plugins field to WorkerOptions +- **MODIFIED**: `src/index.ts` - Export worker Plugin and related types + +### Test Package (`packages/test/`) +- **NEW**: `src/example-plugin.ts` - Comprehensive examples and usage patterns + +## Benefits + +1. **Extensibility**: Easily extend client and worker functionality without modifying core SDK +2. **Composability**: Chain multiple plugins together for complex customizations +3. **Consistency**: Similar pattern to Python SDK for cross-language familiarity +4. **Separation of Concerns**: Keep custom logic separate from core application code +5. **Reusability**: Plugins can be shared across projects and teams + +## Common Use Cases + +- **Authentication**: Add custom auth headers or credentials +- **Observability**: Inject custom metrics, logging, or tracing +- **Data Transformation**: Custom data converters or payload codecs +- **Environment Configuration**: Different settings per environment +- **Activity/Workflow Registration**: Dynamically add activities or workflows +- **Connection Customization**: Modify connection parameters or retry policies +- **Namespace Management**: Automatic namespace prefixing or routing + +This plugin system provides a powerful, flexible way to customize Temporal SDK behavior while maintaining clean separation of concerns and enabling code reuse across projects. \ No newline at end of file diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 8de7daa68..bfbc28288 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -6,6 +6,7 @@ import { ScheduleClient } from './schedule-client'; import { QueryRejectCondition, WorkflowService } from './types'; import { WorkflowClient } from './workflow-client'; import { TaskQueueClient } from './task-queue-client'; +import { Plugin, buildPluginChain, type ClientConfig } from './plugin'; export interface ClientOptions extends BaseClientOptions { /** @@ -15,6 +16,15 @@ export interface ClientOptions extends BaseClientOptions { */ interceptors?: ClientInterceptors; + /** + * List of plugins to register with the client. + * + * Plugins allow you to extend and customize the behavior of Temporal clients through a chain of + * responsibility pattern. They can intercept and modify client creation, service connections, + * and other client operations. + */ + plugins?: Plugin[]; + workflow?: { /** * Should a query be rejected by closed and failed workflows @@ -32,6 +42,21 @@ export type LoadedClientOptions = LoadedWithDefaults; */ export class Client extends BaseClient { public readonly options: LoadedClientOptions; + + /** + * Apply plugins to client options + */ + private static applyPlugins(options?: ClientOptions): ClientOptions { + if (!options?.plugins?.length) { + return options ?? {}; + } + + const pluginChain = buildPluginChain(options.plugins); + const clientConfig: ClientConfig = { ...options }; + const processedConfig = pluginChain.configureClient(clientConfig); + + return { ...processedConfig }; + } /** * Workflow sub-client - use to start and interact with Workflows */ @@ -52,9 +77,12 @@ export class Client extends BaseClient { public readonly taskQueue: TaskQueueClient; constructor(options?: ClientOptions) { - super(options); + // Process plugins first to allow them to modify configuration + const processedOptions = Client.applyPlugins(options); + + super(processedOptions); - const { interceptors, workflow, ...commonOptions } = options ?? {}; + const { interceptors, workflow, plugins, ...commonOptions } = processedOptions ?? {}; this.workflow = new WorkflowClient({ ...commonOptions, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7911b1f44..77e070d69 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -36,6 +36,7 @@ export * from './grpc-retry'; export * from './interceptors'; export * from './types'; export * from './workflow-client'; +export { Plugin, buildPluginChain, type ClientConfig } from './plugin'; export * from './workflow-options'; export * from './schedule-types'; export * from './schedule-client'; diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts new file mode 100644 index 000000000..55d77d278 --- /dev/null +++ b/packages/client/src/plugin.ts @@ -0,0 +1,129 @@ +import type { ConnectionLike } from './types'; +import { Connection, type ConnectionOptions } from './connection'; + +export interface ClientConfig { + connection?: ConnectionLike; + [key: string]: any; +} + +/** + * Abstract base class for Temporal plugins. + * + * Plugins provide a way to extend and customize the behavior of Temporal clients and workers through a chain of + * responsibility pattern. They allow you to intercept and modify client creation, service connections, worker + * configuration, and worker execution. Common customizations may include but are not limited to: + * + * 1. DataConverter + * 2. Activities + * 3. Workflows + * 4. Interceptors + * + * A single plugin class can implement both client and worker plugin interfaces to share common logic between both + * contexts. When used with a client, it will automatically be propagated to any workers created with that client. + */ +export abstract class Plugin { + /** + * Reference to the next plugin in the chain + */ + protected nextClientPlugin?: Plugin; + + /** + * Gets the fully qualified name of this plugin. + * + * Returns: + * The fully qualified name of the plugin class (module.classname). + */ + get name(): string { + return (this.constructor as any).name || this.constructor.toString(); + } + + /** + * Initialize this plugin in the plugin chain. + * + * This method sets up the chain of responsibility pattern by storing a reference + * to the next plugin in the chain. It is called during client creation to build + * the plugin chain. Note, this may be called twice in the case of connect(). + * + * Args: + * next: The next plugin in the chain to delegate to. + * + * Returns: + * This plugin instance for method chaining. + */ + initClientPlugin(next: Plugin): Plugin { + this.nextClientPlugin = next; + return this; + } + + /** + * Hook called when creating a client to allow modification of configuration. + * + * This method is called during client creation and allows plugins to modify + * the client configuration before the client is fully initialized. Plugins + * can add interceptors, modify connection parameters, or change other settings. + * + * Args: + * config: The client configuration to potentially modify. + * + * Returns: + * The modified client configuration. + */ + configureClient(config: ClientConfig): ClientConfig { + return this.nextClientPlugin?.configureClient(config) ?? config; + } + + /** + * Hook called when connecting to the Temporal service. + * + * This method is called during service client connection and allows plugins + * to intercept or modify the connection process. Plugins can modify connection + * parameters, add authentication, or provide custom connection logic. + * + * Args: + * config: The service connection configuration. + * + * Returns: + * The connected service client. + */ + async connectServiceClient(config: ConnectionOptions): Promise { + return this.nextClientPlugin?.connectServiceClient(config) ?? Connection.connect(config); + } +} + +/** + * Root plugin that provides default implementations for all plugin methods. + * This is the final plugin in the chain and provides the actual implementation. + */ +class _RootPlugin extends Plugin { + configureClient(config: ClientConfig): ClientConfig { + return config; + } + + async connectServiceClient(config: ConnectionOptions): Promise { + return Connection.connect(config); + } +} + +/** + * Build a plugin chain from an array of plugins. + * + * @param plugins Array of plugins to chain together + * @returns The first plugin in the chain + */ +export function buildPluginChain(plugins: Plugin[]): Plugin { + if (plugins.length === 0) { + return new _RootPlugin(); + } + + // Start with the root plugin at the end + let chain: Plugin = new _RootPlugin(); + + // Build the chain in reverse order + for (let i = plugins.length - 1; i >= 0; i--) { + const plugin = plugins[i]; + plugin.initClientPlugin(chain); + chain = plugin; + } + + return chain; +} \ No newline at end of file diff --git a/packages/test/src/example-plugin.ts b/packages/test/src/example-plugin.ts new file mode 100644 index 000000000..8d2a70578 --- /dev/null +++ b/packages/test/src/example-plugin.ts @@ -0,0 +1,175 @@ +import { Plugin as ClientPlugin, ClientConfig } from '@temporalio/client'; +import { Plugin as WorkerPlugin, WorkerConfig } from '@temporalio/worker'; + +/** + * Example plugin that demonstrates how to extend both client and worker functionality. + * + * This plugin: + * 1. Adds custom metadata to client connections + * 2. Modifies worker task queue names + * 3. Adds logging functionality + * 4. Demonstrates the chain of responsibility pattern + */ +export class ExamplePlugin extends WorkerPlugin { + private readonly customMetadata: Record; + private readonly taskQueuePrefix: string; + + constructor(options: { metadata?: Record; taskQueuePrefix?: string } = {}) { + super(); + this.customMetadata = options.metadata ?? { 'x-custom-plugin': 'example-plugin' }; + this.taskQueuePrefix = options.taskQueuePrefix ?? 'plugin-'; + } + + /** + * Configure client with custom metadata and logging + */ + configureClient(config: ClientConfig): ClientConfig { + console.log('ExamplePlugin: Configuring client'); + + // Add custom metadata to connection if it exists + if (config.connection && typeof config.connection === 'object') { + // Note: In a real implementation, you would properly extend the connection + // This is just for demonstration + console.log('ExamplePlugin: Adding custom metadata to client connection'); + } + + // Add any custom client interceptors + const interceptors = config.interceptors || {}; + + const modifiedConfig = { + ...config, + interceptors, + // Add any other client-specific configuration + }; + + // Chain to next plugin + return super.configureClient(modifiedConfig); + } + + /** + * Configure worker with custom task queue and additional settings + */ + configureWorker(config: WorkerConfig): WorkerConfig { + console.log('ExamplePlugin: Configuring worker'); + + // Modify task queue name with prefix + const taskQueue = config.taskQueue ? `${this.taskQueuePrefix}${config.taskQueue}` : config.taskQueue; + + const modifiedConfig = { + ...config, + taskQueue, + // Add any custom worker configuration + identity: config.identity ? `${config.identity}-with-plugin` : config.identity, + }; + + console.log(`ExamplePlugin: Modified task queue to: ${taskQueue}`); + + // Chain to next plugin + return super.configureWorker(modifiedConfig); + } +} + +/** + * Another example plugin that demonstrates plugin chaining + */ +export class LoggingPlugin extends WorkerPlugin { + configureClient(config: ClientConfig): ClientConfig { + console.log('LoggingPlugin: Client configuration intercepted'); + console.log('LoggingPlugin: Client namespace:', config.namespace); + + return super.configureClient(config); + } + + configureWorker(config: WorkerConfig): WorkerConfig { + console.log('LoggingPlugin: Worker configuration intercepted'); + console.log('LoggingPlugin: Worker task queue:', config.taskQueue); + console.log('LoggingPlugin: Worker namespace:', config.namespace); + + return super.configureWorker(config); + } +} + +/** + * Example of how to use plugins with a client + */ +export async function exampleClientUsage() { + const { Client } = await import('@temporalio/client'); + + const client = new Client({ + plugins: [ + new ExamplePlugin({ + metadata: { 'x-environment': 'development' }, + taskQueuePrefix: 'dev-' + }), + new LoggingPlugin(), + ], + namespace: 'default', + }); + + console.log('Client created with plugins'); + return client; +} + +/** + * Example of how to use plugins with a worker + */ +export async function exampleWorkerUsage() { + const { Worker } = await import('@temporalio/worker'); + + const worker = await Worker.create({ + plugins: [ + new ExamplePlugin({ + taskQueuePrefix: 'production-' + }), + new LoggingPlugin(), + ], + taskQueue: 'my-task-queue', + namespace: 'default', + workflowsPath: require.resolve('./workflows'), + }); + + console.log('Worker created with plugins'); + return worker; +} + +/** + * Example plugin that could add custom activities + */ +export class ActivityPlugin extends WorkerPlugin { + private activities: Record; + + constructor(activities: Record) { + super(); + this.activities = activities; + } + + configureWorker(config: WorkerConfig): WorkerConfig { + console.log('ActivityPlugin: Adding custom activities'); + + // Merge custom activities with existing ones + const existingActivities = config.activities || {}; + const mergedActivities = { + ...existingActivities, + ...this.activities, + }; + + const modifiedConfig = { + ...config, + activities: mergedActivities, + }; + + return super.configureWorker(modifiedConfig); + } +} + +// Example activities to be added by the plugin +export const customActivities = { + async logMessage(message: string): Promise { + console.log(`Custom activity: ${message}`); + }, + + async processData(data: any): Promise { + console.log('Custom activity: Processing data', data); + return { processed: true, data }; + }, +}; \ No newline at end of file diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 3730aa233..9b0e3b335 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -35,6 +35,7 @@ export { } from './runtime-options'; export * from './sinks'; export { DataConverter, defaultPayloadConverter, State, Worker, WorkerStatus } from './worker'; +export { Plugin, buildWorkerPluginChain, type WorkerConfig } from './plugin'; export { CompiledWorkerOptions, ReplayWorkerOptions, diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts new file mode 100644 index 000000000..938bda451 --- /dev/null +++ b/packages/worker/src/plugin.ts @@ -0,0 +1,140 @@ +import type { NativeConnection } from './connection'; + +export interface WorkerConfig { + connection?: NativeConnection; + taskQueue?: string; + namespace?: string; + [key: string]: any; +} + +/** + * Base Plugin class for both client and worker functionality. + * + * Plugins provide a way to extend and customize the behavior of Temporal clients and workers through a chain of + * responsibility pattern. They allow you to intercept and modify client creation, service connections, worker + * configuration, and worker execution. + */ +export abstract class Plugin { + /** + * Reference to the next client plugin in the chain + */ + protected nextClientPlugin?: Plugin; + /** + * Reference to the next worker plugin in the chain + */ + protected nextWorkerPlugin?: Plugin; + + /** + * Gets the fully qualified name of this plugin. + * + * Returns: + * The fully qualified name of the plugin class (module.classname). + */ + get name(): string { + return (this.constructor as any).name || this.constructor.toString(); + } + + /** + * Initialize this plugin in the client plugin chain. + * + * This method sets up the chain of responsibility pattern by storing a reference + * to the next plugin in the chain. It is called during client creation to build + * the plugin chain. + * + * Args: + * next: The next plugin in the chain to delegate to. + * + * Returns: + * This plugin instance for method chaining. + */ + initClientPlugin(next: Plugin): Plugin { + this.nextClientPlugin = next; + return this; + } + + /** + * Initialize this plugin in the worker plugin chain. + * + * This method sets up the chain of responsibility pattern by storing a reference + * to the next plugin in the chain. It is called during worker creation to build + * the plugin chain. + * + * Args: + * next: The next plugin in the chain to delegate to. + * + * Returns: + * This plugin instance for method chaining. + */ + initWorkerPlugin(next: Plugin): Plugin { + this.nextWorkerPlugin = next; + return this; + } + + /** + * Hook called when creating a client to allow modification of configuration. + * + * This method is called during client creation and allows plugins to modify + * the client configuration before the client is fully initialized. Plugins + * can add interceptors, modify connection parameters, or change other settings. + * + * Args: + * config: The client configuration to potentially modify. + * + * Returns: + * The modified client configuration. + */ + configureClient(config: any): any { + return this.nextClientPlugin?.configureClient(config) ?? config; + } + + /** + * Hook called when creating a worker to allow modification of configuration. + * + * This method is called during worker creation and allows plugins to modify + * the worker configuration before the worker is fully initialized. Plugins + * can add activities, workflows, interceptors, or change other settings. + * + * Args: + * config: The worker configuration to potentially modify. + * + * Returns: + * The modified worker configuration. + */ + configureWorker(config: WorkerConfig): WorkerConfig { + return this.nextWorkerPlugin?.configureWorker(config) ?? config; + } +} + +/** + * Root worker plugin that provides default implementations for all plugin methods. + * This is the final plugin in the chain and provides the actual implementation. + */ +class _RootWorkerPlugin extends Plugin { + configureWorker(config: WorkerConfig): WorkerConfig { + return config; + } +} + +/** + * Build a worker plugin chain from an array of plugins. + * + * @param plugins Array of plugins to chain together + * @returns The first plugin in the chain + */ +export function buildWorkerPluginChain(plugins: Plugin[]): Plugin { + if (plugins.length === 0) { + return new _RootWorkerPlugin(); + } + + // Start with the root plugin at the end + let chain: Plugin = new _RootWorkerPlugin(); + + // Build the chain in reverse order + for (let i = plugins.length - 1; i >= 0; i--) { + const plugin = plugins[i]; + plugin.initWorkerPlugin(chain); + chain = plugin; + } + + return chain; +} \ No newline at end of file diff --git a/packages/worker/src/worker-options.ts b/packages/worker/src/worker-options.ts index 4614eac6b..ed2c1fc4e 100644 --- a/packages/worker/src/worker-options.ts +++ b/packages/worker/src/worker-options.ts @@ -26,6 +26,7 @@ import { InjectedSinks } from './sinks'; import { MiB } from './utils'; import { WorkflowBundleWithSourceMap } from './workflow/bundler'; import { asNativeTuner, WorkerTuner } from './worker-tuner'; +import { Plugin } from './plugin'; /** * Options to configure the {@link Worker} @@ -440,6 +441,17 @@ export interface WorkerOptions { */ interceptors?: WorkerInterceptors; + /** + * List of plugins to register with the worker. + * + * Plugins allow you to extend and customize the behavior of Temporal workers through a chain of + * responsibility pattern. They can intercept and modify worker creation, configuration, and execution. + * + * Worker plugins can be used to add custom activities, workflows, interceptors, or modify other + * worker settings before the worker is fully initialized. + */ + plugins?: Plugin[]; + /** * Registration of a {@link SinkFunction}, including per-sink-function options. * From 65804848cd329623436ab3673f38564c384ee767 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 4 Aug 2025 09:30:20 -0700 Subject: [PATCH 02/11] WIP Plugin shenanigans --- PLUGIN_FEATURE.md | 21 ++++++++----------- packages/client/src/client.ts | 8 ++++---- packages/client/src/index.ts | 2 +- packages/client/src/plugin.ts | 31 +++-------------------------- packages/test/src/example-plugin.ts | 20 +++++++++---------- packages/worker/src/index.ts | 2 +- packages/worker/src/plugin.ts | 12 +++-------- 7 files changed, 30 insertions(+), 66 deletions(-) diff --git a/PLUGIN_FEATURE.md b/PLUGIN_FEATURE.md index b796fcd36..1eade8716 100644 --- a/PLUGIN_FEATURE.md +++ b/PLUGIN_FEATURE.md @@ -49,12 +49,7 @@ export abstract class Plugin { /** * Hook called when creating a client to allow modification of configuration. */ - configureClient(config: ClientConfig): ClientConfig; - - /** - * Hook called when connecting to the Temporal service. - */ - async connectServiceClient(config: ConnectionOptions): Promise; + configureClient(config: ClientOptions): ClientOptions; } ``` @@ -85,7 +80,7 @@ export abstract class Plugin extends ClientPlugin { /** * Hook called when creating a worker to allow modification of configuration. */ - configureWorker(config: WorkerConfig): WorkerConfig; + configureWorker(config: WorkerOptions): WorkerOptions; } ``` @@ -97,7 +92,7 @@ export abstract class Plugin extends ClientPlugin { import { Plugin, Client } from '@temporalio/client'; class CustomClientPlugin extends Plugin { - configureClient(config: ClientConfig): ClientConfig { + configureClient(config: ClientOptions): ClientOptions { // Add custom metadata console.log('Configuring client with custom settings'); @@ -125,7 +120,7 @@ const client = new Client({ import { Plugin, Worker } from '@temporalio/worker'; class CustomWorkerPlugin extends Plugin { - configureWorker(config: WorkerConfig): WorkerConfig { + configureWorker(config: WorkerOptions): WorkerOptions { // Modify task queue name const taskQueue = config.taskQueue ? `custom-${config.taskQueue}` : config.taskQueue; @@ -197,19 +192,19 @@ const worker = await Worker.create({ ```typescript class LoggingPlugin extends Plugin { - configureClient(config: ClientConfig): ClientConfig { + configureClient(config: ClientOptions): ClientOptions { console.log('LoggingPlugin: Client configuration'); return super.configureClient(config); } - configureWorker(config: WorkerConfig): WorkerConfig { + configureWorker(config: WorkerOptions): WorkerOptions { console.log('LoggingPlugin: Worker configuration'); return super.configureWorker(config); } } class MetricsPlugin extends Plugin { - configureClient(config: ClientConfig): ClientConfig { + configureClient(config: ClientOptions): ClientOptions { console.log('MetricsPlugin: Adding metrics interceptors'); // Add metrics interceptors return super.configureClient(config); @@ -272,7 +267,7 @@ private static applyPlugins(options?: ClientOptions): ClientOptions { } const pluginChain = buildPluginChain(options.plugins); - const clientConfig: ClientConfig = { ...options }; + const clientConfig: ClientOptions = { ...options }; const processedConfig = pluginChain.configureClient(clientConfig); return { ...processedConfig }; diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index bfbc28288..caa1f6aa3 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -6,7 +6,7 @@ import { ScheduleClient } from './schedule-client'; import { QueryRejectCondition, WorkflowService } from './types'; import { WorkflowClient } from './workflow-client'; import { TaskQueueClient } from './task-queue-client'; -import { Plugin, buildPluginChain, type ClientConfig } from './plugin'; +import { Plugin, buildPluginChain } from './plugin'; export interface ClientOptions extends BaseClientOptions { /** @@ -20,8 +20,7 @@ export interface ClientOptions extends BaseClientOptions { * List of plugins to register with the client. * * Plugins allow you to extend and customize the behavior of Temporal clients through a chain of - * responsibility pattern. They can intercept and modify client creation, service connections, - * and other client operations. + * responsibility pattern. They can intercept and modify client creation. */ plugins?: Plugin[]; @@ -52,7 +51,7 @@ export class Client extends BaseClient { } const pluginChain = buildPluginChain(options.plugins); - const clientConfig: ClientConfig = { ...options }; + const clientConfig: ClientOptions = { ...options }; const processedConfig = pluginChain.configureClient(clientConfig); return { ...processedConfig }; @@ -123,6 +122,7 @@ export class Client extends BaseClient { workflow: { queryRejectCondition: this.workflow.options.queryRejectCondition, }, + plugins: plugins ?? [], }; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 77e070d69..59220095c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -36,7 +36,7 @@ export * from './grpc-retry'; export * from './interceptors'; export * from './types'; export * from './workflow-client'; -export { Plugin, buildPluginChain, type ClientConfig } from './plugin'; +export { Plugin, buildPluginChain } from './plugin'; export * from './workflow-options'; export * from './schedule-types'; export * from './schedule-client'; diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index 55d77d278..8a1eb5a80 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -1,10 +1,6 @@ import type { ConnectionLike } from './types'; import { Connection, type ConnectionOptions } from './connection'; - -export interface ClientConfig { - connection?: ConnectionLike; - [key: string]: any; -} +import type { ClientOptions } from './client'; /** * Abstract base class for Temporal plugins. @@ -68,26 +64,9 @@ export abstract class Plugin { * Returns: * The modified client configuration. */ - configureClient(config: ClientConfig): ClientConfig { + configureClient(config: ClientOptions): ClientOptions { return this.nextClientPlugin?.configureClient(config) ?? config; } - - /** - * Hook called when connecting to the Temporal service. - * - * This method is called during service client connection and allows plugins - * to intercept or modify the connection process. Plugins can modify connection - * parameters, add authentication, or provide custom connection logic. - * - * Args: - * config: The service connection configuration. - * - * Returns: - * The connected service client. - */ - async connectServiceClient(config: ConnectionOptions): Promise { - return this.nextClientPlugin?.connectServiceClient(config) ?? Connection.connect(config); - } } /** @@ -95,13 +74,9 @@ export abstract class Plugin { * This is the final plugin in the chain and provides the actual implementation. */ class _RootPlugin extends Plugin { - configureClient(config: ClientConfig): ClientConfig { + configureClient(config: ClientOptions): ClientOptions { return config; } - - async connectServiceClient(config: ConnectionOptions): Promise { - return Connection.connect(config); - } } /** diff --git a/packages/test/src/example-plugin.ts b/packages/test/src/example-plugin.ts index 8d2a70578..0bb187745 100644 --- a/packages/test/src/example-plugin.ts +++ b/packages/test/src/example-plugin.ts @@ -1,5 +1,5 @@ -import { Plugin as ClientPlugin, ClientConfig } from '@temporalio/client'; -import { Plugin as WorkerPlugin, WorkerConfig } from '@temporalio/worker'; +import { ClientOptions } from '@temporalio/client'; +import { Plugin, WorkerOptions } from '@temporalio/worker'; /** * Example plugin that demonstrates how to extend both client and worker functionality. @@ -10,7 +10,7 @@ import { Plugin as WorkerPlugin, WorkerConfig } from '@temporalio/worker'; * 3. Adds logging functionality * 4. Demonstrates the chain of responsibility pattern */ -export class ExamplePlugin extends WorkerPlugin { +export class ExamplePlugin extends Plugin { private readonly customMetadata: Record; private readonly taskQueuePrefix: string; @@ -23,7 +23,7 @@ export class ExamplePlugin extends WorkerPlugin { /** * Configure client with custom metadata and logging */ - configureClient(config: ClientConfig): ClientConfig { + configureClient(config: ClientOptions): ClientOptions { console.log('ExamplePlugin: Configuring client'); // Add custom metadata to connection if it exists @@ -49,7 +49,7 @@ export class ExamplePlugin extends WorkerPlugin { /** * Configure worker with custom task queue and additional settings */ - configureWorker(config: WorkerConfig): WorkerConfig { + configureWorker(config: WorkerOptions): WorkerOptions { console.log('ExamplePlugin: Configuring worker'); // Modify task queue name with prefix @@ -72,15 +72,15 @@ export class ExamplePlugin extends WorkerPlugin { /** * Another example plugin that demonstrates plugin chaining */ -export class LoggingPlugin extends WorkerPlugin { - configureClient(config: ClientConfig): ClientConfig { +export class LoggingPlugin extends Plugin { + configureClient(config: ClientOptions): ClientOptions { console.log('LoggingPlugin: Client configuration intercepted'); console.log('LoggingPlugin: Client namespace:', config.namespace); return super.configureClient(config); } - configureWorker(config: WorkerConfig): WorkerConfig { + configureWorker(config: WorkerOptions): WorkerOptions { console.log('LoggingPlugin: Worker configuration intercepted'); console.log('LoggingPlugin: Worker task queue:', config.taskQueue); console.log('LoggingPlugin: Worker namespace:', config.namespace); @@ -135,7 +135,7 @@ export async function exampleWorkerUsage() { /** * Example plugin that could add custom activities */ -export class ActivityPlugin extends WorkerPlugin { +export class ActivityPlugin extends Plugin { private activities: Record; constructor(activities: Record) { @@ -143,7 +143,7 @@ export class ActivityPlugin extends WorkerPlugin { this.activities = activities; } - configureWorker(config: WorkerConfig): WorkerConfig { + configureWorker(config: WorkerOptions): WorkerOptions { console.log('ActivityPlugin: Adding custom activities'); // Merge custom activities with existing ones diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 9b0e3b335..9273becb3 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -35,7 +35,7 @@ export { } from './runtime-options'; export * from './sinks'; export { DataConverter, defaultPayloadConverter, State, Worker, WorkerStatus } from './worker'; -export { Plugin, buildWorkerPluginChain, type WorkerConfig } from './plugin'; +export { Plugin, buildWorkerPluginChain } from './plugin'; export { CompiledWorkerOptions, ReplayWorkerOptions, diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index 938bda451..fe0ba5a0c 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,11 +1,5 @@ import type { NativeConnection } from './connection'; - -export interface WorkerConfig { - connection?: NativeConnection; - taskQueue?: string; - namespace?: string; - [key: string]: any; -} +import type { WorkerOptions } from './worker-options'; /** * Base Plugin class for both client and worker functionality. @@ -100,7 +94,7 @@ export abstract class Plugin { * Returns: * The modified worker configuration. */ - configureWorker(config: WorkerConfig): WorkerConfig { + configureWorker(config: WorkerOptions): WorkerOptions { return this.nextWorkerPlugin?.configureWorker(config) ?? config; } } @@ -110,7 +104,7 @@ export abstract class Plugin { * This is the final plugin in the chain and provides the actual implementation. */ class _RootWorkerPlugin extends Plugin { - configureWorker(config: WorkerConfig): WorkerConfig { + configureWorker(config: WorkerOptions): WorkerOptions { return config; } } From 0f8361c5ec1783f96a9e737cc62c1685c1b50d97 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 6 Oct 2025 12:38:40 -0700 Subject: [PATCH 03/11] Updating to bring more in line with current Python implementation. Will still change --- packages/client/src/client.ts | 2 +- packages/client/src/plugin.ts | 45 +++--- packages/test/src/example-plugin.ts | 175 ------------------------ packages/test/src/mock-native-worker.ts | 2 +- packages/test/src/test-plugins.ts | 138 +++++++++++++++++++ packages/test/src/workflows/plugins.ts | 3 + packages/worker/src/index.ts | 4 +- packages/worker/src/plugin.ts | 112 ++++----------- packages/worker/src/worker.ts | 56 +++++++- packages/worker/src/workflow/bundler.ts | 95 +++++++++++-- 10 files changed, 323 insertions(+), 309 deletions(-) delete mode 100644 packages/test/src/example-plugin.ts create mode 100644 packages/test/src/test-plugins.ts create mode 100644 packages/test/src/workflows/plugins.ts diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index caa1f6aa3..ac8c97820 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -76,7 +76,7 @@ export class Client extends BaseClient { public readonly taskQueue: TaskQueueClient; constructor(options?: ClientOptions) { - // Process plugins first to allow them to modify configuration + // Process plugins first to allow them to modify connect configuration const processedOptions = Client.applyPlugins(options); super(processedOptions); diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index 8a1eb5a80..7ed646be0 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -1,5 +1,3 @@ -import type { ConnectionLike } from './types'; -import { Connection, type ConnectionOptions } from './connection'; import type { ClientOptions } from './client'; /** @@ -17,21 +15,11 @@ import type { ClientOptions } from './client'; * A single plugin class can implement both client and worker plugin interfaces to share common logic between both * contexts. When used with a client, it will automatically be propagated to any workers created with that client. */ -export abstract class Plugin { +export interface Plugin { /** - * Reference to the next plugin in the chain + * Gets the name of this plugin. */ - protected nextClientPlugin?: Plugin; - - /** - * Gets the fully qualified name of this plugin. - * - * Returns: - * The fully qualified name of the plugin class (module.classname). - */ - get name(): string { - return (this.constructor as any).name || this.constructor.toString(); - } + get name(): string; /** * Initialize this plugin in the plugin chain. @@ -42,14 +30,11 @@ export abstract class Plugin { * * Args: * next: The next plugin in the chain to delegate to. - * + * * Returns: * This plugin instance for method chaining. */ - initClientPlugin(next: Plugin): Plugin { - this.nextClientPlugin = next; - return this; - } + initClientPlugin(next: Plugin): Plugin; /** * Hook called when creating a client to allow modification of configuration. @@ -64,16 +49,20 @@ export abstract class Plugin { * Returns: * The modified client configuration. */ - configureClient(config: ClientOptions): ClientOptions { - return this.nextClientPlugin?.configureClient(config) ?? config; - } + configureClient(config: ClientOptions): ClientOptions; } /** * Root plugin that provides default implementations for all plugin methods. * This is the final plugin in the chain and provides the actual implementation. */ -class _RootPlugin extends Plugin { +class RootPlugin implements Plugin { + name: string = 'RootPlugin'; + + initClientPlugin(_next: Plugin): Plugin { + throw new Error("Root plugin should not be initialized") + } + configureClient(config: ClientOptions): ClientOptions { return config; } @@ -85,13 +74,13 @@ class _RootPlugin extends Plugin { * @param plugins Array of plugins to chain together * @returns The first plugin in the chain */ -export function buildPluginChain(plugins: Plugin[]): Plugin { - if (plugins.length === 0) { - return new _RootPlugin(); +export function buildPluginChain(plugins: Plugin[] | undefined): Plugin { + if (plugins === undefined || plugins.length === 0) { + return new RootPlugin(); } // Start with the root plugin at the end - let chain: Plugin = new _RootPlugin(); + let chain: Plugin = new RootPlugin(); // Build the chain in reverse order for (let i = plugins.length - 1; i >= 0; i--) { diff --git a/packages/test/src/example-plugin.ts b/packages/test/src/example-plugin.ts deleted file mode 100644 index 0bb187745..000000000 --- a/packages/test/src/example-plugin.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { ClientOptions } from '@temporalio/client'; -import { Plugin, WorkerOptions } from '@temporalio/worker'; - -/** - * Example plugin that demonstrates how to extend both client and worker functionality. - * - * This plugin: - * 1. Adds custom metadata to client connections - * 2. Modifies worker task queue names - * 3. Adds logging functionality - * 4. Demonstrates the chain of responsibility pattern - */ -export class ExamplePlugin extends Plugin { - private readonly customMetadata: Record; - private readonly taskQueuePrefix: string; - - constructor(options: { metadata?: Record; taskQueuePrefix?: string } = {}) { - super(); - this.customMetadata = options.metadata ?? { 'x-custom-plugin': 'example-plugin' }; - this.taskQueuePrefix = options.taskQueuePrefix ?? 'plugin-'; - } - - /** - * Configure client with custom metadata and logging - */ - configureClient(config: ClientOptions): ClientOptions { - console.log('ExamplePlugin: Configuring client'); - - // Add custom metadata to connection if it exists - if (config.connection && typeof config.connection === 'object') { - // Note: In a real implementation, you would properly extend the connection - // This is just for demonstration - console.log('ExamplePlugin: Adding custom metadata to client connection'); - } - - // Add any custom client interceptors - const interceptors = config.interceptors || {}; - - const modifiedConfig = { - ...config, - interceptors, - // Add any other client-specific configuration - }; - - // Chain to next plugin - return super.configureClient(modifiedConfig); - } - - /** - * Configure worker with custom task queue and additional settings - */ - configureWorker(config: WorkerOptions): WorkerOptions { - console.log('ExamplePlugin: Configuring worker'); - - // Modify task queue name with prefix - const taskQueue = config.taskQueue ? `${this.taskQueuePrefix}${config.taskQueue}` : config.taskQueue; - - const modifiedConfig = { - ...config, - taskQueue, - // Add any custom worker configuration - identity: config.identity ? `${config.identity}-with-plugin` : config.identity, - }; - - console.log(`ExamplePlugin: Modified task queue to: ${taskQueue}`); - - // Chain to next plugin - return super.configureWorker(modifiedConfig); - } -} - -/** - * Another example plugin that demonstrates plugin chaining - */ -export class LoggingPlugin extends Plugin { - configureClient(config: ClientOptions): ClientOptions { - console.log('LoggingPlugin: Client configuration intercepted'); - console.log('LoggingPlugin: Client namespace:', config.namespace); - - return super.configureClient(config); - } - - configureWorker(config: WorkerOptions): WorkerOptions { - console.log('LoggingPlugin: Worker configuration intercepted'); - console.log('LoggingPlugin: Worker task queue:', config.taskQueue); - console.log('LoggingPlugin: Worker namespace:', config.namespace); - - return super.configureWorker(config); - } -} - -/** - * Example of how to use plugins with a client - */ -export async function exampleClientUsage() { - const { Client } = await import('@temporalio/client'); - - const client = new Client({ - plugins: [ - new ExamplePlugin({ - metadata: { 'x-environment': 'development' }, - taskQueuePrefix: 'dev-' - }), - new LoggingPlugin(), - ], - namespace: 'default', - }); - - console.log('Client created with plugins'); - return client; -} - -/** - * Example of how to use plugins with a worker - */ -export async function exampleWorkerUsage() { - const { Worker } = await import('@temporalio/worker'); - - const worker = await Worker.create({ - plugins: [ - new ExamplePlugin({ - taskQueuePrefix: 'production-' - }), - new LoggingPlugin(), - ], - taskQueue: 'my-task-queue', - namespace: 'default', - workflowsPath: require.resolve('./workflows'), - }); - - console.log('Worker created with plugins'); - return worker; -} - -/** - * Example plugin that could add custom activities - */ -export class ActivityPlugin extends Plugin { - private activities: Record; - - constructor(activities: Record) { - super(); - this.activities = activities; - } - - configureWorker(config: WorkerOptions): WorkerOptions { - console.log('ActivityPlugin: Adding custom activities'); - - // Merge custom activities with existing ones - const existingActivities = config.activities || {}; - const mergedActivities = { - ...existingActivities, - ...this.activities, - }; - - const modifiedConfig = { - ...config, - activities: mergedActivities, - }; - - return super.configureWorker(modifiedConfig); - } -} - -// Example activities to be added by the plugin -export const customActivities = { - async logMessage(message: string): Promise { - console.log(`Custom activity: ${message}`); - }, - - async processData(data: any): Promise { - console.log('Custom activity: Processing data', data); - return { processed: true, data }; - }, -}; \ No newline at end of file diff --git a/packages/test/src/mock-native-worker.ts b/packages/test/src/mock-native-worker.ts index e10f92550..e00b9b216 100644 --- a/packages/test/src/mock-native-worker.ts +++ b/packages/test/src/mock-native-worker.ts @@ -176,7 +176,7 @@ export class Worker extends RealWorker { taskQueue: opts.taskQueue, }); const nativeWorker = new MockNativeWorker(); - super(runtime, nativeWorker, workflowCreator, opts, logger, runtime.metricMeter); + super(runtime, nativeWorker, workflowCreator, opts, logger, runtime.metricMeter, RealWorker.buildPluginChain(opts.plugins)); } public runWorkflows(...args: Parameters): Promise { diff --git a/packages/test/src/test-plugins.ts b/packages/test/src/test-plugins.ts new file mode 100644 index 000000000..6829fa7bb --- /dev/null +++ b/packages/test/src/test-plugins.ts @@ -0,0 +1,138 @@ +import { randomUUID } from 'crypto'; +import anyTest, { TestFn } from 'ava'; +import { Client, ClientOptions, Plugin as ClientPlugin } from '@temporalio/client'; +import { + WorkerOptions, + Plugin as WorkerPlugin, + ReplayWorkerOptions, + Worker, + BundlerPlugin, + BundleOptions, + bundleWorkflowCode, +} from '@temporalio/worker'; +import { hello_workflow } from './workflows/plugins'; +import { TestWorkflowEnvironment } from './helpers'; + +interface Context { + testEnv: TestWorkflowEnvironment; +} +const test = anyTest as TestFn; + +test.before(async (t) => { + t.context = { + testEnv: await TestWorkflowEnvironment.createLocal(), + }; +}); + +test.after.always(async (t) => { + await t.context.testEnv?.teardown(); +}); + +export class ExamplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin { + readonly name: string = 'example-plugin'; + private nextClientPlugin?: ClientPlugin; + private nextWorkerPlugin?: WorkerPlugin; + private nextBundlerPlugin?: BundlerPlugin; + + constructor() {} + + initClientPlugin(next: ClientPlugin): ClientPlugin { + this.nextClientPlugin = next; + return this; + } + + configureClient(config: ClientOptions): ClientOptions { + console.log('ExamplePlugin: Configuring client'); + + // Chain to next plugin + return this.nextClientPlugin!.configureClient(config); + } + + initWorkerPlugin(next: WorkerPlugin): WorkerPlugin { + this.nextWorkerPlugin = next; + return this; + } + + /** + * Configure worker with custom task queue and additional settings + */ + configureWorker(config: WorkerOptions): WorkerOptions { + console.log('ExamplePlugin: Configuring worker'); + config.taskQueue = 'plugin-task-queue'; + + // Chain to next plugin + return this.nextWorkerPlugin!.configureWorker(config); + } + + configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { + return this.nextWorkerPlugin!.configureReplayWorker(config); + } + + runWorker(worker: Worker): Promise { + return this.nextWorkerPlugin!.runWorker(worker); + } + + initBundlerPlugin(next: BundlerPlugin): BundlerPlugin { + this.nextBundlerPlugin = next; + return this; + } + + configureBundler(config: BundleOptions): BundleOptions { + console.log("Configure bundler") + config.workflowsPath = require.resolve('./workflows/plugins'); + return this.nextBundlerPlugin!.configureBundler(config); + } +} + + +test('Basic plugin', async (t) => { + const { connection } = t.context.testEnv; + const client = new Client({ connection }); + + const plugin = new ExamplePlugin(); + const bundle = await bundleWorkflowCode({ + workflowsPath: 'replaced', + plugins: [plugin] + }) + + const worker = await Worker.create({ + workflowBundle: bundle, + connection: t.context.testEnv.nativeConnection, + taskQueue: 'will be overridden', + plugins: [plugin], + }); + + await worker.runUntil(async () => { + t.is(worker.options.taskQueue, "plugin-task-queue"); + const result = await client.workflow.execute(hello_workflow, { + taskQueue: "plugin-task-queue", + workflowExecutionTimeout: '30 seconds', + workflowId: randomUUID() + }); + + t.is(result, "Hello"); + }); +}); + +test('Bundler plugins are passed from worker', async (t) => { + const { connection } = t.context.testEnv; + const client = new Client({ connection }); + + const worker = await Worker.create({ + workflowsPath: 'replaced', + connection: t.context.testEnv.nativeConnection, + taskQueue: 'will be overridden', + plugins: [new ExamplePlugin()], + }); + console.log("worker created"); + await worker.runUntil(async () => { + t.is(worker.options.taskQueue, "plugin-task-queue"); + const result = await client.workflow.execute(hello_workflow, { + taskQueue: "plugin-task-queue", + workflowExecutionTimeout: '30 seconds', + workflowId: randomUUID() + }); + + t.is(result, "Hello"); + }); +}); \ No newline at end of file diff --git a/packages/test/src/workflows/plugins.ts b/packages/test/src/workflows/plugins.ts new file mode 100644 index 000000000..3374d3819 --- /dev/null +++ b/packages/test/src/workflows/plugins.ts @@ -0,0 +1,3 @@ +export async function hello_workflow(): Promise { + return "Hello"; +} \ No newline at end of file diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 9273becb3..ae679cef7 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -35,7 +35,7 @@ export { } from './runtime-options'; export * from './sinks'; export { DataConverter, defaultPayloadConverter, State, Worker, WorkerStatus } from './worker'; -export { Plugin, buildWorkerPluginChain } from './plugin'; +export { Plugin } from './plugin'; export { CompiledWorkerOptions, ReplayWorkerOptions, @@ -45,7 +45,7 @@ export { WorkflowBundlePath, } from './worker-options'; export { ReplayError, ReplayHistoriesIterable, ReplayResult } from './replay'; -export { BundleOptions, bundleWorkflowCode, WorkflowBundleWithSourceMap } from './workflow/bundler'; +export { BundleOptions, bundleWorkflowCode, WorkflowBundleWithSourceMap, Plugin as BundlerPlugin } from './workflow/bundler'; export { WorkerTuner, TunerHolder, diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index fe0ba5a0c..cc2813dfd 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,5 +1,6 @@ -import type { NativeConnection } from './connection'; -import type { WorkerOptions } from './worker-options'; +import type { ReplayWorkerOptions, WorkerOptions } from './worker-options'; + +declare class Worker {} /** * Base Plugin class for both client and worker functionality. @@ -8,43 +9,14 @@ import type { WorkerOptions } from './worker-options'; * responsibility pattern. They allow you to intercept and modify client creation, service connections, worker * configuration, and worker execution. */ -export abstract class Plugin { - /** - * Reference to the next client plugin in the chain - */ - protected nextClientPlugin?: Plugin; - /** - * Reference to the next worker plugin in the chain - */ - protected nextWorkerPlugin?: Plugin; - +export interface Plugin { /** - * Gets the fully qualified name of this plugin. + * Gets the name of this plugin. * * Returns: - * The fully qualified name of the plugin class (module.classname). + * The name of the plugin class. */ - get name(): string { - return (this.constructor as any).name || this.constructor.toString(); - } - - /** - * Initialize this plugin in the client plugin chain. - * - * This method sets up the chain of responsibility pattern by storing a reference - * to the next plugin in the chain. It is called during client creation to build - * the plugin chain. - * - * Args: - * next: The next plugin in the chain to delegate to. - * - * Returns: - * This plugin instance for method chaining. - */ - initClientPlugin(next: Plugin): Plugin { - this.nextClientPlugin = next; - return this; - } + get name(): string; /** * Initialize this plugin in the worker plugin chain. @@ -59,76 +31,38 @@ export abstract class Plugin { * Returns: * This plugin instance for method chaining. */ - initWorkerPlugin(next: Plugin): Plugin { - this.nextWorkerPlugin = next; - return this; - } + initWorkerPlugin(next: Plugin): Plugin; + /** - * Hook called when creating a client to allow modification of configuration. + * Hook called when creating a worker to allow modification of configuration. * - * This method is called during client creation and allows plugins to modify - * the client configuration before the client is fully initialized. Plugins - * can add interceptors, modify connection parameters, or change other settings. + * This method is called during worker creation and allows plugins to modify + * the worker configuration before the worker is fully initialized. Plugins + * can add activities, workflows, interceptors, or change other settings. * * Args: - * config: The client configuration to potentially modify. + * config: The worker configuration to potentially modify. * * Returns: - * The modified client configuration. + * The modified worker configuration. */ - configureClient(config: any): any { - return this.nextClientPlugin?.configureClient(config) ?? config; - } + configureWorker(config: WorkerOptions): WorkerOptions; /** - * Hook called when creating a worker to allow modification of configuration. - * + * Hook called when creating a replay worker to allow modification of configuration. + * * This method is called during worker creation and allows plugins to modify * the worker configuration before the worker is fully initialized. Plugins * can add activities, workflows, interceptors, or change other settings. - * + * * Args: - * config: The worker configuration to potentially modify. - * + * config: The replay worker configuration to potentially modify. + * * Returns: * The modified worker configuration. */ - configureWorker(config: WorkerOptions): WorkerOptions { - return this.nextWorkerPlugin?.configureWorker(config) ?? config; - } -} + configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions; -/** - * Root worker plugin that provides default implementations for all plugin methods. - * This is the final plugin in the chain and provides the actual implementation. - */ -class _RootWorkerPlugin extends Plugin { - configureWorker(config: WorkerOptions): WorkerOptions { - return config; - } + runWorker(worker: Worker): Promise; } - -/** - * Build a worker plugin chain from an array of plugins. - * - * @param plugins Array of plugins to chain together - * @returns The first plugin in the chain - */ -export function buildWorkerPluginChain(plugins: Plugin[]): Plugin { - if (plugins.length === 0) { - return new _RootWorkerPlugin(); - } - - // Start with the root plugin at the end - let chain: Plugin = new _RootWorkerPlugin(); - - // Build the chain in reverse order - for (let i = plugins.length - 1; i >= 0; i--) { - const plugin = plugins[i]; - plugin.initWorkerPlugin(chain); - chain = plugin; - } - - return chain; -} \ No newline at end of file diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 47a392d94..195016ce8 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -94,7 +94,7 @@ import { WorkflowBundle, } from './worker-options'; import { WorkflowCodecRunner } from './workflow-codec-runner'; -import { defaultWorkflowInterceptorModules, WorkflowCodeBundler } from './workflow/bundler'; +import { defaultWorkflowInterceptorModules, isBundlerPlugin, WorkflowCodeBundler } from './workflow/bundler'; import { Workflow, WorkflowCreator } from './workflow/interface'; import { ReusableVMWorkflowCreator } from './workflow/reusable-vm'; import { ThreadedVMWorkflowCreator } from './workflow/threaded-vm'; @@ -109,6 +109,7 @@ import { } from './errors'; import { constructNexusOperationContext, NexusHandler } from './nexus'; import { handlerErrorToProto } from './nexus/conversions'; +import { Plugin } from './plugin'; export { DataConverter, defaultPayloadConverter }; @@ -500,6 +501,8 @@ export class Worker { * This method initiates a connection to the server and will throw (asynchronously) on connection failure. */ public static async create(options: WorkerOptions): Promise { + const plugin = Worker.buildPluginChain(options.plugins); + options = plugin.configureWorker(options); if (!options.taskQueue) { throw new TypeError('Task queue name is required'); } @@ -555,7 +558,8 @@ export class Worker { compiledOptionsWithBuildId, logger, metricMeter, - connection + plugin, + connection, ); } @@ -697,6 +701,8 @@ export class Worker { } private static async constructReplayWorker(options: ReplayWorkerOptions): Promise<[Worker, native.HistoryPusher]> { + const plugin = Worker.buildPluginChain(options.plugins) + options = plugin.configureReplayWorker(options); const nativeWorkerCtor: NativeWorkerConstructor = this.nativeWorkerCtor; const fixedUpOptions: WorkerOptions = { taskQueue: (options.replayName ?? 'fake_replay_queue') + '-' + this.replayWorkerCount, @@ -724,7 +730,7 @@ export class Worker { addBuildIdIfMissing(compiledOptions, bundle.code) ); return [ - new this(runtime, replayHandle.worker, workflowCreator, compiledOptions, logger, metricMeter, undefined, true), + new this(runtime, replayHandle.worker, workflowCreator, compiledOptions, logger, metricMeter, plugin,undefined, true), replayHandle.historyPusher, ]; } @@ -762,6 +768,7 @@ export class Worker { throw new TypeError('Invalid WorkflowOptions.workflowBundle'); } } else if (compiledOptions.workflowsPath) { + const bundlerPlugins = compiledOptions.plugins?.filter(p => isBundlerPlugin(p)) const bundler = new WorkflowCodeBundler({ logger, workflowsPath: compiledOptions.workflowsPath, @@ -770,6 +777,7 @@ export class Worker { payloadConverterPath: compiledOptions.dataConverter?.payloadConverterPath, ignoreModules: compiledOptions.bundlerOptions?.ignoreModules, webpackConfigHook: compiledOptions.bundlerOptions?.webpackConfigHook, + plugins: bundlerPlugins, }); const bundle = await bundler.createBundle(); return parseWorkflowCode(bundle.code); @@ -792,6 +800,7 @@ export class Worker { /** Logger bound to 'sdkComponent: worker' */ protected readonly logger: Logger, protected readonly metricMeter: MetricMeter, + protected readonly plugin: Plugin, protected readonly connection?: NativeConnection, protected readonly isReplayWorker: boolean = false ) { @@ -1956,6 +1965,10 @@ export class Worker { * To stop polling, call {@link shutdown} or send one of {@link Runtime.options.shutdownSignals}. */ async run(): Promise { + return this.plugin.runWorker(this); + } + + private async runInternal(): Promise { if (this.state !== 'INITIALIZED') { throw new IllegalStateError('Poller was already started'); } @@ -2035,6 +2048,43 @@ export class Worker { } } } + + private static rootPlugin = class implements Plugin { + name: string = 'RootPlugin'; + initWorkerPlugin(_next: Plugin): Plugin { + throw new Error('Root plugin should not be initialized'); + } + + configureWorker(config: WorkerOptions): WorkerOptions { + return config; + } + + configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { + return config; + } + + runWorker(worker: Worker): Promise { + return worker.runInternal(); + } + } + + protected static buildPluginChain(plugins: Plugin[] | undefined): Plugin { + if (plugins === undefined || plugins.length === 0) { + return new Worker.rootPlugin(); + } + + // Start with the root plugin at the end + let chain: Plugin = new Worker.rootPlugin(); + + // Build the chain in reverse order + for (let i = plugins.length - 1; i >= 0; i--) { + const plugin = plugins[i]; + plugin.initWorkerPlugin(chain); + chain = plugin; + } + + return chain; + } } export function parseWorkflowCode(code: string, codePath?: string): WorkflowBundleWithSourceMapAndFilename { diff --git a/packages/worker/src/workflow/bundler.ts b/packages/worker/src/workflow/bundler.ts index 0361c1a7c..54807bd28 100644 --- a/packages/worker/src/workflow/bundler.ts +++ b/packages/worker/src/workflow/bundler.ts @@ -53,16 +53,19 @@ export class WorkflowCodeBundler { protected readonly failureConverterPath?: string; protected readonly ignoreModules: string[]; protected readonly webpackConfigHook: (config: Configuration) => Configuration; - - constructor({ - logger, - workflowsPath, - payloadConverterPath, - failureConverterPath, - workflowInterceptorModules, - ignoreModules, - webpackConfigHook, - }: BundleOptions) { + protected readonly plugin: Plugin; + + constructor(options: BundleOptions) { + this.plugin = buildPluginChain(options.plugins); + const { + logger, + workflowsPath, + payloadConverterPath, + failureConverterPath, + workflowInterceptorModules, + ignoreModules, + webpackConfigHook, + } = this.plugin.configureBundler(options); this.logger = logger ?? new DefaultLogger('INFO'); this.workflowsPath = workflowsPath; this.payloadConverterPath = payloadConverterPath; @@ -307,6 +310,73 @@ exports.importInterceptors = function importInterceptors() { } } +export interface Plugin { + /** + * Gets the name of this plugin. + * + * Returns: + * The name of the plugin class. + */ + get name(): string; + + /** + * Initialize this plugin in the bundler plugin chain. + * + * This method sets up the chain of responsibility pattern by storing a reference + * to the next plugin in the chain. It is called during bundler creation to build + * the plugin chain. + * + * Args: + * next: The next plugin in the chain to delegate to. + * + * Returns: + * This plugin instance for method chaining. + */ + initBundlerPlugin(next: Plugin): Plugin; + + /** + * Hook called when creating a bundler to allow modification of configuration. + */ + configureBundler(config: BundleOptions): BundleOptions; +} + +class RootPlugin implements Plugin { + name: string = 'RootPlugin'; + + initBundlerPlugin(_next: Plugin): Plugin { + throw new Error('Root plugin should not be initialized'); + } + + configureBundler(config: BundleOptions): BundleOptions { + return config; + } +} + +function buildPluginChain(plugins: Plugin[] | undefined): Plugin { + if (plugins === undefined || plugins.length === 0) { + return new RootPlugin(); + } + + // Start with the root plugin at the end + let chain: Plugin = new RootPlugin(); + + // Build the chain in reverse order + for (let i = plugins.length - 1; i >= 0; i--) { + const plugin = plugins[i]; + plugin.initBundlerPlugin(chain); + chain = plugin; + } + + return chain; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function isBundlerPlugin(p: any): p is Plugin { + console.log(p, "initBundlerPlugin" in p && "configureBundler" in p); + return "initBundlerPlugin" in p && "configureBundler" in p; +} + + /** * Options for bundling Workflow code using Webpack */ @@ -350,6 +420,11 @@ export interface BundleOptions { * {@link https://webpack.js.org/configuration/ | configuration} object so you can modify it. */ webpackConfigHook?: (config: Configuration) => Configuration; + + /** + * List of plugins to register with the bundler. + */ + plugins?: Plugin[]; } /** From a6f8bfbf0f44f5299e4759d18e936cf4af72aa70 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 8 Oct 2025 09:24:47 -0700 Subject: [PATCH 04/11] Additional plugin work --- packages/client/src/client.ts | 32 ++-- packages/client/src/connection.ts | 27 ++- packages/client/src/index.ts | 3 +- packages/client/src/plugin.ts | 73 +------- packages/client/src/types.ts | 1 + packages/common/src/plugin.ts | 0 packages/test/src/mock-native-worker.ts | 2 +- packages/test/src/test-plugins.ts | 104 +++++++---- .../src/testing-workflow-environment.ts | 14 +- packages/worker/src/connection-options.ts | 3 + packages/worker/src/connection.ts | 30 +++- packages/worker/src/index.ts | 2 + packages/worker/src/plugin.ts | 164 +++++++++++++++--- packages/worker/src/worker.ts | 66 +++---- packages/worker/src/workflow/bundler.ts | 56 +----- 15 files changed, 327 insertions(+), 250 deletions(-) create mode 100644 packages/common/src/plugin.ts diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index ac8c97820..06e59998c 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -6,7 +6,7 @@ import { ScheduleClient } from './schedule-client'; import { QueryRejectCondition, WorkflowService } from './types'; import { WorkflowClient } from './workflow-client'; import { TaskQueueClient } from './task-queue-client'; -import { Plugin, buildPluginChain } from './plugin'; +import { isClientPlugin, Plugin } from './plugin'; export interface ClientOptions extends BaseClientOptions { /** @@ -42,20 +42,6 @@ export type LoadedClientOptions = LoadedWithDefaults; export class Client extends BaseClient { public readonly options: LoadedClientOptions; - /** - * Apply plugins to client options - */ - private static applyPlugins(options?: ClientOptions): ClientOptions { - if (!options?.plugins?.length) { - return options ?? {}; - } - - const pluginChain = buildPluginChain(options.plugins); - const clientConfig: ClientOptions = { ...options }; - const processedConfig = pluginChain.configureClient(clientConfig); - - return { ...processedConfig }; - } /** * Workflow sub-client - use to start and interact with Workflows */ @@ -76,12 +62,20 @@ export class Client extends BaseClient { public readonly taskQueue: TaskQueueClient; constructor(options?: ClientOptions) { + options = options ?? {}; + + // Add client plugins from the connection + options.plugins = (options.plugins || []).concat( + (options.connection?.plugins || []).filter(p => isClientPlugin(p)).map(p => p as Plugin)); + // Process plugins first to allow them to modify connect configuration - const processedOptions = Client.applyPlugins(options); - - super(processedOptions); + for (const plugin of options?.plugins ?? []) { + options = plugin.configureClient(options) + } + + super(options); - const { interceptors, workflow, plugins, ...commonOptions } = processedOptions ?? {}; + const { interceptors, workflow, plugins, ...commonOptions } = options; this.workflow = new WorkflowClient({ ...commonOptions, diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts index 4e18e430b..706009fb3 100644 --- a/packages/client/src/connection.ts +++ b/packages/client/src/connection.ts @@ -130,6 +130,8 @@ export interface ConnectionOptions { * @default 10 seconds */ connectTimeout?: Duration; + + plugins?: Plugin[]; } export type ConnectionOptionsWithDefaults = Required< @@ -172,6 +174,7 @@ function addDefaults(options: ConnectionOptions): ConnectionOptionsWithDefaults interceptors: interceptors ?? [makeGrpcRetryInterceptor(defaultGrpcRetryOptions())], metadata: {}, connectTimeoutMs: msOptionalToNumber(connectTimeout) ?? 10_000, + plugins: [], ...filterNullAndUndefined(rest), }; } @@ -182,8 +185,8 @@ function addDefaults(options: ConnectionOptions): ConnectionOptionsWithDefaults * - Add default port to address if port not specified * - Set `Authorization` header based on {@link ConnectionOptions.apiKey} */ -function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions { - const { tls: tlsFromConfig, credentials, callCredentials, ...rest } = options || {}; +function normalizeGRPCConfig(options: ConnectionOptions): ConnectionOptions { + const { tls: tlsFromConfig, credentials, callCredentials, ...rest } = options; if (rest.apiKey) { if (rest.metadata?.['Authorization']) { throw new TypeError( @@ -322,10 +325,12 @@ export class Connection { */ public readonly healthService: HealthService; + public readonly plugins: Plugin[]; + readonly callContextStorage: AsyncLocalStorage; private readonly apiKeyFnRef: { fn?: () => string }; - protected static createCtorOptions(options?: ConnectionOptions): ConnectionCtorOptions { + protected static createCtorOptions(options: ConnectionOptions): ConnectionCtorOptions { const normalizedOptions = normalizeGRPCConfig(options); const apiKeyFnRef: { fn?: () => string } = {}; if (normalizedOptions.apiKey) { @@ -441,6 +446,10 @@ export class Connection { * This method does not verify connectivity with the server. We recommend using {@link connect} instead. */ static lazy(options?: ConnectionOptions): Connection { + options = options || {}; + for (const plugin of options.plugins || []) { + options = plugin.configureConnection(options); + } return new this(this.createCtorOptions(options)); } @@ -474,6 +483,7 @@ export class Connection { this.healthService = healthService; this.callContextStorage = callContextStorage; this.apiKeyFnRef = apiKeyFnRef; + this.plugins = options.plugins || []; } protected static generateRPCImplementation({ @@ -529,7 +539,7 @@ export class Connection { * this will locally result in the request call throwing a {@link grpc.ServiceError|ServiceError} * with code {@link grpc.status.DEADLINE_EXCEEDED|DEADLINE_EXCEEDED}; see {@link isGrpcDeadlineError}. * - * It is stronly recommended to explicitly set deadlines. If no deadline is set, then it is + * It is strongly recommended to explicitly set deadlines. If no deadline is set, then it is * possible for the client to end up waiting forever for a response. * * @param deadline a point in time after which the request will be considered as failed; either a @@ -685,3 +695,12 @@ export class Connection { return wrapper as WorkflowService; } } + +export interface Plugin { + configureConnection(config: ConnectionOptions): ConnectionOptions; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function isConnectionPlugin(p: any): p is Plugin { + return "configureConnection" in p; +} \ No newline at end of file diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 59220095c..d3fae0dfe 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -36,7 +36,8 @@ export * from './grpc-retry'; export * from './interceptors'; export * from './types'; export * from './workflow-client'; -export { Plugin, buildPluginChain } from './plugin'; +export { Plugin } from './plugin'; +export { Plugin as ConnectionPlugin, isConnectionPlugin } from './connection'; export * from './workflow-options'; export * from './schedule-types'; export * from './schedule-client'; diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index 7ed646be0..2ac8579a2 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -1,41 +1,11 @@ import type { ClientOptions } from './client'; -/** - * Abstract base class for Temporal plugins. - * - * Plugins provide a way to extend and customize the behavior of Temporal clients and workers through a chain of - * responsibility pattern. They allow you to intercept and modify client creation, service connections, worker - * configuration, and worker execution. Common customizations may include but are not limited to: - * - * 1. DataConverter - * 2. Activities - * 3. Workflows - * 4. Interceptors - * - * A single plugin class can implement both client and worker plugin interfaces to share common logic between both - * contexts. When used with a client, it will automatically be propagated to any workers created with that client. - */ export interface Plugin { /** * Gets the name of this plugin. */ get name(): string; - /** - * Initialize this plugin in the plugin chain. - * - * This method sets up the chain of responsibility pattern by storing a reference - * to the next plugin in the chain. It is called during client creation to build - * the plugin chain. Note, this may be called twice in the case of connect(). - * - * Args: - * next: The next plugin in the chain to delegate to. - * - * Returns: - * This plugin instance for method chaining. - */ - initClientPlugin(next: Plugin): Plugin; - /** * Hook called when creating a client to allow modification of configuration. * @@ -52,42 +22,7 @@ export interface Plugin { configureClient(config: ClientOptions): ClientOptions; } -/** - * Root plugin that provides default implementations for all plugin methods. - * This is the final plugin in the chain and provides the actual implementation. - */ -class RootPlugin implements Plugin { - name: string = 'RootPlugin'; - - initClientPlugin(_next: Plugin): Plugin { - throw new Error("Root plugin should not be initialized") - } - - configureClient(config: ClientOptions): ClientOptions { - return config; - } -} - -/** - * Build a plugin chain from an array of plugins. - * - * @param plugins Array of plugins to chain together - * @returns The first plugin in the chain - */ -export function buildPluginChain(plugins: Plugin[] | undefined): Plugin { - if (plugins === undefined || plugins.length === 0) { - return new RootPlugin(); - } - - // Start with the root plugin at the end - let chain: Plugin = new RootPlugin(); - - // Build the chain in reverse order - for (let i = plugins.length - 1; i >= 0; i--) { - const plugin = plugins[i]; - plugin.initClientPlugin(chain); - chain = plugin; - } - - return chain; -} \ No newline at end of file +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function isClientPlugin(p: any): p is Plugin { + return "configureClient" in p; +} \ No newline at end of file diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index c478222fd..67d489e75 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -122,6 +122,7 @@ export interface CallContext { */ export interface ConnectionLike { workflowService: WorkflowService; + plugins: any[]; close(): Promise; ensureConnected(): Promise; diff --git a/packages/common/src/plugin.ts b/packages/common/src/plugin.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/test/src/mock-native-worker.ts b/packages/test/src/mock-native-worker.ts index e00b9b216..ae29f7a6c 100644 --- a/packages/test/src/mock-native-worker.ts +++ b/packages/test/src/mock-native-worker.ts @@ -176,7 +176,7 @@ export class Worker extends RealWorker { taskQueue: opts.taskQueue, }); const nativeWorker = new MockNativeWorker(); - super(runtime, nativeWorker, workflowCreator, opts, logger, runtime.metricMeter, RealWorker.buildPluginChain(opts.plugins)); + super(runtime, nativeWorker, workflowCreator, opts, logger, runtime.metricMeter, opts.plugins ?? []); } public runWorkflows(...args: Parameters): Promise { diff --git a/packages/test/src/test-plugins.ts b/packages/test/src/test-plugins.ts index 6829fa7bb..7d8ee8471 100644 --- a/packages/test/src/test-plugins.ts +++ b/packages/test/src/test-plugins.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto'; import anyTest, { TestFn } from 'ava'; -import { Client, ClientOptions, Plugin as ClientPlugin } from '@temporalio/client'; +import { Client, ClientOptions, ConnectionPlugin, Plugin as ClientPlugin, ConnectionOptions } from '@temporalio/client'; import { WorkerOptions, Plugin as WorkerPlugin, @@ -8,8 +8,10 @@ import { Worker, BundlerPlugin, BundleOptions, - bundleWorkflowCode, + bundleWorkflowCode, NativeConnectionPlugin, + NativeConnectionOptions, } from '@temporalio/worker'; +import { native } from '@temporalio/core-bridge'; import { hello_workflow } from './workflows/plugins'; import { TestWorkflowEnvironment } from './helpers'; @@ -28,59 +30,48 @@ test.after.always(async (t) => { await t.context.testEnv?.teardown(); }); -export class ExamplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin { +export class ExamplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin { readonly name: string = 'example-plugin'; - private nextClientPlugin?: ClientPlugin; - private nextWorkerPlugin?: WorkerPlugin; - private nextBundlerPlugin?: BundlerPlugin; constructor() {} - initClientPlugin(next: ClientPlugin): ClientPlugin { - this.nextClientPlugin = next; - return this; - } - configureClient(config: ClientOptions): ClientOptions { console.log('ExamplePlugin: Configuring client'); - - // Chain to next plugin - return this.nextClientPlugin!.configureClient(config); - } - - initWorkerPlugin(next: WorkerPlugin): WorkerPlugin { - this.nextWorkerPlugin = next; - return this; + config.identity = "Plugin Identity"; + return config; } - /** - * Configure worker with custom task queue and additional settings - */ configureWorker(config: WorkerOptions): WorkerOptions { console.log('ExamplePlugin: Configuring worker'); config.taskQueue = 'plugin-task-queue'; - - // Chain to next plugin - return this.nextWorkerPlugin!.configureWorker(config); + return config; } configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { - return this.nextWorkerPlugin!.configureReplayWorker(config); - } - - runWorker(worker: Worker): Promise { - return this.nextWorkerPlugin!.runWorker(worker); + return config; } - initBundlerPlugin(next: BundlerPlugin): BundlerPlugin { - this.nextBundlerPlugin = next; - return this; + runWorker(worker: Worker, next: (w: Worker) => Promise): Promise { + console.log('ExamplePlugin: Running worker'); + return next(worker); } configureBundler(config: BundleOptions): BundleOptions { - console.log("Configure bundler") + console.log('Configure bundler'); config.workflowsPath = require.resolve('./workflows/plugins'); - return this.nextBundlerPlugin!.configureBundler(config); + return config; + } + + configureConnection(config: ConnectionOptions): ConnectionOptions { + return config; + } + + configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions { + return options; + } + + connectNative(next: () => Promise): Promise { + return next(); } } @@ -135,4 +126,47 @@ test('Bundler plugins are passed from worker', async (t) => { t.is(result, "Hello"); }); +}); + + +test('Worker plugins are passed from native connection', async (t) => { + const env = await TestWorkflowEnvironment.createLocal({connectionPlugins: [new ExamplePlugin()]}); + try { + const client = new Client({ connection: env.connection }); + + const worker = await Worker.create({ + workflowsPath: 'replaced', + connection: env.nativeConnection, + taskQueue: 'will be overridden', + }); + + t.is(worker.options.taskQueue, "plugin-task-queue"); + + await worker.runUntil(async () => { + t.is(worker.options.taskQueue, "plugin-task-queue"); + const result = await client.workflow.execute(hello_workflow, { + taskQueue: "plugin-task-queue", + workflowExecutionTimeout: '30 seconds', + workflowId: randomUUID() + }); + + t.is(result, "Hello"); + }); + } finally { + await env.teardown() + } +}); + + +test('Client plugins are passed from connections', async (t) => { + const env = await TestWorkflowEnvironment.createLocal({connectionPlugins: [new ExamplePlugin()]}); + try { + const client = new Client({ connection: env.connection }); + t.is(client.options.identity, "Plugin Identity"); + + const clientNative = new Client({ connection: env.nativeConnection }); + t.is(clientNative.options.identity, "Plugin Identity"); + } finally { + await env.teardown() + } }); \ No newline at end of file diff --git a/packages/testing/src/testing-workflow-environment.ts b/packages/testing/src/testing-workflow-environment.ts index 5187f996f..a65a2904f 100644 --- a/packages/testing/src/testing-workflow-environment.ts +++ b/packages/testing/src/testing-workflow-environment.ts @@ -1,5 +1,5 @@ import 'abort-controller/polyfill'; // eslint-disable-line import/no-unassigned-import -import { AsyncCompletionClient, Client, Connection, WorkflowClient } from '@temporalio/client'; +import { AsyncCompletionClient, Client, Connection, ConnectionPlugin, WorkflowClient, isConnectionPlugin } from '@temporalio/client'; import { ConnectionOptions, InternalConnectionOptions, @@ -7,7 +7,7 @@ import { } from '@temporalio/client/lib/connection'; import { Duration, TypedSearchAttributes } from '@temporalio/common'; import { msToNumber, msToTs, tsToMs } from '@temporalio/common/lib/time'; -import { NativeConnection, NativeConnectionOptions, Runtime } from '@temporalio/worker'; +import { NativeConnection, NativeConnectionPlugin, NativeConnectionOptions, Runtime, isNativeConnectionPlugin } from '@temporalio/worker'; import { native } from '@temporalio/core-bridge'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; import { toNativeEphemeralServerConfig, DevServerConfig, TimeSkippingServerConfig } from './ephemeral-server'; @@ -19,6 +19,7 @@ import { ClientOptionsForTestEnv, TimeSkippingClient } from './client'; export type LocalTestWorkflowEnvironmentOptions = { server?: Omit; client?: ClientOptionsForTestEnv; + connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; }; /** @@ -27,6 +28,7 @@ export type LocalTestWorkflowEnvironmentOptions = { export type TimeSkippingTestWorkflowEnvironmentOptions = { server?: Omit; client?: ClientOptionsForTestEnv; + connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; }; /** @@ -38,6 +40,7 @@ export type ExistingServerTestWorkflowEnvironmentOptions = { /** If not set, defaults to default */ namespace?: string; client?: ClientOptionsForTestEnv; + connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; }; /** @@ -144,6 +147,7 @@ export class TestWorkflowEnvironment { return await this.create({ server: { type: 'time-skipping', ...opts?.server }, client: opts?.client, + connectionPlugins: opts?.connectionPlugins, supportsTimeSkipping: true, }); } @@ -173,6 +177,7 @@ export class TestWorkflowEnvironment { return await this.create({ server: { type: 'dev-server', ...opts?.server }, client: opts?.client, + connectionPlugins: opts?.connectionPlugins, namespace: opts?.server?.namespace, supportsTimeSkipping: false, }); @@ -188,6 +193,7 @@ export class TestWorkflowEnvironment { return await this.create({ server: { type: 'existing' }, client: opts?.client, + connectionPlugins: opts?.connectionPlugins, namespace: opts?.namespace ?? 'default', supportsTimeSkipping: false, address: opts?.address, @@ -231,10 +237,12 @@ export class TestWorkflowEnvironment { const nativeConnection = await NativeConnection.connect({ address, + plugins:opts.connectionPlugins?.filter(p => isNativeConnectionPlugin(p)), [InternalConnectionOptionsSymbol]: { supportsTestService: supportsTimeSkipping }, }); const connection = await Connection.connect({ address, + plugins:opts.connectionPlugins?.filter(p => isConnectionPlugin(p)), [InternalConnectionOptionsSymbol]: { supportsTestService: supportsTimeSkipping }, }); @@ -335,6 +343,7 @@ export class TestWorkflowEnvironment { type TestWorkflowEnvironmentOptions = { server: DevServerConfig | TimeSkippingServerConfig | ExistingServerConfig; client?: ClientOptionsForTestEnv; + connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; }; type ExistingServerConfig = { type: 'existing' }; @@ -348,5 +357,6 @@ function addDefaults(opts: TestWorkflowEnvironmentOptions): TestWorkflowEnvironm server: { ...opts.server, }, + connectionPlugins: [] }; } diff --git a/packages/worker/src/connection-options.ts b/packages/worker/src/connection-options.ts index e263e1d14..fc2b0a60d 100644 --- a/packages/worker/src/connection-options.ts +++ b/packages/worker/src/connection-options.ts @@ -8,6 +8,7 @@ import { TLSConfig, } from '@temporalio/common/lib/internal-non-workflow'; import pkg from './pkg'; +import type { Plugin } from './connection'; export { TLSConfig, ProxyConfig }; @@ -59,6 +60,8 @@ export interface NativeConnectionOptions { * @default false */ disableErrorCodeMetricTags?: boolean; + + plugins?: Plugin[]; } // Compile to Native /////////////////////////////////////////////////////////////////////////////// diff --git a/packages/worker/src/connection.ts b/packages/worker/src/connection.ts index b69ced2ac..aad0b5da4 100644 --- a/packages/worker/src/connection.ts +++ b/packages/worker/src/connection.ts @@ -17,6 +17,7 @@ import { InternalConnectionOptions, InternalConnectionOptionsSymbol } from '@tem import { TransportError } from './errors'; import { NativeConnectionOptions } from './connection-options'; import { Runtime } from './runtime'; +import { ClientOptions } from '@grpc/grpc-js'; /** * A Native Connection object that delegates calls to the Rust Core binary extension. @@ -70,7 +71,8 @@ export class NativeConnection implements ConnectionLike { protected constructor( private readonly runtime: Runtime, private readonly nativeClient: native.Client, - private readonly enableTestService: boolean + private readonly enableTestService: boolean, + readonly plugins: Plugin[], ) { this.workflowService = WorkflowService.create( this.sendRequest.bind(this, native.clientSendWorkflowServiceRequest.bind(undefined, this.nativeClient)), @@ -231,13 +233,24 @@ export class NativeConnection implements ConnectionLike { * Eagerly connect to the Temporal server and return a NativeConnection instance */ static async connect(options?: NativeConnectionOptions): Promise { + options = options || {}; + for (const plugin of options.plugins || []) { + options = plugin.configureNativeConnection(options); + } const internalOptions = (options as InternalConnectionOptions)?.[InternalConnectionOptionsSymbol] ?? {}; const enableTestService = internalOptions.supportsTestService ?? false; try { const runtime = Runtime.instance(); - const client = await runtime.createNativeClient(options); - return new this(runtime, client, enableTestService); + + let connectNative = async () => await runtime.createNativeClient(options) + for (const plugin of options.plugins || []) { + const cn = connectNative + connectNative = async () => await plugin.connectNative(cn); + } + + const client = await connectNative(); + return new this(runtime, client, enableTestService, options.plugins || []); } catch (err) { if (err instanceof TransportError) { throw new TransportError(err.message); @@ -341,3 +354,14 @@ function getRelativeTimeout(deadline: grpc.Deadline) { return timeout; } } + +export interface Plugin { + configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions; + + connectNative(next: () => Promise): Promise; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function isNativeConnectionPlugin(p: any): p is Plugin { + return "configureNativeConnection" in p && "connectNative" in p; +} \ No newline at end of file diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index ae679cef7..757183a2c 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -166,3 +166,5 @@ export { */ MetricsExporterConfig as MetricsExporter, } from './runtime-options'; + +export { Plugin as NativeConnectionPlugin, isNativeConnectionPlugin } from './connection'; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index cc2813dfd..299793058 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,7 +1,24 @@ -import type { ReplayWorkerOptions, WorkerOptions } from './worker-options'; - -declare class Worker {} - +import * as nexus from 'nexus-rpc'; +import type { + ClientInterceptors, + ClientOptions, + ConnectionOptions, + ConnectionPlugin, + Plugin as ClientPlugin, +} from '@temporalio/client'; +import { native } from '@temporalio/core-bridge'; +import type { ReplayWorkerOptions, WorkerOptions, WorkflowBundleOption } from './worker-options'; +import type { + DataConverter, + Worker, +} from './worker'; +import type { + BundleOptions, + BundlerPlugin, + NativeConnectionOptions, + NativeConnectionPlugin, + WorkerInterceptors, +} from './index'; /** * Base Plugin class for both client and worker functionality. * @@ -18,22 +35,6 @@ export interface Plugin { */ get name(): string; - /** - * Initialize this plugin in the worker plugin chain. - * - * This method sets up the chain of responsibility pattern by storing a reference - * to the next plugin in the chain. It is called during worker creation to build - * the plugin chain. - * - * Args: - * next: The next plugin in the chain to delegate to. - * - * Returns: - * This plugin instance for method chaining. - */ - initWorkerPlugin(next: Plugin): Plugin; - - /** * Hook called when creating a worker to allow modification of configuration. * @@ -64,5 +65,126 @@ export interface Plugin { */ configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions; - runWorker(worker: Worker): Promise; + runWorker(worker: Worker, next: (w: Worker) => Promise): Promise; } + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function isWorkerPlugin(p: any): p is Plugin { + return "configureWorker" in p && "configureReplayWorker" in p && "runWorker" in p; +} + +type PluginParameter = T | ((p: T | undefined) => T); + +export class SimplePlugin implements Plugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin { + + constructor( + readonly name: string, + protected readonly dataConverter?: PluginParameter, + protected readonly clientInterceptors?: PluginParameter, + protected readonly activities?: PluginParameter, + protected readonly nexusServices?: PluginParameter[]>, + protected readonly workflowsPath?: PluginParameter, + protected readonly workflowBundle?: PluginParameter, + protected readonly workerInterceptors?: PluginParameter, + protected readonly runContext?: { + before: () => Promise, + after: () => Promise, + }){} + + private static resolveRequiredParameter(existing: T, parameter: PluginParameter | undefined): T { + if (parameter === undefined) { + return existing; + } + if (typeof parameter === 'function') { + // @ts-expect-error Can't infer that parameter is a function + return parameter(existing); + } + return parameter; + } + + private static resolveParameter(existing: T | undefined, parameter: PluginParameter | undefined): T | undefined { + if (parameter === undefined) { + return existing; + } + if (typeof parameter === 'function') { + // @ts-expect-error Can't infer that parameter is a function + return parameter(existing); + } + return parameter; + } + + private static resolveAppendParameter(existing: T[] | undefined, parameter: PluginParameter | undefined): T[] | undefined { + if (parameter === undefined) { + return existing; + } + if (typeof parameter === 'function') { + return parameter(existing); + } + return (existing || []).concat(parameter); + } + + + configureClient(config: ClientOptions): ClientOptions { + config.dataConverter = SimplePlugin.resolveParameter(config.dataConverter, this.dataConverter); + + // TODO: Way to do interceptor append? + config.interceptors = SimplePlugin.resolveParameter(config.interceptors, this.clientInterceptors); + return config; + } + + configureWorker(config: WorkerOptions): WorkerOptions { + config.dataConverter = SimplePlugin.resolveParameter(config.dataConverter, this.dataConverter); + + // TODO: Way to do activities append? + config.activities = SimplePlugin.resolveParameter(config.activities, this.activities); + config.nexusServices = SimplePlugin.resolveAppendParameter(config.nexusServices, this.nexusServices); + config.workflowsPath = SimplePlugin.resolveParameter(config.workflowsPath, this.workflowsPath); + config.workflowBundle = SimplePlugin.resolveParameter(config.workflowBundle, this.workflowBundle); + + // TODO: Way to do interceptor append? + config.interceptors = SimplePlugin.resolveParameter(config.interceptors, this.workerInterceptors); + + return config; + } + + configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { + config.dataConverter = SimplePlugin.resolveParameter(config.dataConverter, this.dataConverter); + + // TODO: Way to do activities append? + config.workflowsPath = SimplePlugin.resolveParameter(config.workflowsPath, this.workflowsPath); + config.workflowBundle = SimplePlugin.resolveParameter(config.workflowBundle, this.workflowBundle); + + // TODO: Way to do interceptor append? + config.interceptors = SimplePlugin.resolveParameter(config.interceptors, this.workerInterceptors); + + return config; + } + + async runWorker(worker: Worker, next: (w: Worker) => Promise): Promise { + if (this.runContext !== undefined) { + await this.runContext.before(); + } + const result = await next(worker); + if (this.runContext !== undefined) { + await this.runContext.after(); + } + return result; + } + + configureBundler(config: BundleOptions): BundleOptions { + config.workflowsPath = SimplePlugin.resolveRequiredParameter(config.workflowsPath, this.workflowsPath); + return config; + } + + configureConnection(config: ConnectionOptions): ConnectionOptions { + return config; + } + + configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions { + return options; + } + + connectNative(next: () => Promise): Promise { + return next(); + } +} \ No newline at end of file diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 195016ce8..067559bfc 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -109,7 +109,7 @@ import { } from './errors'; import { constructNexusOperationContext, NexusHandler } from './nexus'; import { handlerErrorToProto } from './nexus/conversions'; -import { Plugin } from './plugin'; +import { isWorkerPlugin, Plugin } from './plugin'; export { DataConverter, defaultPayloadConverter }; @@ -501,8 +501,11 @@ export class Worker { * This method initiates a connection to the server and will throw (asynchronously) on connection failure. */ public static async create(options: WorkerOptions): Promise { - const plugin = Worker.buildPluginChain(options.plugins); - options = plugin.configureWorker(options); + options.plugins = (options.plugins || []).concat( + (options.connection?.plugins || []).filter(p => isWorkerPlugin(p)).map(p => p as Plugin)); + for (const plugin of options.plugins) { + options = plugin.configureWorker(options); + } if (!options.taskQueue) { throw new TypeError('Task queue name is required'); } @@ -558,7 +561,7 @@ export class Worker { compiledOptionsWithBuildId, logger, metricMeter, - plugin, + options.plugins || [], connection, ); } @@ -701,8 +704,10 @@ export class Worker { } private static async constructReplayWorker(options: ReplayWorkerOptions): Promise<[Worker, native.HistoryPusher]> { - const plugin = Worker.buildPluginChain(options.plugins) - options = plugin.configureReplayWorker(options); + const plugins = options.plugins ?? []; + for (const plugin of plugins) { + options = plugin.configureReplayWorker(options); + } const nativeWorkerCtor: NativeWorkerConstructor = this.nativeWorkerCtor; const fixedUpOptions: WorkerOptions = { taskQueue: (options.replayName ?? 'fake_replay_queue') + '-' + this.replayWorkerCount, @@ -730,7 +735,7 @@ export class Worker { addBuildIdIfMissing(compiledOptions, bundle.code) ); return [ - new this(runtime, replayHandle.worker, workflowCreator, compiledOptions, logger, metricMeter, plugin,undefined, true), + new this(runtime, replayHandle.worker, workflowCreator, compiledOptions, logger, metricMeter, plugins, undefined, true), replayHandle.historyPusher, ]; } @@ -800,7 +805,7 @@ export class Worker { /** Logger bound to 'sdkComponent: worker' */ protected readonly logger: Logger, protected readonly metricMeter: MetricMeter, - protected readonly plugin: Plugin, + protected readonly plugins: Plugin[], protected readonly connection?: NativeConnection, protected readonly isReplayWorker: boolean = false ) { @@ -1965,7 +1970,13 @@ export class Worker { * To stop polling, call {@link shutdown} or send one of {@link Runtime.options.shutdownSignals}. */ async run(): Promise { - return this.plugin.runWorker(this); + let nextFunction = (w: Worker) => w.runInternal(); + for (const plugin of this.plugins) { + // Early bind the nextFunction + const next = nextFunction; + nextFunction = (w: Worker) => plugin.runWorker(w, next); + } + return nextFunction(this); } private async runInternal(): Promise { @@ -2048,43 +2059,6 @@ export class Worker { } } } - - private static rootPlugin = class implements Plugin { - name: string = 'RootPlugin'; - initWorkerPlugin(_next: Plugin): Plugin { - throw new Error('Root plugin should not be initialized'); - } - - configureWorker(config: WorkerOptions): WorkerOptions { - return config; - } - - configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { - return config; - } - - runWorker(worker: Worker): Promise { - return worker.runInternal(); - } - } - - protected static buildPluginChain(plugins: Plugin[] | undefined): Plugin { - if (plugins === undefined || plugins.length === 0) { - return new Worker.rootPlugin(); - } - - // Start with the root plugin at the end - let chain: Plugin = new Worker.rootPlugin(); - - // Build the chain in reverse order - for (let i = plugins.length - 1; i >= 0; i--) { - const plugin = plugins[i]; - plugin.initWorkerPlugin(chain); - chain = plugin; - } - - return chain; - } } export function parseWorkflowCode(code: string, codePath?: string): WorkflowBundleWithSourceMapAndFilename { diff --git a/packages/worker/src/workflow/bundler.ts b/packages/worker/src/workflow/bundler.ts index 54807bd28..cd9ede301 100644 --- a/packages/worker/src/workflow/bundler.ts +++ b/packages/worker/src/workflow/bundler.ts @@ -53,10 +53,13 @@ export class WorkflowCodeBundler { protected readonly failureConverterPath?: string; protected readonly ignoreModules: string[]; protected readonly webpackConfigHook: (config: Configuration) => Configuration; - protected readonly plugin: Plugin; + protected readonly plugins: Plugin[]; constructor(options: BundleOptions) { - this.plugin = buildPluginChain(options.plugins); + this.plugins = options.plugins ?? []; + for (const plugin of this.plugins) { + options = plugin.configureBundler(options); + } const { logger, workflowsPath, @@ -65,7 +68,7 @@ export class WorkflowCodeBundler { workflowInterceptorModules, ignoreModules, webpackConfigHook, - } = this.plugin.configureBundler(options); + } = options; this.logger = logger ?? new DefaultLogger('INFO'); this.workflowsPath = workflowsPath; this.payloadConverterPath = payloadConverterPath; @@ -319,61 +322,16 @@ export interface Plugin { */ get name(): string; - /** - * Initialize this plugin in the bundler plugin chain. - * - * This method sets up the chain of responsibility pattern by storing a reference - * to the next plugin in the chain. It is called during bundler creation to build - * the plugin chain. - * - * Args: - * next: The next plugin in the chain to delegate to. - * - * Returns: - * This plugin instance for method chaining. - */ - initBundlerPlugin(next: Plugin): Plugin; - /** * Hook called when creating a bundler to allow modification of configuration. */ configureBundler(config: BundleOptions): BundleOptions; } -class RootPlugin implements Plugin { - name: string = 'RootPlugin'; - - initBundlerPlugin(_next: Plugin): Plugin { - throw new Error('Root plugin should not be initialized'); - } - - configureBundler(config: BundleOptions): BundleOptions { - return config; - } -} - -function buildPluginChain(plugins: Plugin[] | undefined): Plugin { - if (plugins === undefined || plugins.length === 0) { - return new RootPlugin(); - } - - // Start with the root plugin at the end - let chain: Plugin = new RootPlugin(); - - // Build the chain in reverse order - for (let i = plugins.length - 1; i >= 0; i--) { - const plugin = plugins[i]; - plugin.initBundlerPlugin(chain); - chain = plugin; - } - - return chain; -} // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function isBundlerPlugin(p: any): p is Plugin { - console.log(p, "initBundlerPlugin" in p && "configureBundler" in p); - return "initBundlerPlugin" in p && "configureBundler" in p; + return "configureBundler" in p; } From 748a1c9b441183af55c3226b8da225eb1bc96724 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 13 Oct 2025 16:56:33 -0700 Subject: [PATCH 05/11] Ongoing work on simpleplugin --- PLUGIN_FEATURE.md | 323 ------------------------ package-lock.json | 23 +- package.json | 2 + packages/client/src/client.ts | 8 +- packages/client/src/connection.ts | 14 +- packages/client/src/index.ts | 4 +- packages/client/src/plugin.ts | 4 +- packages/plugin/package.json | 36 +++ packages/plugin/src/plugin.ts | 206 +++++++++++++++ packages/plugin/tsconfig.json | 8 + packages/test/src/test-plugins.ts | 4 +- packages/test/tsconfig.json | 1 + packages/worker/src/connection.ts | 14 +- packages/worker/src/index.ts | 4 +- packages/worker/src/plugin.ts | 151 +---------- packages/worker/src/worker-options.ts | 4 +- packages/worker/src/worker.ts | 17 +- packages/worker/src/workflow/bundler.ts | 8 +- 18 files changed, 320 insertions(+), 511 deletions(-) delete mode 100644 PLUGIN_FEATURE.md create mode 100644 packages/plugin/package.json create mode 100644 packages/plugin/src/plugin.ts create mode 100644 packages/plugin/tsconfig.json diff --git a/PLUGIN_FEATURE.md b/PLUGIN_FEATURE.md deleted file mode 100644 index 1eade8716..000000000 --- a/PLUGIN_FEATURE.md +++ /dev/null @@ -1,323 +0,0 @@ -# Temporal TypeScript SDK Plugin Support - -This implements a Plugin system similar to the Python SDK's Plugin feature, allowing you to extend and customize the behavior of Temporal clients and workers through a chain of responsibility pattern. - -## Overview - -Plugins provide a way to intercept and modify: -- Client creation and configuration -- Service connections -- Worker configuration and execution -- Activities, workflows, and interceptors - -## Architecture - -The plugin system uses a **chain of responsibility pattern** where each plugin can: -1. Modify configuration -2. Pass control to the next plugin in the chain -3. Perform custom logic before/after delegation - -## Client Plugin Support - -### ClientOptions Extension - -```typescript -export interface ClientOptions extends BaseClientOptions { - // ... existing options ... - - /** - * List of plugins to register with the client. - */ - plugins?: Plugin[]; -} -``` - -### Plugin Base Class - -```typescript -export abstract class Plugin { - /** - * Gets the fully qualified name of this plugin. - */ - get name(): string; - - /** - * Initialize this plugin in the plugin chain. - */ - initClientPlugin(next: Plugin): Plugin; - - /** - * Hook called when creating a client to allow modification of configuration. - */ - configureClient(config: ClientOptions): ClientOptions; -} -``` - -## Worker Plugin Support - -### WorkerOptions Extension - -```typescript -export interface WorkerOptions { - // ... existing options ... - - /** - * List of plugins to register with the worker. - */ - plugins?: Plugin[]; -} -``` - -### Worker Plugin Methods - -```typescript -export abstract class Plugin extends ClientPlugin { - /** - * Initialize this plugin in the worker plugin chain. - */ - initWorkerPlugin(next: Plugin): Plugin; - - /** - * Hook called when creating a worker to allow modification of configuration. - */ - configureWorker(config: WorkerOptions): WorkerOptions; -} -``` - -## Usage Examples - -### Basic Client Plugin - -```typescript -import { Plugin, Client } from '@temporalio/client'; - -class CustomClientPlugin extends Plugin { - configureClient(config: ClientOptions): ClientOptions { - // Add custom metadata - console.log('Configuring client with custom settings'); - - // Modify configuration - const modifiedConfig = { - ...config, - // Add custom properties - }; - - // Chain to next plugin - return super.configureClient(modifiedConfig); - } -} - -// Use with client -const client = new Client({ - plugins: [new CustomClientPlugin()], - namespace: 'default', -}); -``` - -### Basic Worker Plugin - -```typescript -import { Plugin, Worker } from '@temporalio/worker'; - -class CustomWorkerPlugin extends Plugin { - configureWorker(config: WorkerOptions): WorkerOptions { - // Modify task queue name - const taskQueue = config.taskQueue ? `custom-${config.taskQueue}` : config.taskQueue; - - const modifiedConfig = { - ...config, - taskQueue, - identity: `${config.identity}-with-plugin`, - }; - - console.log(`Modified task queue to: ${taskQueue}`); - return super.configureWorker(modifiedConfig); - } -} - -// Use with worker -const worker = await Worker.create({ - plugins: [new CustomWorkerPlugin()], - taskQueue: 'my-task-queue', - workflowsPath: './workflows', -}); -``` - -### Activity Plugin Example - -```typescript -class ActivityPlugin extends Plugin { - private activities: Record; - - constructor(activities: Record) { - super(); - this.activities = activities; - } - - configureWorker(config: WorkerConfig): WorkerConfig { - // Merge custom activities with existing ones - const existingActivities = config.activities || {}; - const mergedActivities = { - ...existingActivities, - ...this.activities, - }; - - return super.configureWorker({ - ...config, - activities: mergedActivities, - }); - } -} - -// Custom activities -const customActivities = { - async logMessage(message: string): Promise { - console.log(`Custom activity: ${message}`); - }, - - async processData(data: any): Promise { - return { processed: true, data }; - }, -}; - -// Use the plugin -const worker = await Worker.create({ - plugins: [new ActivityPlugin(customActivities)], - taskQueue: 'my-task-queue', - workflowsPath: './workflows', -}); -``` - -### Multiple Plugin Chain - -```typescript -class LoggingPlugin extends Plugin { - configureClient(config: ClientOptions): ClientOptions { - console.log('LoggingPlugin: Client configuration'); - return super.configureClient(config); - } - - configureWorker(config: WorkerOptions): WorkerOptions { - console.log('LoggingPlugin: Worker configuration'); - return super.configureWorker(config); - } -} - -class MetricsPlugin extends Plugin { - configureClient(config: ClientOptions): ClientOptions { - console.log('MetricsPlugin: Adding metrics interceptors'); - // Add metrics interceptors - return super.configureClient(config); - } -} - -// Chain multiple plugins -const client = new Client({ - plugins: [ - new LoggingPlugin(), - new MetricsPlugin(), - new CustomClientPlugin(), - ], - namespace: 'default', -}); -``` - -## Implementation Details - -### Plugin Chain Building - -The `buildPluginChain()` function creates a chain of responsibility: - -```typescript -export function buildPluginChain(plugins: Plugin[]): Plugin { - if (plugins.length === 0) { - return new _RootPlugin(); - } - - // Start with the root plugin at the end - let chain: Plugin = new _RootPlugin(); - - // Build the chain in reverse order - for (let i = plugins.length - 1; i >= 0; i--) { - const plugin = plugins[i]; - plugin.initClientPlugin(chain); - chain = plugin; - } - - return chain; -} -``` - -### Client Integration - -The Client constructor processes plugins before initialization: - -```typescript -constructor(options?: ClientOptions) { - // Process plugins first to allow them to modify configuration - const processedOptions = Client.applyPlugins(options); - - super(processedOptions); - // ... rest of constructor -} - -private static applyPlugins(options?: ClientOptions): ClientOptions { - if (!options?.plugins?.length) { - return options ?? {}; - } - - const pluginChain = buildPluginChain(options.plugins); - const clientConfig: ClientOptions = { ...options }; - const processedConfig = pluginChain.configureClient(clientConfig); - - return { ...processedConfig }; -} -``` - -### Worker Integration - -Similarly, the Worker.create() method would process plugins: - -```typescript -public static async create(options: WorkerOptions): Promise { - // Apply plugins to modify configuration - const processedOptions = Worker.applyPlugins(options); - - // ... rest of worker creation with processed options -} -``` - -## Files Modified/Added - -### Client Package (`packages/client/`) -- **NEW**: `src/plugin.ts` - Base Plugin class and client plugin support -- **MODIFIED**: `src/client.ts` - Added plugins field to ClientOptions and plugin processing -- **MODIFIED**: `src/index.ts` - Export Plugin and related types - -### Worker Package (`packages/worker/`) -- **NEW**: `src/plugin.ts` - Worker plugin extension and chain building -- **MODIFIED**: `src/worker-options.ts` - Added plugins field to WorkerOptions -- **MODIFIED**: `src/index.ts` - Export worker Plugin and related types - -### Test Package (`packages/test/`) -- **NEW**: `src/example-plugin.ts` - Comprehensive examples and usage patterns - -## Benefits - -1. **Extensibility**: Easily extend client and worker functionality without modifying core SDK -2. **Composability**: Chain multiple plugins together for complex customizations -3. **Consistency**: Similar pattern to Python SDK for cross-language familiarity -4. **Separation of Concerns**: Keep custom logic separate from core application code -5. **Reusability**: Plugins can be shared across projects and teams - -## Common Use Cases - -- **Authentication**: Add custom auth headers or credentials -- **Observability**: Inject custom metrics, logging, or tracing -- **Data Transformation**: Custom data converters or payload codecs -- **Environment Configuration**: Different settings per environment -- **Activity/Workflow Registration**: Dynamically add activities or workflows -- **Connection Customization**: Modify connection parameters or retry policies -- **Namespace Management**: Automatic namespace prefixing or routing - -This plugin system provides a powerful, flexible way to customize Temporal SDK behavior while maintaining clean separation of concerns and enabling code reuse across projects. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 921b96cd4..e0ef5fb6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "packages/interceptors-opentelemetry", "packages/nexus", "packages/nyc-test-coverage", + "packages/plugin", "packages/proto", "packages/test", "packages/testing", @@ -2797,6 +2798,10 @@ "resolved": "packages/nyc-test-coverage", "link": true }, + "node_modules/@temporalio/plugin": { + "resolved": "packages/plugin", + "link": true + }, "node_modules/@temporalio/proto": { "resolved": "packages/proto", "link": true @@ -18433,6 +18438,7 @@ "@temporalio/client": "file:../client", "@temporalio/common": "file:../common", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", + "@temporalio/nexus": "file:../nexus", "@temporalio/proto": "file:../proto", "@temporalio/testing": "file:../testing", "@temporalio/worker": "file:../worker", @@ -18484,6 +18490,14 @@ "webpack": "^5.94.0" } }, + "packages/plugin": { + "name": "@temporalio/plugin", + "version": "1.13.0", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "packages/proto": { "name": "@temporalio/proto", "version": "1.13.0", @@ -20636,6 +20650,9 @@ "webpack": "^5.94.0" } }, + "@temporalio/plugin": { + "version": "file:packages/plugin" + }, "@temporalio/proto": { "version": "file:packages/proto", "requires": { @@ -27574,8 +27591,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "nexus-rpc": { - "version": "git+ssh://git@github.com/nexus-rpc/sdk-typescript.git#f594a7fd9e33bd14e5ce1ed04c5225fc708e7866", - "from": "nexus-rpc@^0.0.1" + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/nexus-rpc/-/nexus-rpc-0.0.1.tgz", + "integrity": "sha512-hAWn8Hh2eewpB5McXR5EW81R3pR/ziuGhKCF3wFyUVCklanPqrIgMNr7jKCbzXeNVad0nUDfWpFRqh2u+zxQtw==" }, "nice-try": { "version": "1.0.5", @@ -30418,6 +30436,7 @@ "@temporalio/client": "file:../client", "@temporalio/common": "file:../common", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", + "@temporalio/nexus": "file:../nexus", "@temporalio/proto": "file:../proto", "@temporalio/testing": "file:../testing", "@temporalio/worker": "file:../worker", diff --git a/package.json b/package.json index 93d406579..6a8a4ba87 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@temporalio/interceptors-opentelemetry": "file:packages/interceptors-opentelemetry", "@temporalio/nexus": "file:packages/nexus", "@temporalio/nyc-test-coverage": "file:packages/nyc-test-coverage", + "@temporalio/plugin": "file:packages/plugin", "@temporalio/proto": "file:packages/proto", "@temporalio/test": "file:packages/test", "@temporalio/testing": "file:packages/testing", @@ -90,6 +91,7 @@ "packages/interceptors-opentelemetry", "packages/nexus", "packages/nyc-test-coverage", + "packages/plugin", "packages/proto", "packages/test", "packages/testing", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 06e59998c..1d5aa753f 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -6,7 +6,7 @@ import { ScheduleClient } from './schedule-client'; import { QueryRejectCondition, WorkflowService } from './types'; import { WorkflowClient } from './workflow-client'; import { TaskQueueClient } from './task-queue-client'; -import { isClientPlugin, Plugin } from './plugin'; +import { isClientPlugin, ClientPlugin } from './plugin'; export interface ClientOptions extends BaseClientOptions { /** @@ -22,7 +22,7 @@ export interface ClientOptions extends BaseClientOptions { * Plugins allow you to extend and customize the behavior of Temporal clients through a chain of * responsibility pattern. They can intercept and modify client creation. */ - plugins?: Plugin[]; + plugins?: ClientPlugin[]; workflow?: { /** @@ -65,8 +65,8 @@ export class Client extends BaseClient { options = options ?? {}; // Add client plugins from the connection - options.plugins = (options.plugins || []).concat( - (options.connection?.plugins || []).filter(p => isClientPlugin(p)).map(p => p as Plugin)); + options.plugins = (options.plugins ?? []).concat( + (options.connection?.plugins ?? []).filter(p => isClientPlugin(p)).map(p => p as ClientPlugin)); // Process plugins first to allow them to modify connect configuration for (const plugin of options?.plugins ?? []) { diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts index 706009fb3..f83ba5840 100644 --- a/packages/client/src/connection.ts +++ b/packages/client/src/connection.ts @@ -131,7 +131,7 @@ export interface ConnectionOptions { */ connectTimeout?: Duration; - plugins?: Plugin[]; + plugins?: ConnectionPlugin[]; } export type ConnectionOptionsWithDefaults = Required< @@ -325,7 +325,7 @@ export class Connection { */ public readonly healthService: HealthService; - public readonly plugins: Plugin[]; + public readonly plugins: ConnectionPlugin[]; readonly callContextStorage: AsyncLocalStorage; private readonly apiKeyFnRef: { fn?: () => string }; @@ -446,8 +446,8 @@ export class Connection { * This method does not verify connectivity with the server. We recommend using {@link connect} instead. */ static lazy(options?: ConnectionOptions): Connection { - options = options || {}; - for (const plugin of options.plugins || []) { + options = options ?? {}; + for (const plugin of options.plugins ?? []) { options = plugin.configureConnection(options); } return new this(this.createCtorOptions(options)); @@ -483,7 +483,7 @@ export class Connection { this.healthService = healthService; this.callContextStorage = callContextStorage; this.apiKeyFnRef = apiKeyFnRef; - this.plugins = options.plugins || []; + this.plugins = options.plugins ?? []; } protected static generateRPCImplementation({ @@ -696,11 +696,11 @@ export class Connection { } } -export interface Plugin { +export interface ConnectionPlugin { configureConnection(config: ConnectionOptions): ConnectionOptions; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isConnectionPlugin(p: any): p is Plugin { +export function isConnectionPlugin(p: any): p is ConnectionPlugin { return "configureConnection" in p; } \ No newline at end of file diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d3fae0dfe..f5bdf3489 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -36,8 +36,8 @@ export * from './grpc-retry'; export * from './interceptors'; export * from './types'; export * from './workflow-client'; -export { Plugin } from './plugin'; -export { Plugin as ConnectionPlugin, isConnectionPlugin } from './connection'; +export { ClientPlugin } from './plugin'; +export { ConnectionPlugin, isConnectionPlugin } from './connection'; export * from './workflow-options'; export * from './schedule-types'; export * from './schedule-client'; diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index 2ac8579a2..1852fe3de 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -1,6 +1,6 @@ import type { ClientOptions } from './client'; -export interface Plugin { +export interface ClientPlugin { /** * Gets the name of this plugin. */ @@ -23,6 +23,6 @@ export interface Plugin { } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isClientPlugin(p: any): p is Plugin { +export function isClientPlugin(p: any): p is ClientPlugin { return "configureClient" in p; } \ No newline at end of file diff --git a/packages/plugin/package.json b/packages/plugin/package.json new file mode 100644 index 000000000..ee73cd091 --- /dev/null +++ b/packages/plugin/package.json @@ -0,0 +1,36 @@ +{ + "name": "@temporalio/plugin", + "version": "1.13.0", + "description": "Library for plugin creation", + "main": "lib/index.js", + "types": "./lib/index.d.ts", + "keywords": [ + "temporal", + "workflow", + "worker", + "plugin" + ], + "author": "Temporal Technologies Inc. ", + "license": "MIT", + "dependencies": { + }, + "bugs": { + "url": "https://github.com/temporalio/sdk-typescript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/temporalio/sdk-typescript.git", + "directory": "packages/plugin" + }, + "homepage": "https://github.com/temporalio/sdk-typescript/tree/main/packages/plugin", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">= 18.0.0" + }, + "files": [ + "src", + "lib" + ] +} diff --git a/packages/plugin/src/plugin.ts b/packages/plugin/src/plugin.ts new file mode 100644 index 000000000..67f93a6de --- /dev/null +++ b/packages/plugin/src/plugin.ts @@ -0,0 +1,206 @@ +import type * as nexus from 'nexus-rpc'; +import type { DataConverter } from '@temporalio/common'; +import type { native } from '@temporalio/core-bridge'; +import { ClientInterceptors, ClientOptions, ClientPlugin, ConnectionPlugin, ConnectionOptions, + WorkflowClientInterceptors, WorkflowClientInterceptor +} from '@temporalio/client'; +import type { + BundlerPlugin, + NativeConnectionPlugin, + WorkerInterceptors, + WorkerPlugin, + WorkflowBundleOption, + WorkerOptions, + ReplayWorkerOptions, + Worker, + BundleOptions, + NativeConnectionOptions, + TLSConfig +} from '@temporalio/worker'; + +type PluginParameter = T | ((p: T | undefined) => T); + +export interface SimplePluginOptions { + readonly name: string; + readonly tls?: PluginParameter; + readonly apiKey?: PluginParameter string)>; + readonly dataConverter?: PluginParameter; + readonly clientInterceptors?: PluginParameter; + readonly clientOptions?: Omit; + readonly activities?: PluginParameter; + readonly nexusServices?: PluginParameter[]>; + readonly workflowsPath?: PluginParameter; + readonly workflowBundle?: PluginParameter; + readonly workerInterceptors?: PluginParameter; + readonly workerOptions?: Omit; + readonly replayWorkerOptions?: Omit; + readonly bundleOptions?: Omit; + readonly connectionOptions?: Omit; + readonly nativeConnectionOptions?: Omit; + readonly runContext?: { + before: () => Promise; + after: () => Promise; + }; +} + +export class SimplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin { + readonly name: string; + + constructor(protected readonly options: SimplePluginOptions) { + this.name = options.name; + } + + configureClient(config: ClientOptions): ClientOptions { + return { + ...config, + ...this.options.clientOptions, + dataConverter: resolveParameter(config.dataConverter, this.options.dataConverter), + interceptors: resolveClientInterceptors(config.interceptors, this.options.clientInterceptors), + }; + } + + configureWorker(config: WorkerOptions): WorkerOptions { + return { + ...config, + ...this.options.workerOptions, + dataConverter: resolveParameter(config.dataConverter, this.options.dataConverter), + activities: resolveAppendObjectParameter(config.activities, this.options.activities), + nexusServices: resolveAppendParameter(config.nexusServices, this.options.nexusServices), + workflowsPath: resolveParameter(config.workflowsPath, this.options.workflowsPath), + workflowBundle: resolveParameter(config.workflowBundle, this.options.workflowBundle), + interceptors: resolveWorkerInterceptors(config.interceptors, this.options.workerInterceptors), + }; + } + + configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { + return { + ...config, + ...this.options.replayWorkerOptions, + dataConverter: resolveParameter(config.dataConverter, this.options.dataConverter), + workflowsPath: resolveParameter(config.workflowsPath, this.options.workflowsPath), + workflowBundle: resolveParameter(config.workflowBundle, this.options.workflowBundle), + interceptors: resolveWorkerInterceptors(config.interceptors, this.options.workerInterceptors), + }; + } + + async runWorker(worker: Worker, next: (w: Worker) => Promise): Promise { + if (this.options.runContext !== undefined) { + await this.options.runContext.before(); + } + const result = await next(worker); + if (this.options.runContext !== undefined) { + await this.options.runContext.after(); + } + return result; + } + + configureBundler(config: BundleOptions): BundleOptions { + return { + ...config, + ...this.options.bundleOptions, + workflowsPath: resolveRequiredParameter(config.workflowsPath, this.options.workflowsPath), + }; + } + + configureConnection(config: ConnectionOptions): ConnectionOptions { + return { + ...config, + ...this.options.connectionOptions, + tls: resolveParameter(config.tls, this.options.tls), + apiKey: resolveParameter(config.apiKey, this.options.apiKey), + }; + } + + configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions { + const resolvedApiKey = resolveParameter(options.apiKey, this.options.apiKey); + if (typeof resolvedApiKey === 'function') { + throw new TypeError('NativeConnectionOptions does not support apiKey as a function'); + } + return { + ...options, + ...this.options.nativeConnectionOptions, + tls: resolveParameter(options.tls, this.options.tls), + apiKey: resolvedApiKey, + }; + } + + connectNative(next: () => Promise): Promise { + return next(); + } +} + +function resolveParameterWithResolution( + existing: T | undefined, + parameter: PluginParameter | undefined, + resolve: (existing: T, param: T) => T +): T | undefined { + if (parameter === undefined) { + return existing; + } + if (typeof parameter === 'function') { + // @ts-expect-error Can't infer that parameter is a function + return parameter(existing); + } + if (existing === undefined) { + return parameter + } + return resolve(existing, parameter); +} + +function resolveRequiredParameter(existing: T, parameter?: PluginParameter): T { + return resolveParameterWithResolution(existing, parameter, (_existing, param) => param)!; +} + +function resolveParameter(existing?: T, parameter?: PluginParameter): T | undefined { + if (parameter === undefined) { + return existing; + } + return resolveParameterWithResolution(existing as T, parameter, (_existing, param) => param); +} + +function resolveAppendParameter(existing?: T[], parameter?: PluginParameter): T[] | undefined { + if (parameter === undefined) { + return existing; + } + return resolveParameterWithResolution(existing ?? [] as T[], parameter, (existing, param) => existing.concat(param)); +} + +function resolveAppendObjectParameter(existing?: object, parameter?: PluginParameter): object | undefined { + if (parameter === undefined) { + return existing; + } + return resolveParameterWithResolution(existing ?? {}, parameter, (existing, param) => ({ ...existing, ...param })); +} + +function resolveClientInterceptors(existing?: ClientInterceptors, parameter?: PluginParameter): ClientInterceptors | undefined{ + return resolveParameterWithResolution(existing, parameter, (existing, parameter) => ({ + workflow: tryConcat(modernWorkflowInterceptors(existing?.workflow), modernWorkflowInterceptors(parameter?.workflow)), + schedule: tryConcat(existing?.schedule, parameter?.schedule), + })); +} + +function resolveWorkerInterceptors(existing?: WorkerInterceptors, parameter?: PluginParameter): WorkerInterceptors | undefined{ + return resolveParameterWithResolution(existing, parameter, (existing, parameter) => ({ + client: resolveClientInterceptors(existing.client, parameter.client), + activity: resolveAppendParameter(existing.activity, parameter.activity), + nexus: resolveAppendParameter(existing.nexus, parameter.nexus), + workflowModules: resolveAppendParameter(existing.workflowModules, parameter.workflowModules), + })); +} + +function modernWorkflowInterceptors(interceptors: WorkflowClientInterceptors | WorkflowClientInterceptor[] | undefined): WorkflowClientInterceptor[] | undefined { + if (interceptors === undefined || Array.isArray(interceptors)) { + return interceptors; + } + throw new Error("Simple plugin doesn't support old style workflow client interceptors"); +} + +function tryConcat(left: T[] | undefined, right: T[] | undefined): T[] | undefined { + if (right === undefined) { + return left + } + if (left === undefined) { + return right + } + return left.concat(right) +} \ No newline at end of file diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json new file mode 100644 index 000000000..1e09513a4 --- /dev/null +++ b/packages/plugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src" + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/test/src/test-plugins.ts b/packages/test/src/test-plugins.ts index 7d8ee8471..eb0e02f8b 100644 --- a/packages/test/src/test-plugins.ts +++ b/packages/test/src/test-plugins.ts @@ -1,9 +1,9 @@ import { randomUUID } from 'crypto'; import anyTest, { TestFn } from 'ava'; -import { Client, ClientOptions, ConnectionPlugin, Plugin as ClientPlugin, ConnectionOptions } from '@temporalio/client'; +import { Client, ClientOptions, ConnectionPlugin, ClientPlugin as ClientPlugin, ConnectionOptions } from '@temporalio/client'; import { WorkerOptions, - Plugin as WorkerPlugin, + WorkerPlugin as WorkerPlugin, ReplayWorkerOptions, Worker, BundlerPlugin, diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json index 3e55850d4..cc60e626c 100644 --- a/packages/test/tsconfig.json +++ b/packages/test/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../common" }, { "path": "../interceptors-opentelemetry" }, { "path": "../nexus" }, + { "path": "../plugin" }, { "path": "../testing" }, { "path": "../worker" }, { "path": "../workflow" }, diff --git a/packages/worker/src/connection.ts b/packages/worker/src/connection.ts index aad0b5da4..84a5d3ead 100644 --- a/packages/worker/src/connection.ts +++ b/packages/worker/src/connection.ts @@ -18,6 +18,7 @@ import { TransportError } from './errors'; import { NativeConnectionOptions } from './connection-options'; import { Runtime } from './runtime'; import { ClientOptions } from '@grpc/grpc-js'; +import { composeInterceptors } from '@temporalio/common/lib/interceptors'; /** * A Native Connection object that delegates calls to the Rust Core binary extension. @@ -233,8 +234,8 @@ export class NativeConnection implements ConnectionLike { * Eagerly connect to the Temporal server and return a NativeConnection instance */ static async connect(options?: NativeConnectionOptions): Promise { - options = options || {}; - for (const plugin of options.plugins || []) { + options = options ?? {}; + for (const plugin of options.plugins ?? []) { options = plugin.configureNativeConnection(options); } const internalOptions = (options as InternalConnectionOptions)?.[InternalConnectionOptionsSymbol] ?? {}; @@ -243,14 +244,9 @@ export class NativeConnection implements ConnectionLike { try { const runtime = Runtime.instance(); - let connectNative = async () => await runtime.createNativeClient(options) - for (const plugin of options.plugins || []) { - const cn = connectNative - connectNative = async () => await plugin.connectNative(cn); - } - + const connectNative = composeInterceptors(options.plugins ?? [], 'connectNative', () => runtime.createNativeClient(options)); const client = await connectNative(); - return new this(runtime, client, enableTestService, options.plugins || []); + return new this(runtime, client, enableTestService, options.plugins ?? []); } catch (err) { if (err instanceof TransportError) { throw new TransportError(err.message); diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 757183a2c..a0d6d1976 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -35,7 +35,7 @@ export { } from './runtime-options'; export * from './sinks'; export { DataConverter, defaultPayloadConverter, State, Worker, WorkerStatus } from './worker'; -export { Plugin } from './plugin'; +export { WorkerPlugin } from './plugin'; export { CompiledWorkerOptions, ReplayWorkerOptions, @@ -45,7 +45,7 @@ export { WorkflowBundlePath, } from './worker-options'; export { ReplayError, ReplayHistoriesIterable, ReplayResult } from './replay'; -export { BundleOptions, bundleWorkflowCode, WorkflowBundleWithSourceMap, Plugin as BundlerPlugin } from './workflow/bundler'; +export { BundleOptions, bundleWorkflowCode, WorkflowBundleWithSourceMap, BundlerPlugin } from './workflow/bundler'; export { WorkerTuner, TunerHolder, diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index 299793058..3aa616a0c 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,32 +1,13 @@ -import * as nexus from 'nexus-rpc'; -import type { - ClientInterceptors, - ClientOptions, - ConnectionOptions, - ConnectionPlugin, - Plugin as ClientPlugin, -} from '@temporalio/client'; -import { native } from '@temporalio/core-bridge'; -import type { ReplayWorkerOptions, WorkerOptions, WorkflowBundleOption } from './worker-options'; -import type { - DataConverter, - Worker, -} from './worker'; -import type { - BundleOptions, - BundlerPlugin, - NativeConnectionOptions, - NativeConnectionPlugin, - WorkerInterceptors, -} from './index'; +import type { ReplayWorkerOptions, WorkerOptions } from './worker-options'; +import type { Worker } from './worker'; + /** - * Base Plugin class for both client and worker functionality. + * Base Plugin class for worker functionality. * - * Plugins provide a way to extend and customize the behavior of Temporal clients and workers through a chain of - * responsibility pattern. They allow you to intercept and modify client creation, service connections, worker - * configuration, and worker execution. + * Plugins provide a way to extend and customize the behavior of Temporal workers. + * They allow you to intercept and modify worker configuration and worker execution. */ -export interface Plugin { +export interface WorkerPlugin { /** * Gets the name of this plugin. * @@ -69,122 +50,6 @@ export interface Plugin { } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isWorkerPlugin(p: any): p is Plugin { +export function isWorkerPlugin(p: any): p is WorkerPlugin { return "configureWorker" in p && "configureReplayWorker" in p && "runWorker" in p; } - -type PluginParameter = T | ((p: T | undefined) => T); - -export class SimplePlugin implements Plugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin { - - constructor( - readonly name: string, - protected readonly dataConverter?: PluginParameter, - protected readonly clientInterceptors?: PluginParameter, - protected readonly activities?: PluginParameter, - protected readonly nexusServices?: PluginParameter[]>, - protected readonly workflowsPath?: PluginParameter, - protected readonly workflowBundle?: PluginParameter, - protected readonly workerInterceptors?: PluginParameter, - protected readonly runContext?: { - before: () => Promise, - after: () => Promise, - }){} - - private static resolveRequiredParameter(existing: T, parameter: PluginParameter | undefined): T { - if (parameter === undefined) { - return existing; - } - if (typeof parameter === 'function') { - // @ts-expect-error Can't infer that parameter is a function - return parameter(existing); - } - return parameter; - } - - private static resolveParameter(existing: T | undefined, parameter: PluginParameter | undefined): T | undefined { - if (parameter === undefined) { - return existing; - } - if (typeof parameter === 'function') { - // @ts-expect-error Can't infer that parameter is a function - return parameter(existing); - } - return parameter; - } - - private static resolveAppendParameter(existing: T[] | undefined, parameter: PluginParameter | undefined): T[] | undefined { - if (parameter === undefined) { - return existing; - } - if (typeof parameter === 'function') { - return parameter(existing); - } - return (existing || []).concat(parameter); - } - - - configureClient(config: ClientOptions): ClientOptions { - config.dataConverter = SimplePlugin.resolveParameter(config.dataConverter, this.dataConverter); - - // TODO: Way to do interceptor append? - config.interceptors = SimplePlugin.resolveParameter(config.interceptors, this.clientInterceptors); - return config; - } - - configureWorker(config: WorkerOptions): WorkerOptions { - config.dataConverter = SimplePlugin.resolveParameter(config.dataConverter, this.dataConverter); - - // TODO: Way to do activities append? - config.activities = SimplePlugin.resolveParameter(config.activities, this.activities); - config.nexusServices = SimplePlugin.resolveAppendParameter(config.nexusServices, this.nexusServices); - config.workflowsPath = SimplePlugin.resolveParameter(config.workflowsPath, this.workflowsPath); - config.workflowBundle = SimplePlugin.resolveParameter(config.workflowBundle, this.workflowBundle); - - // TODO: Way to do interceptor append? - config.interceptors = SimplePlugin.resolveParameter(config.interceptors, this.workerInterceptors); - - return config; - } - - configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { - config.dataConverter = SimplePlugin.resolveParameter(config.dataConverter, this.dataConverter); - - // TODO: Way to do activities append? - config.workflowsPath = SimplePlugin.resolveParameter(config.workflowsPath, this.workflowsPath); - config.workflowBundle = SimplePlugin.resolveParameter(config.workflowBundle, this.workflowBundle); - - // TODO: Way to do interceptor append? - config.interceptors = SimplePlugin.resolveParameter(config.interceptors, this.workerInterceptors); - - return config; - } - - async runWorker(worker: Worker, next: (w: Worker) => Promise): Promise { - if (this.runContext !== undefined) { - await this.runContext.before(); - } - const result = await next(worker); - if (this.runContext !== undefined) { - await this.runContext.after(); - } - return result; - } - - configureBundler(config: BundleOptions): BundleOptions { - config.workflowsPath = SimplePlugin.resolveRequiredParameter(config.workflowsPath, this.workflowsPath); - return config; - } - - configureConnection(config: ConnectionOptions): ConnectionOptions { - return config; - } - - configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions { - return options; - } - - connectNative(next: () => Promise): Promise { - return next(); - } -} \ No newline at end of file diff --git a/packages/worker/src/worker-options.ts b/packages/worker/src/worker-options.ts index 4482f82bd..698a78cf6 100644 --- a/packages/worker/src/worker-options.ts +++ b/packages/worker/src/worker-options.ts @@ -27,7 +27,7 @@ import { InjectedSinks } from './sinks'; import { MiB } from './utils'; import { WorkflowBundleWithSourceMap } from './workflow/bundler'; import { asNativeTuner, WorkerTuner } from './worker-tuner'; -import { Plugin } from './plugin'; +import { WorkerPlugin } from './plugin'; /** * Options to configure the {@link Worker} @@ -493,7 +493,7 @@ export interface WorkerOptions { * Worker plugins can be used to add custom activities, workflows, interceptors, or modify other * worker settings before the worker is fully initialized. */ - plugins?: Plugin[]; + plugins?: WorkerPlugin[]; /** * Registration of a {@link SinkFunction}, including per-sink-function options. diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 067559bfc..49b7741ff 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -109,7 +109,8 @@ import { } from './errors'; import { constructNexusOperationContext, NexusHandler } from './nexus'; import { handlerErrorToProto } from './nexus/conversions'; -import { isWorkerPlugin, Plugin } from './plugin'; +import { isWorkerPlugin, WorkerPlugin } from './plugin'; +import { composeInterceptors } from '@temporalio/common/lib/interceptors'; export { DataConverter, defaultPayloadConverter }; @@ -502,7 +503,7 @@ export class Worker { */ public static async create(options: WorkerOptions): Promise { options.plugins = (options.plugins || []).concat( - (options.connection?.plugins || []).filter(p => isWorkerPlugin(p)).map(p => p as Plugin)); + (options.connection?.plugins || []).filter(p => isWorkerPlugin(p)).map(p => p as WorkerPlugin)); for (const plugin of options.plugins) { options = plugin.configureWorker(options); } @@ -805,7 +806,7 @@ export class Worker { /** Logger bound to 'sdkComponent: worker' */ protected readonly logger: Logger, protected readonly metricMeter: MetricMeter, - protected readonly plugins: Plugin[], + protected readonly plugins: WorkerPlugin[], protected readonly connection?: NativeConnection, protected readonly isReplayWorker: boolean = false ) { @@ -1970,13 +1971,11 @@ export class Worker { * To stop polling, call {@link shutdown} or send one of {@link Runtime.options.shutdownSignals}. */ async run(): Promise { - let nextFunction = (w: Worker) => w.runInternal(); - for (const plugin of this.plugins) { - // Early bind the nextFunction - const next = nextFunction; - nextFunction = (w: Worker) => plugin.runWorker(w, next); + if (this.isReplayWorker) { + return this.runInternal() } - return nextFunction(this); + const composition = composeInterceptors(this.plugins, 'runWorker', (w: Worker) => w.runInternal()) + return composition(this); } private async runInternal(): Promise { diff --git a/packages/worker/src/workflow/bundler.ts b/packages/worker/src/workflow/bundler.ts index cd9ede301..86bfb4161 100644 --- a/packages/worker/src/workflow/bundler.ts +++ b/packages/worker/src/workflow/bundler.ts @@ -53,7 +53,7 @@ export class WorkflowCodeBundler { protected readonly failureConverterPath?: string; protected readonly ignoreModules: string[]; protected readonly webpackConfigHook: (config: Configuration) => Configuration; - protected readonly plugins: Plugin[]; + protected readonly plugins: BundlerPlugin[]; constructor(options: BundleOptions) { this.plugins = options.plugins ?? []; @@ -313,7 +313,7 @@ exports.importInterceptors = function importInterceptors() { } } -export interface Plugin { +export interface BundlerPlugin { /** * Gets the name of this plugin. * @@ -330,7 +330,7 @@ export interface Plugin { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isBundlerPlugin(p: any): p is Plugin { +export function isBundlerPlugin(p: any): p is BundlerPlugin { return "configureBundler" in p; } @@ -382,7 +382,7 @@ export interface BundleOptions { /** * List of plugins to register with the bundler. */ - plugins?: Plugin[]; + plugins?: BundlerPlugin[]; } /** From 6d16100c989a2bdd94bd5a86c3df2b28a3262569 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 14 Oct 2025 09:46:40 -0700 Subject: [PATCH 06/11] Ongoing work on SimplePlugin --- packages/client/src/client.ts | 7 +- packages/client/src/connection.ts | 6 +- packages/client/src/plugin.ts | 14 +- packages/common/src/plugin.ts | 0 packages/plugin/package.json | 3 +- packages/plugin/src/index.ts | 1 + packages/plugin/src/plugin.ts | 120 +++++++------- packages/test/package.json | 1 + packages/test/src/test-plugins.ts | 150 ++++++++++++++---- packages/test/src/workflows/plugins.ts | 16 +- .../src/testing-workflow-environment.ts | 23 ++- packages/worker/src/connection.ts | 13 +- packages/worker/src/plugin.ts | 31 ++-- packages/worker/src/worker-options.ts | 4 +- packages/worker/src/worker.ts | 25 ++- packages/worker/src/workflow/bundler.ts | 6 +- 16 files changed, 271 insertions(+), 149 deletions(-) delete mode 100644 packages/common/src/plugin.ts create mode 100644 packages/plugin/src/index.ts diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 1d5aa753f..a6da27836 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -18,7 +18,7 @@ export interface ClientOptions extends BaseClientOptions { /** * List of plugins to register with the client. - * + * * Plugins allow you to extend and customize the behavior of Temporal clients through a chain of * responsibility pattern. They can intercept and modify client creation. */ @@ -66,11 +66,12 @@ export class Client extends BaseClient { // Add client plugins from the connection options.plugins = (options.plugins ?? []).concat( - (options.connection?.plugins ?? []).filter(p => isClientPlugin(p)).map(p => p as ClientPlugin)); + (options.connection?.plugins ?? []).filter((p) => isClientPlugin(p)).map((p) => p as ClientPlugin) + ); // Process plugins first to allow them to modify connect configuration for (const plugin of options?.plugins ?? []) { - options = plugin.configureClient(options) + options = plugin.configureClient(options); } super(options); diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts index f83ba5840..02f5508ae 100644 --- a/packages/client/src/connection.ts +++ b/packages/client/src/connection.ts @@ -697,10 +697,10 @@ export class Connection { } export interface ConnectionPlugin { - configureConnection(config: ConnectionOptions): ConnectionOptions; + configureConnection(options: ConnectionOptions): ConnectionOptions; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function isConnectionPlugin(p: any): p is ConnectionPlugin { - return "configureConnection" in p; -} \ No newline at end of file + return 'configureConnection' in p; +} diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index 1852fe3de..e2bf61ffa 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -8,21 +8,21 @@ export interface ClientPlugin { /** * Hook called when creating a client to allow modification of configuration. - * + * * This method is called during client creation and allows plugins to modify * the client configuration before the client is fully initialized. Plugins * can add interceptors, modify connection parameters, or change other settings. - * + * * Args: - * config: The client configuration to potentially modify. - * + * options: The client configuration to potentially modify. + * * Returns: * The modified client configuration. */ - configureClient(config: ClientOptions): ClientOptions; + configureClient(options: ClientOptions): ClientOptions; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function isClientPlugin(p: any): p is ClientPlugin { - return "configureClient" in p; -} \ No newline at end of file + return 'configureClient' in p; +} diff --git a/packages/common/src/plugin.ts b/packages/common/src/plugin.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ee73cd091..6200d2be6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -12,8 +12,7 @@ ], "author": "Temporal Technologies Inc. ", "license": "MIT", - "dependencies": { - }, + "dependencies": {}, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" }, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts new file mode 100644 index 000000000..9146de0c5 --- /dev/null +++ b/packages/plugin/src/index.ts @@ -0,0 +1 @@ +export { SimplePlugin, SimplePluginOptions } from './plugin'; diff --git a/packages/plugin/src/plugin.ts b/packages/plugin/src/plugin.ts index 67f93a6de..9b89a908a 100644 --- a/packages/plugin/src/plugin.ts +++ b/packages/plugin/src/plugin.ts @@ -1,8 +1,14 @@ import type * as nexus from 'nexus-rpc'; import type { DataConverter } from '@temporalio/common'; import type { native } from '@temporalio/core-bridge'; -import { ClientInterceptors, ClientOptions, ClientPlugin, ConnectionPlugin, ConnectionOptions, - WorkflowClientInterceptors, WorkflowClientInterceptor +import { + ClientInterceptors, + ClientOptions, + ClientPlugin, + ConnectionPlugin, + ConnectionOptions, + WorkflowClientInterceptors, + WorkflowClientInterceptor, } from '@temporalio/client'; import type { BundlerPlugin, @@ -15,7 +21,7 @@ import type { Worker, BundleOptions, NativeConnectionOptions, - TLSConfig + TLSConfig, } from '@temporalio/worker'; type PluginParameter = T | ((p: T | undefined) => T); @@ -26,60 +32,53 @@ export interface SimplePluginOptions { readonly apiKey?: PluginParameter string)>; readonly dataConverter?: PluginParameter; readonly clientInterceptors?: PluginParameter; - readonly clientOptions?: Omit; readonly activities?: PluginParameter; readonly nexusServices?: PluginParameter[]>; readonly workflowsPath?: PluginParameter; readonly workflowBundle?: PluginParameter; readonly workerInterceptors?: PluginParameter; - readonly workerOptions?: Omit; - readonly replayWorkerOptions?: Omit; - readonly bundleOptions?: Omit; - readonly connectionOptions?: Omit; - readonly nativeConnectionOptions?: Omit; readonly runContext?: { before: () => Promise; after: () => Promise; }; } -export class SimplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin { +export class SimplePlugin + implements WorkerPlugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin +{ readonly name: string; constructor(protected readonly options: SimplePluginOptions) { this.name = options.name; } - configureClient(config: ClientOptions): ClientOptions { + configureClient(options: ClientOptions): ClientOptions { return { - ...config, - ...this.options.clientOptions, - dataConverter: resolveParameter(config.dataConverter, this.options.dataConverter), - interceptors: resolveClientInterceptors(config.interceptors, this.options.clientInterceptors), + ...options, + dataConverter: resolveParameter(options.dataConverter, this.options.dataConverter), + interceptors: resolveClientInterceptors(options.interceptors, this.options.clientInterceptors), }; } - configureWorker(config: WorkerOptions): WorkerOptions { + configureWorker(options: WorkerOptions): WorkerOptions { return { - ...config, - ...this.options.workerOptions, - dataConverter: resolveParameter(config.dataConverter, this.options.dataConverter), - activities: resolveAppendObjectParameter(config.activities, this.options.activities), - nexusServices: resolveAppendParameter(config.nexusServices, this.options.nexusServices), - workflowsPath: resolveParameter(config.workflowsPath, this.options.workflowsPath), - workflowBundle: resolveParameter(config.workflowBundle, this.options.workflowBundle), - interceptors: resolveWorkerInterceptors(config.interceptors, this.options.workerInterceptors), + ...options, + dataConverter: resolveParameter(options.dataConverter, this.options.dataConverter), + activities: resolveAppendObjectParameter(options.activities, this.options.activities), + nexusServices: resolveAppendParameter(options.nexusServices, this.options.nexusServices), + workflowsPath: resolveParameter(options.workflowsPath, this.options.workflowsPath), + workflowBundle: resolveParameter(options.workflowBundle, this.options.workflowBundle), + interceptors: resolveWorkerInterceptors(options.interceptors, this.options.workerInterceptors), }; } - configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { + configureReplayWorker(options: ReplayWorkerOptions): ReplayWorkerOptions { return { - ...config, - ...this.options.replayWorkerOptions, - dataConverter: resolveParameter(config.dataConverter, this.options.dataConverter), - workflowsPath: resolveParameter(config.workflowsPath, this.options.workflowsPath), - workflowBundle: resolveParameter(config.workflowBundle, this.options.workflowBundle), - interceptors: resolveWorkerInterceptors(config.interceptors, this.options.workerInterceptors), + ...options, + dataConverter: resolveParameter(options.dataConverter, this.options.dataConverter), + workflowsPath: resolveParameter(options.workflowsPath, this.options.workflowsPath), + workflowBundle: resolveParameter(options.workflowBundle, this.options.workflowBundle), + interceptors: resolveWorkerInterceptors(options.interceptors, this.options.workerInterceptors), }; } @@ -94,33 +93,29 @@ export class SimplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin, return result; } - configureBundler(config: BundleOptions): BundleOptions { + configureBundler(options: BundleOptions): BundleOptions { return { - ...config, - ...this.options.bundleOptions, - workflowsPath: resolveRequiredParameter(config.workflowsPath, this.options.workflowsPath), + ...options, + workflowsPath: resolveRequiredParameter(options.workflowsPath, this.options.workflowsPath), }; } - configureConnection(config: ConnectionOptions): ConnectionOptions { + configureConnection(options: ConnectionOptions): ConnectionOptions { return { - ...config, - ...this.options.connectionOptions, - tls: resolveParameter(config.tls, this.options.tls), - apiKey: resolveParameter(config.apiKey, this.options.apiKey), + ...options, + tls: resolveParameter(options.tls, this.options.tls), + apiKey: resolveParameter(options.apiKey, this.options.apiKey), }; } configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions { - const resolvedApiKey = resolveParameter(options.apiKey, this.options.apiKey); - if (typeof resolvedApiKey === 'function') { + if (typeof this.options.apiKey === 'function') { throw new TypeError('NativeConnectionOptions does not support apiKey as a function'); } return { ...options, - ...this.options.nativeConnectionOptions, tls: resolveParameter(options.tls, this.options.tls), - apiKey: resolvedApiKey, + apiKey: resolveParameter(options.apiKey, this.options.apiKey), }; } @@ -142,7 +137,7 @@ function resolveParameterWithResolution( return parameter(existing); } if (existing === undefined) { - return parameter + return parameter; } return resolve(existing, parameter); } @@ -152,9 +147,6 @@ function resolveRequiredParameter(existing: T, parameter?: PluginParameter } function resolveParameter(existing?: T, parameter?: PluginParameter): T | undefined { - if (parameter === undefined) { - return existing; - } return resolveParameterWithResolution(existing as T, parameter, (_existing, param) => param); } @@ -162,7 +154,9 @@ function resolveAppendParameter(existing?: T[], parameter?: PluginParameter existing.concat(param)); + return resolveParameterWithResolution(existing ?? ([] as T[]), parameter, (existing, param) => + existing.concat(param) + ); } function resolveAppendObjectParameter(existing?: object, parameter?: PluginParameter): object | undefined { @@ -172,14 +166,23 @@ function resolveAppendObjectParameter(existing?: object, parameter?: PluginParam return resolveParameterWithResolution(existing ?? {}, parameter, (existing, param) => ({ ...existing, ...param })); } -function resolveClientInterceptors(existing?: ClientInterceptors, parameter?: PluginParameter): ClientInterceptors | undefined{ +function resolveClientInterceptors( + existing?: ClientInterceptors, + parameter?: PluginParameter +): ClientInterceptors | undefined { return resolveParameterWithResolution(existing, parameter, (existing, parameter) => ({ - workflow: tryConcat(modernWorkflowInterceptors(existing?.workflow), modernWorkflowInterceptors(parameter?.workflow)), + workflow: tryConcat( + modernWorkflowInterceptors(existing?.workflow), + modernWorkflowInterceptors(parameter?.workflow) + ), schedule: tryConcat(existing?.schedule, parameter?.schedule), })); } -function resolveWorkerInterceptors(existing?: WorkerInterceptors, parameter?: PluginParameter): WorkerInterceptors | undefined{ +function resolveWorkerInterceptors( + existing?: WorkerInterceptors, + parameter?: PluginParameter +): WorkerInterceptors | undefined { return resolveParameterWithResolution(existing, parameter, (existing, parameter) => ({ client: resolveClientInterceptors(existing.client, parameter.client), activity: resolveAppendParameter(existing.activity, parameter.activity), @@ -188,7 +191,10 @@ function resolveWorkerInterceptors(existing?: WorkerInterceptors, parameter?: Pl })); } -function modernWorkflowInterceptors(interceptors: WorkflowClientInterceptors | WorkflowClientInterceptor[] | undefined): WorkflowClientInterceptor[] | undefined { +// eslint-disable-next-line deprecation/deprecation +function modernWorkflowInterceptors( + interceptors: WorkflowClientInterceptors | WorkflowClientInterceptor[] | undefined +): WorkflowClientInterceptor[] | undefined { if (interceptors === undefined || Array.isArray(interceptors)) { return interceptors; } @@ -197,10 +203,10 @@ function modernWorkflowInterceptors(interceptors: WorkflowClientInterceptors | W function tryConcat(left: T[] | undefined, right: T[] | undefined): T[] | undefined { if (right === undefined) { - return left + return left; } if (left === undefined) { - return right + return right; } - return left.concat(right) -} \ No newline at end of file + return left.concat(right); +} diff --git a/packages/test/package.json b/packages/test/package.json index 8e2d4537a..d4d658f98 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -40,6 +40,7 @@ "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/nyc-test-coverage": "file:../nyc-test-coverage", + "@temporalio/plugin": "file:../plugin", "@temporalio/proto": "file:../proto", "@temporalio/testing": "file:../testing", "@temporalio/worker": "file:../worker", diff --git a/packages/test/src/test-plugins.ts b/packages/test/src/test-plugins.ts index eb0e02f8b..c195db1e0 100644 --- a/packages/test/src/test-plugins.ts +++ b/packages/test/src/test-plugins.ts @@ -1,6 +1,12 @@ import { randomUUID } from 'crypto'; import anyTest, { TestFn } from 'ava'; -import { Client, ClientOptions, ConnectionPlugin, ClientPlugin as ClientPlugin, ConnectionOptions } from '@temporalio/client'; +import { + Client, + ClientOptions, + ConnectionPlugin, + ClientPlugin as ClientPlugin, + ConnectionOptions, +} from '@temporalio/client'; import { WorkerOptions, WorkerPlugin as WorkerPlugin, @@ -8,13 +14,17 @@ import { Worker, BundlerPlugin, BundleOptions, - bundleWorkflowCode, NativeConnectionPlugin, + bundleWorkflowCode, + NativeConnectionPlugin, NativeConnectionOptions, } from '@temporalio/worker'; +import { SimplePlugin } from '@temporalio/plugin'; import { native } from '@temporalio/core-bridge'; -import { hello_workflow } from './workflows/plugins'; +import { activity_workflow, hello_workflow } from './workflows/plugins'; import { TestWorkflowEnvironment } from './helpers'; +import * as activities from './activities'; + interface Context { testEnv: TestWorkflowEnvironment; } @@ -30,14 +40,16 @@ test.after.always(async (t) => { await t.context.testEnv?.teardown(); }); -export class ExamplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin { +export class ExamplePlugin + implements WorkerPlugin, ClientPlugin, BundlerPlugin, ConnectionPlugin, NativeConnectionPlugin +{ readonly name: string = 'example-plugin'; constructor() {} configureClient(config: ClientOptions): ClientOptions { console.log('ExamplePlugin: Configuring client'); - config.identity = "Plugin Identity"; + config.identity = 'Plugin Identity'; return config; } @@ -75,7 +87,6 @@ export class ExamplePlugin implements WorkerPlugin, ClientPlugin, BundlerPlugin, } } - test('Basic plugin', async (t) => { const { connection } = t.context.testEnv; const client = new Client({ connection }); @@ -83,8 +94,8 @@ test('Basic plugin', async (t) => { const plugin = new ExamplePlugin(); const bundle = await bundleWorkflowCode({ workflowsPath: 'replaced', - plugins: [plugin] - }) + plugins: [plugin], + }); const worker = await Worker.create({ workflowBundle: bundle, @@ -94,14 +105,14 @@ test('Basic plugin', async (t) => { }); await worker.runUntil(async () => { - t.is(worker.options.taskQueue, "plugin-task-queue"); + t.is(worker.options.taskQueue, 'plugin-task-queue'); const result = await client.workflow.execute(hello_workflow, { - taskQueue: "plugin-task-queue", + taskQueue: 'plugin-task-queue', workflowExecutionTimeout: '30 seconds', - workflowId: randomUUID() + workflowId: randomUUID(), }); - t.is(result, "Hello"); + t.is(result, 'Hello'); }); }); @@ -115,22 +126,21 @@ test('Bundler plugins are passed from worker', async (t) => { taskQueue: 'will be overridden', plugins: [new ExamplePlugin()], }); - console.log("worker created"); + console.log('worker created'); await worker.runUntil(async () => { - t.is(worker.options.taskQueue, "plugin-task-queue"); + t.is(worker.options.taskQueue, 'plugin-task-queue'); const result = await client.workflow.execute(hello_workflow, { - taskQueue: "plugin-task-queue", + taskQueue: 'plugin-task-queue', workflowExecutionTimeout: '30 seconds', - workflowId: randomUUID() + workflowId: randomUUID(), }); - t.is(result, "Hello"); + t.is(result, 'Hello'); }); }); - test('Worker plugins are passed from native connection', async (t) => { - const env = await TestWorkflowEnvironment.createLocal({connectionPlugins: [new ExamplePlugin()]}); + const env = await TestWorkflowEnvironment.createLocal({ connectionPlugins: [new ExamplePlugin()] }); try { const client = new Client({ connection: env.connection }); @@ -140,33 +150,111 @@ test('Worker plugins are passed from native connection', async (t) => { taskQueue: 'will be overridden', }); - t.is(worker.options.taskQueue, "plugin-task-queue"); + t.is(worker.options.taskQueue, 'plugin-task-queue'); await worker.runUntil(async () => { - t.is(worker.options.taskQueue, "plugin-task-queue"); + t.is(worker.options.taskQueue, 'plugin-task-queue'); const result = await client.workflow.execute(hello_workflow, { - taskQueue: "plugin-task-queue", + taskQueue: 'plugin-task-queue', workflowExecutionTimeout: '30 seconds', - workflowId: randomUUID() + workflowId: randomUUID(), }); - t.is(result, "Hello"); + t.is(result, 'Hello'); }); } finally { - await env.teardown() + await env.teardown(); } }); - test('Client plugins are passed from connections', async (t) => { - const env = await TestWorkflowEnvironment.createLocal({connectionPlugins: [new ExamplePlugin()]}); + const env = await TestWorkflowEnvironment.createLocal({ connectionPlugins: [new ExamplePlugin()] }); try { const client = new Client({ connection: env.connection }); - t.is(client.options.identity, "Plugin Identity"); + t.is(client.options.identity, 'Plugin Identity'); const clientNative = new Client({ connection: env.nativeConnection }); - t.is(clientNative.options.identity, "Plugin Identity"); + t.is(clientNative.options.identity, 'Plugin Identity'); } finally { - await env.teardown() + await env.teardown(); } -}); \ No newline at end of file +}); + +// SimplePlugin tests +test('SimplePlugin connection configurations', async (t) => { + const plugin = new SimplePlugin({ + name: 'test-simple-plugin', + tls: true, + apiKey: 'testApiKey', + }); + + const options = plugin.configureNativeConnection({}); + t.is(options.tls, true); + t.is(options.apiKey, 'testApiKey'); +}); + +test('SimplePlugin worker configurations', async (t) => { + const plugin = new SimplePlugin({ + name: 'test-simple-plugin', + activities, + workflowsPath: require.resolve('./workflows/plugins'), + }); + + const { connection } = t.context.testEnv; + const client = new Client({ connection }); + + const worker = await Worker.create({ + workflowsPath: 'replaced', + connection: t.context.testEnv.nativeConnection, + taskQueue: 'simple-plugin-queue', + plugins: [plugin], + }); + + await worker.runUntil(async () => { + const result = await client.workflow.execute(activity_workflow, { + taskQueue: 'simple-plugin-queue', + workflowExecutionTimeout: '30 seconds', + workflowId: randomUUID(), + }); + + t.is(result, 'Hello'); + }); +}); + +test('SimplePlugin with activities merges them correctly', async (t) => { + const activity1 = async () => 'activity1'; + const activity2 = async () => 'activity2'; + + const plugin = new SimplePlugin({ + name: 'simple-test-plugin', + activities: { + pluginActivity: activity2, + }, + }); + + const worker = await Worker.create({ + connection: t.context.testEnv.nativeConnection, + taskQueue: 'simple-plugin-queue', + activities: { + existingActivity: activity1, + }, + plugins: [plugin], + }); + + t.truthy(worker.options.activities); + t.truthy(worker.options.activities.has('existingActivity')); + t.truthy(worker.options.activities.has('pluginActivity')); +}); + +test('SimplePlugin with apiKey function throws error for NativeConnection', async (t) => { + const plugin = new SimplePlugin({ + name: 'simple-test-plugin', + apiKey: () => 'some-key', + }); + + const error = t.throws(() => { + plugin.configureNativeConnection({}); + }); + + t.is(error?.message, 'NativeConnectionOptions does not support apiKey as a function'); +}); diff --git a/packages/test/src/workflows/plugins.ts b/packages/test/src/workflows/plugins.ts index 3374d3819..f2b7db54d 100644 --- a/packages/test/src/workflows/plugins.ts +++ b/packages/test/src/workflows/plugins.ts @@ -1,3 +1,15 @@ +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from '../activities'; + +const { echo } = proxyActivities({ + startToCloseTimeout: '20s', + retry: { initialInterval: 5, maximumAttempts: 1, nonRetryableErrorTypes: ['NonRetryableError'] }, +}); + export async function hello_workflow(): Promise { - return "Hello"; -} \ No newline at end of file + return 'Hello'; +} + +export async function activity_workflow(): Promise { + return echo('Hello'); +} diff --git a/packages/testing/src/testing-workflow-environment.ts b/packages/testing/src/testing-workflow-environment.ts index a65a2904f..7893659c0 100644 --- a/packages/testing/src/testing-workflow-environment.ts +++ b/packages/testing/src/testing-workflow-environment.ts @@ -1,5 +1,12 @@ import 'abort-controller/polyfill'; // eslint-disable-line import/no-unassigned-import -import { AsyncCompletionClient, Client, Connection, ConnectionPlugin, WorkflowClient, isConnectionPlugin } from '@temporalio/client'; +import { + AsyncCompletionClient, + Client, + Connection, + ConnectionPlugin, + WorkflowClient, + isConnectionPlugin, +} from '@temporalio/client'; import { ConnectionOptions, InternalConnectionOptions, @@ -7,7 +14,13 @@ import { } from '@temporalio/client/lib/connection'; import { Duration, TypedSearchAttributes } from '@temporalio/common'; import { msToNumber, msToTs, tsToMs } from '@temporalio/common/lib/time'; -import { NativeConnection, NativeConnectionPlugin, NativeConnectionOptions, Runtime, isNativeConnectionPlugin } from '@temporalio/worker'; +import { + NativeConnection, + NativeConnectionPlugin, + NativeConnectionOptions, + Runtime, + isNativeConnectionPlugin, +} from '@temporalio/worker'; import { native } from '@temporalio/core-bridge'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; import { toNativeEphemeralServerConfig, DevServerConfig, TimeSkippingServerConfig } from './ephemeral-server'; @@ -237,12 +250,12 @@ export class TestWorkflowEnvironment { const nativeConnection = await NativeConnection.connect({ address, - plugins:opts.connectionPlugins?.filter(p => isNativeConnectionPlugin(p)), + plugins: opts.connectionPlugins?.filter((p) => isNativeConnectionPlugin(p)), [InternalConnectionOptionsSymbol]: { supportsTestService: supportsTimeSkipping }, }); const connection = await Connection.connect({ address, - plugins:opts.connectionPlugins?.filter(p => isConnectionPlugin(p)), + plugins: opts.connectionPlugins?.filter((p) => isConnectionPlugin(p)), [InternalConnectionOptionsSymbol]: { supportsTestService: supportsTimeSkipping }, }); @@ -357,6 +370,6 @@ function addDefaults(opts: TestWorkflowEnvironmentOptions): TestWorkflowEnvironm server: { ...opts.server, }, - connectionPlugins: [] + connectionPlugins: [], }; } diff --git a/packages/worker/src/connection.ts b/packages/worker/src/connection.ts index 84a5d3ead..efe9e7485 100644 --- a/packages/worker/src/connection.ts +++ b/packages/worker/src/connection.ts @@ -14,11 +14,10 @@ import { InternalConnectionLikeSymbol, } from '@temporalio/client'; import { InternalConnectionOptions, InternalConnectionOptionsSymbol } from '@temporalio/client/lib/connection'; +import { composeInterceptors } from '@temporalio/common/lib/interceptors'; import { TransportError } from './errors'; import { NativeConnectionOptions } from './connection-options'; import { Runtime } from './runtime'; -import { ClientOptions } from '@grpc/grpc-js'; -import { composeInterceptors } from '@temporalio/common/lib/interceptors'; /** * A Native Connection object that delegates calls to the Rust Core binary extension. @@ -73,7 +72,7 @@ export class NativeConnection implements ConnectionLike { private readonly runtime: Runtime, private readonly nativeClient: native.Client, private readonly enableTestService: boolean, - readonly plugins: Plugin[], + readonly plugins: Plugin[] ) { this.workflowService = WorkflowService.create( this.sendRequest.bind(this, native.clientSendWorkflowServiceRequest.bind(undefined, this.nativeClient)), @@ -244,7 +243,9 @@ export class NativeConnection implements ConnectionLike { try { const runtime = Runtime.instance(); - const connectNative = composeInterceptors(options.plugins ?? [], 'connectNative', () => runtime.createNativeClient(options)); + const connectNative = composeInterceptors(options.plugins ?? [], 'connectNative', () => + runtime.createNativeClient(options) + ); const client = await connectNative(); return new this(runtime, client, enableTestService, options.plugins ?? []); } catch (err) { @@ -359,5 +360,5 @@ export interface Plugin { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function isNativeConnectionPlugin(p: any): p is Plugin { - return "configureNativeConnection" in p && "connectNative" in p; -} \ No newline at end of file + return 'configureNativeConnection' in p && 'connectNative' in p; +} diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index 3aa616a0c..763c5e72b 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -3,33 +3,24 @@ import type { Worker } from './worker'; /** * Base Plugin class for worker functionality. - * + * * Plugins provide a way to extend and customize the behavior of Temporal workers. * They allow you to intercept and modify worker configuration and worker execution. */ export interface WorkerPlugin { /** * Gets the name of this plugin. - * - * Returns: - * The name of the plugin class. */ get name(): string; /** * Hook called when creating a worker to allow modification of configuration. - * + * * This method is called during worker creation and allows plugins to modify * the worker configuration before the worker is fully initialized. Plugins * can add activities, workflows, interceptors, or change other settings. - * - * Args: - * config: The worker configuration to potentially modify. - * - * Returns: - * The modified worker configuration. */ - configureWorker(config: WorkerOptions): WorkerOptions; + configureWorker(options: WorkerOptions): WorkerOptions; /** * Hook called when creating a replay worker to allow modification of configuration. @@ -37,19 +28,19 @@ export interface WorkerPlugin { * This method is called during worker creation and allows plugins to modify * the worker configuration before the worker is fully initialized. Plugins * can add activities, workflows, interceptors, or change other settings. - * - * Args: - * config: The replay worker configuration to potentially modify. - * - * Returns: - * The modified worker configuration. */ - configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions; + configureReplayWorker(options: ReplayWorkerOptions): ReplayWorkerOptions; + /** + * Hook called when running a worker. + * + * This method is not called when running a replay worker, as activities will not be + * executed, and global state can't affect the workflow. + */ runWorker(worker: Worker, next: (w: Worker) => Promise): Promise; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function isWorkerPlugin(p: any): p is WorkerPlugin { - return "configureWorker" in p && "configureReplayWorker" in p && "runWorker" in p; + return 'configureWorker' in p && 'configureReplayWorker' in p && 'runWorker' in p; } diff --git a/packages/worker/src/worker-options.ts b/packages/worker/src/worker-options.ts index 698a78cf6..fb1447660 100644 --- a/packages/worker/src/worker-options.ts +++ b/packages/worker/src/worker-options.ts @@ -486,10 +486,10 @@ export interface WorkerOptions { /** * List of plugins to register with the worker. - * + * * Plugins allow you to extend and customize the behavior of Temporal workers through a chain of * responsibility pattern. They can intercept and modify worker creation, configuration, and execution. - * + * * Worker plugins can be used to add custom activities, workflows, interceptors, or modify other * worker settings before the worker is fully initialized. */ diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 49b7741ff..2b7ec30ef 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -62,6 +62,7 @@ import { Client } from '@temporalio/client'; import { coresdk, temporal } from '@temporalio/proto'; import { type SinkCall, type WorkflowInfo } from '@temporalio/workflow'; import { throwIfReservedName } from '@temporalio/common/lib/reserved'; +import { composeInterceptors } from '@temporalio/common/lib/interceptors'; import { Activity, CancelReason, activityLogAttributes } from './activity'; import { extractNativeClient, extractReferenceHolders, InternalNativeConnection, NativeConnection } from './connection'; import { ActivityExecuteInput } from './interceptors'; @@ -110,7 +111,6 @@ import { import { constructNexusOperationContext, NexusHandler } from './nexus'; import { handlerErrorToProto } from './nexus/conversions'; import { isWorkerPlugin, WorkerPlugin } from './plugin'; -import { composeInterceptors } from '@temporalio/common/lib/interceptors'; export { DataConverter, defaultPayloadConverter }; @@ -503,7 +503,8 @@ export class Worker { */ public static async create(options: WorkerOptions): Promise { options.plugins = (options.plugins || []).concat( - (options.connection?.plugins || []).filter(p => isWorkerPlugin(p)).map(p => p as WorkerPlugin)); + (options.connection?.plugins || []).filter((p) => isWorkerPlugin(p)).map((p) => p as WorkerPlugin) + ); for (const plugin of options.plugins) { options = plugin.configureWorker(options); } @@ -563,7 +564,7 @@ export class Worker { logger, metricMeter, options.plugins || [], - connection, + connection ); } @@ -736,7 +737,17 @@ export class Worker { addBuildIdIfMissing(compiledOptions, bundle.code) ); return [ - new this(runtime, replayHandle.worker, workflowCreator, compiledOptions, logger, metricMeter, plugins, undefined, true), + new this( + runtime, + replayHandle.worker, + workflowCreator, + compiledOptions, + logger, + metricMeter, + plugins, + undefined, + true + ), replayHandle.historyPusher, ]; } @@ -774,7 +785,7 @@ export class Worker { throw new TypeError('Invalid WorkflowOptions.workflowBundle'); } } else if (compiledOptions.workflowsPath) { - const bundlerPlugins = compiledOptions.plugins?.filter(p => isBundlerPlugin(p)) + const bundlerPlugins = compiledOptions.plugins?.filter((p) => isBundlerPlugin(p)); const bundler = new WorkflowCodeBundler({ logger, workflowsPath: compiledOptions.workflowsPath, @@ -1972,9 +1983,9 @@ export class Worker { */ async run(): Promise { if (this.isReplayWorker) { - return this.runInternal() + return this.runInternal(); } - const composition = composeInterceptors(this.plugins, 'runWorker', (w: Worker) => w.runInternal()) + const composition = composeInterceptors(this.plugins, 'runWorker', (w: Worker) => w.runInternal()); return composition(this); } diff --git a/packages/worker/src/workflow/bundler.ts b/packages/worker/src/workflow/bundler.ts index 86bfb4161..5624ce8e4 100644 --- a/packages/worker/src/workflow/bundler.ts +++ b/packages/worker/src/workflow/bundler.ts @@ -325,16 +325,14 @@ export interface BundlerPlugin { /** * Hook called when creating a bundler to allow modification of configuration. */ - configureBundler(config: BundleOptions): BundleOptions; + configureBundler(options: BundleOptions): BundleOptions; } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function isBundlerPlugin(p: any): p is BundlerPlugin { - return "configureBundler" in p; + return 'configureBundler' in p; } - /** * Options for bundling Workflow code using Webpack */ From ba37fd8115de7f825a41fb63497af0c310a7f998 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 16 Oct 2025 13:43:37 -0700 Subject: [PATCH 07/11] Fix plugin composition in connection --- packages/worker/src/connection.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/connection.ts b/packages/worker/src/connection.ts index dbd68c17c..93f20a620 100644 --- a/packages/worker/src/connection.ts +++ b/packages/worker/src/connection.ts @@ -242,9 +242,13 @@ export class NativeConnection implements ConnectionLike { try { const runtime = Runtime.instance(); - const connectNative = composeInterceptors(options.plugins ?? [], 'connectNative', () => - runtime.createNativeClient(options) - ); + const plugins = options.plugins ?? []; + let connectNative = () => runtime.createNativeClient(options); + for (let i = plugins.length - 1; i >= 0; --i) { + const next = connectNative; + connectNative = () => plugins[i].connectNative(next); + } + const client = await connectNative(); return new this(runtime, client, enableTestService, options.plugins ?? []); } catch (err) { From 604e65f906af23956067a61737b260a747475d69 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 20 Oct 2025 11:04:36 -0700 Subject: [PATCH 08/11] PR Feedback --- packages/client/src/client.ts | 10 +-- packages/client/src/connection.ts | 27 +++++-- packages/client/src/index.ts | 3 +- packages/client/src/plugin.ts | 18 ++--- packages/plugin/src/plugin.ts | 26 ++----- packages/test/src/test-plugins.ts | 72 ++++++++++--------- packages/test/src/workflows/plugins.ts | 4 +- .../src/testing-workflow-environment.ts | 25 +++---- packages/worker/src/connection-options.ts | 11 ++- packages/worker/src/connection.ts | 34 ++++----- packages/worker/src/index.ts | 4 +- packages/worker/src/plugin.ts | 13 ++-- packages/worker/src/worker-options.ts | 6 +- packages/worker/src/worker.ts | 29 ++++---- packages/worker/src/workflow/bundler.ts | 13 ++-- 15 files changed, 149 insertions(+), 146 deletions(-) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index a6da27836..4e5d804b9 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -6,7 +6,7 @@ import { ScheduleClient } from './schedule-client'; import { QueryRejectCondition, WorkflowService } from './types'; import { WorkflowClient } from './workflow-client'; import { TaskQueueClient } from './task-queue-client'; -import { isClientPlugin, ClientPlugin } from './plugin'; +import { ClientPlugin } from './plugin'; export interface ClientOptions extends BaseClientOptions { /** @@ -65,13 +65,13 @@ export class Client extends BaseClient { options = options ?? {}; // Add client plugins from the connection - options.plugins = (options.plugins ?? []).concat( - (options.connection?.plugins ?? []).filter((p) => isClientPlugin(p)).map((p) => p as ClientPlugin) - ); + options.plugins = (options.plugins ?? []).concat(options.connection?.plugins ?? []); // Process plugins first to allow them to modify connect configuration for (const plugin of options?.plugins ?? []) { - options = plugin.configureClient(options); + if (plugin.configureClient !== undefined) { + options = plugin.configureClient(options); + } } super(options); diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts index 02f5508ae..34b021ffc 100644 --- a/packages/client/src/connection.ts +++ b/packages/client/src/connection.ts @@ -131,6 +131,13 @@ export interface ConnectionOptions { */ connectTimeout?: Duration; + /** + * List of plugins to register with the connection. + * + * Plugins allow you to configure the connection options. + * + * Any plugins provided will also be passed to any client built from this connection. + */ plugins?: ConnectionPlugin[]; } @@ -448,7 +455,9 @@ export class Connection { static lazy(options?: ConnectionOptions): Connection { options = options ?? {}; for (const plugin of options.plugins ?? []) { - options = plugin.configureConnection(options); + if (plugin.configureConnection !== undefined) { + options = plugin.configureConnection(options); + } } return new this(this.createCtorOptions(options)); } @@ -696,11 +705,17 @@ export class Connection { } } +/** + * Plugin to control the configuration of a connection. + */ export interface ConnectionPlugin { - configureConnection(options: ConnectionOptions): ConnectionOptions; -} + /** + * Gets the name of this plugin. + */ + get name(): string; -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isConnectionPlugin(p: any): p is ConnectionPlugin { - return 'configureConnection' in p; + /** + * Hook called when creating a connection to allow modification of configuration. + */ + configureConnection?(options: ConnectionOptions): ConnectionOptions; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f5bdf3489..957c998a1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -30,14 +30,13 @@ export * from '@temporalio/common/lib/interfaces'; export * from '@temporalio/common/lib/workflow-handle'; export * from './async-completion-client'; export * from './client'; -export { Connection, ConnectionOptions, ConnectionOptionsWithDefaults, LOCAL_TARGET } from './connection'; +export { Connection, ConnectionOptions, ConnectionOptionsWithDefaults, ConnectionPlugin, LOCAL_TARGET } from './connection'; export * from './errors'; export * from './grpc-retry'; export * from './interceptors'; export * from './types'; export * from './workflow-client'; export { ClientPlugin } from './plugin'; -export { ConnectionPlugin, isConnectionPlugin } from './connection'; export * from './workflow-options'; export * from './schedule-types'; export * from './schedule-client'; diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index e2bf61ffa..1fe0a3333 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -1,5 +1,8 @@ import type { ClientOptions } from './client'; +/** + * Plugin to control the configuration of a native connection. + */ export interface ClientPlugin { /** * Gets the name of this plugin. @@ -10,19 +13,8 @@ export interface ClientPlugin { * Hook called when creating a client to allow modification of configuration. * * This method is called during client creation and allows plugins to modify - * the client configuration before the client is fully initialized. Plugins - * can add interceptors, modify connection parameters, or change other settings. - * - * Args: - * options: The client configuration to potentially modify. - * - * Returns: - * The modified client configuration. + * the client configuration before the client is fully initialized. */ - configureClient(options: ClientOptions): ClientOptions; + configureClient?(options: ClientOptions): ClientOptions; } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isClientPlugin(p: any): p is ClientPlugin { - return 'configureClient' in p; -} diff --git a/packages/plugin/src/plugin.ts b/packages/plugin/src/plugin.ts index 9b89a908a..4567e6881 100644 --- a/packages/plugin/src/plugin.ts +++ b/packages/plugin/src/plugin.ts @@ -1,6 +1,5 @@ import type * as nexus from 'nexus-rpc'; import type { DataConverter } from '@temporalio/common'; -import type { native } from '@temporalio/core-bridge'; import { ClientInterceptors, ClientOptions, @@ -29,7 +28,7 @@ type PluginParameter = T | ((p: T | undefined) => T); export interface SimplePluginOptions { readonly name: string; readonly tls?: PluginParameter; - readonly apiKey?: PluginParameter string)>; + readonly apiKey?: PluginParameter; readonly dataConverter?: PluginParameter; readonly clientInterceptors?: PluginParameter; readonly activities?: PluginParameter; @@ -37,10 +36,7 @@ export interface SimplePluginOptions { readonly workflowsPath?: PluginParameter; readonly workflowBundle?: PluginParameter; readonly workerInterceptors?: PluginParameter; - readonly runContext?: { - before: () => Promise; - after: () => Promise; - }; + readonly runContext?: (next: () => Promise) => Promise; } export class SimplePlugin @@ -84,13 +80,9 @@ export class SimplePlugin async runWorker(worker: Worker, next: (w: Worker) => Promise): Promise { if (this.options.runContext !== undefined) { - await this.options.runContext.before(); + return this.options.runContext(() => next(worker)); } - const result = await next(worker); - if (this.options.runContext !== undefined) { - await this.options.runContext.after(); - } - return result; + return next(worker); } configureBundler(options: BundleOptions): BundleOptions { @@ -101,27 +93,21 @@ export class SimplePlugin } configureConnection(options: ConnectionOptions): ConnectionOptions { + const apiKey = typeof options.apiKey === 'function' ? options.apiKey : undefined; return { ...options, tls: resolveParameter(options.tls, this.options.tls), - apiKey: resolveParameter(options.apiKey, this.options.apiKey), + apiKey: apiKey ?? resolveParameter(options.apiKey as string | undefined, this.options.apiKey), }; } configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions { - if (typeof this.options.apiKey === 'function') { - throw new TypeError('NativeConnectionOptions does not support apiKey as a function'); - } return { ...options, tls: resolveParameter(options.tls, this.options.tls), apiKey: resolveParameter(options.apiKey, this.options.apiKey), }; } - - connectNative(next: () => Promise): Promise { - return next(); - } } function resolveParameterWithResolution( diff --git a/packages/test/src/test-plugins.ts b/packages/test/src/test-plugins.ts index c195db1e0..0de36caa8 100644 --- a/packages/test/src/test-plugins.ts +++ b/packages/test/src/test-plugins.ts @@ -5,22 +5,18 @@ import { ClientOptions, ConnectionPlugin, ClientPlugin as ClientPlugin, - ConnectionOptions, } from '@temporalio/client'; import { WorkerOptions, WorkerPlugin as WorkerPlugin, - ReplayWorkerOptions, Worker, BundlerPlugin, BundleOptions, bundleWorkflowCode, NativeConnectionPlugin, - NativeConnectionOptions, } from '@temporalio/worker'; import { SimplePlugin } from '@temporalio/plugin'; -import { native } from '@temporalio/core-bridge'; -import { activity_workflow, hello_workflow } from './workflows/plugins'; +import { activityWorkflow, helloWorkflow } from './workflows/plugins'; import { TestWorkflowEnvironment } from './helpers'; import * as activities from './activities'; @@ -59,32 +55,11 @@ export class ExamplePlugin return config; } - configureReplayWorker(config: ReplayWorkerOptions): ReplayWorkerOptions { - return config; - } - - runWorker(worker: Worker, next: (w: Worker) => Promise): Promise { - console.log('ExamplePlugin: Running worker'); - return next(worker); - } - configureBundler(config: BundleOptions): BundleOptions { console.log('Configure bundler'); config.workflowsPath = require.resolve('./workflows/plugins'); return config; } - - configureConnection(config: ConnectionOptions): ConnectionOptions { - return config; - } - - configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions { - return options; - } - - connectNative(next: () => Promise): Promise { - return next(); - } } test('Basic plugin', async (t) => { @@ -106,7 +81,7 @@ test('Basic plugin', async (t) => { await worker.runUntil(async () => { t.is(worker.options.taskQueue, 'plugin-task-queue'); - const result = await client.workflow.execute(hello_workflow, { + const result = await client.workflow.execute(helloWorkflow, { taskQueue: 'plugin-task-queue', workflowExecutionTimeout: '30 seconds', workflowId: randomUUID(), @@ -126,10 +101,9 @@ test('Bundler plugins are passed from worker', async (t) => { taskQueue: 'will be overridden', plugins: [new ExamplePlugin()], }); - console.log('worker created'); await worker.runUntil(async () => { t.is(worker.options.taskQueue, 'plugin-task-queue'); - const result = await client.workflow.execute(hello_workflow, { + const result = await client.workflow.execute(helloWorkflow, { taskQueue: 'plugin-task-queue', workflowExecutionTimeout: '30 seconds', workflowId: randomUUID(), @@ -140,7 +114,7 @@ test('Bundler plugins are passed from worker', async (t) => { }); test('Worker plugins are passed from native connection', async (t) => { - const env = await TestWorkflowEnvironment.createLocal({ connectionPlugins: [new ExamplePlugin()] }); + const env = await TestWorkflowEnvironment.createLocal({ plugins: [new ExamplePlugin()] }); try { const client = new Client({ connection: env.connection }); @@ -154,7 +128,7 @@ test('Worker plugins are passed from native connection', async (t) => { await worker.runUntil(async () => { t.is(worker.options.taskQueue, 'plugin-task-queue'); - const result = await client.workflow.execute(hello_workflow, { + const result = await client.workflow.execute(helloWorkflow, { taskQueue: 'plugin-task-queue', workflowExecutionTimeout: '30 seconds', workflowId: randomUUID(), @@ -168,7 +142,7 @@ test('Worker plugins are passed from native connection', async (t) => { }); test('Client plugins are passed from connections', async (t) => { - const env = await TestWorkflowEnvironment.createLocal({ connectionPlugins: [new ExamplePlugin()] }); + const env = await TestWorkflowEnvironment.createLocal({ plugins: [new ExamplePlugin()] }); try { const client = new Client({ connection: env.connection }); t.is(client.options.identity, 'Plugin Identity'); @@ -180,6 +154,38 @@ test('Client plugins are passed from connections', async (t) => { } }); + +test('Bundler plugins are passed from connections', async (t) => { + const plugin = new class implements BundlerPlugin { + name: string = 'plugin'; + configureBundler(options: BundleOptions): BundleOptions { + return { ...options, workflowsPath: require.resolve('./workflows/plugins') }; + } + }; + const env = await TestWorkflowEnvironment.createLocal({ plugins: [plugin] }); + try { + const client = new Client({ connection: env.connection }); + const worker = await Worker.create({ + workflowsPath: 'replaced', + connection: env.nativeConnection, + taskQueue: 'plugin-task-queue', + }); + + await worker.runUntil(async () => { + t.is(worker.options.taskQueue, 'plugin-task-queue'); + const result = await client.workflow.execute(helloWorkflow, { + taskQueue: 'plugin-task-queue', + workflowExecutionTimeout: '30 seconds', + workflowId: randomUUID(), + }); + + t.is(result, 'Hello'); + }); + } finally { + await env.teardown(); + } +}); + // SimplePlugin tests test('SimplePlugin connection configurations', async (t) => { const plugin = new SimplePlugin({ @@ -211,7 +217,7 @@ test('SimplePlugin worker configurations', async (t) => { }); await worker.runUntil(async () => { - const result = await client.workflow.execute(activity_workflow, { + const result = await client.workflow.execute(activityWorkflow, { taskQueue: 'simple-plugin-queue', workflowExecutionTimeout: '30 seconds', workflowId: randomUUID(), diff --git a/packages/test/src/workflows/plugins.ts b/packages/test/src/workflows/plugins.ts index f2b7db54d..f9194375d 100644 --- a/packages/test/src/workflows/plugins.ts +++ b/packages/test/src/workflows/plugins.ts @@ -6,10 +6,10 @@ const { echo } = proxyActivities({ retry: { initialInterval: 5, maximumAttempts: 1, nonRetryableErrorTypes: ['NonRetryableError'] }, }); -export async function hello_workflow(): Promise { +export async function helloWorkflow(): Promise { return 'Hello'; } -export async function activity_workflow(): Promise { +export async function activityWorkflow(): Promise { return echo('Hello'); } diff --git a/packages/testing/src/testing-workflow-environment.ts b/packages/testing/src/testing-workflow-environment.ts index 7893659c0..baea47812 100644 --- a/packages/testing/src/testing-workflow-environment.ts +++ b/packages/testing/src/testing-workflow-environment.ts @@ -2,10 +2,10 @@ import 'abort-controller/polyfill'; // eslint-disable-line import/no-unassigned- import { AsyncCompletionClient, Client, + ClientPlugin, Connection, ConnectionPlugin, WorkflowClient, - isConnectionPlugin, } from '@temporalio/client'; import { ConnectionOptions, @@ -19,7 +19,6 @@ import { NativeConnectionPlugin, NativeConnectionOptions, Runtime, - isNativeConnectionPlugin, } from '@temporalio/worker'; import { native } from '@temporalio/core-bridge'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; @@ -32,7 +31,7 @@ import { ClientOptionsForTestEnv, TimeSkippingClient } from './client'; export type LocalTestWorkflowEnvironmentOptions = { server?: Omit; client?: ClientOptionsForTestEnv; - connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; + plugins?: (ClientPlugin | ConnectionPlugin | NativeConnectionPlugin)[]; }; /** @@ -41,7 +40,7 @@ export type LocalTestWorkflowEnvironmentOptions = { export type TimeSkippingTestWorkflowEnvironmentOptions = { server?: Omit; client?: ClientOptionsForTestEnv; - connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; + plugins?: (ClientPlugin | ConnectionPlugin | NativeConnectionPlugin)[]; }; /** @@ -53,7 +52,7 @@ export type ExistingServerTestWorkflowEnvironmentOptions = { /** If not set, defaults to default */ namespace?: string; client?: ClientOptionsForTestEnv; - connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; + plugins?: (ClientPlugin | ConnectionPlugin | NativeConnectionPlugin)[]; }; /** @@ -115,11 +114,13 @@ export class TestWorkflowEnvironment { ? new TimeSkippingClient({ connection, namespace: this.namespace, + plugins: options.plugins, ...options.client, }) : new Client({ connection, namespace: this.namespace, + plugins: options.plugins, ...options.client, }); this.asyncCompletionClient = this.client.activity; // eslint-disable-line deprecation/deprecation @@ -160,7 +161,7 @@ export class TestWorkflowEnvironment { return await this.create({ server: { type: 'time-skipping', ...opts?.server }, client: opts?.client, - connectionPlugins: opts?.connectionPlugins, + plugins: opts?.plugins, supportsTimeSkipping: true, }); } @@ -190,7 +191,7 @@ export class TestWorkflowEnvironment { return await this.create({ server: { type: 'dev-server', ...opts?.server }, client: opts?.client, - connectionPlugins: opts?.connectionPlugins, + plugins: opts?.plugins, namespace: opts?.server?.namespace, supportsTimeSkipping: false, }); @@ -206,7 +207,7 @@ export class TestWorkflowEnvironment { return await this.create({ server: { type: 'existing' }, client: opts?.client, - connectionPlugins: opts?.connectionPlugins, + plugins: opts?.plugins, namespace: opts?.namespace ?? 'default', supportsTimeSkipping: false, address: opts?.address, @@ -250,12 +251,12 @@ export class TestWorkflowEnvironment { const nativeConnection = await NativeConnection.connect({ address, - plugins: opts.connectionPlugins?.filter((p) => isNativeConnectionPlugin(p)), + plugins: opts.plugins, [InternalConnectionOptionsSymbol]: { supportsTestService: supportsTimeSkipping }, }); const connection = await Connection.connect({ address, - plugins: opts.connectionPlugins?.filter((p) => isConnectionPlugin(p)), + plugins: opts.plugins, [InternalConnectionOptionsSymbol]: { supportsTestService: supportsTimeSkipping }, }); @@ -356,7 +357,7 @@ export class TestWorkflowEnvironment { type TestWorkflowEnvironmentOptions = { server: DevServerConfig | TimeSkippingServerConfig | ExistingServerConfig; client?: ClientOptionsForTestEnv; - connectionPlugins?: (ConnectionPlugin | NativeConnectionPlugin)[]; + plugins?: (ClientPlugin | ConnectionPlugin | NativeConnectionPlugin)[]; }; type ExistingServerConfig = { type: 'existing' }; @@ -370,6 +371,6 @@ function addDefaults(opts: TestWorkflowEnvironmentOptions): TestWorkflowEnvironm server: { ...opts.server, }, - connectionPlugins: [], + plugins: [], }; } diff --git a/packages/worker/src/connection-options.ts b/packages/worker/src/connection-options.ts index 0353ba327..02590774a 100644 --- a/packages/worker/src/connection-options.ts +++ b/packages/worker/src/connection-options.ts @@ -9,7 +9,7 @@ import { } from '@temporalio/common/lib/internal-non-workflow'; import type { Metadata } from '@temporalio/client'; import pkg from './pkg'; -import type { Plugin } from './connection'; +import type { NativeConnectionPlugin } from './connection'; export { TLSConfig, ProxyConfig }; @@ -62,7 +62,14 @@ export interface NativeConnectionOptions { */ disableErrorCodeMetricTags?: boolean; - plugins?: Plugin[]; + /** + * List of plugins to register with the native connection. + * + * Plugins allow you to configure the native connection options. + * + * Any plugins provided will also be passed to any Worker, Client, or Bundler built from this connection. + */ + plugins?: NativeConnectionPlugin[]; } // Compile to Native /////////////////////////////////////////////////////////////////////////////// diff --git a/packages/worker/src/connection.ts b/packages/worker/src/connection.ts index 93f20a620..223df5f11 100644 --- a/packages/worker/src/connection.ts +++ b/packages/worker/src/connection.ts @@ -72,7 +72,7 @@ export class NativeConnection implements ConnectionLike { private readonly runtime: Runtime, private readonly nativeClient: native.Client, private readonly enableTestService: boolean, - readonly plugins: Plugin[] + readonly plugins: NativeConnectionPlugin[] ) { this.workflowService = WorkflowService.create( this.sendRequest.bind(this, native.clientSendWorkflowServiceRequest.bind(undefined, this.nativeClient)), @@ -234,7 +234,9 @@ export class NativeConnection implements ConnectionLike { static async connect(options?: NativeConnectionOptions): Promise { options = options ?? {}; for (const plugin of options.plugins ?? []) { - options = plugin.configureNativeConnection(options); + if (plugin.configureNativeConnection !== undefined) { + options = plugin.configureNativeConnection(options); + } } const internalOptions = (options as InternalConnectionOptions)?.[InternalConnectionOptionsSymbol] ?? {}; const enableTestService = internalOptions.supportsTestService ?? false; @@ -242,14 +244,7 @@ export class NativeConnection implements ConnectionLike { try { const runtime = Runtime.instance(); - const plugins = options.plugins ?? []; - let connectNative = () => runtime.createNativeClient(options); - for (let i = plugins.length - 1; i >= 0; --i) { - const next = connectNative; - connectNative = () => plugins[i].connectNative(next); - } - - const client = await connectNative(); + const client = await runtime.createNativeClient(options); return new this(runtime, client, enableTestService, options.plugins ?? []); } catch (err) { if (err instanceof TransportError) { @@ -364,13 +359,18 @@ function tagMetadata(metadata: Metadata): Record { ); } -export interface Plugin { - configureNativeConnection(options: NativeConnectionOptions): NativeConnectionOptions; +/** + * Plugin to control the configuration of a native connection. + */ +export interface NativeConnectionPlugin { + /** + * Gets the name of this plugin. + */ + get name(): string; - connectNative(next: () => Promise): Promise; -} -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isNativeConnectionPlugin(p: any): p is Plugin { - return 'configureNativeConnection' in p && 'connectNative' in p; + /** + * Hook called when creating a native connection to allow modification of configuration. + */ + configureNativeConnection?(options: NativeConnectionOptions): NativeConnectionOptions; } diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index a0d6d1976..b7cbcb3c1 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -8,7 +8,7 @@ * @module */ -export { NativeConnection } from './connection'; +export { NativeConnection, NativeConnectionPlugin } from './connection'; export { NativeConnectionOptions, TLSConfig } from './connection-options'; export { startDebugReplayer } from './debug-replayer'; export { IllegalStateError } from '@temporalio/common'; @@ -166,5 +166,3 @@ export { */ MetricsExporterConfig as MetricsExporter, } from './runtime-options'; - -export { Plugin as NativeConnectionPlugin, isNativeConnectionPlugin } from './connection'; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index 763c5e72b..f30a91223 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -2,7 +2,7 @@ import type { ReplayWorkerOptions, WorkerOptions } from './worker-options'; import type { Worker } from './worker'; /** - * Base Plugin class for worker functionality. + * Plugin interface for worker functionality. * * Plugins provide a way to extend and customize the behavior of Temporal workers. * They allow you to intercept and modify worker configuration and worker execution. @@ -20,7 +20,7 @@ export interface WorkerPlugin { * the worker configuration before the worker is fully initialized. Plugins * can add activities, workflows, interceptors, or change other settings. */ - configureWorker(options: WorkerOptions): WorkerOptions; + configureWorker?(options: WorkerOptions): WorkerOptions; /** * Hook called when creating a replay worker to allow modification of configuration. @@ -29,7 +29,7 @@ export interface WorkerPlugin { * the worker configuration before the worker is fully initialized. Plugins * can add activities, workflows, interceptors, or change other settings. */ - configureReplayWorker(options: ReplayWorkerOptions): ReplayWorkerOptions; + configureReplayWorker?(options: ReplayWorkerOptions): ReplayWorkerOptions; /** * Hook called when running a worker. @@ -37,10 +37,5 @@ export interface WorkerPlugin { * This method is not called when running a replay worker, as activities will not be * executed, and global state can't affect the workflow. */ - runWorker(worker: Worker, next: (w: Worker) => Promise): Promise; -} - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isWorkerPlugin(p: any): p is WorkerPlugin { - return 'configureWorker' in p && 'configureReplayWorker' in p && 'runWorker' in p; + runWorker?(worker: Worker, next: (w: Worker) => Promise): Promise; } diff --git a/packages/worker/src/worker-options.ts b/packages/worker/src/worker-options.ts index e2f0f8dc4..261b29392 100644 --- a/packages/worker/src/worker-options.ts +++ b/packages/worker/src/worker-options.ts @@ -485,11 +485,13 @@ export interface WorkerOptions { /** * List of plugins to register with the worker. * - * Plugins allow you to extend and customize the behavior of Temporal workers through a chain of - * responsibility pattern. They can intercept and modify worker creation, configuration, and execution. + * Plugins allow you to extend and customize the behavior of Temporal workers. + * They can intercept and modify worker creation, configuration, and execution. * * Worker plugins can be used to add custom activities, workflows, interceptors, or modify other * worker settings before the worker is fully initialized. + * + * Any plugins provided will also be passed to the bundler if used. */ plugins?: WorkerPlugin[]; diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 2b7ec30ef..e3f30bb10 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -62,7 +62,6 @@ import { Client } from '@temporalio/client'; import { coresdk, temporal } from '@temporalio/proto'; import { type SinkCall, type WorkflowInfo } from '@temporalio/workflow'; import { throwIfReservedName } from '@temporalio/common/lib/reserved'; -import { composeInterceptors } from '@temporalio/common/lib/interceptors'; import { Activity, CancelReason, activityLogAttributes } from './activity'; import { extractNativeClient, extractReferenceHolders, InternalNativeConnection, NativeConnection } from './connection'; import { ActivityExecuteInput } from './interceptors'; @@ -95,7 +94,7 @@ import { WorkflowBundle, } from './worker-options'; import { WorkflowCodecRunner } from './workflow-codec-runner'; -import { defaultWorkflowInterceptorModules, isBundlerPlugin, WorkflowCodeBundler } from './workflow/bundler'; +import { defaultWorkflowInterceptorModules, WorkflowCodeBundler } from './workflow/bundler'; import { Workflow, WorkflowCreator } from './workflow/interface'; import { ReusableVMWorkflowCreator } from './workflow/reusable-vm'; import { ThreadedVMWorkflowCreator } from './workflow/threaded-vm'; @@ -110,7 +109,7 @@ import { } from './errors'; import { constructNexusOperationContext, NexusHandler } from './nexus'; import { handlerErrorToProto } from './nexus/conversions'; -import { isWorkerPlugin, WorkerPlugin } from './plugin'; +import { WorkerPlugin } from './plugin'; export { DataConverter, defaultPayloadConverter }; @@ -502,11 +501,11 @@ export class Worker { * This method initiates a connection to the server and will throw (asynchronously) on connection failure. */ public static async create(options: WorkerOptions): Promise { - options.plugins = (options.plugins || []).concat( - (options.connection?.plugins || []).filter((p) => isWorkerPlugin(p)).map((p) => p as WorkerPlugin) - ); + options.plugins = (options.plugins ?? []).concat(options.connection?.plugins ?? []); for (const plugin of options.plugins) { - options = plugin.configureWorker(options); + if (plugin.configureWorker !== undefined) { + options = plugin.configureWorker(options); + } } if (!options.taskQueue) { throw new TypeError('Task queue name is required'); @@ -708,7 +707,9 @@ export class Worker { private static async constructReplayWorker(options: ReplayWorkerOptions): Promise<[Worker, native.HistoryPusher]> { const plugins = options.plugins ?? []; for (const plugin of plugins) { - options = plugin.configureReplayWorker(options); + if (plugin.configureReplayWorker !== undefined) { + options = plugin.configureReplayWorker(options); + } } const nativeWorkerCtor: NativeWorkerConstructor = this.nativeWorkerCtor; const fixedUpOptions: WorkerOptions = { @@ -785,7 +786,6 @@ export class Worker { throw new TypeError('Invalid WorkflowOptions.workflowBundle'); } } else if (compiledOptions.workflowsPath) { - const bundlerPlugins = compiledOptions.plugins?.filter((p) => isBundlerPlugin(p)); const bundler = new WorkflowCodeBundler({ logger, workflowsPath: compiledOptions.workflowsPath, @@ -794,7 +794,7 @@ export class Worker { payloadConverterPath: compiledOptions.dataConverter?.payloadConverterPath, ignoreModules: compiledOptions.bundlerOptions?.ignoreModules, webpackConfigHook: compiledOptions.bundlerOptions?.webpackConfigHook, - plugins: bundlerPlugins, + plugins: compiledOptions.plugins, }); const bundle = await bundler.createBundle(); return parseWorkflowCode(bundle.code); @@ -1985,8 +1985,13 @@ export class Worker { if (this.isReplayWorker) { return this.runInternal(); } - const composition = composeInterceptors(this.plugins, 'runWorker', (w: Worker) => w.runInternal()); - return composition(this); + let runWorker = (w: Worker) => w.runInternal(); + for (let i = this.plugins.length - 1; i >= 0; --i) { + const rw = runWorker; + const plugin = this.plugins[i]; + runWorker = (w: Worker) => plugin.runWorker?.(w, rw) ?? rw(w) + } + return runWorker(this); } private async runInternal(): Promise { diff --git a/packages/worker/src/workflow/bundler.ts b/packages/worker/src/workflow/bundler.ts index 5624ce8e4..6a67e132b 100644 --- a/packages/worker/src/workflow/bundler.ts +++ b/packages/worker/src/workflow/bundler.ts @@ -58,7 +58,9 @@ export class WorkflowCodeBundler { constructor(options: BundleOptions) { this.plugins = options.plugins ?? []; for (const plugin of this.plugins) { - options = plugin.configureBundler(options); + if (plugin.configureBundler !== undefined) { + options = plugin.configureBundler(options); + } } const { logger, @@ -318,19 +320,14 @@ export interface BundlerPlugin { * Gets the name of this plugin. * * Returns: - * The name of the plugin class. + * The name of the plugin. */ get name(): string; /** * Hook called when creating a bundler to allow modification of configuration. */ - configureBundler(options: BundleOptions): BundleOptions; -} - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function isBundlerPlugin(p: any): p is BundlerPlugin { - return 'configureBundler' in p; + configureBundler?(options: BundleOptions): BundleOptions; } /** From f85cc3316b58445196b6aaaba898f79fe012e452 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 20 Oct 2025 11:19:17 -0700 Subject: [PATCH 09/11] Linting --- packages/client/src/index.ts | 8 +++++++- packages/client/src/plugin.ts | 1 - packages/test/src/test-plugins.ts | 12 +++--------- packages/testing/src/testing-workflow-environment.ts | 7 +------ packages/worker/src/connection.ts | 2 -- packages/worker/src/worker.ts | 2 +- 6 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 957c998a1..34b594d3c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -30,7 +30,13 @@ export * from '@temporalio/common/lib/interfaces'; export * from '@temporalio/common/lib/workflow-handle'; export * from './async-completion-client'; export * from './client'; -export { Connection, ConnectionOptions, ConnectionOptionsWithDefaults, ConnectionPlugin, LOCAL_TARGET } from './connection'; +export { + Connection, + ConnectionOptions, + ConnectionOptionsWithDefaults, + ConnectionPlugin, + LOCAL_TARGET, +} from './connection'; export * from './errors'; export * from './grpc-retry'; export * from './interceptors'; diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index 1fe0a3333..de44495d0 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -17,4 +17,3 @@ export interface ClientPlugin { */ configureClient?(options: ClientOptions): ClientOptions; } - diff --git a/packages/test/src/test-plugins.ts b/packages/test/src/test-plugins.ts index 0de36caa8..daa38b5f8 100644 --- a/packages/test/src/test-plugins.ts +++ b/packages/test/src/test-plugins.ts @@ -1,11 +1,6 @@ import { randomUUID } from 'crypto'; import anyTest, { TestFn } from 'ava'; -import { - Client, - ClientOptions, - ConnectionPlugin, - ClientPlugin as ClientPlugin, -} from '@temporalio/client'; +import { Client, ClientOptions, ConnectionPlugin, ClientPlugin as ClientPlugin } from '@temporalio/client'; import { WorkerOptions, WorkerPlugin as WorkerPlugin, @@ -154,14 +149,13 @@ test('Client plugins are passed from connections', async (t) => { } }); - test('Bundler plugins are passed from connections', async (t) => { - const plugin = new class implements BundlerPlugin { + const plugin = new (class implements BundlerPlugin { name: string = 'plugin'; configureBundler(options: BundleOptions): BundleOptions { return { ...options, workflowsPath: require.resolve('./workflows/plugins') }; } - }; + })(); const env = await TestWorkflowEnvironment.createLocal({ plugins: [plugin] }); try { const client = new Client({ connection: env.connection }); diff --git a/packages/testing/src/testing-workflow-environment.ts b/packages/testing/src/testing-workflow-environment.ts index baea47812..054e86472 100644 --- a/packages/testing/src/testing-workflow-environment.ts +++ b/packages/testing/src/testing-workflow-environment.ts @@ -14,12 +14,7 @@ import { } from '@temporalio/client/lib/connection'; import { Duration, TypedSearchAttributes } from '@temporalio/common'; import { msToNumber, msToTs, tsToMs } from '@temporalio/common/lib/time'; -import { - NativeConnection, - NativeConnectionPlugin, - NativeConnectionOptions, - Runtime, -} from '@temporalio/worker'; +import { NativeConnection, NativeConnectionPlugin, NativeConnectionOptions, Runtime } from '@temporalio/worker'; import { native } from '@temporalio/core-bridge'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; import { toNativeEphemeralServerConfig, DevServerConfig, TimeSkippingServerConfig } from './ephemeral-server'; diff --git a/packages/worker/src/connection.ts b/packages/worker/src/connection.ts index 223df5f11..a0c63788a 100644 --- a/packages/worker/src/connection.ts +++ b/packages/worker/src/connection.ts @@ -14,7 +14,6 @@ import { InternalConnectionLikeSymbol, } from '@temporalio/client'; import { InternalConnectionOptions, InternalConnectionOptionsSymbol } from '@temporalio/client/lib/connection'; -import { composeInterceptors } from '@temporalio/common/lib/interceptors'; import { TransportError } from './errors'; import { NativeConnectionOptions } from './connection-options'; import { Runtime } from './runtime'; @@ -368,7 +367,6 @@ export interface NativeConnectionPlugin { */ get name(): string; - /** * Hook called when creating a native connection to allow modification of configuration. */ diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index e3f30bb10..716106f85 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -1989,7 +1989,7 @@ export class Worker { for (let i = this.plugins.length - 1; i >= 0; --i) { const rw = runWorker; const plugin = this.plugins[i]; - runWorker = (w: Worker) => plugin.runWorker?.(w, rw) ?? rw(w) + runWorker = (w: Worker) => plugin.runWorker?.(w, rw) ?? rw(w); } return runWorker(this); } From b01a63e1b4f03dfbbe8c7d7aba90da395c6c8c7b Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 20 Oct 2025 11:20:27 -0700 Subject: [PATCH 10/11] Remove no longer needed apikey test, typing has changed --- packages/test/src/test-plugins.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/test/src/test-plugins.ts b/packages/test/src/test-plugins.ts index daa38b5f8..1bf33e852 100644 --- a/packages/test/src/test-plugins.ts +++ b/packages/test/src/test-plugins.ts @@ -245,16 +245,3 @@ test('SimplePlugin with activities merges them correctly', async (t) => { t.truthy(worker.options.activities.has('existingActivity')); t.truthy(worker.options.activities.has('pluginActivity')); }); - -test('SimplePlugin with apiKey function throws error for NativeConnection', async (t) => { - const plugin = new SimplePlugin({ - name: 'simple-test-plugin', - apiKey: () => 'some-key', - }); - - const error = t.throws(() => { - plugin.configureNativeConnection({}); - }); - - t.is(error?.message, 'NativeConnectionOptions does not support apiKey as a function'); -}); From bd2dad0e631f6df40fc5f32ab3b58bae1e235b75 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 22 Oct 2025 10:16:53 -0700 Subject: [PATCH 11/11] Minor PR changes --- packages/client/src/client.ts | 2 +- packages/client/src/types.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 4e5d804b9..7f97500e4 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -68,7 +68,7 @@ export class Client extends BaseClient { options.plugins = (options.plugins ?? []).concat(options.connection?.plugins ?? []); // Process plugins first to allow them to modify connect configuration - for (const plugin of options?.plugins ?? []) { + for (const plugin of options.plugins) { if (plugin.configureClient !== undefined) { options = plugin.configureClient(options); } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 67d489e75..4ce6f9032 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -3,6 +3,7 @@ import type { TypedSearchAttributes, SearchAttributes, SearchAttributeValue, Pri import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow'; import * as proto from '@temporalio/proto'; import { Replace } from '@temporalio/common/lib/type-helpers'; +import type { ConnectionPlugin } from './connection'; export interface WorkflowExecution { workflowId: string; @@ -122,7 +123,7 @@ export interface CallContext { */ export interface ConnectionLike { workflowService: WorkflowService; - plugins: any[]; + plugins: ConnectionPlugin[]; close(): Promise; ensureConnected(): Promise;