webext-messenger 0.20.0 → 0.21.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,8 +1,4 @@
1
- // Imports must use the .js extension because of ESM requires it and TS refuses to rewrite .ts to .js
2
- // This works in TS even if the .js doesn't exist, but it breaks Parcel (the tests builder)
3
- // For this reason, there's an `alias` field in package.json to redirect these imports.
4
- // If you see "@parcel/resolver-default: Cannot load file './yourNewFile.js'" you need to add it to the `alias` list
5
- // 🥲
1
+ // Imports must use the .js extension because ESM requires it and TS refuses to rewrite .ts to .js
6
2
  export * from "./receiver.js";
7
3
  export * from "./sender.js";
8
4
  export * from "./types.js";
@@ -1,8 +1,8 @@
1
1
  import { serializeError } from "serialize-error";
2
- import { getContextName, isBackground } from "webext-detect-page";
2
+ import { getContextName } from "webext-detect-page";
3
3
  import { messenger } from "./sender.js";
4
4
  import { isObject, MessengerError, debug, __webextMessenger, } from "./shared.js";
5
- import { getActionForMessage, nameThisTarget } from "./thisTarget.js";
5
+ import { getActionForMessage } from "./thisTarget.js";
6
6
  import { didUserRegisterMethods, handlers } from "./handlers.js";
7
7
  export function isMessengerMessage(message) {
8
8
  return (isObject(message) &&
@@ -17,7 +17,7 @@ function onMessageListener(message, sender) {
17
17
  return;
18
18
  }
19
19
  // Target check must be synchronous (`await` means we're handling the message)
20
- const action = getActionForMessage(sender, message.target);
20
+ const action = getActionForMessage(sender, message);
21
21
  if (action === "ignore") {
22
22
  return;
23
23
  }
@@ -43,11 +43,13 @@ action) {
43
43
  args,
44
44
  wasForwarded: trace.length > 1,
45
45
  });
46
- if (!didUserRegisterMethods()) {
47
- throw new MessengerError(`No handlers registered in ${getContextName()}`);
48
- }
49
46
  const localHandler = handlers.get(type);
50
47
  if (!localHandler) {
48
+ if (!didUserRegisterMethods()) {
49
+ // TODO: Test the handling of __getTabData in contexts that have no registered methods
50
+ // https://github.com/pixiebrix/webext-messenger/pull/82
51
+ throw new MessengerError(`No handlers registered in ${getContextName()}`);
52
+ }
51
53
  throw new MessengerError(`No handler registered for ${type} in ${getContextName()}`);
52
54
  }
53
55
  handleMessage = async () => localHandler.apply(meta, args);
@@ -61,9 +63,6 @@ action) {
61
63
  return { ...response, __webextMessenger };
62
64
  }
