Skip to content

Commit

Permalink
Implement "to content script" via background page (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
fregante authored Sep 27, 2021
1 parent ef2759f commit 57eac8e
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 60 deletions.
67 changes: 48 additions & 19 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,14 @@ type Method = (this: MessengerMeta, ...args: Arguments) => Promise<unknown>;

// TODO: It may include additional meta, like information about the original sender
type Message<TArguments extends Arguments = Arguments> = {
type: string;
type: keyof MessengerMethods;
args: TArguments;

/** If the message is being sent to an intermediary receiver, also set the target */
target?: Target;

/** If the message is being sent to an intermediary receiver, also set the options */
options?: Target;
};

type MessengerMessage = Message & {
Expand Down Expand Up @@ -82,20 +88,14 @@ function isMessengerResponse(response: unknown): response is MessengerResponse {

const handlers = new Map<string, Method>();

async function handleMessage(
async function handleCall(
message: Message,
sender: MessengerMeta
sender: MessengerMeta,
call: Promise<unknown> | unknown
): Promise<MessengerResponse> {
const handler = handlers.get(message.type);
if (!handler) {
throw new Error("No handler registered for " + message.type);
}

console.debug(`Messenger:`, message.type, message.args, "from", { sender });
// The handler could actually be a synchronous function
const response = await Promise.resolve(
handler.call(sender, ...message.args)
).then(
const response = await Promise.resolve(call).then(
(value) => ({ value }),
(error: unknown) => ({
// Errors must be serialized because the stacktraces are currently lost on Chrome and
Expand All @@ -108,6 +108,27 @@ async function handleMessage(
return { ...response, __webext_messenger__ };
}

async function handleMessage(
message: Message,
sender: MessengerMeta
): Promise<MessengerResponse> {
if (message.target) {
const publicMethod = getContentScriptMethod(message.type);
return handleCall(
message,
sender,
publicMethod(message.target, ...message.args)
);
}

const handler = handlers.get(message.type);
if (handler) {
return handleCall(message, sender, handler.apply(sender, message.args));
}

throw new Error("No handler registered for " + message.type);
}

// Do not turn this into an `async` function; Notifications must turn `void`
function manageConnection(
type: string,
Expand Down Expand Up @@ -179,11 +200,16 @@ interface Options {
isNotification?: boolean;
}

function makeMessage(type: string, args: unknown[]): MessengerMessage {
function makeMessage(
type: keyof MessengerMethods,
args: unknown[],
target?: Target
): MessengerMessage {
return {
__webext_messenger__,
type,
args,
target,
};
}

Expand All @@ -210,13 +236,16 @@ function getContentScriptMethod<
TPublicMethod extends PublicMethodWithTarget<TMethod>
>(type: TType, options: Options = {}): TPublicMethod {
const publicMethod = (target: Target, ...args: Parameters<TMethod>) => {
const sendMessage = async () =>
browser.tabs.sendMessage(
target.tabId,
makeMessage(type, args),
// `frameId` must be specified. If missing, the message would be sent to every frame
{ frameId: target.frameId ?? 0 }
);
// eslint-disable-next-line no-negated-condition -- Looks better
const sendMessage = !browser.tabs
? async () => browser.runtime.sendMessage(makeMessage(type, args, target))
: async () =>
browser.tabs.sendMessage(
target.tabId,
makeMessage(type, args),
// `frameId` must be specified. If missing, the message is sent to every frame
{ frameId: target.frameId ?? 0 }
);

return manageConnection(type, options, sendMessage);
};
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@
"sourceDir": "test/dist",
"run": {
"startUrl": [
"https://legiblenews.com/"
"https://legiblenews.com/",
"https://rawtext.club/"
]
}
}
Expand Down
6 changes: 6 additions & 0 deletions test/demo-extension/background/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { getMethod } from "../../../index";

// Dog-fooding, needed to run the tests
export const openTab = getMethod("openTab");
export const closeTab = getMethod("closeTab");
export const getAllFrames = getMethod("getAllFrames");
export const ensureScripts = getMethod("ensureScripts");

export const sum = getMethod("sum");
export const throws = getMethod("throws");
export const sumIfMeta = getMethod("sumIfMeta");
Expand Down
9 changes: 9 additions & 0 deletions test/demo-extension/background/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getExtensionId } from "./getExtensionId";
import { backgroundOnly } from "./backgroundOnly";
import { notRegistered } from "./notRegistered";
import { getSelf } from "./getSelf";
import { openTab, getAllFrames, ensureScripts, closeTab } from "./testingApi";

declare global {
interface MessengerMethods {
Expand All @@ -18,6 +19,10 @@ declare global {
getExtensionId: typeof getExtensionId;
backgroundOnly: typeof backgroundOnly;
getSelf: typeof getSelf;
openTab: typeof openTab;
getAllFrames: typeof getAllFrames;
ensureScripts: typeof ensureScripts;
closeTab: typeof closeTab;
}
}

Expand All @@ -34,4 +39,8 @@ registerMethods({
sumIfMeta,
throws,
getSelf,
openTab,
getAllFrames,
ensureScripts,
closeTab,
});
33 changes: 33 additions & 0 deletions test/demo-extension/background/testingApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export async function ensureScripts(tabId: number): Promise<void> {
await browser.tabs.executeScript(tabId, {
// https://github.com/parcel-bundler/parcel/issues/5758
file:
"/up_/up_/node_modules/webextension-polyfill/dist/browser-polyfill.js",
});
await browser.tabs.executeScript(tabId, {
file: "contentscript/registration.js",
});
}

export async function getAllFrames(
tabId: number
): Promise<[parentFrame: number, iframe: number]> {
const [parentFrame, iframe] = await browser.webNavigation.getAllFrames({
tabId,
});

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [parentFrame!.frameId, iframe!.frameId];
}

export async function openTab(url: string): Promise<number> {
const tab = await browser.tabs.create({
active: false,
url,
});
return tab.id!;
}

export async function closeTab(tabId: number): Promise<void> {
await browser.tabs.remove(tabId);
}
72 changes: 32 additions & 40 deletions test/demo-extension/contentscript/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as test from "fresh-tape";
import { isBackgroundPage } from "webext-detect-page";
import { Target } from "../../../index";
import * as backgroundContext from "../background/api";
import * as localContext from "../background/testingApi";
import {
getPageTitle,
setPageTitle,
Expand All @@ -13,6 +16,10 @@ import {
getPageTitleNotification,
} from "./api";

const { openTab, getAllFrames, ensureScripts, closeTab } = isBackgroundPage()
? localContext
: backgroundContext;

async function delay(timeout: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, timeout);
Expand Down Expand Up @@ -99,8 +106,13 @@ function runOnTarget(target: Target, expectedTitle: string) {
const self = await getSelf(target);
t.true(self instanceof Object);
t.equals(self.id, chrome.runtime.id);
// Chrome (the types are just for Firefox) || Firefox
t.true((self as any).origin === "null" || self.url === location.href);

// TODO: `as any` because `self` is typed for Firefox only
// TODO: self.url always points to the background page, but it should include the current tab when forwarded https://github.com/pixiebrix/webext-messenger/issues/32
t.true(
(self as any).origin === "null" || // Chrome
self.url?.endsWith("/_generated_background_page.html") // Firefox
);
});

test(expectedTitle + ": notification should return undefined", async (t) => {
Expand All @@ -119,25 +131,19 @@ function runOnTarget(target: Target, expectedTitle: string) {
}

async function init() {
const { id } = await browser.tabs.create({
url: "https://iframe-test-page.vercel.app/",
});
const tabId = await openTab("https://iframe-test-page.vercel.app/");

await delay(1000); // Let frames load so we can query them for the tests
const [parentFrame, iframe] = await browser.webNavigation.getAllFrames({
tabId: id!,
});
const [parentFrame, iframe] = await getAllFrames(tabId);

// All `test` calls must be done synchronously, or else the runner assumes they're done
runOnTarget({ tabId: id!, frameId: parentFrame!.frameId }, "Parent");
runOnTarget({ tabId: id!, frameId: iframe!.frameId }, "Child");
runOnTarget({ tabId, frameId: parentFrame }, "Parent");
runOnTarget({ tabId, frameId: iframe }, "Child");

test("should throw the right error when `registerMethod` was never called", async (t) => {
const tab = await browser.tabs.create({
url: "https://text.npr.org/",
});
const tabId = await openTab("https://text.npr.org/");
try {
await getPageTitle({ tabId: tab.id! });
await getPageTitle({ tabId });
t.fail("getPageTitle() should have thrown but did not");
} catch (error: unknown) {
if (!(error instanceof Error)) {
Expand All @@ -147,49 +153,37 @@ async function init() {

t.equal(error.message, "No handlers registered in receiving end");

await browser.tabs.remove(tab.id!);
await closeTab(tabId);
}
});

test("should be able to close the tab from the content script", async (t) => {
await closeSelf({ tabId: id!, frameId: parentFrame!.frameId });
await closeSelf({ tabId, frameId: parentFrame });
try {
// Since the tab was closed, this is expected to throw
t.notOk(await browser.tabs.get(id!), "The tab should not be open");
t.notOk(await browser.tabs.get(tabId), "The tab should not be open");
} catch {
t.pass("The tab was closed");
}
});

test("retries until target is ready", async (t) => {
const tab = await browser.tabs.create({
url: "http://lite.cnn.com/",
});
const tabId = tab.id!;
const tabId = await openTab("http://lite.cnn.com/");

const request = getPageTitle({ tabId });
await delay(1000); // Simulate a slow-loading tab
await browser.tabs.executeScript(tabId, {
// https://github.com/parcel-bundler/parcel/issues/5758
file:
"/up_/up_/node_modules/webextension-polyfill/dist/browser-polyfill.js",
});
await browser.tabs.executeScript(tabId, {
file: "contentscript/registration.js",
});
await ensureScripts(tabId);

t.equal(await request, "CNN - Breaking News, Latest News and Videos");
await browser.tabs.remove(tabId);
await closeTab(tabId);
});

test("retries until it times out", async (t) => {
const tab = await browser.tabs.create({
url: "http://lite.cnn.com/",
});
const tabId = await openTab("http://lite.cnn.com/");

const startTime = Date.now();
try {
await getPageTitle({ tabId: tab.id! });
await getPageTitle({ tabId });
t.fail("getPageTitle() should have thrown but did not");
} catch (error: unknown) {
if (!(error instanceof Error)) {
Expand All @@ -208,7 +202,7 @@ async function init() {
);
}

await browser.tabs.remove(tab.id!);
await closeTab(tabId);
});

test("notifications on non-existing targets", async (t) => {
Expand All @@ -223,11 +217,9 @@ async function init() {
});

test("notifications when `registerMethod` was never called", async () => {
const tab = await browser.tabs.create({
url: "http://lite.cnn.com/",
});
getPageTitleNotification({ tabId: tab.id! });
await browser.tabs.remove(tab.id!);
const tabId = await openTab("http://lite.cnn.com/");
getPageTitleNotification({ tabId });
await closeTab(tabId);
});
}

Expand Down
4 changes: 4 additions & 0 deletions test/demo-extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"contentscript/registration.ts"
]
},
{
"matches": ["https://rawtext.club/*"],
"js": ["~node_modules/webextension-polyfill", "contentscript/api.test.ts"]
},
{
"all_frames": true,
"matches": ["https://text.npr.org/*"],
Expand Down

0 comments on commit 57eac8e

Please sign in to comment.