webext-messenger 0.22.0-5 โ†’ 0.23.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.
@@ -1,4 +1,5 @@
1
1
  export * from "./receiver.js";
2
2
  export * from "./sender.js";
3
3
  export * from "./types.js";
4
- export { getThisTarget, getTopLevelFrame } from "./thisTarget.js";
4
+ export { getThisFrame, getTopLevelFrame } from "./thisTarget.js";
5
+ export { toggleLogging } from "./shared.js";
@@ -2,6 +2,7 @@
2
2
  export * from "./receiver.js";
3
3
  export * from "./sender.js";
4
4
  export * from "./types.js";
5
- export { getThisTarget, getTopLevelFrame } from "./thisTarget.js";
5
+ export { getThisFrame, getTopLevelFrame } from "./thisTarget.js";
6
+ export { toggleLogging } from "./shared.js";
6
7
  import { initPrivateApi } from "./thisTarget.js";
7
8
  initPrivateApi();
@@ -1,3 +1,5 @@
1
- import { type Message } from "./types.js";
1
+ import { type Message, type MessengerMeta } from "./types.js";
2
2
  export declare function isMessengerMessage(message: unknown): message is Message;
3
3
  export declare function registerMethods(methods: Partial<MessengerMethods>): void;
4
+ /** Ensure/document that the current function was called via Messenger */
5
+ export declare function assertMessengerCall(_this: MessengerMeta): asserts _this is MessengerMeta;
@@ -1,7 +1,7 @@
1
1
  import { serializeError } from "serialize-error";
2
2
  import { getContextName } from "webext-detect-page";
3
3
  import { messenger } from "./sender.js";
4
- import { isObject, MessengerError, debug, __webextMessenger, } from "./shared.js";
4
+ import { isObject, MessengerError, log, __webextMessenger } from "./shared.js";
5
5
  import { getActionForMessage } from "./thisTarget.js";
6
6
  import { didUserRegisterMethods, handlers } from "./handlers.js";
