pixiebrix / webext-messenger Goto Github PK
View Code? Open in Web Editor NEWBrowser Extension component messaging framework
License: MIT License
Browser Extension component messaging framework
License: MIT License
I found a peculiar behavior of the native sendMessage API. You'd think that browser.runtime.sendMessage()
sent a message to the background page, but you'd be wrong. Try:
You'll see a bunch of messages on the options page: It's receiving every message that's meant for the background page.
Now this hasn't generally been a problem because messages that don't have a local handler have been ignored, but the messenger throws errors if there's no handler registered:
Line 134 in 1827186
So this happens:
chrome.runtime.sendMessage()
The issues I've been having all week with #43 and related PRs is that the sidebar (which is an "extension page") has been receiving the messages meant for the background page.
How to proceed now:
This discovery also has interesting results, such as the ability to message the sidebar or modalForm directly, from any context, without knowing anything about it.
Messenger is set up around permanent listeners and that makes sense in the vast majority of cases, but sometimes we want to specifically start listening for a message.
Currently this can be done on the userside, by setting up their own event handler.
I wonder if this could be implemented locally somehow. I have a few ideas:
The user can register a dummy method that just ensures we never throw "handler not registered"
registerMethods({MY_METHOD: () => {}})
and then somewhere else they can attach or remove listeners via:
function temporaryListener() {}
messenger.addListener('MY_METHOD', temporaryListener)
messenger.removeListener('MY_METHOD', temporaryListener)
Pros:
registerMethods
worksCons:
In addition to the static registerMethods
call in registration.ts
, the user can register and unregister methods as necessary.
Pros:
Cons if we allow more than one listener:
Promise<void>
Cons if we allow only one listener:
Perhaps in addition to those generic listeners, there could be a simple one-time listener like:
openTab('page.html');
const target = await expectCall('REGISTRATION');
This specifically would help removing tabReady:
Problem:
.then
)TypeError: Cannot read properties of undefined (reading 'then')
Line 73 in df598a0
Solutions:
registerMethods
I'm currently loading random lightweight websites to test the various parts of the package. I think it's best to publish some flexible "server"/page that can be configured via URL (e.g. in order to add some explanation to each link as well)
Due to this error, mistakenly running the messenger twice (e.g. via executeScript
) will now cause the second handler to respond with an error: The "registered methods" array is empty for every execution because it's not shared.
registerMethods
, which would have to throw an error)
window[MESSENGER_SYMBOLS] ??= []
cc @twschiller
Context
Implementation Sketch
Currently there's not way to determine which responses belong to which messages
Messenger: ENSURE_CONTEXT_MENU [{…}] from {sender: {…}}
Messenger: ENSURE_CONTEXT_MENU responds {value: undefined}
Messenger: ENSURE_CONTEXT_MENU responds {value: undefined}
It would be nice to include a nonce or global sequence number
Related Issues
myGet()
myGet
’s handler returns an error in the background pageSince the error was generated in the background page, its stack trace won't have any reference to myGet()
nor its origin. Only the console will point to the throw Error
inside the response handler, but nothing before that.
Maybe something like this will append the current stack trace to the error that was received:
const error = deserializeError(response.error);
+ error.stack += Error.captureStackTrace();
throw error;
Content scripts should be able to send messages to "the sidebar".
At the moment, the background has some logic to track the sidebar, which I assume is:
This allows the content script to message the sidebar without knowing its ID. This is only possible because there's a smart "middle man" that tracks the connection between the two.
This specific situation could be fixed by having the sidebar register itself directly with the content script instead of the background page. Then the content script will be able to contact the sidebar with the usual target
information:
const registeredSidebar: Target = {tabId: 123 /* current tab */, frameId: 3};
await renderPanelMessage(registeredSidebar, panels);
I like the current setup for a few reasons:
However there are some issues:
We can discuss this further to see whether these issues exist. I can also look into improving messaging without losing the good parts.
I mentioned I had already thought about a possible (explicit) messaging architecture, but this did not carry over the function types.
// unsafe.js
import {sendMessage} from './messenger';
const key = await sendMessage('get-key', otherArg);
// contentscript.js
import './messenger'; // Proxies automatically
// background.js
import {onMessage} from './messenger';
onMessage({
name: 'get-key',
allowUnsafe: true,
async handler(event) {
if (event.source === 'unsafe') {
return getKey(...event.args);
}
}
});
Also I find the code sharing with PB.com problematic because that introduces another invisible step that does not guarantee at all that the shared functions have the same signature. I found that Chrome occasionally does not update the extension until you completely remove it and reinstall it. Refined GitHub users are occasionally a few months behind for no apparent reason. We can discuss why this was implemented and if there's any alternative to it.
We have tabId: "this"
for this purpose:
however it only works between extension pages because:
only extension pages can use chrome.tab without intermediary steps
the current logic only handles "this"
by comparing the sender to the current context, but this could be trivially changed:
on send:
if isContentScript && tabId === "this"
send to background
on background receive:
if tabId === "this"
forward to this.trace[0].tab.id
webext-messenger/source/thisTarget.ts
Lines 68 to 71 in ed302a0
Since the editor is just a page running in a custom app (the dev tools), they don't have a "tab" and don't report which tab they're operating on. This is the sender
information for a message coming from the dev tools (to the background page):
{
id: "mpjjildhmpddojocokjkgmlkkkfjnepo",
origin: "chrome-extension://mpjjildhmpddojocokjkgmlkkkfjnepo",
url: "chrome-extension://mpjjildhmpddojocokjkgmlkkkfjnepo/devtoolsPanel.html"
}
This means that any context known to lack a sender.tab
information will need to also provide a tabId. In this case, the browser.runtime.onMessage
handler in background would be called with these arguments
:
[
{ // Message, custom
type: 'REPORT_ERROR',
args: [],
tab: 123
},
{ // Sender, browser-provided
id: "mpjjildhmpddojocokjkgmlkkkfjnepo",
origin: "chrome-extension://mpjjildhmpddojocokjkgmlkkkfjnepo",
url: "chrome-extension://mpjjildhmpddojocokjkgmlkkkfjnepo/devtoolsPanel.html"
}
]
and then it would build the usual MessengerMeta object like this one before calling the handler:
{
trace: [{
tabId: 123,
context: "devtools"
}]
}
In progress…
Starting points:
This feels like a smell, but I'll open an issue to possibly discuss it.
Lifters could be called in either context because the messenger could also call the locally-available function if it was in the same context.
If you call browser.runtime.sendMessage()
in the background page, it will fail even if runtime.onMessage.addListener
was called.
If a public API finds the type
in the local handlers
Map it could immediately call it.
sender
because there wouldn't be a call. This is easy in a background script, but a content script doesn't know its own data (it would require extra code/messaging anyway)The latter might still be useful in some cases, but perhaps it's best to make it explicit (i.e. don't allow local calls) and avoid the rest of the drawbacks.
The number is per-context, so it might make sense to store it in the trace:
let lastMessage = 0;
export function renderPanelsMessage() {
if (lastMessage > this.trace[0].sequence) {
// Ignore message
}
// Handle it
lastMessage = this.trace[0].sequence
}
A possible alternative/companion to:
Automatic numbering at the origin:
Skipping stale messages in the handler
See pixiebrix/pixiebrix-extension#4058 (comment)
// contentScript
import 'webext-messenger'
// background script
import {messenger} from 'webext-messenger'
messenger('RUN_BRICK', {tab: 1})
The call throws No handler registered for RUN_BRICK
but should throw No handler registered at all yet
Notifications were explicitly implemented as a way to ignore failures and not attempt retries:
webext-messenger/source/sender.ts
Lines 51 to 57 in f713825
This may be unexpected in some cases, especially if attempting to message an iframe that is still loading.
This is actually specified in the code and the solution would be to just use regular messages in this case.
webext-messenger/source/types.ts
Line 60 in f713825
I'll open and close this issue just as a way to track this reasoning. I'll add what a "Notification" implies to the glossary.
The current MessengerMeta
(this
) is the raw information that the browser provides as a sender
of the message.
In forwarded messages, the sender always points to the latest step at the moment.
Preserve whole stack of senders in the MessengerMeta:
// Message sent from CS to background page
[
{tabId, frameId}
]
// Message sent from CS to other tab, via background page
[
{tabId, frameId},
{context: 'background'}
]
// Message sent from unsafe context to background page
[
{tabId, frameId, unsafe: true},
{tabId, frameId}, // Only if `externally_connectable` is missing
{context: background}
]
The polyfill introduces new bugs and makes debugging more difficult. Once we're able to switch to MV3, it can be dropped.
The last attempt lives in #123, including more details on the status of the transition.
Reported in pixiebrix/pixiebrix-extension#4208 (comment)
Currently, the only way to message chrome-extension://
pages is by specifying a page
, with an optional tabId
. If a page
is missing, it's assumes that a message is supposed to be forwarded:
webext-messenger/source/thisTarget.ts
Lines 96 to 98 in 7d66d2e
page
-less target must be forwardedFollows:
Regarding queues, which have not been implemented, they'd block every message until a previous message has responded, which in practice would slow down the application at a minimum and cause pileups in worst case.
The simplest of response (ACK) takes about 100ms (I've seen this before), so we could only ever send 10 messages per second:
|---RETRY---ACK . . . . . . Response|
|---ACK . . . . . . Response|
|---ACK . . . . . . Response|
|---ACK . . . . . . Response|
while a non-queued version (what we have) is able to just queue the messages in the browser, which can then respond as necessary:
// Retries are slightly randomized with https://github.com/tim-kos/node-retry
|---RETRY. . . . . . Response|
|----RETRY . . . . . . . Response|
|--RETRY . . . . . . . Response|
|---RETRY . . . . . . . . . Response|
|----RETRY . . . . . . . . Response|
If the application’s responses benefit from or require a certain order of unrelated messages, that's a code smell to me. I think ordering is best handled where necessary.
Something I could improve though is to stop the retries if a message is currently being retried:
|---RETRY. . . . . . Response|
// Then other calls are attempted while waiting
// but they all start once the first retry is done
|------RETRY . . . . . . . Response|
|-----RETRY . . . . . . . . Response|
|----RETRY . . . . . . . . . Response|
|----RETRY . . . . . . . . Response|
But I don't know whether this is worth it either.
I'll try porting this file to Messenger since I saw this explanation:
https://github.com/pixiebrix/pixiebrix-extension/blob/0fbacfedb1e0eee40d76a2168e6e4cb0b67711c5/src/background/browserAction.ts#L149-L157
If a tab/frame does not exist at all, it should not be retried, as it will never exist. This does not apply to named targets.
The problem is detecting the difference between a non-responsive tab and a non-existing one without also using tabs.get
and/or browser.webNavigation.getAllFrames
Related:
Due to this:
webext-messenger/source/thisTarget.ts
Lines 96 to 98 in 7d66d2e
Any runtime
page that receives a forwardable message, will forward it. This means that a tab could receive the same message multiple times.
{tabId}
, which will trigger a runtime.sendMessage
calltabs.sendMessage
callOnly forward in the background
page
The error is caused by:
await pingSidebar({ tabId: "this", page: "/sidebar.html" });
The target doesn't existing long enough to respond, or rather "it never existed," so the message should be along the lines of "target not found" rather than "no handlers registered."
This message is sent once via browser.runtime.sendMessage
and is received by every open chrome://
page and background, which decides whether to answer or ignore.
chrome.runtime.onMessage.addListener
Could not establish connection. Receiving end does not exist.
Target could not be found
registerMethods
No handlers registered in [target]
No handler registered for # in [target]
The current retrier does not remember errors. If the first call returns "No handlers registered" and the second error is "Target could not be found". This is particularly troublesome because only the last error ever surfaces.
In this case, it should probably stop retrying because the target was found but is now gone.
This issue is somewhat related to a recent one, but it has a completely different cause:
"Notifications" could be easily implemented as:
void handleNavigate(target).catch(noop)
or at the API point as:
export const handleNavigate = ignoreErrors(getContentScriptMethod("HANDLE_NAVIGATE"));
But by looking at the liftContentScript
, it seems that notifications are meant to be completely lost even if they can't be delivered, not just as a way to indicate that we're not listening to its return value:
This becomes more obvious now that I implemented retries (#18), which specifically expect the listener to eventually respond, which is the opposite of the above implementation:
The log above will end up reading:
Messenger: HANDLE_NAVIGATE notification failed, will not retry
More context:
Common occurrence: any ⏩ background ⏩ content script
The syntax was also discussed here: #5 (comment)
sendMessage
is called without callbackNo error will be logged, even if the tab doesn't exist, so for these errors to appear you must pass a callback like chrome.tabs.sendMessage(2, 'hi', console.log)
Could not establish connection. Receiving end does not exist.
chrome.runtime.onMessage.addListener
Could not establish connection. Receiving end does not exist.
onMessage
listener did not call sendResponse
true
The error is thrown when the page is navigates away or the tab is closed.
Before Chrome 102:
The message port closed before a response was received.
In Chrome 102:
A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received
This error is thrown immediately:
The message port closed before a response was received.
(Moved back)
Information about the sender/trace and options is currently stored on this
. This is because:
arguments[0]
is the meta")this
is fine for me, but maybe there alternative solutions. This is what I came up with:
import {getCurrentMeta} from "webext-messenger";
function whoAmI() {
const meta = getCurrentMeta();
return meta.trace[0];
}
This pattern:
this
) import.meta.url; // Only available in the current file, in ES Modules
function onMessageHandler() {
if (chrome.runtime.lastError) { // Available only in this call
}
}
The implementation would look like:
- await handler.apply(meta, args)
+ currentMeta = meta;
+ await handler(...args)
+ currentMeta = undefined;
and
export function getCurrentMeta() {
return currentMeta;
}
notify
method calls browser.runtime.sendMessage
sendMessage
returns Promise<void>
There's no way to determine whether void
is the return value of notify
or if the message was never handled.
undefined
response is not confused with a non-response, like {__webext_messenger_response__: undefined}
Line 59 in fd82916
getMethod
should then unwrap this response before returning itLine 111 in fd82916
The messenger logs a lot of calls to the console. Currently they can be hidden:
debug
calls in the console filter-messenger
in the text filter@twschiller mentioned that we should probably provide a way to silence the messenger further, but there are many ways we can achieve this, so we need to pick what makes the most sense to us. Some examples, can be combined:
Other options? The easiest and most helpful solution is probably:
Opening an issue to keep track of the conversation.
Re: 24192c0#commitcomment-82700570
For our upcoming SOC-2 (security audit) we need to enable branch protections on any code that we publish
We don't publish webext-messenger
directly to consumers, it's an npm dependency that is covered by the branch protections included in the extension itself. Adding a branch protection here is akin to requiring that every one of our npm dependencies must also be protected by us.
For that reason I don’t think we need to have branch protection here (also for your own ease of update)
Notifications never throw errors, they are not meant to be awaited and therefore there's no way to async catch errors either.
I think that currently every notification errors are just logged to the console, but they don't bubble up and reportError
won't catch them.
Lines 147 to 149 in 2e13ca0
unhandledrejection
handler catch it.Alternatively, a user interested in errors should just not send notifications so that they can use the usual:
myApiCall().catch(reportError)
A background forwarder catches an error:
But the original sender just ignores the message:
With integrated message forwarding (#23, #31) this user-defined middleman/handler won't exist.
Context:
Refer to the steps in pixiebrix/pixiebrix-extension#2902 (comment)
Possible solution:
Drawbacks:
Error
s in Firefox (🤷♂️)In some rare cases, only the latest version of a message is useful, so it's not important to preserve messages that are currently being retried.
p-retry
and it might not be easy to check whether a message is currently in queue. Maybe an AbortSignal could be used to automatically cancel any messages in queueDiscarding logic:
https://github.com/pixiebrix/pixiebrix-extension/blob/main/src/actionPanel/native.tsx#L167:L167
Used as a notification only:
The tests run on a few pages and they output their results in the console, which the tester has to manually open to verify:
https://github.com/juliangruber/tape-run
I used this before and it's fantastic, but it runs the code autonomously, as a regular page script (we'd need the code to run in extension contexts)
Minimal, complementary, Node-only testing with mocks could be written, but it would not replace the existing tests. This would verify the internals (logging, retries, exact messages)
function proxyService<T extends string>(value: T): T {
return value;
}
declare global {
interface MessengerMethods {
PROXY: typeof proxyService;
}
}
registerMethods({
PROXY: proxyService,
});
export const messengerProxyService = getMethod("PROXY");
The type of messengerProxyService
won't have the generic:
function proxyService(value: unknown): unknown;
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.