webext-messenger 0.19.1 → 0.21.0-0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,