webext-messenger 0.25.2 → 0.27.0
Sign up to get free protection for your applications and to get access to all the features.
- package/distribution/receiver.js +9 -5
- package/distribution/sender.js +14 -5
- package/distribution/targetLogic.d.ts +3 -0
- package/distribution/targetLogic.js +50 -0
- package/distribution/targetLogic.test.d.ts +1 -0
- package/distribution/targetLogic.test.js +61 -0
- package/distribution/thisTarget.d.ts +25 -2
- package/distribution/thisTarget.js +7 -58
- package/distribution/types.d.ts +2 -2
- package/package.json +13 -11
package/distribution/receiver.js
CHANGED
@@ -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 "./
|
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) { }
|
package/distribution/sender.js
CHANGED
@@ -128,9 +128,16 @@ async function manageMessage(type, target, seq, retry, sendMessage) {
|
|
128
128
|
log.debug(type, seq, "↘️ replied successfully", response.value);
|
129
129
|
return response.value;
|
130
130
|
}
|
131
|
+
// Not a UID nor a truly global sequence. Signal / console noise compromise.
|
132
|
+
// The time part is a pseudo-random number between 0 and 99 that helps visually
|
133
|
+
// group messages from the same context. Keeping it a number also gives it a different
|
134
|
+
// color in the console log.
|
135
|
+
// Example log when seen in the background page:
|
136
|
+
// Tab 1 sends: 33000, 33001, 33002
|
137
|
+
// Tab 2 sends: 12000, 12001, 12002
|
138
|
+
let globalSeq = (Date.now() % 100) * 10_000;
|
131
139
|
function messenger(type, options, target, ...args) {
|
132
|
-
|
133
|
-
options.seq = Date.now() % 100000;
|
140
|
+
options.seq = globalSeq++;
|
134
141
|
const { seq } = options;
|
135
142
|
// Message goes to extension page
|
136
143
|
if ("page" in target) {
|
@@ -160,9 +167,11 @@ function messenger(type, options, target, ...args) {
|
|
160
167
|
// Message tab directly
|
161
168
|
return manageConnection(type, options, target, async (attemptCount) => {
|
162
169
|
log.debug(type, seq, "↗️ sending message to tab", tabId, "frame", frameId, attemptLog(attemptCount));
|
163
|
-
return browser.tabs.sendMessage(tabId, makeMessage(type, args, target, options),
|
164
|
-
|
165
|
-
|
170
|
+
return browser.tabs.sendMessage(tabId, makeMessage(type, args, target, options), frameId === "allFrames"
|
171
|
+
? {}
|
172
|
+
: {
|
173
|
+
frameId,
|
174
|
+
});
|
166
175
|
});
|
167
176
|
}
|
168
177
|
function getMethod(type, target) {
|
@@ -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
|
2
|
-
|
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,
|
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
|
45
|
-
|
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
|
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/distribution/types.d.ts
CHANGED
@@ -57,7 +57,7 @@ export type MessengerMessage = Message & {
|
|
57
57
|
};
|
58
58
|
export interface AnyTarget {
|
59
59
|
tabId?: number | "this";
|
60
|
-
frameId?: number;
|
60
|
+
frameId?: number | "allFrames";
|
61
61
|
page?: string;
|
62
62
|
}
|
63
63
|
export interface TopLevelFrame {
|
@@ -75,7 +75,7 @@ export interface KnownTarget {
|
|
75
75
|
}
|
76
76
|
export interface Target {
|
77
77
|
tabId: number;
|
78
|
-
frameId?: number;
|
78
|
+
frameId?: number | "allFrames";
|
79
79
|
}
|
80
80
|
export interface PageTarget {
|
81
81
|
tabId?: number | "this";
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "webext-messenger",
|
3
|
-
"version": "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.
|
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": "
|
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.
|
30
|
-
"type-fest": "^4.
|
31
|
-
"webext-detect-page": "^5.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.
|
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.
|
44
|
-
"eslint-config-pixiebrix": "^0.
|
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.
|
52
|
-
"typescript": "^5.
|
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
|
},
|