webext-messenger 0.19.1 → 0.21.0-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.
@@ -0,0 +1,10 @@
1
+ import { __getTabData } from "./thisTarget.js";
2
+ import { Method } from "./types.js";
3
+ declare global {
4
+ interface MessengerMethods {
5
+ __getTabData: typeof __getTabData;
6
+ }
7
+ }
8
+ export declare const privateMethods: (typeof __getTabData)[];
9
+ export declare const handlers: Map<string, Method>;
10
+ export declare function didUserRegisterMethods(): boolean;
@@ -0,0 +1,6 @@
1
+ import { __getTabData } from "./thisTarget.js";
2
+ export const privateMethods = [__getTabData];
3
+ export const handlers = new Map();
4
+ export function didUserRegisterMethods() {
5
+ return handlers.size > privateMethods.length;
6
+ }
@@ -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,9 @@
1
1
  import { serializeError } from "serialize-error";
2
+ import { getContextName } from "webext-detect-page";
2
3
  import { messenger } from "./sender.js";
3
- import { handlers, isObject, MessengerError, debug, __webextMessenger, } from "./shared.js";
4
- import { getContextName, isBackground } from "webext-detect-page";
5
- import { getActionForMessage, nameThisTarget } from "./thisTarget.js";
4
+ import { isObject, MessengerError, debug, __webextMessenger, } from "./shared.js";
5
+ import { getActionForMessage } from "./thisTarget.js";
6
+ import { didUserRegisterMethods, handlers } from "./handlers.js";
6
7
  export function isMessengerMessage(message) {
7
8
  return (isObject(message) &&
8
9
  typeof message["type"] === "string" &&
@@ -16,7 +17,7 @@ function onMessageListener(message, sender) {
16
17
  return;
17
18
  }
18
19
  // Target check must be synchronous (`await` means we're handling the message)
19
- const action = getActionForMessage(sender, message.target);
20
+ const action = getActionForMessage(sender, message);
20
21
  if (action === "ignore") {
21
22
  return;
22
23
  }
@@ -44,6 +45,11 @@ action) {
44
45
  });
45
46
  const localHandler = handlers.get(type);
46
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
+ }
47
53
  throw new MessengerError(`No handler registered for ${type} in ${getContextName()}`);
48
54
  }
49
55
  handleMessage = async () => localHandler.apply(meta, args);
@@ -57,9 +63,6 @@ action) {
57
63
  return { ...response, __webextMessenger };
58
64
  }
