(Experimental) Plugins

The new, experimental plugin API is an iteration of the existing extension system. It simplifies the API by removing the need for multiple methods (start, startOnMainThread, handleFile, setupFile, etc.) and instead only requires a single handleComponent method. Plugins are designed to be more extensible and easier to use, and they are intended to replace the concept of extensions in the future.

Similar to the existing extension API, a plugin must specify an extensionModule option. This must be a path to the plugin module source code. The path must resolve from the root of the module directory.

For example, the Harper Next.js Extension config.yaml specifies extensionModule: ./extension.js.

If the plugin is being written in something other than JavaScript (such as TypeScript), ensure that the path resolves to the built version, (i.e. extensionModule: ./dist/index.js)

It is also recommended that all extensions have a package.json that specifies JavaScript package metadata such as name, version, type, etc. Since plugins are just JavaScript packages, they can do anything a JavaScript package can normally do. It can be written in TypeScript, and compiled to JavaScript. It can export an executable (using the bin property). It can be published to npm. The possibilities are endless!

The key to a plugin is the handleComponent() method. It must be exported by the extensionModule, and cannot coexist with any of the other extension methods such as start, handleFile, etc. The component loader will throw an error if both are defined.

The handleComponent() method is executed only on worker threads during the component loading sequence. It receives a single, scope argument that contains all of the relevant metadata and APIs for interacting with the associated component.

The method can be async and is awaited by the component loader.

However, it is highly recommended to avoid event-loop-blocking operations within the handleComponent() method. See the examples section for best practices on how to use the scope argument effectively.

Example: Statically hosting files

This is a functional example of how the handleComponent() method and scope argument can be used to create a simple static file server plugin. This example assumes that the component has a config.yaml with the files option set to a glob pattern that matches the files to be served.

This is a simplified form of the static built-in extension.

export function handleComponent(scope) {
  const staticFiles = new Map();

  scope.options.on('change', (key, value, config) => {
	if (key[0] === 'files' || key[0] === 'urlPath') {
	  // If the files or urlPath options change, we need to reinitialize the static files map
	  staticFiles.clear();
	  logger.info(`Static files reinitialized due to change in ${key.join('.')}`);
	}
  });

  scope.handleEntry((entry) => {
	if (entry.entryType === 'directory') {
	  logger.info(`Cannot serve directories. Update the files option to only match files.`);
	  return;
	}

	switch (entry.eventType) {
	  case 'add':
	  case 'change':
		// Store / Update the file contents in memory for serving
		staticFiles.set(entry.urlPath, entry.contents);
		break;
	  case 'unlink':
		// Remove the file from memory when it is deleted
		staticFiles.delete(entry.urlPath);
		break;
	}
  });

  scope.server.http((req, next) => {
	if (req.method !== 'GET') return next(req);

	// Attempt to retrieve the requested static file from memory
	const staticFile = staticFiles.get(req.pathname);

	return staticFile ? {
	  statusCode: 200,
	  body: staticFile,
	} : {
	  statusCode: 404,
	  body: 'File not found',
	}
  }, { runFirst: true });
}

In this example, the entry handler method passed to handleEntry will manage the map of static files in memory using their computed urlPath and the contents. If the config file changes (and thus a new default file or url path is specified) the plugin will clear the file map as well to remove artifacts. Furthermore, it uses the server.http() middleware to hook into the HTTP request handling.

This example is heavily simplified, but it demonstrates how the different key parts of scope can be used together to provide a performant and reactive application experience.

handleComponent(scope: Scope): void | Promise<void>

Parameters:

  • scope - Scope - An instance of the Scope class that provides access to the component's configuration, resources, and other APIs.

Returns: void | Promise<void>

This is the only method an plugin module must export. It can be async and is awaited by the component loader. The scope argument provides access to the component's configuration, resources, and other APIs.

Class: Scope

Event: 'close'

Emitted after the scope is closed via the close() method.

Event: 'error'

  • error - unknown - The error that occurred.

Event: 'ready'

Emitted when the Scope is ready to be used after loading the associated config file. It is awaited by the component loader, so it is not necessary to await it within the handleComponent() method.

scope.close()

Returns: this - The current Scope instance.

Closes all associated entry handlers, the associated scope.options instance, emits the 'close' event, and then removes all other listeners on the instance.

scope.handleEntry([files][, handler])

Parameters:

Returns: EntryHandler - An instance of the EntryHandler class that can be used to handle entries within the scope.

The handleEntry() method is the key to handling component entries. This method is used to register an entry event handler, specifically for the EntryHandler 'all' event. The method signature is very flexible, and allows for the following variations:

  • scope.handleEntry() (with no arguments) Returns the default EntryHandler created by the files and urlPath options in the config.yaml.

  • scope.handleEntry(handler) (where handler is an onEntryEventHandler) Returns the default EntryHandler instance (based on the options within config.yaml) and uses the provided handler for the 'all' event.

  • scope.handleEntry(files) (where files is FilesOptions or FileAndURLPathConfig) Returns a new EntryHandler instance that handles the specified files configuration.

  • scope.handleEntry(files, handler) (where files is FilesOptions or FileAndURLPathConfig, and handler is an onEntryEventHandler) Returns a new EntryHandler instance that handles the specified files configuration and uses the provided handler for the 'all' event.

