Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"embeddedLanguageFormatting": "off"
}
],
"eqeqeq": "error",
"require-atomic-updates": 0,
"no-extra-semi": 0,
"no-mixed-spaces-and-tabs": 0,
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/unittests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Unit tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
node-version: [20.x, 22.x, 24.x]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ matrix.node-version }}
- name: Install plugin
run: npm ci
- name: Lint
run: npm run lint
- name: Run unit tests
run: npm test
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,58 @@
# Base plugin
# @sitespeed.io/plugin

Base class for [sitespeed.io](https://www.sitespeed.io/) plugins. Extend it,
implement `processMessage`, and sitespeed.io will instantiate and drive your
plugin through the message queue.

See the [plugin documentation](https://www.sitespeed.io/documentation/sitespeed.io/plugins/#how-to-create-your-own-plugin)
for the full plugin lifecycle.

## Install

```bash
npm install @sitespeed.io/plugin
```

## Usage

```js
import { SitespeedioPlugin } from '@sitespeed.io/plugin';

export default class MyPlugin extends SitespeedioPlugin {
constructor(options, context, queue) {
super({ name: 'myplugin', options, context, queue });
}

async open() {
// optional: setup on startup
}

async processMessage(message) {
if (message.type === 'url') {
this.log.info('Got a URL: %s', message.url);
await this.sendMessage('myplugin.data', { hello: 'world' });
}
}

async close() {
// optional: cleanup on shutdown
}
}
```

## API

- `this.name` / `getName()` — plugin name
- `this.options` / `getOptions()` — sitespeed.io start options
- `this.context` / `getContext()` — sitespeed.io context
- `this.queue` — the message queue
- `this.log` / `getLog()` — logger (call levels directly: `this.log.info(...)`)
- `getStorageManager()` — storage manager for writing files
- `getFilterRegistry()` — filter registry for TimeSeries metrics
- `sendMessage(type, data, extras)` — post a message on the queue
- `open()` / `close()` — lifecycle hooks (override as needed)
- `processMessage(message)` — **must be implemented** by your subclass

## License

MIT
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
],
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"lint:fix": "eslint . --fix",
"test": "node --test"
},
"keywords": [
"sitespeed.io",
Expand All @@ -23,6 +24,9 @@
"url": "https://github.com/sitespeedio/plugin.git"
},
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"devDependencies": {
"eslint": "8.34.0",
"eslint-config-prettier": "8.6.0",
Expand Down
22 changes: 9 additions & 13 deletions plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
*/
export class SitespeedioPlugin {
constructor(config) {
if (this.constructor == SitespeedioPlugin) {
if (this.constructor === SitespeedioPlugin) {
throw new Error("Abstract plugin can't be instantiated.");
}
if (!config || !config.name || !config.context || !config.queue) {
throw new Error(
'SitespeedioPlugin requires a config object with name, context and queue'
);
}
if (config.name.includes('.')) {
throw new Error("sitespeed.io plugin names can't contain dots");
}
Expand All @@ -17,18 +22,9 @@ export class SitespeedioPlugin {
this.context = config.context;
this.queue = config.queue;
this.make = config.context.messageMaker(this.name).make;
this.log = config.context.getLogger(
`sitespeed.io.plugin.${config.name}`
);
}

/**
* Log a message. Default log level is info.
* @param {*} message
* @param {*} level - trace|verbose|debug|info|warn|error|critical
*/
log(message, level = 'info', ...args) {
this.log[level](message, args);
// Logger instance. Call levels directly, e.g. this.log.info(msg).
// Levels: trace|verbose|debug|info|warn|error|critical
this.log = config.context.getLogger(`sitespeed.io.plugin.${config.name}`);
}

/**
Expand Down
130 changes: 130 additions & 0 deletions test/plugin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { SitespeedioPlugin } from '../plugin.js';

function makeContext() {
return {
messageMaker(name) {
return {
make(type, data, extras) {
return { type, data, extras, source: name };
}
};
},
getLogger(channel) {
return { channel, info() {}, warn() {}, error() {} };
},
filterRegistry: { id: 'registry' },
storageManager: { id: 'storage' }
};
}

function makeQueue() {
const posted = [];
return {
posted,
postMessage(message) {
posted.push(message);
return message;
}
};
}

class TestPlugin extends SitespeedioPlugin {
async processMessage() {}
}

class NoProcessPlugin extends SitespeedioPlugin {}

test('abstract base class cannot be instantiated directly', () => {
assert.throws(
() =>
new SitespeedioPlugin({
name: 'x',
context: makeContext(),
queue: makeQueue()
}),
/Abstract plugin can't be instantiated/
);
});

test('rejects plugin names containing a dot', () => {
assert.throws(
() =>
new TestPlugin({
name: 'bad.name',
context: makeContext(),
queue: makeQueue()
}),
/can't contain dots/
);
});

test('rejects config missing required fields', () => {
assert.throws(() => new TestPlugin(), /requires a config object/);
assert.throws(
() => new TestPlugin({ context: makeContext(), queue: makeQueue() }),
/requires a config object/
);
assert.throws(
() => new TestPlugin({ name: 'p', queue: makeQueue() }),
/requires a config object/
);
assert.throws(
() => new TestPlugin({ name: 'p', context: makeContext() }),
/requires a config object/
);
});

test('getters return the values passed in via config', () => {
const context = makeContext();
const queue = makeQueue();
const options = { some: 'option' };
const plugin = new TestPlugin({ name: 'myplugin', options, context, queue });

assert.equal(plugin.getName(), 'myplugin');
assert.equal(plugin.getOptions(), options);
assert.equal(plugin.getContext(), context);
assert.equal(plugin.getStorageManager(), context.storageManager);
assert.equal(plugin.getFilterRegistry(), context.filterRegistry);
assert.equal(plugin.getLog().channel, 'sitespeed.io.plugin.myplugin');
});

test('sendMessage posts a made message to the queue', async () => {
const context = makeContext();
const queue = makeQueue();
const plugin = new TestPlugin({ name: 'myplugin', context, queue });

await plugin.sendMessage('myplugin.data', { hello: 'world' }, { extra: 1 });

assert.equal(queue.posted.length, 1);
assert.deepEqual(queue.posted[0], {
type: 'myplugin.data',
data: { hello: 'world' },
extras: { extra: 1 },
source: 'myplugin'
});
});

test('default processMessage throws when not overridden', async () => {
const plugin = new NoProcessPlugin({
name: 'noproc',
context: makeContext(),
queue: makeQueue()
});
await assert.rejects(
() => plugin.processMessage({ type: 'anything' }),
/must be implemented/
);
});

test('default open and close resolve without error', async () => {
const plugin = new TestPlugin({
name: 'myplugin',
context: makeContext(),
queue: makeQueue()
});
await plugin.open();
await plugin.close();
});
Loading