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
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
module.exports = {
collectCoverage: true,
collectCoverageFrom: ['<rootDir>/src/**/*.ts', '!<rootDir>/src/**/*.test.ts'],
collectCoverageFrom: [
'<rootDir>/src/**/*.ts',
'!<rootDir>/src/**/*.test.ts',
'!<rootDir>/src/vendor/**/*',
],
coverageDirectory: 'coverage',
coverageReporters: ['html', 'json-summary', 'text'],
coverageThreshold: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@metamask/eslint-config-jest": "^9.0.0",
"@metamask/eslint-config-nodejs": "^9.0.0",
"@metamask/eslint-config-typescript": "^9.0.1",
"@types/chrome": "^0.0.204",
"@types/jest": "^26.0.13",
"@types/node": "^18.0.0",
"@types/readable-stream": "^2.3.9",
Expand Down
2 changes: 1 addition & 1 deletion src/BasePostMessageStream.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { WindowPostMessageStream } from '.';
import { WindowPostMessageStream } from './window/WindowPostMessageStream';

describe('BasePostMessageStream', () => {
let stream: WindowPostMessageStream;
Expand Down
1 change: 1 addition & 0 deletions src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('post-message-stream', () => {
'WindowPostMessageStream',
'WebWorkerPostMessageStream',
'WebWorkerParentPostMessageStream',
'BrowserRuntimePostMessageStream',
];

it('package has expected exports', () => {
Expand Down
1 change: 1 addition & 0 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
export * from './window/WindowPostMessageStream';
export * from './WebWorker/WebWorkerPostMessageStream';
export * from './WebWorker/WebWorkerParentPostMessageStream';
export * from './runtime/BrowserRuntimePostMessageStream';
export * from './BasePostMessageStream';
export { StreamData, StreamMessage } from './utils';
1 change: 1 addition & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('post-message-stream', () => {
'ProcessMessageStream',
'ThreadParentMessageStream',
'ThreadMessageStream',
'BrowserRuntimePostMessageStream',
];

it('package has expected exports', () => {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export * from './node-process/ProcessParentMessageStream';
export * from './node-process/ProcessMessageStream';
export * from './node-thread/ThreadParentMessageStream';
export * from './node-thread/ThreadMessageStream';
export * from './runtime/BrowserRuntimePostMessageStream';
export * from './BasePostMessageStream';
export { StreamData, StreamMessage } from './utils';
135 changes: 135 additions & 0 deletions src/runtime/BrowserRuntimePostMessageStream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { BrowserRuntimePostMessageStream } from './BrowserRuntimePostMessageStream';

describe('BrowserRuntimePostMessageStream', () => {
beforeEach(() => {
const addListener = jest.fn();
const sendMessage = jest.fn().mockImplementation((message) => {
// Propagate message to all listeners.
addListener.mock.calls.forEach(([listener]) => {
setTimeout(() => listener(message));
});
});

Object.assign(global, {
chrome: undefined,
browser: {
runtime: {
sendMessage,
onMessage: {
addListener,
removeListener: jest.fn(),
},
},
},
});
});

it('throws if browser.runtime.sendMessage is not a function', () => {
// @ts-expect-error - Invalid function type.
browser.runtime.sendMessage = undefined;

expect(
() => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }),
).toThrow(
'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.',
);

// @ts-expect-error - Invalid function type.
browser.runtime.sendMessage = 'foo';

expect(
() => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }),
).toThrow(
'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.',
);

// @ts-expect-error - Invalid function type.
browser.runtime = undefined;

expect(
() => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }),
).toThrow(
'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.',
);

// @ts-expect-error - Invalid function type.
browser = undefined;

expect(
() => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }),
).toThrow(
'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.',
);
});

it('supports chrome.runtime', () => {
const addListener = jest.fn();
const sendMessage = jest.fn().mockImplementation((message) => {
// Propagate message to all listeners.
addListener.mock.calls.forEach(([listener]) => {
setTimeout(() => listener(message));
});
});

Object.assign(global, {
browser: undefined,
chrome: {
runtime: {
sendMessage,
onMessage: {
addListener,
removeListener: jest.fn(),
},
},
},
});

expect(
() => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }),
).not.toThrow();
});

it('can communicate between streams and be destroyed', async () => {
// Initialize sender stream
const streamA = new BrowserRuntimePostMessageStream({
name: 'a',
target: 'b',
});

// Initialize receiver stream. Multiplies incoming values by 5 and
// returns them.
const streamB = new BrowserRuntimePostMessageStream({
name: 'b',
target: 'a',
});

streamB.on('data', (value) => streamB.write(value * 5));

// Get a deferred Promise for the result
const responsePromise = new Promise((resolve) => {
streamA.once('data', (num) => {
resolve(Number(num));
});
});

// Write to stream A, triggering a response from stream B
streamA.write(111);

expect(await responsePromise).toStrictEqual(555);

const throwingListener = (data: any) => {
throw new Error(`Unexpected data on stream: ${data}`);
};

streamA.once('data', throwingListener);
streamB.once('data', throwingListener);

browser.runtime.sendMessage(new Event('message'));

// Destroy streams and confirm that they were destroyed
streamA.destroy();
streamB.destroy();
expect(streamA.destroyed).toStrictEqual(true);
expect(streamB.destroyed).toStrictEqual(true);
});
});
83 changes: 83 additions & 0 deletions src/runtime/BrowserRuntimePostMessageStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
BasePostMessageStream,
PostMessageEvent,
} from '../BasePostMessageStream';
import { isValidStreamMessage } from '../utils';

