0

Context

I am building a Chrome Extension that can receive commands and it shall be based on TypeScript. I want to extract concrete implementations of a class into it's own file that can be dynamically loaded.

Typescript dynamic loading command pattern

Example

In my chrome extension I receive the following JSON.

{
    "action": "get_tasking",
    "tasks": [
        {
            "command": "ConcreteContentCommand",
            "parameters": "{'param1': 'bla', 'param2': 'bla2'}",
            "timestamp": 1578706611.324671, //timestamp provided to help with ordering
            "id": "task uuid",
        }
    ]
}

First I'll load the appropriate file from IndexedDB that can be named ConcreteContentCommand.js containing the ConcreteContentCommand-Implementation. Now I want to create an instance of ConcreteContentCommand using the parameters param1, param2 that can then be used by my invoker.

class ContentReceiver {
  targetURL: string;
  payload: string;

  constructor(targetURL: string, payload: string) {
    this.targetURL = targetURL;
    this.payload = payload;
  }

  InjectRunnable() {
    // inject the payload into tab
    console.log(`Do something with ${this.payload}`);
  }
}

interface Command {
  execute(): void;
}

abstract class ContentCommand implements Command {
  receiver: ContentReceiver;

  protected constructor(url: string) {
    this.receiver = new ContentReceiver(url, this.runnable.toString());
  }

  abstract runnable(): void;

  execute() {
    this.receiver.InjectRunnable();
  }
}

abstract class BackgroundCommand implements Command {
  abstract runnable(): void;

  execute() {
    this.runnable();
  }
}

// This class shall be extracted, my extension will not know about this class
class ConcreteContentCommand extends ContentCommand {
  param1: string;
  param2: string;

  constructor(param1: string, param2: string) {
    super("https://staticurl");
    this.param1 = param1;
    this.param2 = param2;
  }

  runnable() {
    // Do Concrete Stuff here
  }
}

What options do I have to do this?

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
pewpew
  • 23
  • 5

1 Answers1

1

Props on starting with a UML!

In an ideal case of the command pattern, you can execute any command without knowing what the command is or which commands are available because each command is a self-containing object. However if you are creating a command instance based on stringified data, then at some point you have to map a string name to its class implementation.

You mention a CommandListener in your UML. I really don't know how all of this is executed on the server, but it would probably be ideal if each ConcreteCommand could attach its own listener, which listens to all commands and handles the ones that match its name.

Another idea is to have a centralized TaskParser that responds to all JSON actions by converting them to Commands and assigning them to the invoker (which I've called CommandRunner here). Each ConcreteCommand has to register itself to the TaskParser.

In thinking about what a Command needs in order to be created from a JSON string, I first came up with a generic StringableCommand which depends on parameters Args. It has a name for identification, can be constructed from Args, and it has a method validateArgs that serves as a type guard. If validateArgs returns true, then the args array is known to be of type Args and it is safe to call new.

interface StringableCommand<Args extends any[]> extends Command {
    new(...args: Args): Command;
    name: string;
    validateArgs( args: any[] ): args is Args;
}

Note that this interface applies to the class prototype, not to the class instance (that part tripped me up a bit) so we don't say that our class extends StringableCommand. We must implement validateArgs as a static method in order for it to be available on the prototype. We don't need to explicitly implement name because it already exists.

However having the generic in StringableCommand makes things confusing for our TaskParser, which will hold many commands of different types. For that reason, I think it's better to define a constructable command as one with a method fromArgs that takes an array of any/unknown arguments and either returns a Command or throws an Error (you could return null or undefined instead). The class can deal with validation internally, or we can rely on the fact that the constructor should throw an error (but we would have to tell typescript to ignore that).

interface StringableCommand {
    name: string;
    fromArgs( ...args: any[] ): Command; // or throw an error
}

We are dealing with a JSON string that parses to this:

interface ActionPayload {
    action: string;
    tasks: TaskPayload[];
}

interface TaskPayload {
    command: string;
    parameters: any[];
    timestamp: number;
    id: string;
}

I've changed your parameters to be an array rather than a keyed object so that it's easier to pass them as arguments to the constructor of the command.

Our TaskParser class to resolve these commands looks something like this:

class TaskParser {

    private readonly runner: CommandRunner;
    private commandConstructors: Record<string, StringableCommand> = {}
    private errors: Error[] = [];

    constructor(runner: CommandRunner) {
        this.runner = runner;
    }

    public registerCommand( command: StringableCommand ): void {
        this.commandConstructors[command.name] = command;
    }

    public parseJSON( json: string ): void {
        const data = JSON.parse(json) as ActionPayload;
        data.tasks.forEach(({command, parameters, id, timestamp}) => {
            const commandObj = this.findCommand(command);
            if ( ! commandObj ) {
                this.storeError( new Error(`invalid command name ${command}`) );
                return;
            }
            try {
                const c = commandObj.fromArgs(parameters);
                this.runner.addCommand(c, timestamp, id);
            } catch (e) { // catch errors thrown by `fromArgs`
                this.storeError(e);
            }
        });
    }

    private findCommand( name: string ): StringableCommand | undefined {
        return this.commandConstructors[name];
    }

    private storeError( error: Error ): void {
        this.errors.push(error);
    }

}

It takes a CommandRunner which right now is just a stub because I don't know your implementation details, but this would be where you invoke/execute the commands.

class CommandRunner {

    addCommand( command: Command, timestamp: number, id: string ): void { // whatever args you need
        // do something
    }
}

Typescript Playground Link

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102