Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement "to content script" via background page #29

Merged
merged 3 commits into from
Sep 27, 2021
Merged
Show file tree
Hide file tree
Changes from 2 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
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;
Copy link
Collaborator Author

@fregante fregante Sep 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notification forwarding hasn't been implemented yet, so this is currently unused. I'll implement them separately

};

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)
);
}
Comment on lines +115 to +122
Copy link
Collaborator Author

@fregante fregante Sep 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the core of the forwarder at this point. Information about the original sender is not preserved yet.

I think I will turn MessengerMeta into an array where each step just prepends its own "sender" information, sort of like a stack trace.


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 }
);
Comment on lines +240 to +248
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the current "routing" implementation.

Since getContentScriptMethod is specific to content scripts, it's clear what the direction is: If we can't message the CS directly, we must contact the background page.


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> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey this looks familiar!

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);
}
63 changes: 25 additions & 38 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 @@ -119,25 +126,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 +148,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 +197,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 +212,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