export interface BrowserRuntimePostMessageStreamArgs {
name: string;
target: string;
}

/**
* A {@link browser.runtime} stream.
*/
export class BrowserRuntimePostMessageStream extends BasePostMessageStream {
#name: string;

#target: string;

/**
* Creates a stream for communicating with other streams across the extension
* runtime.
*
* @param args - Options bag.
* @param args.name - The name of the stream. Used to differentiate between
* multiple streams sharing the same runtime.
* @param args.target - The name of the stream to exchange messages with.
*/
constructor({ name, target }: BrowserRuntimePostMessageStreamArgs) {
super();

this.#name = name;
this.#target = target;
this._onMessage = this._onMessage.bind(this);

this._getRuntime().onMessage.addListener(this._onMessage);

this._handshake();
}

protected _postMessage(data: unknown): void {
// This returns a Promise, which resolves if the receiver responds to the
// message. Rather than responding to specific messages, we send new
// messages in response to incoming messages, so we don't care about the
// Promise.
this._getRuntime().sendMessage({
target: this.#target,
data,
});
}

private _onMessage(message: PostMessageEvent): void {
if (!isValidStreamMessage(message) || message.target !== this.#name) {
return;
}

this._onData(message.data);
}

private _getRuntime(): typeof browser.runtime {
if (
'chrome' in globalThis &&
typeof chrome?.runtime?.sendMessage === 'function'
) {
return chrome.runtime;
}

if (
'browser' in globalThis &&
typeof browser?.runtime?.sendMessage === 'function'
) {
return browser.runtime;
}

throw new Error(
'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.',
);
}

_destroy(): void {
this._getRuntime().onMessage.removeListener(this._onMessage);
}
}
9 changes: 9 additions & 0 deletions src/vendor/types/browser.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// eslint-disable-next-line import/unambiguous
declare namespace browser.runtime {
export function sendMessage<Response>(message: any): Promise<Response>;

export const onMessage: {
addListener(listener: (message: any) => void): void;
removeListener(listener: (message: any) => void): void;
};
}
25 changes: 25 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1535,13 +1535,33 @@
dependencies:
"@babel/types" "^7.3.0"

"@types/chrome@^0.0.204":
version "0.0.204"
resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.204.tgz#6125f5dbac7628e9f22370d25d63779bea3d64b0"
integrity sha512-EvnHfxMHUWP5EAlRMK66uIEUiy36t72vg5RwmzQv9tdIl2ZmAp92NwvmEZJKpbRnIMTEc2BmSmtrFiEISUJ0Sw==
dependencies:
"@types/filesystem" "*"
"@types/har-format" "*"

"@types/debug@^4.1.7":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==
dependencies:
"@types/ms" "*"

"@types/filesystem@*":
version "0.0.32"
resolved "https://registry.yarnpkg.com/@types/filesystem/-/filesystem-0.0.32.tgz#307df7cc084a2293c3c1a31151b178063e0a8edf"
integrity sha512-Yuf4jR5YYMR2DVgwuCiP11s0xuVRyPKmz8vo6HBY3CGdeMj8af93CFZX+T82+VD1+UqHOxTq31lO7MI7lepBtQ==
dependencies:
"@types/filewriter" "*"

"@types/filewriter@*":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.29.tgz#a48795ecadf957f6c0d10e0c34af86c098fa5bee"
integrity sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==

"@types/glob@^7.1.1":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"
Expand All @@ -1557,6 +1577,11 @@
dependencies:
"@types/node" "*"

"@types/har-format@*":
version "1.2.10"
resolved "https://registry.yarnpkg.com/@types/har-format/-/har-format-1.2.10.tgz#7b4e1e0ada4d17684ac3b05d601a4871cfab11fc"
integrity sha512-o0J30wqycjF5miWDKYKKzzOU1ZTLuA42HZ4HE7/zqTOc/jTLdQ5NhYWvsRQo45Nfi1KHoRdNhteSI4BAxTF1Pg==

"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
Expand Down