Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: type-safe messaging api #899

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft

Conversation

Timeraa
Copy link
Contributor

@Timeraa Timeraa commented Aug 4, 2024

Caution

Implementation and code are not done at all, feel free to test and provide feedback about changes though but note that code can change anytime

Note

Status: POC (Proof of Concept)

This is a tracking PR which aims to implement #643

Internal Messaging (via ports aka chrome.runtime.connect)

  • Chromium mv3
  • Chromium mv2
  • Firefox mv3
  • Firefox mv2
  • Safari mv2

Native Messaging (via ports aka chrome.runtime.connectNative)

  • Chromium mv3
  • Chromium mv2
  • Firefox mv3
  • Firefox mv2
  • Safari mv2

Internal Messaging (via sendMessage)

  • Chromium mv3
  • Chromium mv2
  • Firefox mv3
  • Firefox mv2
  • Safari mv2

Native Messaging (via sendNativeMessage)

  • Chromium mv3
  • Chromium mv2
  • Firefox mv3
  • Firefox mv2
  • Safari mv2

UserScript Messaging (via ports)

  • Chromium mv3
    Chromium mv2 - Doesn't exist
  • Firefox mv3
  • Firefox mv2
    Safari mv2 - Doesn't exist?

Injected script in root page Messaging

  • Chromium mv3
    • ISOLATED World
    • MAIN World
  • Chromium mv2
  • Firefox mv3
  • Firefox mv2
  • Safari mv2

General Todos

  • Documentation
  • Tests

I am currently focusing on implementing the Background as an API approach, after this is done I will try to implement others if wanted by @aklinker1.

Questions for @aklinker1

  1. How would webpage messaging be done type safe? As for example my website would connect to it but I obviously can't just import wxt from there?
  2. How would native messaging work in a type safe way, probs same question as 1.
  3. What is an example for an injected iframe?
  4. I added a wxt/util export for a useful function imo, is that okay to keep there?
  5. How would I implement tests into this, could you maybe help with that if possible?