63
65
  export function registerMethods(methods) {
64
- if (!isBackground()) {
65
- void nameThisTarget();
66
- }
67
66
  for (const [type, method] of Object.entries(methods)) {
68
67
  if (handlers.has(type)) {
69
68
  throw new MessengerError(`Handler already set for ${type}`);
@@ -33,10 +33,27 @@ function manageConnection(type, options, target, sendMessage) {
33
33
  async function manageMessage(type, target, sendMessage) {
34
34
  const response = await pRetry(async () => {
35
35
  const response = await sendMessage();
36
- if (!isMessengerResponse(response)) {
37
- throw new MessengerError(`No handler registered for ${type} in the receiving end`);
36
+ if (isMessengerResponse(response)) {
37
+ return response;
38
38
  }
39
- return response;
39
+ // If no one answers, `response` will be `undefined`
40
+ // If the target does not have any `onMessage` listener at all, it will throw
41
+ // Possible:
42
+ // - Any target exists and has onMessage handler, but never handled the message
43
+ // - Extension page exists and has Messenger, but never handled the message (Messenger in Runtime ignores messages when the target isn't found)
44
+ // Not possible:
45
+ // - Tab exists and has Messenger, but never handled the message (Messenger in CS always handles messages)
46
+ // - Any target exists, but Messenger didn't have the specific Type handler (The receiving Messenger will throw an error)
47
+ // - No targets exist (the browser immediately throws "Could not establish connection. Receiving end does not exist.")
48
+ if (response === undefined) {
49
+ if ("page" in target) {
50
+ throw new MessengerError(`The target ${JSON.stringify(target)} for ${type} was not found`);
51
+ }
52
+ throw new MessengerError(`Messenger was not available in the target ${JSON.stringify(target)} for ${type}`);
53
+ }
54
+ // Possible:
55
+ // - Non-Messenger handler responded
56
+ throw new MessengerError(`Conflict: The message ${type} was handled by a third-party listener`);
40
57
  }, {
41
58
  minTimeout: 100,
42
59
  factor: 1.3,
@@ -49,7 +66,7 @@ async function manageMessage(type, target, sendMessage) {
49
66
  // Don't retry sending to the background page unless it really hasn't loaded yet
50
67
  (target.page !== "background" && error instanceof MessengerError) ||
51
68
  // Page or its content script not yet loaded
52
- String(error.message).startsWith(_errorNonExistingTarget) ||
69
+ error.message === _errorNonExistingTarget ||
53
70
  // `registerMethods` not yet loaded
54
71
  String(error.message).startsWith("No handlers registered in ")) {
55
72
  if (browser.tabs &&
@@ -63,6 +80,11 @@ async function manageMessage(type, target, sendMessage) {
63
80
  throw error;
64
81
  }
65
82
  },
83
+ }).catch((error) => {
84
+ if (error?.message === _errorNonExistingTarget) {
85
+ throw new MessengerError(`The target ${JSON.stringify(target)} for ${type} was not found`);
86
+ }
87
+ throw error;
66
88
  });
67
89
  if ("error" in response) {
68
90
  debug(type, "↘️ replied with error", response.error);
@@ -80,7 +102,7 @@ function messenger(type, options, target, ...args) {
80
102
  warn(type, "is being handled locally");
81
103
  return handler.apply({ trace: [] }, args);
82
104
  }
83
- throw new MessengerError("No handler registered for " + type);
105
+ throw new MessengerError("No handler registered locally for " + type);
84
106
  }
85
107
  const sendMessage = async () => {
86
108
  debug(type, "↗️ sending message to runtime");
@@ -1,3 +1,4 @@
1
+ import { errorConstructors } from "serialize-error";
1
2
  const logging = (() => {
2
3
  try {
3
4
  // @ts-expect-error it would break Webpack
@@ -25,6 +26,8 @@ export class MessengerError extends Error {
25
26
  });
26
27
  }
27
28
  }
29
+ // @ts-expect-error Wrong `errorConstructors` types
30
+ errorConstructors.set("MessengerError", MessengerError);
28
31
  // .bind preserves the call location in the console
29
32
  export const debug = logging ? console.debug.bind(console, "Messenger:") : noop;
30
33
  export const warn = logging ? console.warn.bind(console, "Messenger:") : noop;
@@ -1,5 +1,4 @@
1
- import { AnyTarget, MessengerMeta, Sender } from "./types.js";
2
- export declare function getActionForMessage(from: Sender, { ...to }: AnyTarget): "respond" | "forward" | "ignore";
3
- export declare function nameThisTarget(): Promise<void>;
1
+ import { AnyTarget, Message, MessengerMeta, Sender } from "./types.js";
2
+ export declare function getActionForMessage(from: Sender, message: Message): "respond" | "forward" | "ignore";
4
3
  export declare function __getTabData(this: MessengerMeta): AnyTarget;
5
4
  export declare function initPrivateApi(): void;
@@ -1,11 +1,44 @@
1
1
  import { isBackground, isContentScript, isExtensionContext, } from "webext-detect-page";
2
2
  import { messenger } from "./sender.js";
3
3
  import { registerMethods } from "./receiver.js";
4
- import { debug } from "./shared.js";
4
+ import { debug, MessengerError } from "./shared.js";
5
+ /**
6
+ * @file This file exists because `runtime.sendMessage` acts as a broadcast to
7
+ * all open extension pages, so the receiver needs a way to figure out if the
8
+ * message was intended for them.
9
+ *
10
+ * If the requested target only includes a `page` (URL), then it can be determined
11
+ * immediately. If the target also specifies a tab, like `{tabId: 1, page: '/sidebar.html'}`,
12
+ * then the receiving target needs to fetch the tab information via `__getTabData`.
13
+ *
14
+ * `__getTabData` is called automatically when `webext-messenger` is imported in
15
+ * a context that requires this logic (most extension:// pages).
16
+ *
17
+ * If a broadcast message with `tabId` target is received before `__getTabData` is "received",
18
+ * the message will be ignored and it can be retried. If `__getTabData` somehow fails,
19
+ * the target will forever ignore any messages that require the `tabId`. In that case,
20
+ * an error would be thrown once and will be visible in the console, uncaught.
21
+ *
22
+ * Content scripts do not use this logic at all at the moment because they're
23
+ * always targeted via `tabId/frameId` combo and `tabs.sendMessage`.
24
+ */
5
25
  // Soft warning: Race conditions are possible.
6
26
  // This CANNOT be awaited because waiting for it means "I will handle the message."
7
27
  // If a message is received before this is ready, it will just have to be ignored.
8
- let thisTarget;
28
+ const thisTarget = isBackground()
29
+ ? { page: "background" }
30
+ : {
31
+ get page() {
32
+ return location.pathname + location.search;
33
+ },
34
+ };
35
+ let tabDataStatus =
36
+ // The background page doesn't have a tab
37
+ isBackground() ||
38
+ // Content scripts don't use named targets yet
39
+ isContentScript()
40
+ ? "not-needed"
41
+ : "needed";
9
42
  function compareTargets(to, thisTarget) {
10
43
  for (const [key, value] of Object.entries(to)) {
11
44
  if (thisTarget[key] === value) {
@@ -27,8 +60,10 @@ function compareTargets(to, thisTarget) {
27
60
  }
28
61
  return true;
29
62
  }
30
- export function getActionForMessage(from, { ...to } // Clone object because we're editing it
31
- ) {
63
+ // TODO: Test this in Jest, outside the browser
64
+ export function getActionForMessage(from, message) {
65
+ // Clone object because we're editing it
66
+ const to = { ...message.target };
32
67
  if (to.page === "any") {
33
68
  return "respond";
34
69
  }
@@ -41,11 +76,6 @@ export function getActionForMessage(from, { ...to } // Clone object because we'r
41
76
  if (!to.page) {
42
77
  return "forward";
43
78
  }
44
- if (!thisTarget) {
45
- console.warn("A message was received before this context was ready");
46
- // If this *was* the target, then probably no one else answered
47
- return "ignore";
48
- }
49
79
  // Set "this" tab to the current tabId
50
80
  if (to.tabId === "this" && thisTarget.tabId === from.tab?.id) {
51
81
  to.tabId = thisTarget.tabId;
@@ -53,28 +83,42 @@ export function getActionForMessage(from, { ...to } // Clone object because we'r
53
83
  // Every `target` key must match `thisTarget`
54
84
  const isThisTarget = compareTargets(to, thisTarget);
55
85
  if (!isThisTarget) {
56
- debug("The message’s target is", to, "but this is", thisTarget);
86
+ debug(message.type, "🤫 ignored due to target mismatch", {
87
+ requestedTarget: to,
88
+ thisTarget,
89
+ tabDataStatus,
90
+ });
57
91
  }
58
92
  return isThisTarget ? "respond" : "ignore";
59
93
  }
60
- let nameRequested = false;
61
- export async function nameThisTarget() {
62
- // Same as above: CS receives messages correctly
63
- if (!nameRequested && !thisTarget && !isContentScript()) {
64
- nameRequested = true;
65
- thisTarget = await messenger("__getTabData", {}, { page: "any" });
66
- thisTarget.page = location.pathname + location.search;
94
+ async function storeTabData() {
95
+ if (tabDataStatus !== "needed") {
96
+ return;
97
+ }
98
+ try {
99
+ tabDataStatus = "pending";
100
+ Object.assign(thisTarget, {
101
+ ...(await messenger("__getTabData", {}, { page: "any" })),
102
+ });
103
+ tabDataStatus = "received";
104
+ }
105
+ catch (error) {
106
+ tabDataStatus = "error";
107
+ throw new MessengerError("Tab registration failed. This page won’t be able to receive messages that require tab information",
108
+ // @ts-expect-error TODO: update lib to accept Error#cause
109
+ { cause: error });
67
110
  }
68
111
  }
69
112
  export function __getTabData() {
70
113
  return { tabId: this.trace[0]?.tab?.id, frameId: this.trace[0]?.frameId };
71
114
  }
72
115
  export function initPrivateApi() {
73
- if (isBackground()) {
74
- thisTarget = { page: "background" };
75
- }
76
116
  if (isExtensionContext()) {
77
- // Any context can handler this message
117
+ // Only `runtime` pages can handle this message but I can't remove it because its listener
118
+ // also serves the purpose of throwing a specific error when no methods have been registered.
119
+ // https://github.com/pixiebrix/webext-messenger/pull/80
78
120
  registerMethods({ __getTabData });
121
+ // `getTabInformation` includes per-context exclusion logic
122
+ void storeTabData();
79
123
  }
80
124
  }
@@ -40,7 +40,9 @@ export declare type Message<LocalArguments extends Arguments = Arguments> = {
40
40
  /** If the message is being sent to an intermediary receiver, also set the options */
41
41
  options?: Options;
42
42
  };
43
- export declare type Sender = Runtime.MessageSender;
43
+ export declare type Sender = Runtime.MessageSender & {
44
+ origin?: string;
45
+ };
44
46
  export declare type MessengerMessage = Message & {
45
47
  /** Guarantees that a message is meant to be handled by this library */
46
48
  __webextMessenger: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webext-messenger",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Browser Extension component messaging framework",
5
5
  "keywords": [],
6
6
  "repository": "pixiebrix/webext-messenger",
package/readme.md CHANGED
@@ -18,3 +18,7 @@ import messenger from "webext-messenger";
18
18
  ## Context
19
19
 
20
20
  - [Initial considerations for this library](https://github.com/pixiebrix/webext-messenger/issues/1)
21
+
22
+ ## npm publishing
23
+
24
+ Collaborators can publish a new version of what's on main [via "workflow_dispatch"](https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/) under [Actions » Publish](https://github.com/pixiebrix/webext-messenger/actions/workflows/npm-publish.yml)