59
65
  export function registerMethods(methods) {
60
- if (!isBackground()) {
61
- void nameThisTarget();
62
- }
63
66
  for (const [type, method] of Object.entries(methods)) {
64
67
  if (handlers.has(type)) {
65
68
  throw new MessengerError(`Handler already set for ${type}`);
@@ -2,7 +2,8 @@ import pRetry from "p-retry";
2
2
  import { isBackground } from "webext-detect-page";
3
3
  import { doesTabExist } from "webext-tools";
4
4
  import { deserializeError } from "serialize-error";
5
- import { isObject, MessengerError, __webextMessenger, handlers, debug, warn, } from "./shared.js";
5
+ import { isObject, MessengerError, __webextMessenger, debug, warn, } from "./shared.js";
6
+ import { handlers } from "./handlers.js";
6
7
  const _errorNonExistingTarget = "Could not establish connection. Receiving end does not exist.";
7
8
  // https://github.com/mozilla/webextension-polyfill/issues/384
8
9
  const _errorTargetClosedEarly = "A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received";
@@ -32,10 +33,27 @@ function manageConnection(type, options, target, sendMessage) {
32
33
  async function manageMessage(type, target, sendMessage) {
33
34
  const response = await pRetry(async () => {
34
35
  const response = await sendMessage();
35
- if (!isMessengerResponse(response)) {
36
- throw new MessengerError(`No handler registered for ${type} in the receiving end`);
36
+ if (isMessengerResponse(response)) {
37
+ return response;
37
38
  }
38
- 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`);
39
57
  }, {
40
58
  minTimeout: 100,
41
59
  factor: 1.3,
@@ -47,7 +65,10 @@ async function manageMessage(type, target, sendMessage) {
47
65
  if (
48
66
  // Don't retry sending to the background page unless it really hasn't loaded yet
49
67
  (target.page !== "background" && error instanceof MessengerError) ||
50
- String(error.message).startsWith(_errorNonExistingTarget)) {
68
+ // Page or its content script not yet loaded
69
+ error.message === _errorNonExistingTarget ||
70
+ // `registerMethods` not yet loaded
71
+ String(error.message).startsWith("No handlers registered in ")) {
51
72
  if (browser.tabs &&
52
73
  typeof target.tabId === "number" &&
53
74
  !(await doesTabExist(target.tabId))) {
@@ -59,6 +80,11 @@ async function manageMessage(type, target, sendMessage) {
59
80
  throw error;
60
81
  }
61
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;
62
88
  });
63
89
  if ("error" in response) {
64
90
  debug(type, "↘️ replied with error", response.error);
@@ -76,7 +102,7 @@ function messenger(type, options, target, ...args) {
76
102
  warn(type, "is being handled locally");
77
103
  return handler.apply({ trace: [] }, args);
78
104
  }
79
- throw new MessengerError("No handler registered for " + type);
105
+ throw new MessengerError("No handler registered locally for " + type);
80
106
  }
81
107
  const sendMessage = async () => {
82
108
  debug(type, "↗️ sending message to runtime");
@@ -1,5 +1,4 @@
1
1
  import { JsonObject } from "type-fest";
2
- import { Method } from "./types.js";
3
2
  declare type ErrorObject = {
4
3
  name?: string;
5
4
  stack?: string;
@@ -11,7 +10,6 @@ export declare function isObject(value: unknown): value is Record<string, unknow
11
10
  export declare class MessengerError extends Error {
12
11
  name: string;
13
12
  }
14
- export declare const handlers: Map<string, Method>;
15
13
  export declare const debug: (...args: any[]) => void;
16
14
  export declare const warn: (...args: any[]) => void;
17
15
  export declare function isErrorObject(error: unknown): error is ErrorObject;
@@ -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,13 +26,14 @@ export class MessengerError extends Error {
25
26
  });
26
27
  }
27
28
  }
28
- export const handlers = new Map();
29
+ // @ts-expect-error Wrong `errorConstructors` types
30
+ errorConstructors.set("MessengerError", MessengerError);
29
31
  // .bind preserves the call location in the console
30
32
  export const debug = logging ? console.debug.bind(console, "Messenger:") : noop;
31
33
  export const warn = logging ? console.warn.bind(console, "Messenger:") : noop;
32
34
  export function isErrorObject(error) {
33
35
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a type guard function and it uses ?.
34
- return typeof (error === null || error === void 0 ? void 0 : error.message) === "string";
36
+ return typeof error?.message === "string";
35
37
  }
36
38
  export async function delay(milliseconds) {
37
39
  return new Promise((resolve) => {
@@ -1,11 +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>;
4
- declare function __getTabData(this: MessengerMeta): AnyTarget;
5
- declare global {
6
- interface MessengerMethods {
7
- __getTabData: typeof __getTabData;
8
- }
9
- }
1
+ import { AnyTarget, Message, MessengerMeta, Sender } from "./types.js";
2
+ export declare function getActionForMessage(from: Sender, message: Message): "respond" | "forward" | "ignore";
3
+ export declare function __getTabData(this: MessengerMeta): AnyTarget;
10
4
  export declare function initPrivateApi(): void;
11
- export {};
@@ -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,9 +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
- ) {
32
- var _a;
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 };
33
67
  if (to.page === "any") {
34
68
  return "respond";
35
69
  }
@@ -42,41 +76,49 @@ export function getActionForMessage(from, { ...to } // Clone object because we'r
42
76
  if (!to.page) {
43
77
  return "forward";
44
78
  }
45
- if (!thisTarget) {
46
- console.warn("A message was received before this context was ready");
47
- // If this *was* the target, then probably no one else answered
48
- return "ignore";
49
- }
50
79
  // Set "this" tab to the current tabId
51
- if (to.tabId === "this" && thisTarget.tabId === ((_a = from.tab) === null || _a === void 0 ? void 0 : _a.id)) {
80
+ if (to.tabId === "this" && thisTarget.tabId === from.tab?.id) {
52
81
  to.tabId = thisTarget.tabId;
53
82
  }
54
83
  // Every `target` key must match `thisTarget`
55
84
  const isThisTarget = compareTargets(to, thisTarget);
56
85
  if (!isThisTarget) {
57
- 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
+ });
58
91
  }
59
92
  return isThisTarget ? "respond" : "ignore";
60
93
  }
61
- let nameRequested = false;
62
- export async function nameThisTarget() {
63
- // Same as above: CS receives messages correctly
64
- if (!nameRequested && !thisTarget && !isContentScript()) {
65
- nameRequested = true;
66
- thisTarget = await messenger("__getTabData", {}, { page: "any" });
67
- 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 });
68
110
  }
69
111
  }
70
- function __getTabData() {
71
- var _a, _b, _c;
72
- return { tabId: (_b = (_a = this.trace[0]) === null || _a === void 0 ? void 0 : _a.tab) === null || _b === void 0 ? void 0 : _b.id, frameId: (_c = this.trace[0]) === null || _c === void 0 ? void 0 : _c.frameId };
112
+ export function __getTabData() {
113
+ return { tabId: this.trace[0]?.tab?.id, frameId: this.trace[0]?.frameId };
73
114
  }
74
115
  export function initPrivateApi() {
75
- if (isBackground()) {
76
- thisTarget = { page: "background" };
77
- }
78
116
  if (isExtensionContext()) {
79
- // 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
80
120
  registerMethods({ __getTabData });
121
+ // `getTabInformation` includes per-context exclusion logic
122
+ void storeTabData();
81
123
  }
82
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.19.1",
3
+ "version": "0.21.0-0",
4
4
  "description": "Browser Extension component messaging framework",
5
5
  "keywords": [],
6
6
  "repository": "pixiebrix/webext-messenger",
@@ -18,85 +18,6 @@
18
18
  "fix": "eslint . --fix",
19
19
  "watch": "tsc --watch"
20
20
  },
21
- "eslintConfig": {
22
- "env": {
23
- "browser": true
24
- },
25
- "parserOptions": {
26
- "project": "tsconfig.json"
27
- },
28
- "plugins": [
29
- "import"
30
- ],
31
- "extends": [
32
- "plugin:@typescript-eslint/recommended",
33
- "xo",
34
- "xo-typescript",
35
- "prettier",
36
- "plugin:import/recommended",
37
- "plugin:import/typescript",
38
- "plugin:unicorn/recommended"
39
- ],
40
- "rules": {
41
- "no-restricted-imports": [
42
- "error",
43
- {
44
- "paths": [
45
- {
46
- "name": "./index",
47
- "message": "The index file is only used to re-export internal files. Use direct imports instead."
48
- }
49
- ]
50
- }
51
- ],
52
- "import/extensions": [
53
- "error",
54
- "always"
55
- ],
56
- "import/no-unresolved": "off",
57
- "unicorn/filename-case": [
58
- "error",
59
- {
60
- "case": "camelCase"
61
- }
62
- ],
63
- "unicorn/no-useless-undefined": [
64
- "error",
65
- {
66
- "checkArguments": false
67
- }
68
- ],
69
- "unicorn/prevent-abbreviations": [
70
- "error",
71
- {
72
- "allowList": {
73
- "args": true
74
- }
75
- }
76
- ]
77
- },
78
- "overrides": [
79
- {
80
- "files": [
81
- "*.test.ts",
82
- "testingApi.ts"
83
- ],
84
- "rules": {
85
- "@typescript-eslint/no-explicit-any": "off",
86
- "@typescript-eslint/no-non-null-assertion": "off",
87
- "@typescript-eslint/no-unsafe-member-access": "off"
88
- }
89
- },
90
- {
91
- "files": [
92
- "source/test/**/*"
93
- ],
94
- "rules": {
95
- "import/extensions": "off"
96
- }
97
- }
98
- ]
99
- },
100
21
  "dependencies": {
101
22
  "p-retry": "^5.1.1",
102
23
  "serialize-error": "^11.0.0",
@@ -131,12 +52,8 @@
131
52
  "webextension-polyfill": "^0.9.0"
132
53
  },
133
54
  "alias": {
134
- "./this-stuff-is-just-for-local-parcel-tests": "./package.json",
135
- "./source/sender.js": "./source/sender.ts",
136
- "./source/receiver.js": "./source/receiver.ts",
137
- "./source/types.js": "./source/types.ts",
138
- "./source/shared.js": "./source/shared.ts",
139
- "./source/thisTarget.js": "./source/thisTarget.ts"
55
+ "./this-stuff-is-just-for-local-parcel-tests": "https://github.com/parcel-bundler/parcel/issues/4936",
56
+ "./source/**/*.js": "./source/$1/$2.ts"
140
57
  },
141
58
  "targets": {
142
59
  "main": false,