General Questions

  1. Should class properties be accessible on the other side too? (This would mean they'd be "streamed" to the other side but could only support basic types) - I don't think this should be done

Usage

Check the package README

Copy link

netlify bot commented Aug 4, 2024

Deploy Preview for creative-fairy-df92c4 failed.

Name Link
🔨 Latest commit 96f1734
🔍 Latest deploy log https://app.netlify.com/sites/creative-fairy-df92c4/deploys/66afe9a281aaf600095c583d

Copy link

codecov bot commented Aug 4, 2024

Codecov Report

Attention: Patch coverage is 0.80000% with 124 lines in your changes missing coverage. Please review.

Project coverage is 81.13%. Comparing base (fc246ff) to head (a6c3f07).
Report is 3 commits behind head on main.

Files Patch % Lines
packages/wxt/src/rpc/index.ts 0.00% 115 Missing and 1 partial ⚠️
packages/wxt/src/util/isBackground.ts 12.50% 7 Missing ⚠️
packages/wxt/src/util/index.ts 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #899      +/-   ##
==========================================
- Coverage   82.29%   81.13%   -1.17%     
==========================================
  Files         124      127       +3     
  Lines        6513     6642     +129     
  Branches     1094     1104      +10     
==========================================
+ Hits         5360     5389      +29     
- Misses       1139     1238      +99     
- Partials       14       15       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Collaborator

@aklinker1 aklinker1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. How would webpage messaging be done type safe? As for example my website would connect to it but I obviously can't just import wxt from there?

We should just allow importing the messaging API there! If it's a separate package (see other comment), it's much easier to add to a web project without the entire framework. And if we don't use the polyfill, there's no harm in a website importing it!

Then ideally, they would copy the interfaces/types or reference the original types if it's in the same repo.

I think this is different from what you're thinking, you are exporting the proxy in the PR's readme. I'm thinking we don't import the proxy directly in the project, but call the API from the messaging library to create a proxy in the web project.

// apps/web/extension-proxy.ts
import { createRpcProxy } from '@wxt-dev/messaging/rpc'
import type { SomeService } from 'apps/extension/utils/...'

export const someService = createRpcProxy<SomeService>(...)
  1. How would native messaging work in a type safe way, probs same question as 1.

This one is a hard one. I have a safari extension at work, and I wrote a script that looks at the TypeScript AST for the messaging protocol, and generates iOS swift code to match. A generic approach for this... probably isn't possible.

But for Electron, which uses JS, the messaging library could also just be imported there. If it's in a monorepo, they could inherit types from the extension's package.

But yeah, TLDR, there's probably not a generic solution for this.

  1. What is an example for an injected iframe?

Send a message from a content script to an iframe created with createIframeUi. I think I have a simple solution for this, a transport that uses window.postMessage, keep reading...

  1. How would I implement tests into this, could you maybe help with that if possible?

Also covered in the next section, but yes, I will help with testing.

General Questions

  1. Should class properties be accessible on the other side too? (This would mean they'd be "streamed" to the other side but could only support basic types) - I don't think this should be done

Nope, stripping them out of the types like you did is fine... The other option would be to convert it into an async function and return the value that way? Doesn't seem super useful to me.


Alright, I like where this is headed, but there's one thing that stands out to me. Seems like you're struggling on how to incorporate the different types of messaging (ports vs one time requests vs native etc). Adding flags to "enable" each method is one approach, but I don't think it scales well. The number of if-statements will scale exponentially in this case.

This is something I also struggled with in @webext-core/messaging, and my solution is a spaghetti nightmare lol. Every time I look at that code, I cringe.

So after I saw some of your messages from the last few days, I spent a long time today thinking about how to handle this problem.

Let's consider at the RPC API as an example. Right now, your code supports proxy-ing some service in the background to a HTML page or content script. What if someone wants to proxy the extension API from a content script to a main world content script? Or proxy native code to the background? Or worst case, they want to call native code inside a main world content script lol. These are things that are missing from @webext-core/proxy-service, your code, and other RPC like libraries trying to accomplish the same thing.

We basically need to pass in a custom communication layer that the RPC APIs will use to send messages. Then the user could choose which comunication method to use, and we don't have any if-statements in our code.

I just pushed a new folder to this branch. It re-imagines what an RPC library would look like based on this concept of communication layers.

https://github.com/Timeraa/wxt/tree/wxt-messaging/packages/messaging-aaron

Main thing to look at is the MessageTransport interface. Each communication method (native, one-time message, ports, window, external, custom events, user scripts, etc) will have an implementation for this interface, and thus can be hot-swapped when necessary.

Each transport implementation is responsible for sending and receiving messages, as well as handling errors.

At this point, I've done 3 things:

  1. Define the MessageTransport interface
  2. Implement an example RPC module based on the transports interface and supports all the use-cases I mentioned above
  3. Implement a unit test friendly version of the MessageTransport to test the RPC module

Note: I have not implemented any of the real transports using the extension API, like ports or one-time messages or native yet. Because with the interface, I didn't need to implement them to know that the RPC module will work - the testing implementation is enough. As for writing tests for the other transports... I can help with that.

I'd love to have your thoughts on this. I didn't talk about the other two APIs (distributed and event-based), but I designed the transport interrface to work for both of those as well. This post will be 3x longer if I go into that detail now...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I added a wxt/util export for a useful function imo, is that okay to keep there?

Add utils like this to wxt/client if it uses the extension API or wxt/sandbox if it doesn't. For example, the createShadowRootUi is imported from wxt/client, while the MatchPattern class is imported from wxt/sandbox.

This was originally done so the polyfill isn't ran anywhere that doesn't support it. However, with the move away from the polyfill, I'll likely simplify this setup and export everything from wxt/client in the future.

But I digress, this is a great function! Could also be as simple as checking if location.pathname contains "background" somehow...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I had some issues with just checking location.pathname in other browsers, would need to test that in depth at some point, thanks for the heads up on the first part!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want the messaging APIs to be a separate NPM pacakge, I think it would be neat to make them available to everyone to use, not just to people using WXT.

But I also maybe want to include them in WXT by default. In that case, this file would become:

export * from '@wxt-dev/messaging/rpc';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, abstracting it so we can make it a different module defo solves a lot of issues mentioned above, I'll rework it.

@Timeraa
Copy link
Contributor Author

Timeraa commented Aug 4, 2024

I like your abstraction approach, should've thought of that earlier tbh! I need that brain of yours

I'll implement it the way you suggested it, I don't see any issues with it.

The only thing is that if you want to send something from the injected frame > background you'd need an in between proxy but I think that should be fine and makes it logically followable (Which is really good especially because extensions can be a huge brainfuck with messaging in different contexts), just needs to be documented. I think you already did that with the message bridge, I'm half asleep already

@Timeraa
Copy link
Contributor Author

Timeraa commented Aug 4, 2024

in the MessageTransport I added a async close() because for the port transport I have to close the ports I opened somehow on demand, for example if I use connect from a popup to a tab for example. What would the sentAt be used for btw?

Copy link
Collaborator

@aklinker1 aklinker1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing is that if you want to send something from the injected frame > background you'd need an in between proxy but I think that should be fine and makes it logically followable (Which is really good especially because extensions can be a huge brainfuck with messaging in different contexts), just needs to be documented. I think you already did that with the message bridge, I'm half asleep already

Message bridge is separate, it's meant for the "decentralized" approach, though maybe it could be used by the RPC api. I was thinking you should chain the proxies together:

// background
const myService = createRpcProxy<MyService>("my-service", createNativeTransport());
registerRpcService(myService);
// content script
const myService = createRpcProxy<MyService>("my-service"); // Use ports to send to background
registerRpcService(myService);
// main world
const myService = createRpcProxy<MyService>("my-service", createExternalMessageTransport())

myService.xyz();

But maybe something like this would be much simpler...

// background
bridge.init(); // or just import the object created by `createMessageBridge()`?
// content script
bridge.init(); // or just import the object created by `createMessageBridge()`?
// main world
const myService = createRpcProxy<MyService>("my-service", { target: "native" });

myService.xyz();

What would the sentAt be used for btw?

Just debugging purposes. Could probably be removed. I figured it would be a nice-to-have for some kind of devtools UI eventually.

packages/messaging/package.json Outdated Show resolved Hide resolved
packages/messaging-aaron/package.json Outdated Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants