webext-messenger 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,7 +42,7 @@ function onMessageListener(message, sender, sendResponse) {
42
42
  return;
43
43
  }
44
44
  const { type, target, args, options = {} } = message;
45
- const { trace = [], seq } = options;
45
+ const { trace = [], seq, retry } = options;
46
46
  if (action === "forward") {
47
47
  log.debug(type, seq, "🔀 forwarded", { sender, target });
48
48
  }
@@ -57,7 +57,7 @@ function onMessageListener(message, sender, sendResponse) {
57
57
  (async () => {
58
58
  try {
59
59
  trace.push(sender);
60
- const value = await prepareResponse(message, action, { trace });
60
+ const value = await prepareResponse(message, action, { trace, retry });
61
61
  log.debug(type, seq, "↗️ responding", { value });
62
62
  sendResponse({ __webextMessenger, value });
63
63
  }
@@ -87,17 +87,18 @@ function onMessageExternalListener(message, sender, sendResponse) {
87
87
  });
88
88
  }
89
89
  /** Generates the value or error to return to the sender; does not include further messaging logic */
90
- async function prepareResponse(message, action, meta) {
90
+ async function prepareResponse(message, action, options) {
91
91
  const { type, target, args } = message;
92
92
  if (action === "forward") {
93
- return messenger(type, meta, target, ...args);
93
+ return messenger(type, options, target, ...args);
94
94
  }
95
95
  const localHandler = handlers.get(type);
96
96
  if (localHandler) {
97
97
  if ("extensionId" in target && !externalMethods.has(type)) {
98
98
  throw new MessengerError(`The ${type} handler is registered in ${getContextName()} for internal use only`);
99
99
  }
100
- return localHandler.apply(meta, args);
100
+ const { trace = [] } = options;
101
+ return localHandler.apply({ trace }, args);
101
102
  }
102
103
  if (didUserRegisterMethods()) {
103
104
  throw new MessengerError(`No handler registered for ${type} in ${getContextName()}`);
@@ -1,10 +1,11 @@
1
- import pRetry from "p-retry";
2
- import { isBackground, isExtensionContext } from "webext-detect";
1
+ import { isExtensionContext } from "webext-detect";
3
2
  import { deserializeError } from "serialize-error";
4
3
  import { isObject, MessengerError, ExtensionNotFoundError, __webextMessenger, } from "./shared.js";
5
4
  import { log } from "./logging.js";
6
5
  import { handlers } from "./handlers.js";
7
6
  import { events } from "./events.js";
7
+ import { compareTargets } from "./targetLogic.js";
8
+ import { thisTarget } from "./thisTarget.js";
8
9
  const _errorNonExistingTarget = "Could not establish connection. Receiving end does not exist.";
9
10
  const _errorTargetClosedEarly = "A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received";
10
11
  export const errorTargetClosedEarly = "The target was closed before receiving a response";
@@ -20,6 +21,28 @@ function attemptLog(attemptCount) {
20
21
  function wasContextInvalidated() {
21
22
  return !chrome.runtime?.id;
22
23
  }
24
+ function getErrorMessage(error) {
25
+ if (error && typeof error === "object" && "message" in error) {
26
+ return String(error.message);
27
+ }
28
+ return undefined;
29
+ }
30
+ function shouldRetryError(error, target) {
31
+ const message = getErrorMessage(error);
32
+ // Don't retry sending to the background page unless it really hasn't loaded yet
33
+ if (target.page !== "background" && error instanceof MessengerError) {
34
+ return true;
35
+ }
36
+ // Page or its content script not yet loaded
37
+ if (message === _errorNonExistingTarget) {
38
+ return true;
39
+ }
40
+ // `registerMethods` not yet loaded
41
+ if (message?.startsWith("No handlers registered in ")) {
42
+ return true;
43
+ }
44
+ return false;
45
+ }
23
46
  function makeMessage(type, args, target, options) {
24
47
  return {
25
48
  __webextMessenger,
@@ -39,102 +62,108 @@ function manageConnection(type, { seq, isNotification, retry }, target, sendMess
39
62
  });
40
63
  }
41
64
  async function manageMessage(type, target, seq, retry, sendMessage) {
42
- // TODO: Split this up a bit because it's too long. Probably drop p-retry
43
- const response = await pRetry(async (attemptCount) => {
44
- const response = await sendMessage(attemptCount);
45
- if (isMessengerResponse(response)) {
46
- return response;
47
- }
48
- // If no one answers, `response` will be `undefined`
49
- // If the target does not have any `onMessage` listener at all, it will throw
50
- // Possible:
51
- // - Any target exists and has onMessage handler, but never handled the message
52
- // - Extension page exists and has Messenger, but never handled the message (Messenger in Runtime ignores messages when the target isn't found)
53
- // Not possible:
54
- // - Tab exists and has Messenger, but never handled the message (Messenger in CS always handles messages)
55
- // - Any target exists, but Messenger didn't have the specific Type handler (The receiving Messenger will throw an error)
56
- // - No targets exist (the browser immediately throws "Could not establish connection. Receiving end does not exist.")
57
- if (response === undefined) {
58
- if ("page" in target) {
59
- throw new MessengerError(`The target ${JSON.stringify(target)} for ${type} was not found`);
65
+ const startTime = Date.now();
66
+ const maxRetryTime = 4000;
67
+ const minTimeout = 100;
68
+ const factor = 1.3;
69
+ // Safety cap to avoid infinite loops, generally stops at 11 with current setting
70
+ // MUST BE UPDATED if maxRetryTime, minTimeout or factor are changed
71
+ const maxRetryCount = 15;
72
+ let attemptCount = 0;
73
+ let currentTimeout = minTimeout;
74
+ while (attemptCount < maxRetryCount) {
75
+ attemptCount++;
76
+ try {
77
+ // eslint-disable-next-line no-await-in-loop -- Necessary for retry logic
78
+ const response = await sendMessage(attemptCount);
79
+ if (isMessengerResponse(response)) {
80
+ if ("error" in response) {
81
+ log.debug(type, seq, "↘️ replied with error", response.error);
82
+ throw deserializeError(response.error);
83
+ }
84
+ log.debug(type, seq, "↘️ replied successfully", response.value);
85
+ return response.value;
86
+ }
87
+ // If no one answers, `response` will be `undefined`
88
+ // If the target does not have any `onMessage` listener at all, it will throw
89
+ // Possible:
90
+ // - Any target exists and has onMessage handler, but never handled the message
91
+ // - Extension page exists and has Messenger, but never handled the message (Messenger in Runtime ignores messages when the target isn't found)
92
+ // Not possible:
93
+ // - Tab exists and has Messenger, but never handled the message (Messenger in CS always handles messages)
94
+ // - Any target exists, but Messenger didn't have the specific Type handler (The receiving Messenger will throw an error)
95
+ // - No targets exist (the browser immediately throws "Could not establish connection. Receiving end does not exist.")
96
+ if (response === undefined) {
97
+ if ("page" in target) {
98
+ throw new MessengerError(`The target ${JSON.stringify(target)} for ${type} was not found`);
99
+ }
100
+ throw new MessengerError(`Messenger was not available in the target ${JSON.stringify(target)} for ${type}`);
60
101
  }
61
- throw new MessengerError(`Messenger was not available in the target ${JSON.stringify(target)} for ${type}`);
102
+ // Possible:
103
+ // - Non-Messenger handler responded
104
+ throw new MessengerError(`Conflict: The message ${type} was handled by a third-party listener`);
62
105
  }
63
- // Possible:
64
- // - Non-Messenger handler responded
65
- throw new MessengerError(`Conflict: The message ${type} was handled by a third-party listener`);
66
- }, {
67
- minTimeout: 100,
68
- factor: 1.3,
69
- // Do not set this to undefined or Infinity, it doesn't work the same way
70
- ...(retry ? {} : { retries: 0 }),
71
- maxRetryTime: 4000,
72
- async onFailedAttempt(error) {
106
+ catch (error) {
107
+ const errorMessage = getErrorMessage(error);
73
108
  events.dispatchEvent(new CustomEvent("failed-attempt", {
74
109
  detail: {
75
110
  type,
76
111
  seq,
77
112
  target,
78
113
  error,
79
- attemptCount: error.attemptNumber,
114
+ attemptCount,
80
115
  },
81
116
  }));
82
- if ("extensionId" in target &&
83
- error.message === _errorNonExistingTarget) {
84
- // The extension is not available and it will not be. Do not retry.
117
+ // Check for non-retryable errors
118
+ if ("extensionId" in target && errorMessage === _errorNonExistingTarget) {
85
119
  throw new ExtensionNotFoundError(errorExtensionNotFound.replace("$ID", target.extensionId));
86
120
  }
87
121
  if (isExtensionContext() && wasContextInvalidated()) {
88
- // The error matches the native context invalidated error
89
- // *.sendMessage() might fail with a message-specific error that is less useful,
90
- // like "Sender closed without responding"
91
122
  throw new Error("Extension context invalidated.");
92
123
  }
93
- if (error.message === _errorTargetClosedEarly) {
124
+ if (errorMessage === _errorTargetClosedEarly) {
94
125
  throw new Error(errorTargetClosedEarly);
95
126
  }
96
- if (!(
97
- // If NONE of these conditions is true, stop retrying
98
- // Don't retry sending to the background page unless it really hasn't loaded yet
99
- ((target.page !== "background" &&
100
- error instanceof MessengerError) ||
101
- // Page or its content script not yet loaded
102
- error.message === _errorNonExistingTarget ||
103
- // `registerMethods` not yet loaded
104
- String(error.message).startsWith("No handlers registered in ")))) {
127
+ if (!shouldRetryError(error, target)) {
105
128
  throw error;
106
129
  }
130
+ // Check if tab is still valid
107
131
  if (chrome.tabs && typeof target.tabId === "number") {
132
+ let tabInfo;
108
133
  try {
109
- const tabInfo = await chrome.tabs.get(target.tabId);
110
- if (tabInfo.discarded) {
111
- throw new Error(errorTabWasDiscarded);
112
- }
134
+ // eslint-disable-next-line no-await-in-loop -- Necessary to check tab status during retry
135
+ tabInfo = await chrome.tabs.get(target.tabId);
113
136
  }
114
137
  catch {
115
138
  throw new Error(errorTabDoesntExist);
116
139
  }
140
+ if (tabInfo.discarded) {
141
+ throw new Error(errorTabWasDiscarded);
142
+ }
117
143
  }
118
- log.debug(type, seq, "will retry. Attempt", error.attemptNumber);
119
- },
120
- }).catch((error) => {
121
- if (error &&
122
- typeof error === "object" &&
123
- "message" in error &&
124
- error?.message === _errorNonExistingTarget) {
125
- throw new MessengerError(`The target ${JSON.stringify(target)} for ${type} was not found`);
144
+ // Check if we should stop retrying
145
+ const elapsedTime = Date.now() - startTime;
146
+ if (!retry || elapsedTime >= maxRetryTime) {
147
+ if (errorMessage === _errorNonExistingTarget) {
148
+ throw new MessengerError(`The target ${JSON.stringify(target)} for ${type} was not found`);
149
+ }
150
+ events.dispatchEvent(new CustomEvent("attempts-exhausted", {
151
+ detail: { type, seq, target, error },
152
+ }));
153
+ throw error;
154
+ }
155
+ log.debug(type, seq, "will retry", attemptLog(attemptCount));
156
+ // Wait before retrying with exponential backoff
157
+ const waitTime = currentTimeout;
158
+ // eslint-disable-next-line no-await-in-loop -- Necessary for retry delay
159
+ await new Promise((resolve) => {
160
+ setTimeout(resolve, waitTime);
161
+ });
162
+ currentTimeout = Math.floor(currentTimeout * factor);
126
163
  }
127
- events.dispatchEvent(new CustomEvent("attempts-exhausted", {
128
- detail: { type, seq, target, error },
129
- }));
130
- throw error;
131
- });
132
- if ("error" in response) {
133
- log.debug(type, seq, "↘️ replied with error", response.error);
134
- throw deserializeError(response.error);
135
164
  }
136
- log.debug(type, seq, "↘️ replied successfully", response.value);
137
- return response.value;
165
+ // If you reach this, refer to note above `maxRetryCount` definition
166
+ throw new MessengerError("Exceeded maximum retry attempts. This suggests a low `maxRetryCount`");
138
167
  }
139
168
  // Not a UID nor a truly global sequence. Signal / console noise compromise.
140
169
  // The time part is a pseudo-random number between 0 and 99 that helps visually
@@ -157,16 +186,17 @@ function messenger(type, options, target, ...args) {
157
186
  };
158
187
  return manageConnection(type, options, target, sendMessage);
159
188
  }
189
+ // Use local methods if the target matches the current context
190
+ if (compareTargets(target, thisTarget)) {
191
+ const handler = handlers.get(type);
192
+ if (handler) {
193
+ log.warn(type, seq, "is being handled locally");
194
+ return handler.apply({ trace: [] }, args);
195
+ }
196
+ throw new MessengerError("No handler registered locally for " + type);
197
+ }
160
198
  // Message goes to extension page
161
199
  if ("page" in target) {
162
- if (target.page === "background" && isBackground()) {
163
- const handler = handlers.get(type);
164
- if (handler) {
165
- log.warn(type, seq, "is being handled locally");
166
- return handler.apply({ trace: [] }, args);
167
- }
168
- throw new MessengerError("No handler registered locally for " + type);
169
- }
170
200
  const sendMessage = async (attemptCount) => {
171
201
  log.debug(type, seq, "↗️ sending message to runtime", attemptLog(attemptCount));
172
202
  return chrome.runtime.sendMessage(makeMessage(type, args, target, options));
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ interface MessengerMethods {
3
+ testMethod: () => Promise<string>;
4
+ }
5
+ }
6
+ export {};
@@ -0,0 +1,160 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/naming-convention */
2
+ import { describe, test, expect, vi, beforeEach } from "vitest";
3
+ import { handlers } from "./handlers.js";
4
+ import * as thisTargetModule from "./thisTarget.js";
5
+ // Mock dependencies
6
+ vi.mock("webext-detect", () => ({
7
+ isBackground: vi.fn(() => false),
8
+ isExtensionContext: vi.fn(() => true),
9
+ isOffscreenDocument: vi.fn(() => false),
10
+ isContentScript: vi.fn(() => true),
11
+ }));
12
+ vi.mock("./logging.js", () => ({
13
+ log: {
14
+ debug: vi.fn(),
15
+ warn: vi.fn(),
16
+ },
17
+ }));
18
+ vi.mock("./events.js", () => ({
19
+ events: {
20
+ dispatchEvent: vi.fn(),
21
+ },
22
+ }));
23
+ // Mock chrome APIs - simulate content script environment (no chrome.tabs)
24
+ globalThis.chrome = {
25
+ runtime: {
26
+ id: "test-extension-id",
27
+ sendMessage: vi.fn(),
28
+ },
29
+ // Note: chrome.tabs is undefined in content scripts
30
+ };
31
+ describe("messenger with tab targets and local methods", () => {
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ handlers.clear();
35
+ });
36
+ test("should use local method when targeting same tab and frame", async () => {
37
+ // Setup: Set thisTarget to have tabId: 1, frameId: 0
38
+ const mockThisTarget = {
39
+ tabId: 1,
40
+ frameId: 0,
41
+ page: "test",
42
+ };
43
+ vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
44
+ // Register a local handler
45
+ const mockHandler = vi.fn(async () => "local result");
46
+ handlers.set("testMethod", mockHandler);
47
+ // Import messenger after mocks are set up
48
+ const { messenger } = await import("./sender.js");
49
+ // Test: Send message to same tab and frame
50
+ const result = await messenger("testMethod", {}, { tabId: 1, frameId: 0 });
51
+ // Verify: Handler was called locally
52
+ expect(mockHandler).toHaveBeenCalledOnce();
53
+ expect(mockHandler).toHaveBeenCalledWith();
54
+ expect(result).toBe("local result");
55
+ // Verify: No message was sent via chrome.runtime.sendMessage
56
+ expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
57
+ });
58
+ test("should send message when targeting different tab", async () => {
59
+ // Setup: Set thisTarget to have tabId: 1, frameId: 0
60
+ const mockThisTarget = {
61
+ tabId: 1,
62
+ frameId: 0,
63
+ page: "test",
64
+ };
65
+ vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
66
+ // Register a local handler (should not be used)
67
+ const mockHandler = vi.fn(async () => "local result");
68
+ handlers.set("testMethod", mockHandler);
69
+ // Mock chrome.runtime.sendMessage to return a messenger response
70
+ vi.mocked(chrome.runtime.sendMessage).mockResolvedValue({
71
+ __webextMessenger: true,
72
+ value: "remote result",
73
+ });
74
+ // Import messenger after mocks are set up
75
+ const { messenger } = await import("./sender.js");
76
+ // Test: Send message to different tab
77
+ const result = await messenger("testMethod", {}, { tabId: 2, frameId: 0 });
78
+ // Verify: Message was sent via chrome.runtime.sendMessage
79
+ expect(chrome.runtime.sendMessage).toHaveBeenCalledOnce();
80
+ expect(result).toBe("remote result");
81
+ // Verify: Local handler was not called
82
+ expect(mockHandler).not.toHaveBeenCalled();
83
+ });
84
+ test("should send message when targeting different frame in same tab", async () => {
85
+ // Setup: Set thisTarget to have tabId: 1, frameId: 0
86
+ const mockThisTarget = {
87
+ tabId: 1,
88
+ frameId: 0,
89
+ page: "test",
90
+ };
91
+ vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
92
+ // Register a local handler (should not be used)
93
+ const mockHandler = vi.fn(async () => "local result");
94
+ handlers.set("testMethod", mockHandler);
95
+ // Mock chrome.runtime.sendMessage to return a messenger response
96
+ vi.mocked(chrome.runtime.sendMessage).mockResolvedValue({
97
+ __webextMessenger: true,
98
+ value: "remote result",
99
+ });
100
+ // Import messenger after mocks are set up
101
+ const { messenger } = await import("./sender.js");
102
+ // Test: Send message to different frame in same tab
103
+ const result = await messenger("testMethod", {}, { tabId: 1, frameId: 1 });
104
+ // Verify: Message was sent via chrome.runtime.sendMessage
105
+ expect(chrome.runtime.sendMessage).toHaveBeenCalledOnce();
106
+ expect(result).toBe("remote result");
107
+ // Verify: Local handler was not called
108
+ expect(mockHandler).not.toHaveBeenCalled();
109
+ });
110
+ test("should send message when targeting allFrames", async () => {
111
+ // Setup: Set thisTarget to have tabId: 1, frameId: 0
112
+ const mockThisTarget = {
113
+ tabId: 1,
114
+ frameId: 0,
115
+ page: "test",
116
+ };
117
+ vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
118
+ // Register a local handler (should not be used for allFrames)
119
+ const mockHandler = vi.fn(async () => "local result");
120
+ handlers.set("testMethod", mockHandler);
121
+ // Mock chrome.runtime.sendMessage to return a messenger response
122
+ vi.mocked(chrome.runtime.sendMessage).mockResolvedValue({
123
+ __webextMessenger: true,
124
+ value: "remote result",
125
+ });
126
+ // Import messenger after mocks are set up
127
+ const { messenger } = await import("./sender.js");
128
+ // Test: Send message to allFrames in same tab
129
+ const result = await messenger("testMethod", {}, { tabId: 1, frameId: "allFrames" });
130
+ // Verify: Message was sent via chrome.runtime.sendMessage
131
+ expect(chrome.runtime.sendMessage).toHaveBeenCalledOnce();
132
+ expect(result).toBe("remote result");
133
+ // Verify: Local handler was not called
134
+ expect(mockHandler).not.toHaveBeenCalled();
135
+ });
136
+ test("should throw error when no local handler registered for same tab/frame", async () => {
137
+ // Setup: Set thisTarget to have tabId: 1, frameId: 0
138
+ const mockThisTarget = {
139
+ tabId: 1,
140
+ frameId: 0,
141
+ page: "test",
142
+ };
143
+ vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
144
+ // No handler registered
145
+ // Import messenger and MessengerError after mocks are set up
146
+ const { messenger } = await import("./sender.js");
147
+ const { MessengerError } = await import("./shared.js");
148
+ // Test: Send message to same tab and frame without handler
149
+ try {
150
+ await messenger("testMethod", {}, { tabId: 1, frameId: 0 });
151
+ expect.fail("Should have thrown an error");
152
+ }
153
+ catch (error) {
154
+ expect(error).toBeInstanceOf(MessengerError);
155
+ expect(error.message).toBe("No handler registered locally for testMethod");
156
+ }
157
+ // Verify: No message was sent via chrome.runtime.sendMessage
158
+ expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
159
+ });
160
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webext-messenger",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Browser Extension component messaging framework",
5
5
  "keywords": [],
6
6
  "repository": "pixiebrix/webext-messenger",
@@ -28,7 +28,6 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "one-event": "^4.3.0",
31
- "p-retry": "^6.2.1",
32
31
  "serialize-error": "^12.0.0",
33
32
  "type-fest": "^5.0.1",
34
33
  "webext-detect": "^5.3.2"
@@ -37,16 +36,16 @@
37
36
  "packageExports": true
38
37
  },
39
38
  "devDependencies": {
40
- "@parcel/config-webextension": "^2.15.4",
39
+ "@parcel/config-webextension": "^2.16.0",
41
40
  "@sindresorhus/tsconfig": "^8.0.1",
42
- "@types/chrome": "^0.1.16",
41
+ "@types/chrome": "^0.1.22",
43
42
  "@types/tape": "^5.8.1",
44
43
  "buffer": "^6.0.3",
45
44
  "eslint": "^8.57.0",
46
45
  "eslint-config-pixiebrix": "^0.41.1",
47
46
  "events": "^3.3.0",
48
47
  "npm-run-all": "^4.1.5",
49
- "parcel": "^2.15.4",
48
+ "parcel": "^2.16.0",
50
49
  "path-browserify": "^1.0.1",
51
50
  "process": "^0.11.10",
52
51
  "stream-browserify": "^3.0.0",