Add Web bridge & Update

This commit is contained in:
johnathan 2025-06-23 15:32:26 +08:00
parent 6645cd2b75
commit 2d29e5c95a
48 changed files with 7478 additions and 109 deletions

View File

@ -46,6 +46,7 @@
"esno": "^4.8.0",
"exceljs": "^4.4.0",
"fs-extra": "^11.2.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"jsdom": "^26.0.0",
"kolorist": "^1.8.0",
@ -65,8 +66,8 @@
"vue-demi": "^0.14.10",
"vue-router": "^4.5.1",
"web-ext": "^8.5.0",
"webext-bridge": "^6.0.1",
"webextension-polyfill": "^0.12.0"
"webextension-polyfill": "^0.12.0",
"webext-bridge": "link:webext-bridge"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"

82
pnpm-lock.yaml generated
View File

@ -58,6 +58,9 @@ importers:
fs-extra:
specifier: ^11.2.0
version: 11.2.0
html-to-image:
specifier: ^1.11.13
version: 1.11.13
husky:
specifier: ^9.1.7
version: 9.1.7
@ -116,8 +119,8 @@ importers:
specifier: ^8.5.0
version: 8.5.0
webext-bridge:
specifier: ^6.0.1
version: 6.0.1
specifier: link:webext-bridge
version: link:webext-bridge
webextension-polyfill:
specifier: ^0.12.0
version: 0.12.0
@ -1373,12 +1376,6 @@ packages:
integrity: sha512-xPTFWwQ8BxPevPF2IKsf4hpZNss4LxaOLZXypQH4E63BDLmcwX/RMGdI4tB4VO4Nb6xDBH3F/p4gz4wvof1o9w==,
}
'@types/webextension-polyfill@0.8.3':
resolution:
{
integrity: sha512-GN+Hjzy9mXjWoXKmaicTegv3FJ0WFZ3aYz77Wk8TMp1IY3vEzvzj1vnsa0ggV7vMI1i+PUxe4qqnIJKCzf9aTg==,
}
'@types/yauzl@2.10.3':
resolution:
{
@ -3456,6 +3453,12 @@ packages:
}
engines: { node: '>=18' }
html-to-image@1.11.13:
resolution:
{
integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==,
}
htmlparser2@8.0.2:
resolution:
{
@ -4496,13 +4499,6 @@ packages:
peerDependencies:
vue: ^3.0.0
nanoevents@6.0.2:
resolution:
{
integrity: sha512-FRS2otuFcPPYDPYViNWQ42+1iZqbXydinkRHTHFxrF4a1CpBfmydR9zkI44WSXAXCyPrkcGtPk5CnpW6Y3lFKQ==,
}
engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 }
nanoid@3.3.11:
resolution:
{
@ -5567,13 +5563,6 @@ packages:
engines: { node: '>=10' }
hasBin: true
serialize-error@9.1.1:
resolution:
{
integrity: sha512-6uZQLGyUkNA4N+Zii9fYukmNu9PEA1F5rqcwXzN/3LtBjwl2dFBbVZ1Zyn08/CGkB4H440PIemdOQBt1Wvjbrg==,
}
engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 }
set-function-length@1.2.2:
resolution:
{
@ -6032,12 +6021,6 @@ packages:
integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==,
}
tiny-uid@1.1.2:
resolution:
{
integrity: sha512-0beRFXR+fv4C40ND2PqgNjq6iyB1dKXciKJjslLw0kPYCcR82aNd2b+Tt2yy06LimIlvtoehgvrm/fUZCutSfg==,
}
tinybench@2.9.0:
resolution:
{
@ -6166,13 +6149,6 @@ packages:
}
engines: { node: '>=10' }
type-fest@2.19.0:
resolution:
{
integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==,
}
engines: { node: '>=12.20' }
type-fest@3.13.1:
resolution:
{
@ -6573,24 +6549,12 @@ packages:
engines: { node: '>=18.0.0', npm: '>=8.0.0' }
hasBin: true
webext-bridge@6.0.1:
resolution:
{
integrity: sha512-GruIrN+vNwbxVCi8UW4Dqk5YkcGA9V0ZfJ57jXP9JXHbrsDs5k2N6NNYQR5e+wSCnQpGYOGAGihwUpKlhg8QIw==,
}
webextension-polyfill@0.12.0:
resolution:
{
integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==,
}
webextension-polyfill@0.9.0:
resolution:
{
integrity: sha512-LTtHb0yR49xa9irkstDxba4GATDAcDw3ncnFH9RImoFwDlW47U95ME5sn5IiQX2ghfaECaf6xyXM8yvClIBkkw==,
}
webidl-conversions@7.0.0:
resolution:
{
@ -7522,8 +7486,6 @@ snapshots:
'@types/webextension-polyfill@0.12.1': {}
'@types/webextension-polyfill@0.8.3': {}
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 22.10.5
@ -8962,6 +8924,8 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
html-to-image@1.11.13: {}
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@ -9526,8 +9490,6 @@ snapshots:
vue: 3.5.13(typescript@5.8.2)
vueuc: 0.4.64(vue@3.5.13(typescript@5.8.2))
nanoevents@6.0.2: {}
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
@ -10143,10 +10105,6 @@ snapshots:
semver@7.7.1: {}
serialize-error@9.1.1:
dependencies:
type-fest: 2.19.0
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@ -10416,8 +10374,6 @@ snapshots:
through@2.3.8: {}
tiny-uid@1.1.2: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@ -10474,8 +10430,6 @@ snapshots:
type-fest@0.20.2: {}
type-fest@2.19.0: {}
type-fest@3.13.1: {}
type-fest@4.32.0: {}
@ -10813,18 +10767,8 @@ snapshots:
- supports-color
- utf-8-validate
webext-bridge@6.0.1:
dependencies:
'@types/webextension-polyfill': 0.8.3
nanoevents: 6.0.2
serialize-error: 9.1.1
tiny-uid: 1.1.2
webextension-polyfill: 0.9.0
webextension-polyfill@0.12.0: {}
webextension-polyfill@0.9.0: {}
webidl-conversions@7.0.0: {}
webpack-virtual-modules@0.6.2: {}

11
shim.d.ts vendored
View File

@ -4,6 +4,17 @@ declare module 'webext-bridge' {
export interface ProtocolMap {
// define message protocol types
// see https://github.com/antfu/webext-bridge#type-safe-protocols
'html-to-image': ProtocolWithReturn<
| {
type: 'CSS';
selector: string;
}
| {
type: 'XPath';
xpath: string;
},
{ b64: string }
>;
}
}

View File

@ -1,3 +1,6 @@
// https://github.com/serversideup/webext-bridge/issues/67#issuecomment-2676094094
import('webext-bridge/background');
// only on dev mode
if (import.meta.hot) {
// @ts-expect-error for background HMR

View File

@ -24,7 +24,7 @@ const message = useMessage();
const formItemRef = useTemplateRef('detail-form-item');
const formItemRule: FormItemRule = {
required: true,
trigger: ['submit', 'blur'],
trigger: ['submit'],
message: props.validateMessage,
validator: () => {
return props.matchPattern.exec(modelValue.value) !== null;

View File

@ -0,0 +1,13 @@
import { toPng } from 'html-to-image';
import { onMessage } from 'webext-bridge/content-script';
onMessage('html-to-image', async (ev) => {
const params = ev.data;
const targetNode =
params.type == 'CSS'
? document.querySelector<HTMLElement>(params.selector)!
: (document.evaluate(params.xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE)
.singleNodeValue as HTMLElement);
const imgData = await toPng(targetNode);
return { b64: imgData };
});

View File

@ -1,2 +1,4 @@
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
(() => {})();
(() => {
import('./html-to-image');
})();

View File

@ -43,21 +43,16 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
private async wanderSearchSinglePage(tab: Tabs.Tab) {
const injector = new AmazonSearchPageInjector(tab);
// #region Wait for the Next button to appear, indicating that the product items have finished loading
// Wait for the Next button to appear, indicating that the product items have finished loading
await injector.waitForPageLoaded();
// #endregion
// #region Determine the type of product search page https://github.com/primedigitaltech/azon_seeker/issues/1
// Determine the type of product search page https://github.com/primedigitaltech/azon_seeker/issues/1
const pagePattern = await injector.getPagePattern();
// #endregion
// #region Retrieve key nodes and their information from the critical product search page
// Retrieve key nodes and their information from the critical product search page
const data = await injector.getPageData(pagePattern);
// #endregion
// #region get current page
// get current page
const page = await injector.getCurrentPage();
// #endregion
// #region Determine if it is the last page, otherwise navigate to the next page
// Determine if it is the last page, otherwise navigate to the next page
const hasNextPage = await injector.determineHasNextPage();
// #endregion
await new Promise((resolve) => setTimeout(resolve, 1000));
if (data === null || typeof hasNextPage !== 'boolean') {
this.channel.emit('error', { message: '爬取单页信息失败', url: tab.url });
@ -135,21 +130,13 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
//#endregion
//#region Fetch Base Info
const baseInfo = await injector.getBaseInfo();
const ratingInfo = await injector.getRatingInfo();
this.channel.emit('item-base-info-collected', {
asin: params.asin,
title: baseInfo.title,
price: baseInfo.price,
...baseInfo,
...ratingInfo,
});
//#endregion
//#region Fetch Rating Info
const ratingInfo = await injector.getRatingInfo();
if (ratingInfo && (ratingInfo.rating !== 0 || ratingInfo.ratingCount !== 0)) {
this.channel.emit('item-rating-collected', {
asin: params.asin,
...ratingInfo,
});
}
//#endregion
//#region Fetch Category Rank Info
let rawRankingText: string | null = await injector.getRankText();
if (rawRankingText) {
@ -194,6 +181,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
// topReviews: reviews,
// });
//#endregion
// #region Get APlus Sreen shot
await injector.scanAPlus();
// #endregion
}
@withErrorHandling

View File

@ -47,11 +47,10 @@ interface AmazonPageWorkerEvents {
/**
* The event is fired when worker collected goods' base info on the Amazon detail page.
*/
['item-base-info-collected']: Pick<AmazonDetailItem, 'asin' | 'title' | 'price'>;
/**
* The event is fired when worker collected goods' rating on the Amazon detail page.
*/
['item-rating-collected']: Pick<AmazonDetailItem, 'asin' | 'rating' | 'ratingCount'>;
['item-base-info-collected']: Pick<
AmazonDetailItem,
'asin' | 'title' | 'price' | 'rating' | 'ratingCount'
>;
/**
* The event is fired when worker
*/

View File

@ -299,6 +299,24 @@ export class AmazonDetailPageInjector extends BaseInjector {
return items;
});
}
public async scanAPlus() {
return this.run(async () => {
const aplusEl = document.querySelector<HTMLElement>('#aplus')!;
while (aplusEl.getClientRects().length === 0) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
aplusEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
while (true) {
const rect = aplusEl.getClientRects()[0];
if (rect.top + rect.height < 100) {
break;
}
window.scrollBy({ top: 100, behavior: 'smooth' });
await new Promise((resolve) => setTimeout(resolve, 100 + ~~(100 * Math.random())));
}
});
}
}
export class AmazonReviewPageInjector extends BaseInjector {

View File

@ -369,6 +369,10 @@ const handleClearData = async () => {
</template>
<style lang="scss" scoped>
.result-table {
width: 100%;
}
:deep(.filter-switch) {
font-size: 15px;
}

View File

@ -39,16 +39,7 @@ worker.channel.on('item-base-info-collected', (ev) => {
type: 'success',
title: `商品${ev.asin}基本信息`,
time: new Date().toLocaleString(),
content: `标题: ${ev.title};价格:${ev.price}`,
});
updateDetailItems(ev);
});
worker.channel.on('item-rating-collected', (ev) => {
timelines.value.push({
type: 'success',
title: `商品${ev.asin}评价信息`,
time: new Date().toLocaleString(),
content: `评分: ${ev.rating};评价数:${ev.ratingCount}`,
content: `标题: ${ev.title};价格:${ev.price}; 评分: ${ev.rating}; 评论数: ${ev.ratingCount}`,
});
updateDetailItems(ev);
});

View File

@ -0,0 +1,2 @@
dist
node_modules

View File

@ -0,0 +1,3 @@
{
"extends": ["@antfu"]
}

View File

@ -0,0 +1,41 @@
name: "\U0001F41B Bug Report"
description: "You found a bug in the code \U0001F914"
labels: ['🧐 Bug: Needs Confirmation']
body:
- type: input
attributes:
label: Version
validations:
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
validations:
required: true
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@ -0,0 +1,13 @@
blank_issues_enabled: false
contact_links:
- name: ❓ Support Question
url: https://github.com/serversideup/webext-bridge/discussions/75
about: Get friendly support from the community on our forum.
- name: ✨ Request a feature
url: https://github.com/serversideup/webext-bridge/discussions/76
about: Learn how to request a new feature.
- name: 🤵 Get Professional Support & Customizations
url: https://serversideup.net/get-help/?quick_question=webext-bridge
about: Skip the line and get priority support directly from the creators of webext-bridge.

132
webext-bridge/.github/code_of_conduct.md vendored Normal file
View File

@ -0,0 +1,132 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD].
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

BIN
webext-bridge/.github/header.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,28 @@
name: Generate Sponsors README
on:
workflow_dispatch:
schedule:
- cron: 30 15 * * 0-6
jobs:
deploy:
runs-on: ubuntu-22.04
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@v1
with:
organization: true
maximum: 500
fallback: '<p align="center"><a href="https://github.com/sponsors/serversideup"><img src="https://521public.s3.amazonaws.com/serversideup/sponsors/sponsor-empty-state.png" alt="Sponsors"></a></p>'
token: ${{ secrets.SPONSORS_README_ACTION_PERSONAL_ACCESS_TOKEN }}
marker: 'supporters'
template: '<a href="https://github.com/{{{ login }}}"><img src="https://github.com/{{{ login }}}.png" width="40px" alt="{{{ login }}}" /></a>&nbsp;&nbsp;'
file: 'README.md'
- name: Deploy to GitHub Pages 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: main
folder: '.'

61
webext-bridge/.gitignore vendored Normal file
View File

@ -0,0 +1,61 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# transpiled output
dist

2
webext-bridge/.npmignore Normal file
View File

@ -0,0 +1,2 @@
tsconfig.json
tslint.json

View File

@ -0,0 +1,27 @@
# Changelog
## v6.0.0
This revision is primarily focused on codebase improvements all around. The code should be now much
more readable and easy to comprehend as the responsibilities have been split into smaller pieces and
composed as needed by each runtime context.
For end users of the library, the breaking changes aren't that "breaking", they'll just need to do a
bit of import restructring. The API behaviour is mostly unchanged, with just minor exceptions.
### Breaking changes
- Runtime context is no longer automatically detected by `webext-bridge`. You must import the relevant part yourself depending on the context, eg: `import Bridge from 'webext-bridge/window'`
for a script that'll be running in the Window context. Learn more about the change [here](https://github.com/zikaari/crx-bridge/issues/11).
- `setNamespace` is not available in any context except `window`, and `allowWindowMessaging` is not available in any context except `content-script`.
- `getCurrentContext` export has been removed.
- `isInternalEndpoint` returns `true` for some new contexts. In summary it'll be `true` for `background`, `content-script`, `devtools`, `popup`, and `options`.
- For messages sent from `background`, message queuing feature can no longer be trusted due to manifest v3 terminating the service worker runtime after certain time. The queue of messages
sent from `background` will be disposed off along with the termination of the said service worker. Queuing still works for messages sent from all other contexts.
### Fixes
- Fixed an issue with messages sometimes not reaching `content-script` or `window` when being sent by some other context right after a tab had navigated forward or back. This was caused by old port's
`onDisconnect` callback being called _after_ the new port's `onConnect` callback. The `onDisconnect` would then remove the port mapping preventing messages from being routed to `content-script` or `window`.
- If the message recipient terminates _(tab closure for example)_ before replying to the sender, the sender will be notified about the session termination instead of it waiting indefinetly for a response
that's never coming back. Now, the `sendMessage` call in the sender will reject with an error.

21
webext-bridge/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Server Side Up
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

95
webext-bridge/README.md Normal file
View File

@ -0,0 +1,95 @@
<p align="center">
<a href="https://serversideup.net/open-source/webext-bridge/"><img src="./.github/header.png" width="1200" alt="webext-bridge Header" /></a>
</p>
<p align="center">
<a href="https://actions-badge.atrox.dev/serversideup/webext-bridge/goto?ref=main"><img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fserversideup%2Fwebext-bridge%2Fbadge%3Fref%3Dmain&style=flat" /></a>
<a href="https://github.com/serversideup/webext-bridge/blob/main/LICENSE" target="_blank"><img src="https://badgen.net/github/license/serversideup/webext-bridge" alt="License"></a>
<a href="https://github.com/sponsors/serversideup"><img src="https://badgen.net/badge/icon/Support%20Us?label=GitHub%20Sponsors&color=orange" alt="Support us"></a>
<br />
<a href="https://www.npmjs.com/package/webext-bridge"><img alt="npm" src="https://img.shields.io/npm/dm/webext-bridge?color=red&label=downloads&logo=npm"></a>
<a href="https://www.npmjs.com/package/webext-bridge"><img alt="npm" src="https://img.shields.io/npm/v/webext-bridge?color=2B90B6&label=Latest"></a>
<a href="https://serversideup.net/discord"><img alt="Discord" src="https://img.shields.io/discord/910287105714954251?color=blueviolet"></a>
</p>
> [!IMPORTANT]
> **`webext-bridge` just joined the Server Side Up family of open source projects.** [Read the announcement →](https://github.com/serversideup/webext-bridge/discussions/74)
# Introduction
**Messaging in web extensions made easy. Batteries included.** Reduce headache and simplify the effort of keeping data in sync across different parts of your extension. `webext-bridge` is a tiny library that provides a simple and consistent API for sending and receiving messages between different parts of your web extension, such as `background`, `content-script`, `devtools`, `popup`, `options`, and `window` contexts.
## Resources
<!-- - **[Website](https://serversideup.net/open-source/webext-bridge/)** overview of the product.
- **[Docs](https://serversideup.net/open-source/webext-bridge/docs)** for a deep-dive on how to use the product. -->
- **[Discord](https://serversideup.net/discord)** for friendly support from the community and the team.
- **[GitHub](https://github.com/serversideup/webext-bridge)** for source code, bug reports, and project management.
- **[Get Professional Help](https://serversideup.net/professional-support)** - Get video + screen-sharing help directly from the core contributors.
## Contributing
As an open-source project, we strive for transparency and collaboration in our development process. We greatly appreciate any contributions members of our community can provide. Whether you're fixing bugs, proposing features, improving documentation, or spreading awareness - your involvement strengthens the project. Please review our [contribution guidelines](https://serversideup.net/open-source/webext-bridge/docs/community/contributing) and [code of conduct](./.github/code_of_conduct.md) to understand how we work together respectfully.
- **Bug Report**: If you're experiencing an issue while using these images, please [create an issue](https://github.com/serversideup/webext-bridge/issues/new/choose).
- **Feature Request**: Make this project better by [submitting a feature request](https://github.com/serversideup/webext-bridge/discussions/76).
- **Documentation**: Improve our documentation by [submitting a documentation change](./docs/README.md).
- **Community Support**: Help others on [GitHub Discussions](https://github.com/serversideup/webext-bridge/discussions) or [Discord](https://serversideup.net/discord).
- **Security Report**: Report critical security issues via [our responsible disclosure policy](https://www.notion.so/Responsible-Disclosure-Policy-421a6a3be1714d388ebbadba7eebbdc8).
Need help getting started? Join our Discord community and we'll help you out!
<a href="https://serversideup.net/discord"><img src="https://serversideup.net/wp-content/themes/serversideup/images/open-source/join-discord.svg" title="Join Discord"></a>
## Our Sponsors
All of our software is free an open to the world. None of this can be brought to you without the financial backing of our sponsors.
<p align="center"><a href="https://github.com/sponsors/serversideup"><img src="https://521public.s3.amazonaws.com/serversideup/sponsors/sponsor-box.png" alt="Sponsors"></a></p>
#### Individual Supporters
<!-- supporters --><a href="https://github.com/GeekDougle"><img src="https://github.com/GeekDougle.png" width="40px" alt="GeekDougle" /></a>&nbsp;&nbsp;<a href="https://github.com/JQuilty"><img src="https://github.com/JQuilty.png" width="40px" alt="JQuilty" /></a>&nbsp;&nbsp;<a href="https://github.com/MaltMethodDev"><img src="https://github.com/MaltMethodDev.png" width="40px" alt="MaltMethodDev" /></a>&nbsp;&nbsp;<a href="https://github.com/harrisonratcliffe"><img src="https://github.com/harrisonratcliffe.png" width="40px" alt="harrisonratcliffe" /></a>&nbsp;&nbsp;<!-- supporters -->
## About Us
We're [Dan](https://twitter.com/danpastori) and [Jay](https://twitter.com/jaydrogers) - a two person team with a passion for open source products. We created [Server Side Up](https://serversideup.net) to help share what we learn.
<div align="center">
| <div align="center">Dan Pastori</div> | <div align="center">Jay Rogers</div> |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| <div align="center"><a href="https://twitter.com/danpastori"><img src="https://serversideup.net/wp-content/uploads/2023/08/dan.jpg" title="Dan Pastori" width="150px"></a><br /><a href="https://twitter.com/danpastori"><img src="https://serversideup.net/wp-content/themes/serversideup/images/open-source/twitter.svg" title="Twitter" width="24px"></a><a href="https://github.com/danpastori"><img src="https://serversideup.net/wp-content/themes/serversideup/images/open-source/github.svg" title="GitHub" width="24px"></a></div> | <div align="center"><a href="https://twitter.com/jaydrogers"><img src="https://serversideup.net/wp-content/uploads/2023/08/jay.jpg" title="Jay Rogers" width="150px"></a><br /><a href="https://twitter.com/jaydrogers"><img src="https://serversideup.net/wp-content/themes/serversideup/images/open-source/twitter.svg" title="Twitter" width="24px"></a><a href="https://github.com/jaydrogers"><img src="https://serversideup.net/wp-content/themes/serversideup/images/open-source/github.svg" title="GitHub" width="24px"></a></div> |
</div>
### Find us at:
- **📖 [Blog](https://serversideup.net)** - Get the latest guides and free courses on all things web/mobile development.
- **🙋 [Community](https://community.serversideup.net)** - Get friendly help from our community members.
- **🤵‍♂️ [Get Professional Help](https://serversideup.net/professional-support)** - Get video + screen-sharing support from the core contributors.
- **💻 [GitHub](https://github.com/serversideup)** - Check out our other open source projects.
- **📫 [Newsletter](https://serversideup.net/subscribe)** - Skip the algorithms and get quality content right to your inbox.
- **🐥 [Twitter](https://twitter.com/serversideup)** - You can also follow [Dan](https://twitter.com/danpastori) and [Jay](https://twitter.com/jaydrogers).
- **❤️ [Sponsor Us](https://github.com/sponsors/serversideup)** - Please consider sponsoring us so we can create more helpful resources.
## Our products
If you appreciate this project, be sure to check out our other projects.
### 📚 Books
- **[The Ultimate Guide to Building APIs & SPAs](https://serversideup.net/ultimate-guide-to-building-apis-and-spas-with-laravel-and-nuxt3/)**: Build web & mobile apps from the same codebase.
- **[Building Multi-Platform Browser Extensions](https://serversideup.net/building-multi-platform-browser-extensions/)**: Ship extensions to all browsers from the same codebase.
### 🛠️ Software-as-a-Service
- **[Bugflow](https://bugflow.io/)**: Get visual bug reports directly in GitHub, GitLab, and more.
- **[SelfHost Pro](https://selfhostpro.com/)**: Connect Stripe or Lemonsqueezy to a private docker registry for self-hosted apps.
### 🌍 Open Source
- **[serversideup/php Docker Images](https://serversideup.net/open-source/docker-php/)**: PHP Docker images optimized for Laravel and running PHP applications in production.
- **[Financial Freedom](https://github.com/serversideup/financial-freedom)**: Open source alternative to Mint, YNAB, & Monarch Money.
- **[AmplitudeJS](https://521dimensions.com/open-source/amplitudejs)**: Open-source HTML5 & JavaScript Web Audio Library.
- **[webext-bridge](https://github.com/serversideup/webext-bridge)**: Messaging in web extensions made easy. Batteries included.

119
webext-bridge/package.json Normal file
View File

@ -0,0 +1,119 @@
{
"name": "webext-bridge",
"version": "6.0.1",
"description": "Messaging in Web Extensions made easy. Out of the box.",
"keywords": [
"chrome",
"extension",
"messaging",
"communication",
"protocol",
"content",
"background",
"devtools",
"script",
"crx",
"bridge"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/benmo1602/webext-bridge.git"
},
"author": "Neek Sandhu <neek.sandhu@outlook.com>",
"scripts": {
"build": "tsup src/index.ts src/background.ts src/content-script.ts src/devtools.ts src/options.ts src/popup.ts src/window.ts src/sidepanel.ts --format esm,cjs --dts",
"watch": "npm run build -- --watch",
"release": "bumpp --commit --push --tag && npm run build && npm publish"
},
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./background": {
"import": "./dist/background.js",
"require": "./dist/background.cjs"
},
"./content-script": {
"import": "./dist/content-script.js",
"require": "./dist/content-script.cjs"
},
"./devtools": {
"import": "./dist/devtools.js",
"require": "./dist/devtools.cjs"
},
"./options": {
"import": "./dist/options.js",
"require": "./dist/options.cjs"
},
"./popup": {
"import": "./dist/popup.js",
"require": "./dist/popup.cjs"
},
"./window": {
"import": "./dist/window.js",
"require": "./dist/window.cjs"
},
"./sidepanel": {
"import": "./dist/sidepanel.js",
"require": "./dist/sidepanel.cjs"
}
},
"typesVersions": {
"*": {
"*": [
"dist/index.d.ts"
],
"background": [
"dist/background.d.ts"
],
"content-script": [
"dist/content-script.d.ts"
],
"devtools": [
"dist/devtools.d.ts"
],
"options": [
"dist/options.d.ts"
],
"popup": [
"dist/popup.d.ts"
],
"window": [
"dist/window.d.ts"
],
"sidepanel": [
"dist/sidepanel.d.ts"
]
}
},
"files": [
"README.md",
"package.json",
"dist/**/*"
],
"bugs": {
"url": "https://github.com/zikaari/webext-bridge/issues"
},
"homepage": "https://github.com/zikaari/webext-bridge#readme",
"dependencies": {
"@types/webextension-polyfill": "^0.8.3",
"nanoevents": "^6.0.2",
"serialize-error": "^9.0.0",
"tiny-uid": "^1.1.1",
"webextension-polyfill": "^0.9.0"
},
"devDependencies": {
"@antfu/eslint-config": "^0.16.1",
"@types/node": "^17.0.16",
"@typescript-eslint/eslint-plugin": "^5.11.0",
"@typescript-eslint/parser": "^5.11.0",
"bumpp": "^7.1.1",
"eslint": "^8.8.0",
"tsup": "^5.11.13",
"type-fest": "^2.11.1",
"typescript": "^4.5.5"
}
}

5463
webext-bridge/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,326 @@
import browser from 'webextension-polyfill';
import type { Runtime } from 'webextension-polyfill';
import { createEndpointRuntime } from './internal/endpoint-runtime';
import { formatEndpoint, parseEndpoint } from './internal/endpoint';
import { createStreamWirings } from './internal/stream';
import type { EndpointFingerprint } from './internal/endpoint-fingerprint';
import { createFingerprint } from './internal/endpoint-fingerprint';
import { decodeConnectionArgs } from './internal/connection-args';
import type { DeliveryReceipt } from './internal/delivery-logger';
import { createDeliveryLogger } from './internal/delivery-logger';
import type { RequestMessage } from './internal/port-message';
import { PortMessage } from './internal/port-message';
import type { InternalMessage } from './types';
interface PortConnection {
port: Runtime.Port;
fingerprint: EndpointFingerprint;
}
const pendingResponses = createDeliveryLogger();
const connMap = new Map<string, PortConnection>();
const oncePortConnectedCbs = new Map<string, Set<() => void>>();
const onceSessionEndCbs = new Map<EndpointFingerprint, Set<() => void>>();
const oncePortConnected = (endpointName: string, cb: () => void) => {
oncePortConnectedCbs.set(
endpointName,
(oncePortConnectedCbs.get(endpointName) || new Set()).add(cb),
);
return () => {
const su = oncePortConnectedCbs.get(endpointName);
if (su?.delete(cb) && su?.size === 0) oncePortConnectedCbs.delete(endpointName);
};
};
const onceSessionEnded = (sessionFingerprint: EndpointFingerprint, cb: () => void) => {
onceSessionEndCbs.set(
sessionFingerprint,
(onceSessionEndCbs.get(sessionFingerprint) || new Set()).add(cb),
);
};
const notifyEndpoint = (endpoint: string) => ({
withFingerprint: (fingerprint: EndpointFingerprint) => {
const nextChain = <T>(v: T) => ({ and: () => v });
const notifications = {
aboutIncomingMessage: (message: InternalMessage) => {
const recipient = connMap.get(endpoint);
PortMessage.toExtensionContext(recipient.port, {
status: 'incoming',
message,
});
return nextChain(notifications);
},
aboutSuccessfulDelivery: (receipt: DeliveryReceipt) => {
const sender = connMap.get(endpoint);
PortMessage.toExtensionContext(sender.port, {
status: 'delivered',
receipt,
});
return nextChain(notifications);
},
aboutMessageUndeliverability: (resolvedDestination: string, message: InternalMessage) => {
const sender = connMap.get(endpoint);
if (sender?.fingerprint === fingerprint) {
PortMessage.toExtensionContext(sender.port, {
status: 'undeliverable',
resolvedDestination,
message,
});
}
return nextChain(notifications);
},
whenDeliverableTo: (targetEndpoint: string) => {
const notifyDeliverability = () => {
const origin = connMap.get(endpoint);
if (origin?.fingerprint === fingerprint && connMap.has(targetEndpoint)) {
PortMessage.toExtensionContext(origin.port, {
status: 'deliverable',
deliverableTo: targetEndpoint,
});
return true;
}
};
if (!notifyDeliverability()) {
const unsub = oncePortConnected(targetEndpoint, notifyDeliverability);
onceSessionEnded(fingerprint, unsub);
}
return nextChain(notifications);
},
aboutSessionEnded: (endedSessionFingerprint: EndpointFingerprint) => {
const conn = connMap.get(endpoint);
if (conn?.fingerprint === fingerprint) {
PortMessage.toExtensionContext(conn.port, {
status: 'terminated',
fingerprint: endedSessionFingerprint,
});
}
return nextChain(notifications);
},
};
return notifications;
},
});
const sessFingerprint = createFingerprint();
const endpointRuntime = createEndpointRuntime(
'background',
(message) => {
if (
message.origin.context === 'background' &&
['content-script', 'devtools '].includes(message.destination.context) &&
!message.destination.tabId
) {
throw new TypeError(
'When sending messages from background page, use @tabId syntax to target specific tab',
);
}
const resolvedSender = formatEndpoint({
...message.origin,
...(message.origin.context === 'window' && { context: 'content-script' }),
});
const resolvedDestination = formatEndpoint({
...message.destination,
...(message.destination.context === 'window' && {
context: 'content-script',
}),
tabId: message.destination.tabId || message.origin.tabId,
});
// downstream endpoints are agnostic of these attributes, presence of these attrs will make them think the message is not intended for them
message.destination.tabId = null;
message.destination.frameId = null;
const dest = () => connMap.get(resolvedDestination);
const sender = () => connMap.get(resolvedSender);
const deliver = () => {
notifyEndpoint(resolvedDestination)
.withFingerprint(dest().fingerprint)
.aboutIncomingMessage(message);
const receipt: DeliveryReceipt = {
message,
to: dest().fingerprint,
from: {
endpointId: resolvedSender,
fingerprint: sender()?.fingerprint,
},
};
if (message.messageType === 'message') pendingResponses.add(receipt);
if (message.messageType === 'reply') pendingResponses.remove(message.messageID);
if (sender()) {
notifyEndpoint(resolvedSender)
.withFingerprint(sender().fingerprint)
.aboutSuccessfulDelivery(receipt);
}
};
if (dest()?.port) {
deliver();
} else if (message.messageType === 'message') {
if (message.origin.context === 'background') {
oncePortConnected(resolvedDestination, deliver);
} else if (sender()) {
notifyEndpoint(resolvedSender)
.withFingerprint(sender().fingerprint)
.aboutMessageUndeliverability(resolvedDestination, message)
.and()
.whenDeliverableTo(resolvedDestination);
}
}
},
(message) => {
const resolvedSender = formatEndpoint({
...message.origin,
...(message.origin.context === 'window' && { context: 'content-script' }),
});
const sender = connMap.get(resolvedSender);
const receipt: DeliveryReceipt = {
message,
to: sessFingerprint,
from: {
endpointId: resolvedSender,
fingerprint: sender.fingerprint,
},
};
notifyEndpoint(resolvedSender)
.withFingerprint(sender.fingerprint)
.aboutSuccessfulDelivery(receipt);
},
);
browser.runtime.onConnect.addListener((incomingPort) => {
const connArgs = decodeConnectionArgs(incomingPort.name);
if (!connArgs) return;
// all other contexts except 'content-script' are aware of, and pass their identity as name
connArgs.endpointName ||= formatEndpoint({
context: 'content-script',
tabId: incomingPort.sender.tab.id,
frameId: incomingPort.sender.frameId,
});
// For sidepanel, popup, options and devtools, we need the tab ID of the current tab
if (
['sidepanel', 'popup', 'options', 'devtools'].includes(
parseEndpoint(connArgs.endpointName).context,
)
) {
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
if (tabs[0]) {
const activeTabId = tabs[0].id;
// Update the endpoint name with the active tab ID for sidepanel
if (
parseEndpoint(connArgs.endpointName).context === 'sidepanel' &&
parseEndpoint(connArgs.endpointName).tabId == null
) {
connArgs.endpointName = formatEndpoint({
context: 'sidepanel',
tabId: activeTabId,
});
}
}
});
}
// literal tab id in case of content script, however tab id of inspected page in case of devtools context
const { tabId: linkedTabId, frameId: linkedFrameId } = parseEndpoint(connArgs.endpointName);
connMap.set(connArgs.endpointName, {
fingerprint: connArgs.fingerprint,
port: incomingPort,
});
oncePortConnectedCbs.get(connArgs.endpointName)?.forEach((cb) => cb());
oncePortConnectedCbs.delete(connArgs.endpointName);
onceSessionEnded(connArgs.fingerprint, () => {
const rogueMsgs = pendingResponses
.entries()
.filter((pendingMessage) => pendingMessage.to === connArgs.fingerprint);
pendingResponses.remove(rogueMsgs);
rogueMsgs.forEach((rogueMessage) => {
if (rogueMessage.from.endpointId === 'background') {
endpointRuntime.endTransaction(rogueMessage.message.transactionId);
} else {
notifyEndpoint(rogueMessage.from.endpointId)
.withFingerprint(rogueMessage.from.fingerprint)
.aboutSessionEnded(connArgs.fingerprint);
}
});
});
incomingPort.onDisconnect.addListener(() => {
// sometimes previous content script's onDisconnect is called *after* the fresh content-script's
// onConnect. So without this fingerprint equality check, we would remove the new port from map
if (connMap.get(connArgs.endpointName)?.fingerprint === connArgs.fingerprint)
connMap.delete(connArgs.endpointName);
onceSessionEndCbs.get(connArgs.fingerprint)?.forEach((cb) => cb());
onceSessionEndCbs.delete(connArgs.fingerprint);
});
incomingPort.onMessage.addListener((msg: RequestMessage) => {
if (msg.type === 'sync') {
const allActiveSessions = [...connMap.values()].map((conn) => conn.fingerprint);
const stillPending = msg.pendingResponses.filter((fp) => allActiveSessions.includes(fp.to));
pendingResponses.add(...stillPending);
msg.pendingResponses
.filter((deliveryReceipt) => !allActiveSessions.includes(deliveryReceipt.to))
.forEach((deliveryReceipt) =>
notifyEndpoint(connArgs.endpointName)
.withFingerprint(connArgs.fingerprint)
.aboutSessionEnded(deliveryReceipt.to),
);
msg.pendingDeliveries.forEach((intendedDestination) =>
notifyEndpoint(connArgs.endpointName)
.withFingerprint(connArgs.fingerprint)
.whenDeliverableTo(intendedDestination),
);
return;
}
if (msg.type === 'deliver' && msg.message?.origin?.context) {
// origin tab ID is resolved from the port identifier (also prevent "MITM attacks" of extensions)
msg.message.origin.tabId = linkedTabId;
msg.message.origin.frameId = linkedFrameId;
endpointRuntime.handleMessage(msg.message);
}
});
});
export const { sendMessage, onMessage } = endpointRuntime;
export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime);

View File

@ -0,0 +1,48 @@
import { createEndpointRuntime } from './internal/endpoint-runtime';
import { usePostMessaging } from './internal/post-message';
import { createStreamWirings } from './internal/stream';
import { createPersistentPort } from './internal/persistent-port';
import type { InternalMessage } from './types';
const win = usePostMessaging('content-script');
const port = createPersistentPort();
const endpointRuntime = createEndpointRuntime('content-script', (message) => {
if (message.destination.context === 'window') win.postMessage(message);
else port.postMessage(message);
});
win.onMessage((message: InternalMessage) => {
endpointRuntime.handleMessage(
Object.assign({}, message, {
origin: {
// a message event inside `content-script` means a script inside `window` dispatched it to be forwarded
// so we're making sure that the origin is not tampered (i.e script is not masquerading it's true identity)
context: 'window',
tabId: null,
},
}),
);
});
port.onMessage(endpointRuntime.handleMessage);
port.onFailure((message) => {
if (message.origin.context === 'window') {
win.postMessage({
type: 'error',
transactionID: message.transactionId,
});
return;
}
endpointRuntime.endTransaction(message.transactionId);
});
export function allowWindowMessaging(nsps: string): void {
win.setNamespace(nsps);
win.enable();
}
export const { sendMessage, onMessage } = endpointRuntime;
export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime);

View File

@ -0,0 +1,12 @@
import browser from 'webextension-polyfill';
import { createEndpointRuntime } from './internal/endpoint-runtime';
import { createStreamWirings } from './internal/stream';
import { createPersistentPort } from './internal/persistent-port';
const port = createPersistentPort(`devtools@${browser.devtools.inspectedWindow.tabId}`);
const endpointRuntime = createEndpointRuntime('devtools', (message) => port.postMessage(message));
port.onMessage(endpointRuntime.handleMessage);
export const { sendMessage, onMessage } = endpointRuntime;
export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime);

View File

@ -0,0 +1,3 @@
export * from './types';
export { isInternalEndpoint } from './internal/is-internal-endpoint';
export { parseEndpoint } from './internal/endpoint';

View File

@ -0,0 +1,27 @@
import type { EndpointFingerprint } from './endpoint-fingerprint';
export interface ConnectionArgs {
endpointName: string;
fingerprint: EndpointFingerprint;
}
const isValidConnectionArgs = (
args: unknown,
requiredKeys: (keyof ConnectionArgs)[] = ['endpointName', 'fingerprint'],
): args is ConnectionArgs =>
typeof args === 'object' && args !== null && requiredKeys.every((k) => k in args);
export const encodeConnectionArgs = (args: ConnectionArgs) => {
if (!isValidConnectionArgs(args)) throw new TypeError('Invalid connection args');
return JSON.stringify(args);
};
export const decodeConnectionArgs = (encodedArgs: string): ConnectionArgs => {
try {
const args = JSON.parse(encodedArgs);
return isValidConnectionArgs(args) ? args : null;
} catch (error) {
return null;
}
};

View File

@ -0,0 +1,28 @@
import type { InternalMessage } from '../types';
import type { EndpointFingerprint } from './endpoint-fingerprint';
export interface DeliveryReceipt {
message: InternalMessage;
to: EndpointFingerprint;
from: {
endpointId: string;
fingerprint: EndpointFingerprint;
};
}
export const createDeliveryLogger = () => {
let logs: ReadonlyArray<DeliveryReceipt> = [];
return {
add: (...receipts: DeliveryReceipt[]) => {
logs = [...logs, ...receipts];
},
remove: (message: string | DeliveryReceipt[]) => {
logs =
typeof message === 'string'
? logs.filter((receipt) => receipt.message.transactionId !== message)
: logs.filter((receipt) => !message.includes(receipt));
},
entries: () => logs,
};
};

View File

@ -0,0 +1,5 @@
import uid from 'tiny-uid';
export type EndpointFingerprint = `uid::${string}`;
export const createFingerprint = (): EndpointFingerprint => `uid::${uid(7)}`;

View File

@ -0,0 +1,173 @@
import type { JsonValue } from 'type-fest';
import uuid from 'tiny-uid';
import { serializeError } from 'serialize-error';
import type {
BridgeMessage,
DataTypeKey,
Destination,
GetDataType,
GetReturnType,
InternalMessage,
OnMessageCallback,
RuntimeContext,
} from '../types';
import { parseEndpoint } from './endpoint';
export interface EndpointRuntime {
sendMessage: <ReturnType extends JsonValue, K extends DataTypeKey = DataTypeKey>(
messageID: K,
data: GetDataType<K, JsonValue>,
destination?: Destination,
) => Promise<GetReturnType<K, ReturnType>>;
onMessage: <Data extends JsonValue, K extends DataTypeKey = DataTypeKey>(
messageID: K,
callback: OnMessageCallback<GetDataType<K, Data>, GetReturnType<K, any>>,
) => () => void;
/**
* @internal
*/
handleMessage: (message: InternalMessage) => void;
endTransaction: (transactionID: string) => void;
}
export const createEndpointRuntime = (
thisContext: RuntimeContext,
routeMessage: (msg: InternalMessage) => void,
localMessage?: (msg: InternalMessage) => void,
): EndpointRuntime => {
const runtimeId = uuid();
const openTransactions = new Map<
string,
{ resolve: (v: unknown) => void; reject: (e: unknown) => void }
>();
const onMessageListeners = new Map<string, OnMessageCallback<JsonValue>>();
const handleMessage = (message: InternalMessage) => {
if (
message.destination.context === thisContext &&
!message.destination.frameId &&
!message.destination.tabId
) {
localMessage?.(message);
const { transactionId, messageID, messageType } = message;
const handleReply = () => {
const transactionP = openTransactions.get(transactionId);
if (transactionP) {
const { err, data } = message;
if (err) {
const dehydratedErr = err as Record<string, string>;
const errCtr = self[dehydratedErr.name] as any;
const hydratedErr = new (typeof errCtr === 'function' ? errCtr : Error)(
dehydratedErr.message,
);
// eslint-disable-next-line no-restricted-syntax
for (const prop in dehydratedErr) hydratedErr[prop] = dehydratedErr[prop];
transactionP.reject(hydratedErr);
} else {
transactionP.resolve(data);
}
openTransactions.delete(transactionId);
}
};
const handleNewMessage = async () => {
let reply: JsonValue | void;
let err: Error;
let noHandlerFoundError = false;
try {
const cb = onMessageListeners.get(messageID);
if (typeof cb === 'function') {
// eslint-disable-next-line n/no-callback-literal
reply = await cb({
sender: message.origin,
id: messageID,
data: message.data,
timestamp: message.timestamp,
} as BridgeMessage<JsonValue>);
} else {
noHandlerFoundError = true;
throw new Error(
`[webext-bridge] No handler registered in '${thisContext}' to accept messages with id '${messageID}'`,
);
}
} catch (error) {
err = error;
} finally {
if (err) message.err = serializeError(err);
handleMessage({
...message,
messageType: 'reply',
data: reply,
origin: { context: thisContext, tabId: null },
destination: message.origin,
hops: [],
});
if (err && !noHandlerFoundError)
// eslint-disable-next-line no-unsafe-finally
throw reply;
}
};
switch (messageType) {
case 'reply':
return handleReply();
case 'message':
return handleNewMessage();
}
}
message.hops.push(`${thisContext}::${runtimeId}`);
return routeMessage(message);
};
return {
handleMessage,
endTransaction: (transactionID) => {
const transactionP = openTransactions.get(transactionID);
transactionP?.reject('Transaction was ended before it could complete');
openTransactions.delete(transactionID);
},
sendMessage: (messageID, data, destination = 'background') => {
const endpoint = typeof destination === 'string' ? parseEndpoint(destination) : destination;
const errFn = 'Bridge#sendMessage ->';
if (!endpoint.context) {
throw new TypeError(`${errFn} Destination must be any one of known destinations`);
}
return new Promise((resolve, reject) => {
const payload: InternalMessage = {
messageID,
data,
destination: endpoint,
messageType: 'message',
transactionId: uuid(),
origin: { context: thisContext, tabId: null },
hops: [],
timestamp: Date.now(),
};
openTransactions.set(payload.transactionId, { resolve, reject });
try {
handleMessage(payload);
} catch (error) {
openTransactions.delete(payload.transactionId);
reject(error);
}
});
},
onMessage: (messageID, callback) => {
onMessageListeners.set(messageID, callback);
return () => onMessageListeners.delete(messageID);
},
};
};

View File

@ -0,0 +1,20 @@
import type { Endpoint, RuntimeContext } from '../types';
const ENDPOINT_RE =
/^((?:background$)|devtools|popup|options|content-script|window|sidepanel)(?:@(\d+)(?:\.(\d+))?)?$/;
export const parseEndpoint = (endpoint: string): Endpoint => {
const [, context, tabId, frameId] = endpoint.match(ENDPOINT_RE) || [];
return {
context: context as RuntimeContext,
tabId: +tabId,
frameId: frameId ? +frameId : undefined,
};
};
export const formatEndpoint = ({ context, tabId, frameId }: Endpoint): string => {
if (['background', 'popup', 'options', 'sidepanel'].includes(context)) return context;
return `${context}@${tabId}${frameId ? `.${frameId}` : ''}`;
};

View File

@ -0,0 +1,13 @@
import type { Endpoint, RuntimeContext } from '../types';
const internalEndpoints: RuntimeContext[] = [
'background',
'devtools',
'content-script',
'options',
'popup',
'sidepanel',
];
export const isInternalEndpoint = ({ context: ctx }: Endpoint): boolean =>
internalEndpoints.includes(ctx);

View File

@ -0,0 +1,56 @@
let promise: Promise<MessagePort>;
/**
* Returns a MessagePort for one-on-one communication
*
* Depending on which context's code runs first, either an incoming port from the other side
* is accepted OR a port will be offered, which the other side will then accept.
*/
export const getMessagePort = (
thisContext: 'window' | 'content-script',
namespace: string,
onMessage: (e: MessageEvent<any>) => void,
): Promise<MessagePort> =>
(promise ??= new Promise((resolve) => {
const acceptMessagingPort = (event: MessageEvent) => {
const {
data: { cmd, scope, context },
ports,
} = event;
if (cmd === 'webext-port-offer' && scope === namespace && context !== thisContext) {
window.removeEventListener('message', acceptMessagingPort);
ports[0].onmessage = onMessage;
ports[0].postMessage('port-accepted');
return resolve(ports[0]);
}
};
const offerMessagingPort = () => {
const channel = new MessageChannel();
channel.port1.onmessage = (event: MessageEvent) => {
if (event.data === 'port-accepted') {
window.removeEventListener('message', acceptMessagingPort);
return resolve(channel.port1);
}
onMessage?.(event);
};
window.postMessage(
{
cmd: 'webext-port-offer',
scope: namespace,
context: thisContext,
},
'*',
[channel.port2],
);
};
window.addEventListener('message', acceptMessagingPort);
// one of the contexts needs to be offset by at least 1 tick to prevent a race condition
// where both of them are offering, and then also accepting the port at the same time
if (thisContext === 'window') setTimeout(offerMessagingPort, 0);
else offerMessagingPort();
}));

View File

@ -0,0 +1,114 @@
import browser from 'webextension-polyfill';
import type { Runtime } from 'webextension-polyfill';
import type { InternalMessage } from '../types';
import { createFingerprint } from './endpoint-fingerprint';
import type { QueuedMessage } from './types';
import { encodeConnectionArgs } from './connection-args';
import { createDeliveryLogger } from './delivery-logger';
import type { StatusMessage } from './port-message';
import { PortMessage } from './port-message';
/**
* Manfiest V3 extensions can have their service worker terminated at any point
* by the browser. That termination of service worker also terminates any messaging
* porta created by other parts of the extension. This class is a wrapper around the
* built-in Port object that re-instantiates the port connection everytime it gets
* suspended
*/
export const createPersistentPort = (name = '') => {
const fingerprint = createFingerprint();
let port: Runtime.Port;
let undeliveredQueue: ReadonlyArray<QueuedMessage> = [];
const pendingResponses = createDeliveryLogger();
const onMessageListeners = new Set<(message: InternalMessage, port: Runtime.Port) => void>();
const onFailureListeners = new Set<(message: InternalMessage) => void>();
const handleMessage = (msg: StatusMessage, port: Runtime.Port) => {
switch (msg.status) {
case 'undeliverable':
if (!undeliveredQueue.some((m) => m.message.messageID === msg.message.messageID)) {
undeliveredQueue = [
...undeliveredQueue,
{
message: msg.message,
resolvedDestination: msg.resolvedDestination,
},
];
}
return;
case 'deliverable':
undeliveredQueue = undeliveredQueue.reduce((acc, queuedMsg) => {
if (queuedMsg.resolvedDestination === msg.deliverableTo) {
PortMessage.toBackground(port, {
type: 'deliver',
message: queuedMsg.message,
});
return acc;
}
return [...acc, queuedMsg];
}, [] as ReadonlyArray<QueuedMessage>);
return;
case 'delivered':
if (msg.receipt.message.messageType === 'message') pendingResponses.add(msg.receipt);
return;
case 'incoming':
if (msg.message.messageType === 'reply') pendingResponses.remove(msg.message.messageID);
onMessageListeners.forEach((cb) => cb(msg.message, port));
return;
case 'terminated': {
const rogueMsgs = pendingResponses
.entries()
.filter((receipt) => msg.fingerprint === receipt.to);
pendingResponses.remove(rogueMsgs);
rogueMsgs.forEach(({ message }) => onFailureListeners.forEach((cb) => cb(message)));
}
}
};
const connect = () => {
port = browser.runtime.connect({
name: encodeConnectionArgs({
endpointName: name,
fingerprint,
}),
});
port.onMessage.addListener(handleMessage);
port.onDisconnect.addListener(connect);
PortMessage.toBackground(port, {
type: 'sync',
pendingResponses: pendingResponses.entries(),
pendingDeliveries: [
...new Set(undeliveredQueue.map(({ resolvedDestination }) => resolvedDestination)),
],
});
};
connect();
return {
onFailure(cb: (message: InternalMessage) => void) {
onFailureListeners.add(cb);
},
onMessage(cb: (message: InternalMessage) => void): void {
onMessageListeners.add(cb);
},
postMessage(message: any): void {
PortMessage.toBackground(port, {
type: 'deliver',
message,
});
},
};
};

View File

@ -0,0 +1,48 @@
import type { Runtime } from 'webextension-polyfill';
import type { InternalMessage } from '../types';
import type { DeliveryReceipt } from './delivery-logger';
import type { EndpointFingerprint } from './endpoint-fingerprint';
export type StatusMessage =
| {
status: 'undeliverable';
message: InternalMessage;
resolvedDestination: string;
}
| {
status: 'deliverable';
deliverableTo: string;
}
| {
status: 'delivered';
receipt: DeliveryReceipt;
}
| {
status: 'incoming';
message: InternalMessage;
}
| {
status: 'terminated';
fingerprint: EndpointFingerprint;
};
export type RequestMessage =
| {
type: 'sync';
pendingResponses: ReadonlyArray<DeliveryReceipt>;
pendingDeliveries: ReadonlyArray<string>;
}
| {
type: 'deliver';
message: InternalMessage;
};
export class PortMessage {
static toBackground(port: Runtime.Port, message: RequestMessage) {
return port.postMessage(message);
}
static toExtensionContext(port: Runtime.Port, message: StatusMessage) {
return port.postMessage(message);
}
}

View File

@ -0,0 +1,46 @@
import type { InternalMessage } from '../types';
import { getMessagePort } from './message-port';
export interface EndpointWontRespondError {
type: 'error';
transactionID: string;
}
export const usePostMessaging = (thisContext: 'window' | 'content-script') => {
let allocatedNamespace: string;
let messagingEnabled = false;
let onMessageCallback: (msg: InternalMessage | EndpointWontRespondError) => void;
let portP: Promise<MessagePort>;
return {
enable: () => (messagingEnabled = true),
onMessage: (cb: typeof onMessageCallback) => (onMessageCallback = cb),
postMessage: async (msg: InternalMessage | EndpointWontRespondError) => {
if (thisContext !== 'content-script' && thisContext !== 'window')
throw new Error('Endpoint does not use postMessage');
if (!messagingEnabled) throw new Error('Communication with window has not been allowed');
ensureNamespaceSet(allocatedNamespace);
return (await portP).postMessage(msg);
},
setNamespace: (nsps: string) => {
if (allocatedNamespace) throw new Error('Namespace once set cannot be changed');
allocatedNamespace = nsps;
portP = getMessagePort(thisContext, nsps, ({ data }) => onMessageCallback?.(data));
},
};
};
function ensureNamespaceSet(namespace: string) {
if (typeof namespace !== 'string' || namespace.trim().length === 0) {
throw new Error(
'webext-bridge uses window.postMessage to talk with other "window"(s) for message routing' +
'which is global/conflicting operation in case there are other scripts using webext-bridge. ' +
"Call Bridge#setNamespace(nsps) to isolate your app. Example: setNamespace('com.facebook.react-devtools'). " +
'Make sure to use same namespace across all your scripts whereever window.postMessage is likely to be used`',
);
}
}

View File

@ -0,0 +1,203 @@
import { createNanoEvents } from 'nanoevents';
import uuid from 'tiny-uid';
import type { Emitter } from 'nanoevents';
import type { JsonValue } from 'type-fest';
import type { Endpoint, HybridUnsubscriber, RuntimeContext, StreamInfo } from '../types';
import type { EndpointRuntime } from './endpoint-runtime';
import { parseEndpoint } from './endpoint';
/**
* Built on top of Bridge. Nothing much special except that Stream allows
* you to create a namespaced scope under a channel name of your choice
* and allows continuous e2e communication, with less possibility of
* conflicting messageId's, since streams are strictly scoped.
*/
export class Stream {
private static initDone = false;
private static openStreams: Map<string, Stream> = new Map();
private emitter: Emitter = createNanoEvents();
private isClosed = false;
constructor(
private endpointRuntime: EndpointRuntime,
private streamInfo: StreamInfo,
) {
if (!Stream.initDone) {
endpointRuntime.onMessage<
{ streamId: string; action: 'transfer' | 'close'; streamTransfer: JsonValue },
string
>('__crx_bridge_stream_transfer__', (msg) => {
const { streamId, streamTransfer, action } = msg.data;
const stream = Stream.openStreams.get(streamId);
if (stream && !stream.isClosed) {
if (action === 'transfer') stream.emitter.emit('message', streamTransfer);
if (action === 'close') {
Stream.openStreams.delete(streamId);
stream.handleStreamClose();
}
}
});
Stream.initDone = true;
}
Stream.openStreams.set(this.streamInfo.streamId, this);
}
/**
* Returns stream info
*/
public get info(): StreamInfo {
return this.streamInfo;
}
/**
* Sends a message to other endpoint.
* Will trigger onMessage on the other side.
*
* Warning: Before sending sensitive data, verify the endpoint using `stream.info.endpoint.isInternal()`
* The other side could be malicious webpage speaking same language as webext-bridge
* @param msg
*/
public send(msg?: JsonValue): void {
if (this.isClosed)
throw new Error(
'Attempting to send a message over closed stream. Use stream.onClose(<callback>) to keep an eye on stream status',
);
this.endpointRuntime.sendMessage(
'__crx_bridge_stream_transfer__',
{
streamId: this.streamInfo.streamId,
streamTransfer: msg,
action: 'transfer',
},
this.streamInfo.endpoint,
);
}
/**
* Closes the stream.
* Will trigger stream.onClose(<callback>) on both endpoints.
* If needed again, spawn a new Stream, as this instance cannot be re-opened
* @param msg
*/
public close(msg?: JsonValue): void {
if (msg) this.send(msg);
this.handleStreamClose();
this.endpointRuntime.sendMessage(
'__crx_bridge_stream_transfer__',
{
streamId: this.streamInfo.streamId,
streamTransfer: null,
action: 'close',
},
this.streamInfo.endpoint,
);
}
/**
* Registers a callback to fire whenever other endpoint sends a message
* @param callback
*/
public onMessage<T extends JsonValue>(callback: (msg?: T) => void): HybridUnsubscriber {
return this.getDisposable('message', callback);
}
/**
* Registers a callback to fire whenever stream.close() is called on either endpoint
* @param callback
*/
public onClose<T extends JsonValue>(callback: (msg?: T) => void): HybridUnsubscriber {
return this.getDisposable('closed', callback);
}
private handleStreamClose = () => {
if (!this.isClosed) {
this.isClosed = true;
this.emitter.emit('closed', true);
this.emitter.events = {};
}
};
private getDisposable(event: string, callback: () => void): HybridUnsubscriber {
const off = this.emitter.on(event, callback);
return Object.assign(off, {
dispose: off,
close: off,
});
}
}
export const createStreamWirings = (endpointRuntime: EndpointRuntime) => {
const openStreams = new Map<string, Stream>();
const onOpenStreamCallbacks = new Map<string, (stream: Stream) => void>();
const streamyEmitter = createNanoEvents();
endpointRuntime.onMessage<{ channel: string; streamId: string }, string>(
'__crx_bridge_stream_open__',
(message) => {
return new Promise((resolve) => {
const { sender, data } = message;
const { channel } = data;
let watching = false;
let off = () => {};
const readyup = () => {
const callback = onOpenStreamCallbacks.get(channel);
if (typeof callback === 'function') {
callback(new Stream(endpointRuntime, { ...data, endpoint: sender }));
if (watching) off();
resolve(true);
} else if (!watching) {
watching = true;
off = streamyEmitter.on('did-change-stream-callbacks', readyup);
}
};
readyup();
});
},
);
async function openStream(
channel: string,
destination: RuntimeContext | Endpoint | string,
): Promise<Stream> {
if (openStreams.has(channel))
throw new Error('webext-bridge: A Stream is already open at this channel');
const endpoint = typeof destination === 'string' ? parseEndpoint(destination) : destination;
const streamInfo: StreamInfo = { streamId: uuid(), channel, endpoint };
const stream = new Stream(endpointRuntime, streamInfo);
stream.onClose(() => openStreams.delete(channel));
await endpointRuntime.sendMessage(
'__crx_bridge_stream_open__',
streamInfo as unknown as JsonValue,
endpoint,
);
openStreams.set(channel, stream);
return stream;
}
function onOpenStreamChannel(channel: string, callback: (stream: Stream) => void): void {
if (onOpenStreamCallbacks.has(channel))
throw new Error(
'webext-bridge: This channel has already been claimed. Stream allows only one-on-one communication',
);
onOpenStreamCallbacks.set(channel, callback);
streamyEmitter.emit('did-change-stream-callbacks');
}
return {
openStream,
onOpenStreamChannel,
};
};

View File

@ -0,0 +1,6 @@
import type { InternalMessage } from '../types';
export interface QueuedMessage {
resolvedDestination: string;
message: InternalMessage;
}

View File

@ -0,0 +1,11 @@
import { createEndpointRuntime } from './internal/endpoint-runtime';
import { createStreamWirings } from './internal/stream';
import { createPersistentPort } from './internal/persistent-port';
const port = createPersistentPort('options');
const endpointRuntime = createEndpointRuntime('options', (message) => port.postMessage(message));
port.onMessage(endpointRuntime.handleMessage);
export const { sendMessage, onMessage } = endpointRuntime;
export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime);

View File

@ -0,0 +1,11 @@
import { createEndpointRuntime } from './internal/endpoint-runtime';
import { createStreamWirings } from './internal/stream';
import { createPersistentPort } from './internal/persistent-port';
const port = createPersistentPort('popup');
const endpointRuntime = createEndpointRuntime('popup', (message) => port.postMessage(message));
port.onMessage(endpointRuntime.handleMessage);
export const { sendMessage, onMessage } = endpointRuntime;
export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime);

View File

@ -0,0 +1,104 @@
import { createEndpointRuntime } from './internal/endpoint-runtime';
import { createStreamWirings } from './internal/stream';
import { createPersistentPort } from './internal/persistent-port';
import browser from 'webextension-polyfill';
// Chrome API types for sidepanel
declare global {
interface Chrome {
sidePanel?: {
setPanelBehavior: (options: { openPanelOnActionClick: boolean }) => void;
setOptions: (options: { path?: string }) => void;
onShown: {
addListener: (callback: () => void) => void;
removeListener: (callback: () => void) => void;
hasListener: (callback: () => void) => boolean;
};
onHidden: {
addListener: (callback: () => void) => void;
removeListener: (callback: () => void) => void;
hasListener: (callback: () => void) => boolean;
};
// V3 还支持指定页面的侧边栏配置
getOptions: (options: { tabId?: number }) => Promise<{ path?: string }>;
};
}
var chrome: Chrome | undefined;
}
const port = createPersistentPort('sidepanel');
const endpointRuntime = createEndpointRuntime('sidepanel', (message) => port.postMessage(message));
port.onMessage(endpointRuntime.handleMessage);
/**
* Set up Chrome's sidepanel API for Manifest V3 extensions
*
* This function initializes the Chrome sidepanel API and configures its behavior.
* Use this in your sidepanel entry point to ensure the sidepanel works correctly.
*
* Example usage in your sidepanel script:
*
* ```ts
* import { setupSidepanel, sendMessage } from 'webext-bridge/sidepanel'
*
* // Initialize the sidepanel
* setupSidepanel({ defaultPath: 'sidepanel.html' })
*
* // Send a message to background
* sendMessage('get-data', { key: 'value' }, 'background')
* .then(response => console.log(response))
*
* // Listen for messages from other contexts
* onMessage('update-sidebar', (message) => {
* console.log(message.data)
* // Update sidebar UI
* })
* ```
*
* @param options Configuration options for the sidepanel
* @param options.defaultPath Default HTML path for the sidepanel
*/
export function setupSidepanel(options: { defaultPath?: string } = {}) {
if (typeof chrome !== 'undefined' && chrome.sidePanel) {
// Chrome specific sidepanel API
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
if (options.defaultPath) {
chrome.sidePanel.setOptions({ path: options.defaultPath });
}
}
}
/**
*
* @param callback
* @returns
*/
export function onSidepanelShown(callback: () => void): () => void {
if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.onShown) {
chrome.sidePanel.onShown.addListener(callback);
return () => chrome.sidePanel.onShown.removeListener(callback);
}
return () => {};
}
/**
*
* @param callback
* @returns
*/
export function onSidepanelHidden(callback: () => void): () => void {
if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.onHidden) {
chrome.sidePanel.onHidden.addListener(callback);
return () => chrome.sidePanel.onHidden.removeListener(callback);
}
return () => {};
}
export function isSidepanelSupported(): boolean {
return !!chrome.sidePanel;
}
export const { sendMessage, onMessage } = endpointRuntime;
export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime);

View File

@ -0,0 +1,98 @@
import type { JsonValue, Jsonify } from 'type-fest';
export type RuntimeContext =
| 'devtools'
| 'background'
| 'popup'
| 'options'
| 'content-script'
| 'window'
| 'sidepanel';
export interface Endpoint {
context: RuntimeContext;
tabId: number;
frameId?: number;
}
export interface BridgeMessage<T extends JsonValue> {
sender: Endpoint;
id: string;
data: T;
timestamp: number;
}
export type OnMessageCallback<T extends JsonValue, R = void | JsonValue> = (
message: BridgeMessage<T>,
) => R | Promise<R>;
export interface InternalMessage {
origin: Endpoint;
destination: Endpoint;
transactionId: string;
hops: string[];
messageID: string;
messageType: 'message' | 'reply';
err?: JsonValue;
data?: JsonValue | void;
timestamp: number;
}
export interface StreamInfo {
streamId: string;
channel: string;
endpoint: Endpoint;
}
export interface HybridUnsubscriber {
(): void;
dispose: () => void;
close: () => void;
}
export type Destination = Endpoint | RuntimeContext | string;
declare const ProtocolWithReturnSymbol: unique symbol;
export interface ProtocolWithReturn<Data, Return> {
data: Jsonify<Data>;
return: Jsonify<Return>;
/**
* Type differentiator only.
*/
[ProtocolWithReturnSymbol]: true;
}
/**
* Extendable by user.
*/
export interface ProtocolMap {
// foo: { id: number, name: string }
// bar: ProtocolWithReturn<string, number>
}
export type DataTypeKey = keyof ProtocolMap extends never ? string : keyof ProtocolMap;
export type GetDataType<
K extends DataTypeKey,
Fallback extends JsonValue = undefined,
> = K extends keyof ProtocolMap
? ProtocolMap[K] extends (...args: infer Args) => any
? Args['length'] extends 0
? undefined
: Args[0]
: ProtocolMap[K] extends ProtocolWithReturn<infer Data, any>
? Data
: ProtocolMap[K]
: Fallback;
export type GetReturnType<
K extends DataTypeKey,
Fallback extends JsonValue = undefined,
> = K extends keyof ProtocolMap
? ProtocolMap[K] extends (...args: any[]) => infer R
? R
: ProtocolMap[K] extends ProtocolWithReturn<any, infer Return>
? Return
: void
: Fallback;

View File

@ -0,0 +1,20 @@
import { createEndpointRuntime } from './internal/endpoint-runtime';
import { usePostMessaging } from './internal/post-message';
import { createStreamWirings } from './internal/stream';
const win = usePostMessaging('window');
const endpointRuntime = createEndpointRuntime('window', (message) => win.postMessage(message));
win.onMessage((msg) => {
if ('type' in msg && 'transactionID' in msg) endpointRuntime.endTransaction(msg.transactionID);
else endpointRuntime.handleMessage(msg);
});
export function setNamespace(nsps: string): void {
win.setNamespace(nsps);
win.enable();
}
export const { sendMessage, onMessage } = endpointRuntime;
export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime);

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2018",
"outDir": "./dist",
"jsx": "react",
"sourceMap": true,
"declaration": true,
"esModuleInterop": true,
"moduleResolution": "node",
"declarationDir": "./dist"
},
"exclude": ["node_modules", "dist"]
}