For example,

export function handleComponent(scope) {
   // Get the default EntryHandler instance
  const defaultEntryHandler = scope.handleEntry();

  // Assign a handler for the 'all' event on the default EntryHandler
  scope.handleEntry((entry) => { /* ... */ });

  // Create a new EntryHandler for the 'src/**/*.js' files option with a custom `'all'` event handler.
  const customEntryHandler = scope.handleEntry({
	files: 'src/**/*.js',
  }, (entry) => { /* ... */ });

  // Create another custom EntryHandler for the 'src/**/*.ts' files option, but without a `'all'` event handler.
  const anotherCustomEntryHandler = scope.handleEntry({
	files: 'src/**/*.ts',
  });
}

And thus, if the previous code was used by a component with the following config.yaml:

customPlugin:
  files: 'web/**/*'

Then the default EntryHandler instances would be created to handle all entries within the web directory.

scope.requestRestart()

Returns: void

Request a Harper restart. This does not restart the instance immediately, but rather indicates to the user that a restart is required. This should be called when the plugin cannot handle the entry event and wants to indicate to the user that the Harper instance should be restarted.

This method is called automatically by the scope instance if the user has not defined an scope.options.on('change') handler or any event handlers for the default EntryHandler instance.

scope.resources

  • Map<string, Resource> - A map of the currently loaded Resource instances.

scope.server

  • server - A reference to the server global API.

scope.options

  • OptionsWatcher

An OptionsWatcher instance associated with the component using the plugin. Emits 'change' events when the respective plugin part of the component's config file is modified.

Interface: FilesOption

Interface: FilesOptionsObject

  • source - string | string[] - required - The glob pattern string or array of strings.

  • ignore - string | string[] - optional - An array of glob patterns to exclude from matches. This is an alternative way to use negative patterns. Defaults to [].

Interface: FileAndURLPathConfig

  • files - FilesOptions - required - A glob pattern string, array of glob pattern strings, or a more expressive glob options object determining the set of files and directories to be resolved for the plugin.

  • urlPath - string - optional - A base URL path to prepend to the resolved files entries.

Class: OptionsWatcher

Event: 'change'

  • key - string[] - The key of the changed option split into parts (e.g. foo.bar becomes ['foo', 'bar']).

  • value - ConfigValue - The new value of the option.

  • config - ConfigValue - The entire configuration object of the plugin.

The 'change' event is emitted whenever an configuration option is changed in the configuration file relative to the component and respective plugin.

Given a component using the following config.yaml:

customPlugin:
  files: 'web/**/*'
