webext-messenger 0.26.0 → 0.27.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,8 +3,9 @@ import { getContextName } from "webext-detect-page";
3
3
  import { messenger } from "./sender.js";
4
4
  import { isObject, MessengerError, __webextMessenger } from "./shared.js";
5
5
  import { log } from "./logging.js";
6
- import { getActionForMessage } from "./thisTarget.js";
6
+ import { getActionForMessage } from "./targetLogic.js";
7
7
  import { didUserRegisterMethods, handlers } from "./handlers.js";
8
+ import { getTabDataStatus, thisTarget } from "./thisTarget.js";
8
9
  export function isMessengerMessage(message) {
9
10
  return (isObject(message) &&
10
11
  typeof message["type"] === "string" &&
@@ -18,8 +19,13 @@ function onMessageListener(message, sender) {
18
19
  return;
19
20
  }
20
21
  // Target check must be synchronous (`await` means we're handling the message)
21
- const action = getActionForMessage(sender, message);
22
+ const action = getActionForMessage(sender, message.target, thisTarget);
22
23
  if (action === "ignore") {
24
+ log.debug(message.type, "🤫 ignored due to target mismatch", {
25
+ requestedTarget: message.target,
26
+ thisTarget,
27
+ tabDataStatus: getTabDataStatus(),
28
+ });
23
29
  return;
24
30
  }
25
31
  return handleMessage(message, sender, action);
@@ -74,6 +80,4 @@ export function registerMethods(methods) {
74
80
  browser.runtime.onMessage.addListener(onMessageListener);
75
81
  }
76
82
  /** Ensure/document that the current function was called via Messenger */
77
- export function assertMessengerCall(_this
78
- // eslint-disable-next-line @typescript-eslint/no-empty-function -- TypeScript already does this, it's a documentation-only call
79
- ) { }
83
+ export function assertMessengerCall(_this) { }
@@ -135,7 +135,7 @@ async function manageMessage(type, target, seq, retry, sendMessage) {
135
135
  // Example log when seen in the background page:
136
136
  // Tab 1 sends: 33000, 33001, 33002
137
137
  // Tab 2 sends: 12000, 12001, 12002
138
- let globalSeq = (Date.now() % 100) * 10000;
138
+ let globalSeq = (Date.now() % 100) * 10_000;
139
139
  function messenger(type, options, target, ...args) {
140
140
  options.seq = globalSeq++;
141
141
  const { seq } = options;
@@ -0,0 +1,3 @@
1
+ import { type AnyTarget, type Sender } from "./types.js";
2
+ export declare function compareTargets(to: AnyTarget, thisTarget: AnyTarget): boolean;
3
+ export declare function getActionForMessage(from: Sender, target: AnyTarget, thisTarget: AnyTarget): "respond" | "forward" | "ignore";
@@ -0,0 +1,50 @@
1
+ import { isBackground, isContentScript } from "webext-detect-page";
2
+ export function compareTargets(to, thisTarget) {
3
+ for (const [key, value] of Object.entries(to)) {
4
+ if (thisTarget[key] === value) {
5
+ continue;
6
+ }
7
+ if (key !== "page") {
8
+ return false;
9
+ }
10
+ const toUrl = new URL(to.page, location.origin);
11
+ const thisUrl = new URL(thisTarget.page, location.origin);
12
+ if (toUrl.pathname !== thisUrl.pathname) {
13
+ return false;
14
+ }
15
+ for (const [parameterKey, parameterValue] of toUrl.searchParams) {
16
+ if (thisUrl.searchParams.get(parameterKey) !== parameterValue) {
17
+ return false;
18
+ }
19
+ }
20
+ }
21
+ return true;
22
+ }
23
+ export function getActionForMessage(from, target, thisTarget) {
24
+ // Clone object because we're editing it
25
+ const to = { ...target };
26
+ if (to.page === "any") {
27
+ return "respond";
28
+ }
29
+ // Content scripts only receive messages that are meant for them. In the future
30
+ // they'll also forward them, but that still means they need to be handled here.
31
+ if (isContentScript()) {
32
+ return "respond";
33
+ }
34
+ // We're in an extension page, but the target is not one.
35
+ if (!to.page) {
36
+ // Only the background page can forward messages at the moment
37
+ // https://github.com/pixiebrix/webext-messenger/issues/219#issuecomment-2011000477
38
+ // If this condition is changed, it might also need to ensure that messages are not
39
+ // forwarded to itself, e.g. when using the tabs API in an iframed extension page.
40
+ // https://github.com/pixiebrix/webext-messenger/issues/221#issuecomment-2010165690
41
+ return isBackground() ? "forward" : "ignore";
42
+ }
43
+ // Set "this" tab to the current tabId
44
+ if (to.tabId === "this" && thisTarget.tabId === from.tab?.id) {
45
+ to.tabId = thisTarget.tabId;
46
+ }
47
+ // Every `target` key must match `thisTarget`
48
+ const isThisTarget = compareTargets(to, thisTarget);
49
+ return isThisTarget ? "respond" : "ignore";
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { assert, describe, test, vi } from "vitest";
2
+ import { getActionForMessage } from "./targetLogic.js";
3
+ import { isContentScript, isBackground } from "webext-detect-page";
4
+ vi.mock("webext-detect-page");
5
+ const tab = {
6
+ id: 1,
7
+ url: "http://example.com",
8
+ windowId: 1,
9
+ active: true,
10
+ index: 0,
11
+ pinned: false,
12
+ highlighted: true,
13
+ incognito: false,
14
+ };
15
+ const senders = {
16
+ background: { page: "background" },
17
+ contentScript: { tab },
18
+ somePage: { page: "/page.html" },
19
+ };
20
+ const targets = {
21
+ background: { page: "background" },
22
+ somePage: { page: "/page.html" },
23
+ anyPage: { page: "any" },
24
+ thisTab: { tabId: "this" },
25
+ };
26
+ const thisTarget = {
27
+ background: { page: "background" },
28
+ somePage: { page: "/page.html" },
29
+ tab: { tabId: 1, frameId: 0 },
30
+ frame: { tabId: 1, frameId: 1 },
31
+ };
32
+ describe("getActionForMessage", async () => {
33
+ test.each([
34
+ // Sender Target Receiver Expected
35
+ ["contentScript", "background", "background", "respond"],
36
+ ["contentScript", "background", "somePage", "ignore"],
37
+ ["contentScript", "background", "tab", "respond"], // Wrong, but won't happen
38
+ ["contentScript", "background", "frame", "respond"], // Wrong, but won't happen
39
+ ["contentScript", "anyPage", "background", "respond"],
40
+ ["contentScript", "anyPage", "somePage", "respond"],
41
+ ["contentScript", "anyPage", "tab", "respond"],
42
+ ["contentScript", "anyPage", "frame", "respond"],
43
+ ["contentScript", "somePage", "background", "ignore"],
44
+ ["contentScript", "somePage", "somePage", "respond"],
45
+ ["contentScript", "somePage", "tab", "respond"], // Wrong, but won't happen
46
+ ["contentScript", "somePage", "frame", "respond"], // Wrong, but won't happen
47
+ ["contentScript", "thisTab", "background", "forward"],
48
+ ["contentScript", "thisTab", "somePage", "ignore"],
49
+ ["contentScript", "thisTab", "tab", "respond"], // Won't happen, content scripts cannot target tabs
50
+ ["contentScript", "thisTab", "frame", "respond"], // Won't happen, content scripts cannot target tabs
51
+ ])("from %s to %s, receiver %s should %s", async (from, to, receiver, expected) => {
52
+ const isCs = receiver === "tab" || receiver === "frame";
53
+ vi.mocked(isContentScript).mockReturnValueOnce(isCs);
54
+ vi.mocked(isBackground).mockReturnValueOnce(receiver === "background");
55
+ vi.stubGlobal("location", {
56
+ origin: isCs ? "http://example.com" : "chrome-extension://extension-id",
57
+ });
58
+ const result = getActionForMessage(senders[from], targets[to], thisTarget[receiver]);
59
+ assert(result === expected, `"${receiver}" got message for "${to}" and decided to ${result.toUpperCase()} instead of ${expected.toUpperCase()}`);
60
+ });
61
+ });
@@ -1,6 +1,29 @@
1
- import { type AnyTarget, type TopLevelFrame, type Message, type MessengerMeta, type Sender, type FrameTarget } from "./types.js";
2
- export declare function getActionForMessage(from: Sender, message: Message): "respond" | "forward" | "ignore";
1
+ import { type AnyTarget, type KnownTarget, type TopLevelFrame, type MessengerMeta, type FrameTarget } from "./types.js";
2
+ /**
3
+ * @file This file exists because `runtime.sendMessage` acts as a broadcast to
4
+ * all open extension pages, so the receiver needs a way to figure out if the
5
+ * message was intended for them.
6
+ *
7
+ * If the requested target only includes a `page` (URL), then it can be determined
8
+ * immediately. If the target also specifies a tab, like `{tabId: 1, page: '/sidebar.html'}`,
9
+ * then the receiving target needs to fetch the tab information via `__getTabData`.
10
+ *
11
+ * `__getTabData` is called automatically when `webext-messenger` is imported in
12
+ * a context that requires this logic (most extension:// pages).
13
+ *
14
+ * If a broadcast message with `tabId` target is received before `__getTabData` is "received",
15
+ * the message will be ignored and it can be retried. If `__getTabData` somehow fails,
16
+ * the target will forever ignore any messages that require the `tabId`. In that case,
17
+ * an error would be thrown once and will be visible in the console, uncaught.
18
+ *
19
+ * Content scripts do not use this logic at all at the moment because they're
20
+ * always targeted via `tabId/frameId` combo and `tabs.sendMessage`.
21
+ */
22
+ export declare const thisTarget: KnownTarget;
23
+ declare let tabDataStatus: "needed" | "pending" | "received" | "not-needed" | "error";
24
+ export declare function getTabDataStatus(): typeof tabDataStatus;
3
25
  export declare function __getTabData(this: MessengerMeta): AnyTarget;
4
26
  export declare function getThisFrame(): Promise<FrameTarget>;
5
27
  export declare function getTopLevelFrame(): Promise<TopLevelFrame>;
6
28
  export declare function initPrivateApi(): void;
29
+ export {};
@@ -1,8 +1,7 @@
1
- import { getContextName, isBackground, isContentScript, isExtensionContext, } from "webext-detect-page";
1
+ import { getContextName, isBackground, isExtensionContext, } from "webext-detect-page";
2
2
  import { messenger } from "./sender.js";
3
3
  import { registerMethods } from "./receiver.js";
4
4
  import { MessengerError, once } from "./shared.js";
5
- import { log } from "./logging.js";
6
5
  /**
7
6
  * @file This file exists because `runtime.sendMessage` acts as a broadcast to
8
7
  * all open extension pages, so the receiver needs a way to figure out if the
@@ -26,7 +25,7 @@ import { log } from "./logging.js";
26
25
  // Soft warning: Race conditions are possible.
27
26
  // This CANNOT be awaited because waiting for it means "I will handle the message."
28
27
  // If a message is received before this is ready, it will just have to be ignored.
29
- const thisTarget = isBackground()
28
+ export const thisTarget = isBackground()
30
29
  ? { page: "background" }
31
30
  : {
32
31
  get page() {
@@ -41,57 +40,8 @@ const thisTarget = isBackground()
41
40
  let tabDataStatus =
42
41
  // The background page doesn't have a tab
43
42
  isBackground() ? "not-needed" : "needed";
44
- function compareTargets(to, thisTarget) {
45
- for (const [key, value] of Object.entries(to)) {
46
- if (thisTarget[key] === value) {
47
- continue;
48
- }
49
- if (key !== "page") {
50
- return false;
51
- }
52
- const toUrl = new URL(to.page, location.origin);
53
- const thisUrl = new URL(thisTarget.page, location.origin);
54
- if (toUrl.pathname !== thisUrl.pathname) {
55
- return false;
56
- }
57
- for (const [parameterKey, parameterValue] of toUrl.searchParams) {
58
- if (thisUrl.searchParams.get(parameterKey) !== parameterValue) {
59
- return false;
60
- }
61
- }
62
- }
63
- return true;
64
- }
65
- // TODO: Test this in Jest, outside the browser
66
- export function getActionForMessage(from, message) {
67
- // Clone object because we're editing it
68
- const to = { ...message.target };
69
- if (to.page === "any") {
70
- return "respond";
71
- }
72
- // Content scripts only receive messages that are meant for them. In the future
73
- // they'll also forward them, but that still means they need to be handled here.
74
- if (isContentScript()) {
75
- return "respond";
76
- }
77
- // We're in an extension page, but the target is not one.
78
- if (!to.page) {
79
- return "forward";
80
- }
81
- // Set "this" tab to the current tabId
82
- if (to.tabId === "this" && thisTarget.tabId === from.tab?.id) {
83
- to.tabId = thisTarget.tabId;
84
- }
85
- // Every `target` key must match `thisTarget`
86
- const isThisTarget = compareTargets(to, thisTarget);
87
- if (!isThisTarget) {
88
- log.debug(message.type, "🤫 ignored due to target mismatch", {
89
- requestedTarget: to,
90
- thisTarget,
91
- tabDataStatus,
92
- });
93
- }
94
- return isThisTarget ? "respond" : "ignore";
43
+ export function getTabDataStatus() {
44
+ return tabDataStatus;
95
45
  }
96
46
  const storeTabData = once(async () => {
97
47
  if (tabDataStatus !== "needed") {
@@ -99,9 +49,7 @@ const storeTabData = once(async () => {
99
49
  }
100
50
  try {
101
51
  tabDataStatus = "pending";
102
- Object.assign(thisTarget, {
103
- ...(await messenger("__getTabData", {}, { page: "any" })),
104
- });
52
+ Object.assign(thisTarget, await messenger("__getTabData", {}, { page: "any" }));
105
53
  tabDataStatus = "received";
106
54
  }
107
55
  catch (error) {
@@ -119,7 +67,8 @@ export async function getThisFrame() {
119
67
  if (typeof tabId !== "number" || typeof frameId !== "number") {
120
68
  let moreInfo = "(error retrieving context information)";
121
69
  try {
122
- moreInfo = `(context: ${getContextName()}, url: ${globalThis.location?.href})`;
70
+ moreInfo = `(context: ${getContextName()}, url: ${globalThis.location
71
+ ?.href})`;
123
72
  }
124
73
  catch { }
125
74
  throw new TypeError(`This target is not in a frame ${moreInfo}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webext-messenger",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Browser Extension component messaging framework",
5
5
  "keywords": [],
6
6
  "repository": "pixiebrix/webext-messenger",
@@ -9,7 +9,7 @@
9
9
  "type": "module",
10
10
  "exports": {
11
11
  ".": {
12
- "types": "./distribution/index.js",
12
+ "types": "./distribution/index.d.ts",
13
13
  "default": "./distribution/index.js"
14
14
  },
15
15
  "./*": "./distribution/*"
@@ -19,16 +19,17 @@
19
19
  "demo:watch": "parcel watch --no-cache --no-hmr",
20
20
  "demo:build": "parcel build --no-cache --no-scope-hoist",
21
21
  "prepack": "tsc --sourceMap false",
22
- "test": "eslint . && tsc --noEmit",
22
+ "test": "run-p test:unit lint build demo:build",
23
+ "test:unit": "vitest run",
23
24
  "lint": "eslint .",
24
25
  "fix": "eslint . --fix",
25
26
  "watch": "tsc --watch"
26
27
  },
27
28
  "dependencies": {
28
29
  "p-retry": "^6.2.0",
29
- "serialize-error": "^11.0.2",
30
- "type-fest": "^4.10.2",
31
- "webext-detect-page": "^5.0.0"
30
+ "serialize-error": "^11.0.3",
31
+ "type-fest": "^4.13.0",
32
+ "webext-detect-page": "^5.0.1"
32
33
  },
33
34
  "@parcel/resolver-default": {
34
35
  "packageExports": true
@@ -36,20 +37,21 @@
36
37
  "devDependencies": {
37
38
  "@parcel/config-webextension": "^2.11.0",
38
39
  "@sindresorhus/tsconfig": "^5.0.0",
39
- "@types/chrome": "^0.0.259",
40
+ "@types/chrome": "^0.0.263",
40
41
  "@types/tape": "^5.6.4",
41
42
  "@types/webextension-polyfill": "^0.10.7",
42
43
  "buffer": "^6.0.3",
43
- "eslint": "^8.56.0",
44
- "eslint-config-pixiebrix": "^0.34.1",
44
+ "eslint": "^8.57.0",
45
+ "eslint-config-pixiebrix": "^0.37.2",
45
46
  "events": "^3.3.0",
46
47
  "npm-run-all": "^4.1.5",
47
48
  "parcel": "^2.11.0",
48
49
  "path-browserify": "^1.0.1",
49
50
  "process": "^0.11.10",
50
51
  "stream-browserify": "^3.0.0",
51
- "tape": "^5.7.4",
52
- "typescript": "^5.3.3",
52
+ "tape": "^5.7.5",
53
+ "typescript": "^5.4.2",
54
+ "vitest": "^1.4.0",
53
55
  "webext-content-scripts": "^2.6.1",
54
56
  "webextension-polyfill": "^0.10.0"
55
57
  },