7
7
  export function isMessengerMessage(message) {
@@ -29,16 +29,16 @@ async function handleMessage(message, sender,
29
29
  // Once messages reach this function they cannot be "ignored", they're already being handled
30
30
  action) {
31
31
  const { type, target, args, options = {} } = message;
32
- const { trace = [] } = options;
32
+ const { trace = [], seq } = options;
33
33
  trace.push(sender);
34
34
  const meta = { trace };
35
35
  let handleMessage;
36
36
  if (action === "forward") {
37
- debug(type, "๐Ÿ”€ forwarded", { sender, target });
37
+ log.debug(type, seq, "๐Ÿ”€ forwarded", { sender, target });
38
38
  handleMessage = async () => messenger(type, meta, target, ...args);
39
39
  }
40
40
  else {
41
- debug(type, "โ†˜๏ธ received in", getContextName(), {
41
+ log.debug(type, seq, "โ†˜๏ธ received in", getContextName(), {
42
42
  sender,
43
43
  args,
44
44
  wasForwarded: trace.length > 1,
@@ -59,7 +59,7 @@ action) {
59
59
  // and https://github.com/mozilla/webextension-polyfill/issues/210
60
60
  error: serializeError(error),
61
61
  }));
62
- debug(type, "โ†—๏ธ responding", response);
62
+ log.debug(type, seq, "โ†—๏ธ responding", response);
63
63
  return { ...response, __webextMessenger };
64
64
  }
65
65
  export function registerMethods(methods) {
@@ -67,8 +67,12 @@ export function registerMethods(methods) {
67
67
  if (handlers.has(type)) {
68
68
  throw new MessengerError(`Handler already set for ${type}`);
69
69
  }
70
- debug("Registered", type);
70
+ log.debug("Registered", type);
71
71
  handlers.set(type, method);
72
72
  }
73
73
  browser.runtime.onMessage.addListener(onMessageListener);
74
74
  }
75
+ /** Ensure/document that the current function was called via Messenger */
76
+ export function assertMessengerCall(_this
77
+ // eslint-disable-next-line @typescript-eslint/no-empty-function -- TypeScript already does this, it's a documentation-only call
78
+ ) { }
@@ -2,7 +2,7 @@ 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, debug, warn, } from "./shared.js";
5
+ import { isObject, MessengerError, __webextMessenger, log } from "./shared.js";
6
6
  import { handlers } from "./handlers.js";
7
7
  const _errorNonExistingTarget = "Could not establish connection. Receiving end does not exist.";
8
8
  // https://github.com/mozilla/webextension-polyfill/issues/384
@@ -12,6 +12,9 @@ export const errorTabDoesntExist = "The tab doesn't exist";
12
12
  function isMessengerResponse(response) {
13
13
  return isObject(response) && response["__webextMessenger"] === true;
14
14
  }
15
+ function attemptLog(attemptCount) {
16
+ return attemptCount > 1 ? `(try: ${attemptCount})` : "";
17
+ }
15
18
  function makeMessage(type, args, target, options) {
16
19
  return {
17
20
  __webextMessenger,
@@ -22,17 +25,17 @@ function makeMessage(type, args, target, options) {
22
25
  };
23
26
  }
24
27
  // Do not turn this into an `async` function; Notifications must turn `void`
25
- function manageConnection(type, options, target, sendMessage) {
26
- if (!options.isNotification) {
27
- return manageMessage(type, target, sendMessage);
28
+ function manageConnection(type, { seq, isNotification }, target, sendMessage) {
29
+ if (!isNotification) {
30
+ return manageMessage(type, target, seq, sendMessage);
28
31
  }
29
- void sendMessage().catch((error) => {
30
- debug(type, "notification failed", { error });
32
+ void sendMessage(1).catch((error) => {
33
+ log.debug(type, seq, "notification failed", { error });
31
34
  });
32
35
  }
33
- async function manageMessage(type, target, sendMessage) {
34
- const response = await pRetry(async () => {
35
- const response = await sendMessage();
36
+ async function manageMessage(type, target, seq, sendMessage) {
37
+ const response = await pRetry(async (attemptCount) => {
38
+ const response = await sendMessage(attemptCount);
36
39
  if (isMessengerResponse(response)) {
37
40
  return response;
38
41
  }
@@ -74,7 +77,7 @@ async function manageMessage(type, target, sendMessage) {
74
77
  !(await doesTabExist(target.tabId))) {
75
78
  throw new Error(errorTabDoesntExist);
76
79
  }
77
- debug(type, "will retry. Attempt", error.attemptNumber);
80
+ log.debug(type, seq, "will retry. Attempt", error.attemptNumber);
78
81
  }
79
82
  else {
80
83
  throw error;
@@ -87,41 +90,44 @@ async function manageMessage(type, target, sendMessage) {
87
90
  throw error;
88
91
  });
89
92
  if ("error" in response) {
90
- debug(type, "โ†˜๏ธ replied with error", response.error);
93
+ log.debug(type, seq, "โ†˜๏ธ replied with error", response.error);
91
94
  throw deserializeError(response.error);
92
95
  }
93
- debug(type, "โ†˜๏ธ replied successfully", response.value);
96
+ log.debug(type, seq, "โ†˜๏ธ replied successfully", response.value);
94
97
  return response.value;
95
98
  }
96
99
  function messenger(type, options, target, ...args) {
100
+ // Not a UID. Signal / console noise compromise. They repeat every 100 seconds
101
+ options.seq = Date.now() % 100000;
102
+ const { seq } = options;
97
103
  // Message goes to extension page
98
104
  if ("page" in target) {
99
105
  if (target.page === "background" && isBackground()) {
100
106
  const handler = handlers.get(type);
101
107
  if (handler) {
102
- warn(type, "is being handled locally");
108
+ log.warn(type, seq, "is being handled locally");
103
109
  return handler.apply({ trace: [] }, args);
104
110
  }
105
111
  throw new MessengerError("No handler registered locally for " + type);
106
112
  }
107
- const sendMessage = async () => {
108
- debug(type, "โ†—๏ธ sending message to runtime");
113
+ const sendMessage = async (attemptCount) => {
114
+ log.debug(type, seq, "โ†—๏ธ sending message to runtime", attemptLog(attemptCount));
109
115
  return browser.runtime.sendMessage(makeMessage(type, args, target, options));
110
116
  };
111
117
  return manageConnection(type, options, target, sendMessage);
112
118
  }
113
119
  // Contexts without direct Tab access must go through background
114
120
  if (!browser.tabs) {
115
- return manageConnection(type, options, target, async () => {
116
- debug(type, "โ†—๏ธ sending message to runtime");
121
+ return manageConnection(type, options, target, async (attemptCount) => {
122
+ log.debug(type, seq, "โ†—๏ธ sending message to runtime", attemptLog(attemptCount));
117
123
  return browser.runtime.sendMessage(makeMessage(type, args, target, options));
118
124
  });
119
125
  }
120
126
  // `frameId` must be specified. If missing, the message is sent to every frame
121
127
  const { tabId, frameId = 0 } = target;
122
128
  // Message tab directly
123
- return manageConnection(type, options, target, async () => {
124
- debug(type, "โ†—๏ธ sending message to tab", tabId, "frame", frameId);
129
+ return manageConnection(type, options, target, async (attemptCount) => {
130
+ log.debug(type, seq, "โ†—๏ธ sending message to tab", tabId, "frame", frameId, attemptLog(attemptCount));
125
131
  return browser.tabs.sendMessage(tabId, makeMessage(type, args, target, options), {
126
132
  frameId,
127
133
  });
@@ -1,5 +1,5 @@
1
1
  import { type JsonObject } from "type-fest";
2
- declare type ErrorObject = {
2
+ type ErrorObject = {
3
3
  name?: string;
4
4
  stack?: string;
5
5
  message?: string;
@@ -10,8 +10,11 @@ export declare function isObject(value: unknown): value is Record<string, unknow
10
10
  export declare class MessengerError extends Error {
11
11
  name: string;
12
12
  }
13
- export declare const debug: (...args: any[]) => void;
14
- export declare const warn: (...args: any[]) => void;
13
+ export declare const log: {
14
+ debug: (...args: any[]) => void;
15
+ warn: (...args: any[]) => void;
16
+ };
17
+ export declare function toggleLogging(enabled: boolean): void;
15
18
  export declare function isErrorObject(error: unknown): error is ErrorObject;
16
19
  export declare function delay(milliseconds: number): Promise<void>;
17
20
  export declare function once<Callback extends (...arguments_: unknown[]) => unknown>(function_: Callback): Callback;
@@ -1,20 +1,11 @@
1
1
  import { errorConstructors } from "serialize-error";
2
- const logging = (() => {
3
- try {
4
- // @ts-expect-error it would break Webpack
5
- return process.env.WEBEXT_MESSENGER_LOGGING === "true";
6
- }
7
- catch {
8
- return false;
9
- }
10
- })();
11
- function noop() {
12
- /* */
13
- }
14
2
  export const __webextMessenger = true;
15
3
  export function isObject(value) {
16
4
  return typeof value === "object" && value !== null;
17
5
  }
6
+ function noop() {
7
+ /* */
8
+ }
18
9
  export class MessengerError extends Error {
19
10
  constructor() {
20
11
  super(...arguments);
@@ -29,8 +20,15 @@ export class MessengerError extends Error {
29
20
  // @ts-expect-error Wrong `errorConstructors` types
30
21
  errorConstructors.set("MessengerError", MessengerError);
31
22
  // .bind preserves the call location in the console
32
- export const debug = logging ? console.debug.bind(console, "Messenger:") : noop;
33
- export const warn = logging ? console.warn.bind(console, "Messenger:") : noop;
23
+ const debug = console.debug.bind(console, "Messenger:");
24
+ const warn = console.warn.bind(console, "Messenger:");
25
+ export const log = { debug, warn };
26
+ export function toggleLogging(enabled) {
27
+ log.debug = enabled ? debug : noop;
28
+ log.warn = enabled ? warn : noop;
29
+ }
30
+ // Default to "no logs"
31
+ toggleLogging(false);
34
32
  export function isErrorObject(error) {
35
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a type guard function and it uses ?.
36
34
  return typeof error?.message === "string";
@@ -1,6 +1,6 @@
1
- import { type AnyTarget, type KnownTarget, type TopLevelFrame, type Message, type MessengerMeta, type Sender } from "./types.js";
1
+ import { type AnyTarget, type TopLevelFrame, type Message, type MessengerMeta, type Sender, type FrameTarget } from "./types.js";
2
2
  export declare function getActionForMessage(from: Sender, message: Message): "respond" | "forward" | "ignore";
3
3
  export declare function __getTabData(this: MessengerMeta): AnyTarget;
4
- export declare function getThisTarget(): Promise<KnownTarget>;
4
+ export declare function getThisFrame(): Promise<FrameTarget>;
5
5
  export declare function getTopLevelFrame(): Promise<TopLevelFrame>;
6
6
  export declare function initPrivateApi(): void;
@@ -1,7 +1,7 @@
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, MessengerError, once } from "./shared.js";
4
+ import { log, MessengerError, once } from "./shared.js";
5
5
  /**
6
6
  * @file This file exists because `runtime.sendMessage` acts as a broadcast to
7
7
  * all open extension pages, so the receiver needs a way to figure out if the
@@ -29,16 +29,17 @@ const thisTarget = isBackground()
29
29
  ? { page: "background" }
30
30
  : {
31
31
  get page() {
32
- return location.pathname + location.search;
32
+ // Extension pages have relative URLs to simplify comparison
33
+ const origin = location.protocol.startsWith("http")
34
+ ? location.origin
35
+ : "";
36
+ // Don't use the hash
37
+ return origin + location.pathname + location.search;
33
38
  },
34
39
  };
35
40
  let tabDataStatus =
36
41
  // 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";
42
+ isBackground() ? "not-needed" : "needed";
42
43
  function compareTargets(to, thisTarget) {
43
44
  for (const [key, value] of Object.entries(to)) {
44
45
  if (thisTarget[key] === value) {
@@ -83,7 +84,7 @@ export function getActionForMessage(from, message) {
83
84
  // Every `target` key must match `thisTarget`
84
85
  const isThisTarget = compareTargets(to, thisTarget);
85
86
  if (!isThisTarget) {
86
- debug(message.type, "๐Ÿคซ ignored due to target mismatch", {
87
+ log.debug(message.type, "๐Ÿคซ ignored due to target mismatch", {
87
88
  requestedTarget: to,
88
89
  thisTarget,
89
90
  tabDataStatus,
@@ -112,15 +113,17 @@ const storeTabData = once(async () => {
112
113
  export function __getTabData() {
113
114
  return { tabId: this.trace[0]?.tab?.id, frameId: this.trace[0]?.frameId };
114
115
  }
115
- export async function getThisTarget() {
116
+ export async function getThisFrame() {
116
117
  await storeTabData(); // It should already have been called by we still need to await it
117
- return thisTarget;
118
+ const { tabId, frameId } = thisTarget;
119
+ if (typeof tabId !== "number" || typeof frameId !== "number") {
120
+ throw new TypeError("This target is not in a frame");
121
+ }
122
+ // Rebuild object to return exactly these two properties and nothing more
123
+ return { tabId, frameId };
118
124
  }
119
125
  export async function getTopLevelFrame() {
120
- const { tabId } = await getThisTarget();
121
- if (typeof tabId !== "number") {
122
- throw new TypeError("This target is not in a tab");
123
- }
126
+ const { tabId } = await getThisFrame();
124
127
  return {
125
128
  tabId,
126
129
  frameId: 0,
@@ -1,72 +1,83 @@
1
1
  import { type Runtime } from "webextension-polyfill";
2
2
  import { type Asyncify, type ValueOf } from "type-fest";
3
3
  import { type ErrorObject } from "serialize-error";
4
+ /**
5
+ * @file Target types are a bit overlapping. That's because some are "request" targets
6
+ * and some are "known" targets. The difference is that you could "request" `{tabId: 1}`, but you "know" that a specific target is exactly `{tabId: 1, frameId: 5}`
7
+ * TODO: Cleanup, clarify, deduplicate Target types
8
+ */
4
9
  declare global {
5
10
  interface MessengerMethods {
6
11
  _: Method;
7
12
  }
8
13
  }
9
- declare type WithTarget<Method> = Method extends (...args: infer PreviousArguments) => infer TReturnValue ? (target: Target | PageTarget, ...args: PreviousArguments) => TReturnValue : never;
10
- declare type ActuallyOmitThisParameter<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : T;
14
+ type WithTarget<Method> = Method extends (...args: infer PreviousArguments) => infer TReturnValue ? (target: Target | PageTarget, ...args: PreviousArguments) => TReturnValue : never;
15
+ type ActuallyOmitThisParameter<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : T;
11
16
  /** Removes the `this` type and ensure it's always Promised */
12
- export declare type PublicMethod<Method extends ValueOf<MessengerMethods>> = Asyncify<ActuallyOmitThisParameter<Method>>;
13
- export declare type PublicMethodWithTarget<Method extends ValueOf<MessengerMethods>> = WithTarget<PublicMethod<Method>>;
14
- export declare type MessengerMeta = {
17
+ export type PublicMethod<Method extends ValueOf<MessengerMethods>> = Asyncify<ActuallyOmitThisParameter<Method>>;
18
+ export type PublicMethodWithTarget<Method extends ValueOf<MessengerMethods>> = WithTarget<PublicMethod<Method>>;
19
+ export interface MessengerMeta {
15
20
  trace: Sender[];
16
- };
17
- declare type RawMessengerResponse = {
21
+ }
22
+ type RawMessengerResponse = {
18
23
  value: unknown;
19
24
  } | {
20
25
  error: ErrorObject;
21
26
  };
22
- export declare type MessengerResponse = RawMessengerResponse & {
27
+ export type MessengerResponse = RawMessengerResponse & {
23
28
  /** Guarantees that the message was handled by this library */
24
29
  __webextMessenger: true;
25
30
  };
26
- declare type Arguments = any[];
27
- export declare type Method = (this: MessengerMeta, ...args: Arguments) => Promise<unknown>;
28
- export declare type Options = {
31
+ type Arguments = unknown[];
32
+ export type Method = (this: MessengerMeta, ...args: Arguments) => Promise<unknown>;
33
+ export interface Options {
29
34
  /**
30
35
  * "Notifications" won't await the response, return values, attempt retries, nor throw errors
31
36
  * @default false
32
37
  */
33
38
  isNotification?: boolean;
34
39
  trace?: Sender[];
35
- };
36
- export declare type Message<LocalArguments extends Arguments = Arguments> = {
40
+ /** Automatically generated internally */
41
+ seq?: number;
42
+ }
43
+ export type Message<LocalArguments extends Arguments = Arguments> = {
37
44
  type: keyof MessengerMethods;
38
45
  args: LocalArguments;
39
46
  target: Target | PageTarget;
40
47
  /** If the message is being sent to an intermediary receiver, also set the options */
41
48
  options?: Options;
42
49
  };
43
- export declare type Sender = Runtime.MessageSender & {
50
+ export type Sender = Runtime.MessageSender & {
44
51
  origin?: string;
45
52
  };
46
- export declare type MessengerMessage = Message & {
53
+ export type MessengerMessage = Message & {
47
54
  /** Guarantees that a message is meant to be handled by this library */
48
55
  __webextMessenger: true;
49
56
  };
50
- export declare type AnyTarget = {
57
+ export interface AnyTarget {
51
58
  tabId?: number | "this";
52
59
  frameId?: number;
53
60
  page?: string;
54
- };
55
- export declare type TopLevelFrame = {
61
+ }
62
+ export interface TopLevelFrame {
56
63
  tabId: number;
57
64
  frameId: 0;
58
- };
59
- export declare type KnownTarget = {
65
+ }
66
+ export interface FrameTarget {
67
+ tabId: number;
68
+ frameId: number;
69
+ }
70
+ export interface KnownTarget {
60
71
  tabId?: number;
61
72
  frameId?: number;
62
73
  page: string;
63
- };
64
- export declare type Target = {
74
+ }
75
+ export interface Target {
65
76
  tabId: number;
66
77
  frameId?: number;
67
- };
68
- export declare type PageTarget = {
78
+ }
79
+ export interface PageTarget {
69
80
  tabId?: number | "this";
70
81
  page: string;
71
- };
82
+ }
72
83
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webext-messenger",
3
- "version": "0.22.0-5",
3
+ "version": "0.23.0",
4
4
  "description": "Browser Extension component messaging framework",
5
5
  "keywords": [],
6
6
  "repository": "pixiebrix/webext-messenger",
@@ -10,7 +10,7 @@
10
10
  "main": "distribution/index.js",
11
11
  "scripts": {
12
12
  "build": "tsc",
13
- "demo:watch": "WEBEXT_MESSENGER_LOGGING=true parcel watch --no-cache --no-hmr",
13
+ "demo:watch": "parcel watch --no-cache --no-hmr",
14
14
  "demo:build": "parcel build --no-cache",
15
15
  "prepack": "tsc --sourceMap false",
16
16
  "test": "eslint . && tsc --noEmit",
@@ -19,30 +19,30 @@
19
19
  "watch": "tsc --watch"
20
20
  },
21
21
  "dependencies": {
22
- "p-retry": "^5.1.1",
23
- "serialize-error": "^11.0.0",
24
- "type-fest": "^3.2.0",
25
- "webext-detect-page": "^4.0.1",
26
- "webext-tools": "^1.1.0"
22
+ "p-retry": "^6.0.0",
23
+ "serialize-error": "^11.0.2",
24
+ "type-fest": "^4.3.1",
25
+ "webext-detect-page": "^4.1.1",
26
+ "webext-tools": "^1.1.3"
27
27
  },
28
28
  "devDependencies": {
29
- "@parcel/config-webextension": "^2.8.0",
30
- "@sindresorhus/tsconfig": "^3.0.1",
31
- "@types/chrome": "^0.0.203",
32
- "@types/tape": "^4.13.2",
33
- "@types/webextension-polyfill": "^0.9.0",
29
+ "@parcel/config-webextension": "^2.6.2",
30
+ "@sindresorhus/tsconfig": "^4.0.0",
31
+ "@types/chrome": "^0.0.245",
32
+ "@types/tape": "^5.6.1",
33
+ "@types/webextension-polyfill": "^0.10.2",
34
34
  "buffer": "^6.0.3",
35
- "eslint": "^8.28.0",
36
- "eslint-config-pixiebrix": "^0.20.0",
35
+ "eslint": "^8.29.0",
36
+ "eslint-config-pixiebrix": "^0.27.2",
37
37
  "events": "^3.3.0",
38
38
  "npm-run-all": "^4.1.5",
39
- "parcel": "^2.6.0",
39
+ "parcel": "^2.6.2",
40
40
  "path-browserify": "^1.0.1",
41
41
  "process": "^0.11.10",
42
42
  "stream-browserify": "^3.0.0",
43
- "tape": "^5.5.3",
44
- "typescript": "^4.9.3",
45
- "webext-content-scripts": "^1.0.2",
43
+ "tape": "^5.6.6",
44
+ "typescript": "^5.2.2",
45
+ "webext-content-scripts": "^2.5.5",
46
46
  "webextension-polyfill": "^0.10.0"
47
47
  },
48
48
  "alias": {
package/readme.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [badge-gzip]: https://img.shields.io/bundlephobia/minzip/webext-messenger.svg?label=gzipped
4
4
  [link-bundlephobia]: https://bundlephobia.com/result?p=webext-messenger
5
5
 
6
- > WIP: Browser Extension component messaging framework
6
+ > Browser Extension component messaging framework
7
7
 
8
8
  ## Install
9
9
 
@@ -17,4 +17,9 @@ import messenger from "webext-messenger";
17
17
 
18
18
  ## Context
19
19
 
20
- - [Initial considerations for this library](https://github.com/pixiebrix/webext-messenger/issues/1)
20
+ - [Initial considertions for this library](https://github.com/pixiebrix/webext-messenger/issues/1)
21
+
22
+
23
+ ## npm publishing
24
+
25
+ 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)