mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Add Web bridge & Update
This commit is contained in:
parent
6645cd2b75
commit
2d29e5c95a
@ -46,6 +46,7 @@
|
|||||||
"esno": "^4.8.0",
|
"esno": "^4.8.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"kolorist": "^1.8.0",
|
"kolorist": "^1.8.0",
|
||||||
@ -65,8 +66,8 @@
|
|||||||
"vue-demi": "^0.14.10",
|
"vue-demi": "^0.14.10",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"web-ext": "^8.5.0",
|
"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": {
|
"lint-staged": {
|
||||||
"**/*": "prettier --write --ignore-unknown"
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
|
|||||||
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
@ -58,6 +58,9 @@ importers:
|
|||||||
fs-extra:
|
fs-extra:
|
||||||
specifier: ^11.2.0
|
specifier: ^11.2.0
|
||||||
version: 11.2.0
|
version: 11.2.0
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
husky:
|
husky:
|
||||||
specifier: ^9.1.7
|
specifier: ^9.1.7
|
||||||
version: 9.1.7
|
version: 9.1.7
|
||||||
@ -116,8 +119,8 @@ importers:
|
|||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.0
|
version: 8.5.0
|
||||||
webext-bridge:
|
webext-bridge:
|
||||||
specifier: ^6.0.1
|
specifier: link:webext-bridge
|
||||||
version: 6.0.1
|
version: link:webext-bridge
|
||||||
webextension-polyfill:
|
webextension-polyfill:
|
||||||
specifier: ^0.12.0
|
specifier: ^0.12.0
|
||||||
version: 0.12.0
|
version: 0.12.0
|
||||||
@ -1373,12 +1376,6 @@ packages:
|
|||||||
integrity: sha512-xPTFWwQ8BxPevPF2IKsf4hpZNss4LxaOLZXypQH4E63BDLmcwX/RMGdI4tB4VO4Nb6xDBH3F/p4gz4wvof1o9w==,
|
integrity: sha512-xPTFWwQ8BxPevPF2IKsf4hpZNss4LxaOLZXypQH4E63BDLmcwX/RMGdI4tB4VO4Nb6xDBH3F/p4gz4wvof1o9w==,
|
||||||
}
|
}
|
||||||
|
|
||||||
'@types/webextension-polyfill@0.8.3':
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-GN+Hjzy9mXjWoXKmaicTegv3FJ0WFZ3aYz77Wk8TMp1IY3vEzvzj1vnsa0ggV7vMI1i+PUxe4qqnIJKCzf9aTg==,
|
|
||||||
}
|
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -3456,6 +3453,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: '>=18' }
|
engines: { node: '>=18' }
|
||||||
|
|
||||||
|
html-to-image@1.11.13:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==,
|
||||||
|
}
|
||||||
|
|
||||||
htmlparser2@8.0.2:
|
htmlparser2@8.0.2:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -4496,13 +4499,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.0
|
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:
|
nanoid@3.3.11:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -5567,13 +5563,6 @@ packages:
|
|||||||
engines: { node: '>=10' }
|
engines: { node: '>=10' }
|
||||||
hasBin: true
|
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:
|
set-function-length@1.2.2:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -6032,12 +6021,6 @@ packages:
|
|||||||
integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==,
|
integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==,
|
||||||
}
|
}
|
||||||
|
|
||||||
tiny-uid@1.1.2:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-0beRFXR+fv4C40ND2PqgNjq6iyB1dKXciKJjslLw0kPYCcR82aNd2b+Tt2yy06LimIlvtoehgvrm/fUZCutSfg==,
|
|
||||||
}
|
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -6166,13 +6149,6 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: '>=10' }
|
engines: { node: '>=10' }
|
||||||
|
|
||||||
type-fest@2.19.0:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==,
|
|
||||||
}
|
|
||||||
engines: { node: '>=12.20' }
|
|
||||||
|
|
||||||
type-fest@3.13.1:
|
type-fest@3.13.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -6573,24 +6549,12 @@ packages:
|
|||||||
engines: { node: '>=18.0.0', npm: '>=8.0.0' }
|
engines: { node: '>=18.0.0', npm: '>=8.0.0' }
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
webext-bridge@6.0.1:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-GruIrN+vNwbxVCi8UW4Dqk5YkcGA9V0ZfJ57jXP9JXHbrsDs5k2N6NNYQR5e+wSCnQpGYOGAGihwUpKlhg8QIw==,
|
|
||||||
}
|
|
||||||
|
|
||||||
webextension-polyfill@0.12.0:
|
webextension-polyfill@0.12.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==,
|
integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==,
|
||||||
}
|
}
|
||||||
|
|
||||||
webextension-polyfill@0.9.0:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-LTtHb0yR49xa9irkstDxba4GATDAcDw3ncnFH9RImoFwDlW47U95ME5sn5IiQX2ghfaECaf6xyXM8yvClIBkkw==,
|
|
||||||
}
|
|
||||||
|
|
||||||
webidl-conversions@7.0.0:
|
webidl-conversions@7.0.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -7522,8 +7486,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/webextension-polyfill@0.12.1': {}
|
'@types/webextension-polyfill@0.12.1': {}
|
||||||
|
|
||||||
'@types/webextension-polyfill@0.8.3': {}
|
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.10.5
|
'@types/node': 22.10.5
|
||||||
@ -8962,6 +8924,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
whatwg-encoding: 3.1.1
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
|
html-to-image@1.11.13: {}
|
||||||
|
|
||||||
htmlparser2@8.0.2:
|
htmlparser2@8.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@ -9526,8 +9490,6 @@ snapshots:
|
|||||||
vue: 3.5.13(typescript@5.8.2)
|
vue: 3.5.13(typescript@5.8.2)
|
||||||
vueuc: 0.4.64(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: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
@ -10143,10 +10105,6 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.1: {}
|
semver@7.7.1: {}
|
||||||
|
|
||||||
serialize-error@9.1.1:
|
|
||||||
dependencies:
|
|
||||||
type-fest: 2.19.0
|
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@ -10416,8 +10374,6 @@ snapshots:
|
|||||||
|
|
||||||
through@2.3.8: {}
|
through@2.3.8: {}
|
||||||
|
|
||||||
tiny-uid@1.1.2: {}
|
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
@ -10474,8 +10430,6 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@0.20.2: {}
|
type-fest@0.20.2: {}
|
||||||
|
|
||||||
type-fest@2.19.0: {}
|
|
||||||
|
|
||||||
type-fest@3.13.1: {}
|
type-fest@3.13.1: {}
|
||||||
|
|
||||||
type-fest@4.32.0: {}
|
type-fest@4.32.0: {}
|
||||||
@ -10813,18 +10767,8 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- 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.12.0: {}
|
||||||
|
|
||||||
webextension-polyfill@0.9.0: {}
|
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|
||||||
webpack-virtual-modules@0.6.2: {}
|
webpack-virtual-modules@0.6.2: {}
|
||||||
|
|||||||
11
shim.d.ts
vendored
11
shim.d.ts
vendored
@ -4,6 +4,17 @@ declare module 'webext-bridge' {
|
|||||||
export interface ProtocolMap {
|
export interface ProtocolMap {
|
||||||
// define message protocol types
|
// define message protocol types
|
||||||
// see https://github.com/antfu/webext-bridge#type-safe-protocols
|
// see https://github.com/antfu/webext-bridge#type-safe-protocols
|
||||||
|
'html-to-image': ProtocolWithReturn<
|
||||||
|
| {
|
||||||
|
type: 'CSS';
|
||||||
|
selector: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'XPath';
|
||||||
|
xpath: string;
|
||||||
|
},
|
||||||
|
{ b64: string }
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
// https://github.com/serversideup/webext-bridge/issues/67#issuecomment-2676094094
|
||||||
|
import('webext-bridge/background');
|
||||||
|
|
||||||
// only on dev mode
|
// only on dev mode
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
// @ts-expect-error for background HMR
|
// @ts-expect-error for background HMR
|
||||||
|
|||||||
@ -24,7 +24,7 @@ const message = useMessage();
|
|||||||
const formItemRef = useTemplateRef('detail-form-item');
|
const formItemRef = useTemplateRef('detail-form-item');
|
||||||
const formItemRule: FormItemRule = {
|
const formItemRule: FormItemRule = {
|
||||||
required: true,
|
required: true,
|
||||||
trigger: ['submit', 'blur'],
|
trigger: ['submit'],
|
||||||
message: props.validateMessage,
|
message: props.validateMessage,
|
||||||
validator: () => {
|
validator: () => {
|
||||||
return props.matchPattern.exec(modelValue.value) !== null;
|
return props.matchPattern.exec(modelValue.value) !== null;
|
||||||
|
|||||||
13
src/contentScripts/html-to-image.ts
Normal file
13
src/contentScripts/html-to-image.ts
Normal 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 };
|
||||||
|
});
|
||||||
@ -1,2 +1,4 @@
|
|||||||
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
||||||
(() => {})();
|
(() => {
|
||||||
|
import('./html-to-image');
|
||||||
|
})();
|
||||||
|
|||||||
@ -43,21 +43,16 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
|
|
||||||
private async wanderSearchSinglePage(tab: Tabs.Tab) {
|
private async wanderSearchSinglePage(tab: Tabs.Tab) {
|
||||||
const injector = new AmazonSearchPageInjector(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();
|
await injector.waitForPageLoaded();
|
||||||
// #endregion
|
// Determine the type of product search page https://github.com/primedigitaltech/azon_seeker/issues/1
|
||||||
// #region Determine the type of product search page https://github.com/primedigitaltech/azon_seeker/issues/1
|
|
||||||
const pagePattern = await injector.getPagePattern();
|
const pagePattern = await injector.getPagePattern();
|
||||||
// #endregion
|
// Retrieve key nodes and their information from the critical product search page
|
||||||
// #region Retrieve key nodes and their information from the critical product search page
|
|
||||||
const data = await injector.getPageData(pagePattern);
|
const data = await injector.getPageData(pagePattern);
|
||||||
// #endregion
|
// get current page
|
||||||
// #region get current page
|
|
||||||
const page = await injector.getCurrentPage();
|
const page = await injector.getCurrentPage();
|
||||||
// #endregion
|
// Determine if it is the last page, otherwise navigate to the next page
|
||||||
// #region Determine if it is the last page, otherwise navigate to the next page
|
|
||||||
const hasNextPage = await injector.determineHasNextPage();
|
const hasNextPage = await injector.determineHasNextPage();
|
||||||
// #endregion
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
if (data === null || typeof hasNextPage !== 'boolean') {
|
if (data === null || typeof hasNextPage !== 'boolean') {
|
||||||
this.channel.emit('error', { message: '爬取单页信息失败', url: tab.url });
|
this.channel.emit('error', { message: '爬取单页信息失败', url: tab.url });
|
||||||
@ -135,21 +130,13 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
//#endregion
|
//#endregion
|
||||||
//#region Fetch Base Info
|
//#region Fetch Base Info
|
||||||
const baseInfo = await injector.getBaseInfo();
|
const baseInfo = await injector.getBaseInfo();
|
||||||
|
const ratingInfo = await injector.getRatingInfo();
|
||||||
this.channel.emit('item-base-info-collected', {
|
this.channel.emit('item-base-info-collected', {
|
||||||
asin: params.asin,
|
asin: params.asin,
|
||||||
title: baseInfo.title,
|
...baseInfo,
|
||||||
price: baseInfo.price,
|
...ratingInfo,
|
||||||
});
|
});
|
||||||
//#endregion
|
//#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
|
//#region Fetch Category Rank Info
|
||||||
let rawRankingText: string | null = await injector.getRankText();
|
let rawRankingText: string | null = await injector.getRankText();
|
||||||
if (rawRankingText) {
|
if (rawRankingText) {
|
||||||
@ -194,6 +181,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
// topReviews: reviews,
|
// topReviews: reviews,
|
||||||
// });
|
// });
|
||||||
//#endregion
|
//#endregion
|
||||||
|
// #region Get APlus Sreen shot
|
||||||
|
await injector.scanAPlus();
|
||||||
|
// #endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
@withErrorHandling
|
@withErrorHandling
|
||||||
|
|||||||
9
src/logic/page-worker/types.d.ts
vendored
9
src/logic/page-worker/types.d.ts
vendored
@ -47,11 +47,10 @@ interface AmazonPageWorkerEvents {
|
|||||||
/**
|
/**
|
||||||
* The event is fired when worker collected goods' base info on the Amazon detail page.
|
* The event is fired when worker collected goods' base info on the Amazon detail page.
|
||||||
*/
|
*/
|
||||||
['item-base-info-collected']: Pick<AmazonDetailItem, 'asin' | 'title' | 'price'>;
|
['item-base-info-collected']: Pick<
|
||||||
/**
|
AmazonDetailItem,
|
||||||
* The event is fired when worker collected goods' rating on the Amazon detail page.
|
'asin' | 'title' | 'price' | 'rating' | 'ratingCount'
|
||||||
*/
|
>;
|
||||||
['item-rating-collected']: Pick<AmazonDetailItem, 'asin' | 'rating' | 'ratingCount'>;
|
|
||||||
/**
|
/**
|
||||||
* The event is fired when worker
|
* The event is fired when worker
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -299,6 +299,24 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
return items;
|
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 {
|
export class AmazonReviewPageInjector extends BaseInjector {
|
||||||
|
|||||||
@ -369,6 +369,10 @@ const handleClearData = async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.result-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.filter-switch) {
|
:deep(.filter-switch) {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,16 +39,7 @@ worker.channel.on('item-base-info-collected', (ev) => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
title: `商品${ev.asin}基本信息`,
|
title: `商品${ev.asin}基本信息`,
|
||||||
time: new Date().toLocaleString(),
|
time: new Date().toLocaleString(),
|
||||||
content: `标题: ${ev.title};价格:${ev.price}`,
|
content: `标题: ${ev.title};价格:${ev.price}; 评分: ${ev.rating}; 评论数: ${ev.ratingCount}`,
|
||||||
});
|
|
||||||
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}`,
|
|
||||||
});
|
});
|
||||||
updateDetailItems(ev);
|
updateDetailItems(ev);
|
||||||
});
|
});
|
||||||
|
|||||||
2
webext-bridge/.eslintignore
Normal file
2
webext-bridge/.eslintignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
3
webext-bridge/.eslintrc.json
Normal file
3
webext-bridge/.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@antfu"]
|
||||||
|
}
|
||||||
41
webext-bridge/.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
41
webext-bridge/.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal 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
|
||||||
13
webext-bridge/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
13
webext-bridge/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
132
webext-bridge/.github/code_of_conduct.md
vendored
Normal 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
BIN
webext-bridge/.github/header.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
28
webext-bridge/.github/workflows/sponsors.yml
vendored
Normal file
28
webext-bridge/.github/workflows/sponsors.yml
vendored
Normal 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> '
|
||||||
|
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
61
webext-bridge/.gitignore
vendored
Normal 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
2
webext-bridge/.npmignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
tsconfig.json
|
||||||
|
tslint.json
|
||||||
27
webext-bridge/CHANGELOG.md
Normal file
27
webext-bridge/CHANGELOG.md
Normal 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
21
webext-bridge/LICENSE
Normal 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
95
webext-bridge/README.md
Normal 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> <a href="https://github.com/JQuilty"><img src="https://github.com/JQuilty.png" width="40px" alt="JQuilty" /></a> <a href="https://github.com/MaltMethodDev"><img src="https://github.com/MaltMethodDev.png" width="40px" alt="MaltMethodDev" /></a> <a href="https://github.com/harrisonratcliffe"><img src="https://github.com/harrisonratcliffe.png" width="40px" alt="harrisonratcliffe" /></a> <!-- 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
119
webext-bridge/package.json
Normal 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
5463
webext-bridge/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
326
webext-bridge/src/background.ts
Normal file
326
webext-bridge/src/background.ts
Normal 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);
|
||||||
48
webext-bridge/src/content-script.ts
Normal file
48
webext-bridge/src/content-script.ts
Normal 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);
|
||||||
12
webext-bridge/src/devtools.ts
Normal file
12
webext-bridge/src/devtools.ts
Normal 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);
|
||||||
3
webext-bridge/src/index.ts
Normal file
3
webext-bridge/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './types';
|
||||||
|
export { isInternalEndpoint } from './internal/is-internal-endpoint';
|
||||||
|
export { parseEndpoint } from './internal/endpoint';
|
||||||
27
webext-bridge/src/internal/connection-args.ts
Normal file
27
webext-bridge/src/internal/connection-args.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
28
webext-bridge/src/internal/delivery-logger.ts
Normal file
28
webext-bridge/src/internal/delivery-logger.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
5
webext-bridge/src/internal/endpoint-fingerprint.ts
Normal file
5
webext-bridge/src/internal/endpoint-fingerprint.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import uid from 'tiny-uid';
|
||||||
|
|
||||||
|
export type EndpointFingerprint = `uid::${string}`;
|
||||||
|
|
||||||
|
export const createFingerprint = (): EndpointFingerprint => `uid::${uid(7)}`;
|
||||||
173
webext-bridge/src/internal/endpoint-runtime.ts
Normal file
173
webext-bridge/src/internal/endpoint-runtime.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
20
webext-bridge/src/internal/endpoint.ts
Normal file
20
webext-bridge/src/internal/endpoint.ts
Normal 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}` : ''}`;
|
||||||
|
};
|
||||||
13
webext-bridge/src/internal/is-internal-endpoint.ts
Normal file
13
webext-bridge/src/internal/is-internal-endpoint.ts
Normal 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);
|
||||||
56
webext-bridge/src/internal/message-port.ts
Normal file
56
webext-bridge/src/internal/message-port.ts
Normal 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();
|
||||||
|
}));
|
||||||
114
webext-bridge/src/internal/persistent-port.ts
Normal file
114
webext-bridge/src/internal/persistent-port.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
48
webext-bridge/src/internal/port-message.ts
Normal file
48
webext-bridge/src/internal/port-message.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
webext-bridge/src/internal/post-message.ts
Normal file
46
webext-bridge/src/internal/post-message.ts
Normal 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`',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
webext-bridge/src/internal/stream.ts
Normal file
203
webext-bridge/src/internal/stream.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
6
webext-bridge/src/internal/types.ts
Normal file
6
webext-bridge/src/internal/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import type { InternalMessage } from '../types';
|
||||||
|
|
||||||
|
export interface QueuedMessage {
|
||||||
|
resolvedDestination: string;
|
||||||
|
message: InternalMessage;
|
||||||
|
}
|
||||||
11
webext-bridge/src/options.ts
Normal file
11
webext-bridge/src/options.ts
Normal 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);
|
||||||
11
webext-bridge/src/popup.ts
Normal file
11
webext-bridge/src/popup.ts
Normal 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);
|
||||||
104
webext-bridge/src/sidepanel.ts
Normal file
104
webext-bridge/src/sidepanel.ts
Normal 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);
|
||||||
98
webext-bridge/src/types.ts
Normal file
98
webext-bridge/src/types.ts
Normal 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;
|
||||||
20
webext-bridge/src/window.ts
Normal file
20
webext-bridge/src/window.ts
Normal 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);
|
||||||
14
webext-bridge/tsconfig.json
Normal file
14
webext-bridge/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user