webext-messenger 0.22.0-5 โ†’ 0.23.0

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