other{lugin:
  file: 'index.js'

The scope.options for the respective plugin's customPlugin and otherPlugin would emit 'change' events when the files options relative to them are modified.

For example, if the files option for customPlugin is changed to web/**/*.js, the following event would be emitted only within the customPlugin scope:

scope.options.on('change', (key, value, config) => {
  key // ['files']
  value // 'web/**/*.js'
  config // { files: 'web/**/*.js' }
});

Event: 'close'

Emitted when the OptionsWatcher is closed via the close() method. The watcher is not usable after this event is emitted.

Event: 'error'

  • error - unknown - The error that occurred.

Event: 'ready'

  • config - ConfigValue | undefined - The configuration object of the plugin, if present.

This event can be emitted multiple times. It is first emitted upon the initial load, but will also be emitted after restoring a configuration file or configuration object after a 'remove' event.

Event: 'remove'

The configuration was removed. This can happen if the configuration file was deleted, the configuration object within the file is deleted, or if the configuration file fails to parse. Once restored, the 'ready' event will be emitted again.

options.close()

Returns: this - The current OptionsWatcher instance.

Closes the options watcher, removing all listeners and preventing any further events from being emitted. The watcher is not usable after this method is called.

options.get(key)

Parameters:

  • key - string[] - The key of the option to get, split into parts (e.g. foo.bar is represented as ['foo', 'bar']).

Returns: ConfigValue | undefined

If the config is defined it will attempt to retrieve the value of the option at the specified key. If the key does not exist, it will return undefined.

options.getAll()

Returns: ConfigValue | undefined

Returns the entire configuration object of the plugin. If the config is not defined, it will return undefined.

options.getRoot()

Returns: Config | undefined

Returns the root configuration object of the component. This is the entire configuration object, basically the parsed form of the config.yaml. If the config is not defined, it will return undefined.

Interface: Config

An object representing the configuration of the plugin.

Interface: ConfigValue

  • string | number | boolean | null | undefined | ConfigValue[] | Config

Any valid configuration value type. Essentially, the primitive types, an array of those types, or an object comprised of values of those types.

Class: EntryHandler

Extends: EventEmitter

Event: 'all'

The 'all' event is emitted for all entry events, including file and directory events. This is the event that the handler method in scope.handleEntry is registered for. The event handler receives an entry object that contains the entry metadata, such as the file contents, URL path, and absolute path.

An effective pattern for this event is:

async function handleComponent(scope) {
  scope.handleEntry((entry) => {
	switch(entry.eventType) {
	  case 'add':
		// Handle file addition
		break;
	  case 'change':
		// Handle file change
		break;
	  case 'unlink':
		// Handle file deletion
		break;
	  case 'addDir':
		// Handle directory addition
		break;
	  case 'unlinkDir':
		// Handle directory deletion
		break;
	}
  });
}

Event: 'add'

The 'add' event is emitted when a file is created (or the watcher sees it for the first time). The event handler receives an AddFileEvent object that contains the file contents, URL path, absolute path, and other metadata.

Event: 'addDir'

  • entry - AddDirEvent - The directory entry that was added.

The 'addDir' event is emitted when a directory is created (or the watcher sees it for the first time). The event handler receives an AddDirEvent object that contains the URL path and absolute path of the directory.

Event: 'change'

The 'change' event is emitted when a file is modified. The event handler receives a ChangeFileEvent object that contains the updated file contents, URL path, absolute path, and other metadata.

Event: 'close'

Emitted when the entry handler is closed via the entryHandler.close() method.

Event: 'error'

  • error - unknown - The error that occurred.

Event: 'ready'

Emitted when the entry handler is ready to be used.

The 'unlink' event is emitted when a file is deleted. The event handler receives an UnlinkFileEvent object that contains the URL path and absolute path of the deleted file.

Event: 'unlinkDir'

The 'unlinkDir' event is emitted when a directory is deleted. The event handler receives an UnlinkDirEvent object that contains the URL path and absolute path of the deleted directory.

entryHandler.name

Returns: string

The name of the associated component.

entryHandler.directory

Returns: string

The directory of the associated component. This is the root directory of the component where the config.yaml file is located.

entryHandler.close()

Returns: this - The current EntryHandler instance.

Closes the entry handler, removing all listeners and preventing any further events from being emitted. The handler can be started again using the entryHandler.update() method.

entryHandler.update(config)

Parameters:

This method will update an existing entry handler to watch new entries. It will close the underlying watcher and create a new one, but will maintain any existing listeners on the EntryHandler instance itself.

This method returns a promise associated with the ready event of the updated handler.

Interface: BaseEntry

  • stats - fs.Stats | undefined - The file system stats for the entry.

  • urlPath - string - The recommended URL path of the entry.

  • absolutePath - string - The absolute path of the entry.

The foundational entry handle event object. The stats may or may not be present depending on the event, entry type, and platform.

The urlPath is resolved based on the configured pattern (files: option) combined with the optional urlPath option. This path is generally useful for uniquely representing the entry. It is used in the built-in components such as jsResource and static.

The absolutePath is the file system path for the entry.

Interface: FileEntry

Extends BaseEntry

  • contents - Buffer - The contents of the file.

A specific extension of the BaseEntry interface representing a file entry. We automatically read the contents of the file so the user doesn't have to bother with FS operations.

There is no DirectoryEntry since there is no other important metadata aside from the BaseEntry properties. If a user wants the contents of a directory, they should adjust the pattern to resolve files instead.

Interface: EntryEvent

Extends BaseEntry

  • eventType - string - The type of entry event.

  • entryType - string - The type of entry, either a file or a directory.

A general interface representing the entry handle event objects.

Interface: AddFileEvent

Extends EntryEvent, FileEntry

  • eventType - 'add'

  • entryType - 'file'

Event object emitted when a file is created (or the watcher sees it for the first time).

Interface: ChangeFileEvent

Extends EntryEvent, FileEntry

  • eventType - 'change'

  • entryType - 'file'

Event object emitted when a file is modified.

Interface: UnlinkFileEvent

Extends EntryEvent, FileEntry

  • eventType - 'unlink'

  • entryType - 'file'

Event object emitted when a file is deleted.

Interface: FileEntryEvent

  • AddFileEvent | ChangeFileEvent | UnlinkFileEvent

A union type representing the file entry events. These events are emitted when a file is created, modified, or deleted. The FileEntry interface provides the file contents and other metadata.

Interface: AddDirEvent

Extends EntryEvent

  • eventType - 'addDir'

  • entryType - 'directory'

Event object emitted when a directory is created (or the watcher sees it for the first time).

Interface: UnlinkDirEvent

Extends EntryEvent

  • eventType - 'unlinkDir'

  • entryType - 'directory'

Event object emitted when a directory is deleted.

Interface: DirectoryEntryEvent

  • AddDirEvent | UnlinkDirEvent

A union type representing the directory entry events. There are no change events for directories since they are not modified in the same way as files.

Last updated