From fdfa48bab7e69daa2900ca7a63baf6a30293b42c Mon Sep 17 00:00:00 2001 From: Jack Works Date: Thu, 5 Nov 2020 10:09:06 +0800 Subject: [PATCH] feat: new API WebExtensionMessage and getExtensionEnvironment (#298) * chore: prototype of new API * chore: add asyncIterator demo * chore: adjust api * chore: redesign api * chore: resolve review * refactor: remove regenerator-runtime as dep * test: add test extension * chore: not emit dts in umd/ folder * test: update test extension * tests: update test-extension * feat: new API of getting environments * feat: change some name in envs, add shortcuts * feat: new API WebExtensionMessage * fix: logic bug of sending messages * test: add test page * chore: remove async iter on class instance of webext msg --- .gitignore | 3 +- .npmignore | 4 +- package.json | 3 +- pnpm-lock.yaml | 32 ++- rollup.config.js | 22 ++- src/DOM/Watcher.ts | 5 +- src/Extension/AutomatedTabTask.ts | 2 +- src/Extension/Context.ts | 138 ++++++++++++- src/Extension/MessageCenter.ts | 1 + src/Extension/MessageChannel.ts | 313 ++++++++++++++++++++++++++++++ src/Extension/index.ts | 1 + src/types/concurrent-lock.d.ts | 8 - src/util/ConcurrentLock.ts | 51 +++++ test-extension/background.html | 13 ++ test-extension/devtools.html | 13 ++ test-extension/index.html | 19 ++ test-extension/manifest.json | 20 ++ test-extension/package.json | 14 ++ test-extension/pnpm-lock.yaml | 10 + test-extension/popup.html | 13 ++ test-extension/setup.js | 42 ++++ 21 files changed, 682 insertions(+), 45 deletions(-) create mode 100644 src/Extension/MessageChannel.ts delete mode 100644 src/types/concurrent-lock.d.ts create mode 100644 src/util/ConcurrentLock.ts create mode 100644 test-extension/background.html create mode 100644 test-extension/devtools.html create mode 100644 test-extension/index.html create mode 100644 test-extension/manifest.json create mode 100644 test-extension/package.json create mode 100644 test-extension/pnpm-lock.yaml create mode 100644 test-extension/popup.html create mode 100644 test-extension/setup.js diff --git a/.gitignore b/.gitignore index f3b7287..97ba773 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,10 @@ /temp .rpt2_cache *.tsbuildinfo +test-extension/kit.* # dependencies -/node_modules +node_modules # testing /coverage diff --git a/.npmignore b/.npmignore index 5eda76f..006623e 100644 --- a/.npmignore +++ b/.npmignore @@ -2,9 +2,7 @@ /.vscode /.circleci -# use less build output -/umd/**/*.ts -/umd/**/*.ts.map +/test-extension # can be regenerated /api-documents diff --git a/package.json b/package.json index dcb6c10..f84defb 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "dependencies": { "@servie/events": "^1.0.0", "async-call-rpc": "^4.1.0", - "concurrent-lock": "^1.0.7", + "event-iterator": "^2.0.0", "jsx-jsonml-devtools-renderer": "^1.4.3", "lodash-es": "^4.17.15", "memorize-decorator": "^0.2.2", @@ -64,6 +64,7 @@ "npm-run-all": "^4.1.5", "rimraf": "^3.0.1", "rollup": "^2.30.0", + "rollup-plugin-analyzer": "^3.3.0", "rollup-plugin-typescript2": "^0.28.0", "ts-jest": "^26.4.3", "ts-node": "^9.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0774bc1..c1724da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,7 +1,7 @@ dependencies: '@servie/events': 1.0.0 async-call-rpc: 4.1.0 - concurrent-lock: 1.0.7 + event-iterator: 2.0.0 jsx-jsonml-devtools-renderer: 1.4.3 lodash-es: 4.17.15 memorize-decorator: 0.2.4 @@ -23,6 +23,7 @@ devDependencies: npm-run-all: 4.1.5 rimraf: 3.0.2 rollup: 2.32.1 + rollup-plugin-analyzer: 3.3.0 rollup-plugin-typescript2: 0.28.0_rollup@2.32.1+typescript@4.0.5 ts-jest: 26.4.3_jest@26.6.1+typescript@4.0.5 ts-node: 9.0.0_typescript@4.0.5 @@ -264,12 +265,6 @@ packages: '@babel/core': ^7.0.0-0 resolution: integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - /@babel/runtime/7.11.2: - dependencies: - regenerator-runtime: 0.13.7 - dev: false - resolution: - integrity: sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== /@babel/template/7.10.4: dependencies: '@babel/code-frame': 7.10.4 @@ -1475,12 +1470,6 @@ packages: dev: true resolution: integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - /concurrent-lock/1.0.7: - dependencies: - '@babel/runtime': 7.11.2 - dev: false - resolution: - integrity: sha512-WhivxLzkjaqpd3Bs+8UCq8cDznvx4vX5tzW17Zbk1c8nyuALt3h7EK5qpc3BSLFZADpCmMJieqUoZ6iOSTYIPA== /convert-source-map/1.7.0: dependencies: safe-buffer: 5.1.2 @@ -1968,6 +1957,10 @@ packages: node: '>=0.10.0' resolution: integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + /event-iterator/2.0.0: + dev: false + resolution: + integrity: sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ== /exec-sh/0.3.4: dev: true resolution: @@ -4148,10 +4141,6 @@ packages: node: '>=8.10.0' resolution: integrity: sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== - /regenerator-runtime/0.13.7: - dev: false - resolution: - integrity: sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== /regex-not/1.0.2: dependencies: extend-shallow: 3.0.2 @@ -4311,6 +4300,12 @@ packages: hasBin: true resolution: integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + /rollup-plugin-analyzer/3.3.0: + dev: true + engines: + node: '>=8.0.0' + resolution: + integrity: sha512-zUPGitW4usmZcVa0nKecRvw3odtXgnxdCben9Hx1kxVoR3demek8RU9tmRG/R35hnRPQTb7wEsYEe3GUcjxIMA== /rollup-plugin-typescript2/0.28.0_rollup@2.32.1+typescript@4.0.5: dependencies: '@rollup/pluginutils': 3.1.0_rollup@2.32.1 @@ -5298,10 +5293,10 @@ specifiers: '@typescript-eslint/eslint-plugin': ^4.6.0 '@typescript-eslint/parser': ^4.6.0 async-call-rpc: ^4.1.0 - concurrent-lock: ^1.0.7 env-cmd: ^10.1.0 eslint: ^7.12.1 eslint-watch: ^7.0.0 + event-iterator: ^2.0.0 jest: ^26.6.1 jsx-jsonml-devtools-renderer: ^1.4.3 lodash-es: ^4.17.15 @@ -5309,6 +5304,7 @@ specifiers: npm-run-all: ^4.1.5 rimraf: ^3.0.1 rollup: ^2.30.0 + rollup-plugin-analyzer: ^3.3.0 rollup-plugin-typescript2: ^0.28.0 ts-jest: ^26.4.3 ts-node: ^9.0.0 diff --git a/rollup.config.js b/rollup.config.js index 2dbfedc..c704a11 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,6 +2,8 @@ import typescript from 'rollup-plugin-typescript2' import commonjs from '@rollup/plugin-commonjs' import nodeResolve from '@rollup/plugin-node-resolve' import replace from '@rollup/plugin-replace' +// import analyze from 'rollup-plugin-analyzer' +// import { writeFileSync } from 'fs' function parseMaybe(s) { return typeof s === 'string' ? JSON.parse(s) : {} @@ -9,11 +11,10 @@ function parseMaybe(s) { const config = { input: './src/index.ts', - output: { - file: './umd/index.cjs', - format: 'umd', - name: 'HoloflowsKit', - }, + output: [ + { file: './umd/index.cjs', format: 'umd', name: 'HoloflowsKit' }, + { file: './test-extension/kit.js', format: 'umd', name: 'HoloflowsKit' }, + ], plugins: [ nodeResolve({ browser: true, @@ -25,7 +26,12 @@ const config = { }), typescript({ tsconfigOverride: { - compilerOptions: { target: 'ES2018', ...parseMaybe(process.env.TS_OPTS) }, + compilerOptions: { + target: 'ES2018', + declaration: false, + declarationMap: false, + ...parseMaybe(process.env.TS_OPTS), + }, }, }), replace({ @@ -35,6 +41,10 @@ const config = { extensions: ['.js', '.ts', '.tsx'], exclude: ['node_modules/lodash-es/'], }), + // analyze({ + // writeTo: (k) => writeFileSync('./out.log', k), + // filter: (x) => !x.id.includes('lodash') && !x.id.includes('src/'), + // }), ], } diff --git a/src/DOM/Watcher.ts b/src/DOM/Watcher.ts index e2852ca..39d72a6 100644 --- a/src/DOM/Watcher.ts +++ b/src/DOM/Watcher.ts @@ -13,11 +13,8 @@ import { DOMProxy, DOMProxyOptions } from './Proxy' import { Emitter, EventListener } from '@servie/events' import { LiveSelector } from './LiveSelector' -import differenceWith from 'lodash-es/differenceWith' -import intersectionWith from 'lodash-es/intersectionWith' -import uniqWith from 'lodash-es/uniqWith' import { Deadline, requestIdleCallback } from '../util/requestIdleCallback' -import { isNil } from 'lodash-es' +import { isNil, uniqWith, intersectionWith, differenceWith } from 'lodash-es' import { timeout } from '../util/sleep' import { WatcherDevtoolsEnhancer } from '../Debuggers/WatcherDevtoolsEnhancer' import { installCustomObjectFormatter } from 'jsx-jsonml-devtools-renderer' diff --git a/src/Extension/AutomatedTabTask.ts b/src/Extension/AutomatedTabTask.ts index ce0ed3e..66152a4 100644 --- a/src/Extension/AutomatedTabTask.ts +++ b/src/Extension/AutomatedTabTask.ts @@ -1,7 +1,7 @@ import { sleep, timeout as timeoutFn } from '../util/sleep' import { AsyncCall, AsyncCallOptions } from 'async-call-rpc' import { GetContext } from './Context' -import Lock from 'concurrent-lock' +import Lock from '../util/ConcurrentLock' import { memorize } from 'memorize-decorator' import { MessageCenter } from './MessageCenter' diff --git a/src/Extension/Context.ts b/src/Extension/Context.ts index d625c00..c3edfef 100644 --- a/src/Extension/Context.ts +++ b/src/Extension/Context.ts @@ -1,10 +1,11 @@ /** * All context that possible in when developing a WebExtension + * @deprecated, remove in 0.9.0 */ export type Contexts = 'background' | 'content' | 'webpage' | 'unknown' | 'options' | 'debugging' /** * Get current running context. - * + * @deprecated Use getExtensionEnvironment(), remove in 0.9.0 * @remarks * - background: background script * - content: content script @@ -19,27 +20,125 @@ export function GetContext(): Contexts { if (scheme || location.hostname === 'localhost') { if ( backgroundURL === location.href || - ['generated', 'background', 'page', '.html'].every(x => location.pathname.match(x)) + ['generated', 'background', 'page', '.html'].every((x) => location.pathname.match(x)) ) return 'background' } if (scheme) return 'options' if (browser.runtime?.getManifest !== undefined) return 'content' } - // What about rollup? if (location.hostname === 'localhost') return 'debugging' return 'webpage' } + +/** Current running environment of Web Extension */ +export enum Environment { + /** has browser as a global variable */ HasBrowserAPI = 1 << 1, + /** URL protocol ends with "-extension:" */ ExtensionProtocol = 1 << 2, + /** Current running context is Content Script */ ContentScript = 1 << 3, + // userScript = 1 << 4, + /** URL is listed in the manifest.background or generated background page */ ManifestBackground = 1 << 6, + /** URL is listed in the manifest.options_ui */ ManifestOptions = 1 << 7, + /** URL is listed in the manifest.browser_action */ ManifestBrowserAction = 1 << 8, + /** URL is listed in the manifest.page_action */ ManifestPageAction = 1 << 9, + /** URL is listed in the manifest.devtools_page */ ManifestDevTools = 1 << 10, + /** URL is listed in the manifest.sidebar_action. Firefox Only */ ManifestSidebar = 1 << 11, + /** URL is listed in the manifest.chrome_url_overrides.newtab */ ManifestOverridesNewTab = 1 << 12, + /** URL is listed in the manifest.chrome_url_overrides.bookmarks */ ManifestOverridesBookmarks = 1 << 13, + /** URL is listed in the manifest.chrome_url_overrides.history */ ManifestOverridesHistory = 1 << 14, + // DO NOT USE value that bigger than 1 << 20 +} +declare const __holoflows_kit_get_environment_debug__: number +let result: Environment +/** + * Get the current running environment + * @remarks You can use the global variable `__holoflows_kit_get_environment_debug__` to overwrite the return value if the current hostname is localhost or 127.0.0.1 + */ +export function getExtensionEnvironment(): Environment { + if (result !== undefined) return result + try { + if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { + const val = __holoflows_kit_get_environment_debug__ + if (val !== undefined) return Number(val) + } + } catch {} + let flag = 0 + // Scheme test + try { + const scheme = location.protocol + if (scheme.endsWith('-extension:')) flag |= Environment.ExtensionProtocol + } catch {} + // Browser API test + if (typeof browser !== 'undefined' && browser !== null) { + flag |= Environment.HasBrowserAPI + if (!(flag & Environment.ExtensionProtocol)) flag |= Environment.ContentScript + else { + try { + const manifest = browser.runtime.getManifest() + const current = location.pathname + + const background = + manifest.background?.page || manifest.background_page || '/_generated_background_page.html' + const options = manifest.options_ui?.page || manifest.options_page + + if (current === slashSuffix(background)) flag |= Environment.ManifestBackground + // TODO: this property support i18n. What will I get when call browser.runtime.getManifest()? + if (current === slashSuffix(manifest.browser_action?.default_popup)) + flag |= Environment.ManifestBrowserAction + if (current === slashSuffix(manifest.sidebar_action?.default_panel)) flag |= Environment.ManifestSidebar + if (current === slashSuffix(options)) flag |= Environment.ManifestOptions + if (current === slashSuffix(manifest.devtools_page)) flag |= Environment.ManifestDevTools + if (current === slashSuffix(manifest.page_action?.default_popup)) flag |= Environment.ManifestPageAction + + // TODO: this property support i18n. + const { bookmarks, history, newtab } = manifest.chrome_url_overrides || {} + if (current === slashSuffix(bookmarks)) flag |= Environment.ManifestOverridesBookmarks + if (current === slashSuffix(history)) flag |= Environment.ManifestOverridesHistory + if (current === slashSuffix(newtab)) flag |= Environment.ManifestOverridesNewTab + } catch {} + } + } + return (result = flag) + function slashSuffix(x: string | undefined) { + if (x === undefined) return '_' + if (x[0] !== '/') return '/' + x + else return x + } +} + +/** + * Print the Environment bit flag in a human-readable format + * @param e - Printing environment bit flag + */ +export function printExtensionEnvironment(e: Environment = getExtensionEnvironment()) { + const flag: (keyof typeof Environment)[] = [] + if (Environment.ContentScript & e) flag.push('ContentScript') + if (Environment.ExtensionProtocol & e) flag.push('ExtensionProtocol') + if (Environment.HasBrowserAPI & e) flag.push('HasBrowserAPI') + if (Environment.ManifestBackground & e) flag.push('ManifestBackground') + if (Environment.ManifestDevTools & e) flag.push('ManifestDevTools') + if (Environment.ManifestOptions & e) flag.push('ManifestOptions') + if (Environment.ManifestPageAction & e) flag.push('ManifestPageAction') + if (Environment.ManifestOverridesBookmarks & e) flag.push('ManifestOverridesBookmarks') + if (Environment.ManifestOverridesHistory & e) flag.push('ManifestOverridesHistory') + if (Environment.ManifestOverridesNewTab & e) flag.push('ManifestOverridesNewTab') + if (Environment.ManifestBrowserAction & e) flag.push('ManifestBrowserAction') + if (Environment.ManifestSidebar & e) flag.push('ManifestSidebar') + return flag.join('|') +} + /** * Make sure this file only run in wanted context * @param context - Wanted context or contexts * @param name - name to throw + * @deprecated Remove in 0.9.0, use assertEnvironment */ export function OnlyRunInContext(context: Contexts | Contexts[], name: string): void /** * Make sure this file only run in wanted context * @param context - Wanted context or contexts * @param throws - set to false, OnlyRunInContext will not throws but return a boolean + * @deprecated Remove in 0.9.0, use isEnvironment */ export function OnlyRunInContext(context: Contexts | Contexts[], throws: false): boolean export function OnlyRunInContext(context: Contexts | Contexts[], name: string | false) { @@ -51,3 +150,36 @@ export function OnlyRunInContext(context: Contexts | Contexts[], name: string | } return true } + +/** + * Assert the current environment satisfy the expectation + * @param env The expected environment + */ +export function assertEnvironment(env: Environment) { + if (!isEnvironment(env)) + throw new TypeError( + `Running in the wrong context, (expected ${printExtensionEnvironment( + env, + )}, actually ${printExtensionEnvironment()})`, + ) +} +/** + * Assert the current environment NOT satisfy the rejected flags + * @param env The rejected environment + */ +export function assertNotEnvironment(env: Environment) { + if (getExtensionEnvironment() & env) + throw new TypeError( + `Running in wrong context, (expected not match ${printExtensionEnvironment( + env, + )}, actually ${printExtensionEnvironment()})`, + ) +} +/** + * Check if the current environment satisfy the expectation + * @param env The expectation environment + */ +export function isEnvironment(env: Environment) { + const now = getExtensionEnvironment() + return Boolean(env & now) +} diff --git a/src/Extension/MessageCenter.ts b/src/Extension/MessageCenter.ts index 54d25b7..af3712f 100644 --- a/src/Extension/MessageCenter.ts +++ b/src/Extension/MessageCenter.ts @@ -23,6 +23,7 @@ type InternalMessageType = { const noop = () => {} /** * Send and receive messages in different contexts. + * @deprecated Remove in 0.9.0 */ export class MessageCenter { /** diff --git a/src/Extension/MessageChannel.ts b/src/Extension/MessageChannel.ts new file mode 100644 index 0000000..6e95799 --- /dev/null +++ b/src/Extension/MessageChannel.ts @@ -0,0 +1,313 @@ +import { NoSerialization } from 'async-call-rpc' +import { Serialization } from './MessageCenter' +import { Emitter } from '@servie/events' +import { EventIterator } from 'event-iterator' +import { assertEnvironment, Environment, getExtensionEnvironment, isEnvironment } from './Context' + +export enum MessageTarget { + /** Current execution context */ IncludeLocal = 1 << 20, + LocalOnly = 1 << 21, + /** Visible page, maybe have more than 1 page. */ VisiblePageOnly = 1 << 22, + /** Page that has focus (devtools not included), 0 or 1 page. */ FocusedPageOnly = 1 << 23, + Broadcast = Environment.HasBrowserAPI, + All = Broadcast | IncludeLocal, +} +export interface TargetBoundEventRegistry { + /** @returns A function to remove the listener */ + on(callback: (data: T) => void): () => void + off(callback: (data: T) => void): void + send(data: T): void +} +// export interface EventTargetRegistry extends EventTarget {} +// export interface EventEmitterRegistry extends NodeJS.EventEmitter {} +export interface UnboundedRegistry extends Omit, 'send'>, AsyncIterable { + // For different send targets + send(target: MessageTarget | Environment, data: T): void + sendToLocal(data: T): void + sendToBackgroundPage(data: T): void + sendToContentScripts(data: T): void + sendToVisiblePages(data: T): void + sendToFocusedPage(data: T): void + sendByBroadcast(data: T): void + sendToAll(data: T): void + /** You may create a bound version that have a clear interface. */ + bind(target: MessageTarget | Environment): BindType +} +export interface WebExtensionMessageOptions { + readonly domain?: string +} +const throwSetter = () => { + throw new TypeError() +} +type BackgroundOnlyLivingPortsInfo = { + sender?: browser.runtime.MessageSender + environment?: Environment +} + +// Only available in background page +const backgroundOnlyLivingPorts = new Map() +// Only be set in other pages +let currentTabID = -1 +// Shared global +let postMessage: ((message: number | InternalMessageType) => void) | undefined = undefined +const domainRegistry = new Emitter>() +const constant = '@holoflows/kit/WebExtensionMessage/setupBroadcastBetweenContentScripts' +export class WebExtensionMessage { + // Only execute once. + private static setup() { + if (isEnvironment(Environment.ManifestBackground)) { + WebExtensionMessage.setup = () => {} + // Wait for other pages to connect + browser.runtime.onConnect.addListener((port) => { + if (port.name !== constant) return // not for ours + const sender = port.sender + backgroundOnlyLivingPorts.set(port, { sender }) + // let the client know it's tab id + // sender.tab might be undefined if it is a popup + // TODO: check sender if same as ourself? Support external / cross-extension message? + port.postMessage(sender?.tab?.id ?? -1) + // Client will report it's environment flag on connection + port.onMessage.addListener(function environmentListener(x) { + backgroundOnlyLivingPorts.get(port)!.environment = Number(x) + port.onMessage.removeListener(environmentListener) + }) + port.onMessage.addListener(backgroundPageMessageHandler.bind(port)) + port.onDisconnect.addListener(() => backgroundOnlyLivingPorts.delete(port)) + }) + postMessage = backgroundPageMessageHandler + } else { + WebExtensionMessage.setup = () => {} + function reconnect() { + const port = browser.runtime.connect({ name: constant }) + postMessage = (payload) => { + if (typeof payload !== 'object') return port.postMessage(payload) + + const bound = payload.target + if (bound.kind === 'tab') return port.postMessage(payload) + if (bound.kind === 'port') + throw new Error('Unreachable case: bound type = port in non-background script') + const target = bound.target + if (target & (MessageTarget.IncludeLocal | MessageTarget.LocalOnly)) { + domainRegistry.emit(payload.domain, payload) + if (target & MessageTarget.LocalOnly) return + bound.target &= ~MessageTarget.IncludeLocal // unset IncludeLocal + } + port.postMessage(payload) + } + // report self environment + port.postMessage(getExtensionEnvironment()) + // server will send self tab ID on connected + port.onMessage.addListener(function tabIDListener(x) { + currentTabID = Number(x) + port.onMessage.removeListener(tabIDListener) + }) + port.onMessage.addListener((data) => { + if (!isInternalMessageType(data)) return + domainRegistry.emit(data.domain, data) + }) + // ? Will it cause infinite loop? + port.onDisconnect.addListener(reconnect) + } + reconnect() + } + } + #domain: string + /** Same message name within different domain won't collide with each other. */ + get domain() { + return this.#domain + } + /** + * @param options WebExtensionMessage options + */ + constructor(options?: WebExtensionMessageOptions) { + WebExtensionMessage.setup() + this.#domain = options?.domain ?? '' + } + private __createEventObject__(event: string): any { + return UnboundedRegistry(this, event, this.#eventRegistry, (target) => + TargetBoundEventRegisterImpl( + this.#domain, + event, + this.#eventRegistry, + { kind: 'target', target }, + this.serialization, + ), + ) + } + //#region Simple API + #events: any = new Proxy({ __proto__: null } as any, { + get: (cache, key) => { + if (typeof key !== 'string') throw new Error('Only string can be event keys') + if (cache[key]) return cache[key] + const event = this.__createEventObject__(key) + Object.defineProperty(cache, key, { value: event }) + return event + }, + defineProperty: () => false, + setPrototypeOf: () => false, + set: throwSetter, + }) + /** Event listeners */ + get events(): { readonly [K in keyof Message]: UnboundedRegistry> } { + return this.#events + } + //#endregion + + // declare readonly eventTarget: { readonly [key in keyof Message]: UnboundedRegister> } + // declare readonly eventEmitter: { readonly [key in keyof Message]: UnboundedRegister> } + /** + * Watch new tabs created and get event listener register of that tab. + * + * This API only works in the BackgroundPage. + */ + public serialization: Serialization = NoSerialization + public logFormatter: (instance: this, key: string, data: unknown) => unknown[] = (instance, key, data) => { + return [ + `%cReceive%c %c${String(key)}`, + 'background: rgba(0, 255, 255, 0.6); color: black; padding: 0px 6px; border-radius: 4px;', + '', + 'text-decoration: underline', + data, + ] + } + public enableLog = false + public log: (...args: unknown[]) => void = console.log + #eventRegistry: EventRegistry = new Emitter() + protected get eventRegistry() { + return this.#eventRegistry + } +} + +type InternalMessageType = { + domain: string + event: string + data: unknown + target: BoundTarget +} +function isInternalMessageType(e: unknown): e is InternalMessageType { + if (typeof e !== 'object' || e === null) return false + const { domain, event, target } = e as InternalMessageType + // Message is not for us + if (typeof domain !== 'string') return false + if (typeof event !== 'string') return false + if (typeof target !== 'object' || target === null) return false + return true +} +function shouldAcceptThisMessage(target: BoundTarget) { + if (target.kind === 'tab') return target.id === currentTabID + if (target.kind === 'port') return true + const flag = target.target + if (flag & (MessageTarget.IncludeLocal | MessageTarget.LocalOnly)) return true + const here = getExtensionEnvironment() + if (flag & MessageTarget.FocusedPageOnly) return typeof document === 'object' && document?.hasFocus?.() + if (flag & MessageTarget.VisiblePageOnly) { + // background page has document.visibilityState === 'visible' for reason I don't know why + if (here & Environment.ManifestBackground) return false + return typeof document === 'object' && document?.visibilityState === 'visible' + } + return Boolean(here & flag) +} +function UnboundedRegistry( + instance: WebExtensionMessage, + eventName: string, + eventListener: Emitter, + createBind: (f: MessageTarget | Environment) => Binder, +): UnboundedRegistry { + domainRegistry.on(instance.domain, async function (payload: InternalMessageType) { + if (!isInternalMessageType(payload)) return + let { event, data, target } = payload + if (!shouldAcceptThisMessage(target)) return + data = await instance.serialization.deserialization(data) + if (instance.enableLog) { + instance.log(...instance.logFormatter(instance, event, data)) + } + eventListener.emit(event, data) + }) + async function send(target: MessageTarget | Environment, data: T) { + if (typeof target !== 'number') throw new TypeError('target must be a bit flag of MessageTarget | Environment') + postMessage!({ + data: await instance.serialization.serialization(data), + domain: instance.domain, + event: eventName, + target: { kind: 'target', target }, + }) + } + let binder: Binder + return { + send, + sendToLocal: send.bind(null, MessageTarget.LocalOnly), + sendToBackgroundPage: send.bind(null, Environment.ManifestBackground), + sendToContentScripts: send.bind(null, Environment.ContentScript), + sendToVisiblePages: send.bind(null, MessageTarget.VisiblePageOnly), + sendToFocusedPage: send.bind(null, MessageTarget.FocusedPageOnly), + sendByBroadcast: send.bind(null, MessageTarget.Broadcast), + sendToAll: send.bind(null, MessageTarget.All), + bind: (target) => { + if (typeof binder === 'undefined') { + binder = createBind(target) + createBind = undefined! + } + return binder + }, + on: (cb) => (eventListener.on(eventName, cb), () => eventListener.off(eventName, cb)), + off: (cb) => void eventListener.off(eventName, cb), + async *[Symbol.asyncIterator]() { + yield* new EventIterator(({ push }) => this.on(push)) + }, + } +} + +type EventRegistry = Emitter> +type BoundTarget = + | { kind: 'tab'; id: number } + | { kind: 'target'; target: MessageTarget | Environment } + | { kind: 'port'; port: browser.runtime.Port } + +function TargetBoundEventRegisterImpl( + domain: string, + event: string, + eventRegistry: EventRegistry, + boundTarget: BoundTarget, + serialization: Serialization, +): TargetBoundEventRegistry { + return { + on: (callback) => (eventRegistry.on(event, callback), () => eventRegistry.off(event, callback)), + off: (callback) => eventRegistry.off(event, callback), + send: async (data) => + postMessage!({ + data: await serialization.serialization(data), + domain, + event, + target: boundTarget, + }), + } +} + +function backgroundPageMessageHandler(this: browser.runtime.Port | undefined, data: unknown) { + // receive payload from the other side + if (!isInternalMessageType(data)) return + if (data.target.kind === 'tab') { + for (const [port, { sender }] of backgroundOnlyLivingPorts) { + if (data.target.id !== sender?.tab?.id) continue + return port.postMessage(data) + } + } else if (data.target.kind === 'port') { + data.target.port.postMessage(data) + } else { + const flag = data.target.target + // Also dispatch this message to background page itself. shouldAcceptThisMessage will help us to filter the message + domainRegistry.emit(data.domain, data) + if (flag & MessageTarget.LocalOnly) return + for (const [port, { environment }] of backgroundOnlyLivingPorts) { + if (port === this) continue // Not sending to the source. + if (environment === undefined) continue + try { + if (environment & flag) port.postMessage(data) + // they will handle this by thyself + else if (flag & (MessageTarget.FocusedPageOnly | MessageTarget.VisiblePageOnly)) port.postMessage(data) + } catch (e) { + console.error(e) + } + } + } +} diff --git a/src/Extension/index.ts b/src/Extension/index.ts index 079bdaa..2d95bf4 100644 --- a/src/Extension/index.ts +++ b/src/Extension/index.ts @@ -1,3 +1,4 @@ export * from './Context' export * from './MessageCenter' export * from './AutomatedTabTask' +export * from './MessageChannel' diff --git a/src/types/concurrent-lock.d.ts b/src/types/concurrent-lock.d.ts deleted file mode 100644 index db1000c..0000000 --- a/src/types/concurrent-lock.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module 'concurrent-lock' { - export default class Lock { - constructor(limit: number) - isLocked(): boolean - lock(timeout: number): Promise - unlock(): void - } -} diff --git a/src/util/ConcurrentLock.ts b/src/util/ConcurrentLock.ts new file mode 100644 index 0000000..300d8a6 --- /dev/null +++ b/src/util/ConcurrentLock.ts @@ -0,0 +1,51 @@ +/** + * This file is copied from https://github.com/yusukeshibata/concurrent-lock + * + * It is licensed under the MIT license. + * I copy it here because it is introducing @babel/runtime as a runtime dependency. + */ +class Signal { + static instances: Signal[] = [] + private _fn: Function | undefined + static async wait(timeout: number) { + const signal = new Signal() + this.instances.push(signal) + await signal._wait(timeout) + } + static fire() { + const signal = this.instances.shift() + if (signal) signal._fire() + } + _fire(err?: Error) { + const fn = this._fn + delete this._fn + if (fn) fn(err) + } + _wait(timeout: number | undefined) { + return new Promise((resolve, reject) => { + this._fn = (err: any) => (err ? reject(err) : resolve()) + if (timeout !== undefined) setTimeout(() => this._fire(new Error('Timeout')), timeout) + }) + } +} + +export default class Lock { + private _locked = 0 + constructor(private _limit = 1) {} + isLocked() { + return this._locked >= this._limit + } + async lock(timeout: any) { + if (this.isLocked()) { + await Signal.wait(timeout) + } + this._locked++ + } + unlock() { + if (this._locked <= 0) { + throw new Error('Already unlocked') + } + this._locked-- + Signal.fire() + } +} diff --git a/test-extension/background.html b/test-extension/background.html new file mode 100644 index 0000000..0b30cad --- /dev/null +++ b/test-extension/background.html @@ -0,0 +1,13 @@ + + + + + + Document + + + + + + + diff --git a/test-extension/devtools.html b/test-extension/devtools.html new file mode 100644 index 0000000..0b30cad --- /dev/null +++ b/test-extension/devtools.html @@ -0,0 +1,13 @@ + + + + + + Document + + + + + + + diff --git a/test-extension/index.html b/test-extension/index.html new file mode 100644 index 0000000..9a2dbec --- /dev/null +++ b/test-extension/index.html @@ -0,0 +1,19 @@ + + + + + + Document + + + + + + + + diff --git a/test-extension/manifest.json b/test-extension/manifest.json new file mode 100644 index 0000000..07051fc --- /dev/null +++ b/test-extension/manifest.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json.schemastore.org/chrome-manifest", + "name": "holoflows-kit test", + "version": "1.0.0", + "manifest_version": 2, + "permissions": ["", "tabs"], + "background": { + "scripts": ["./node_modules/webextension-polyfill/dist/browser-polyfill.js", "kit.js", "setup.js"] + }, + "options_ui": { "page": "index.html", "open_in_tab": true }, + "browser_action": { "default_popup": "popup.html" }, + "content_scripts": [ + { + "matches": [""], + "js": ["./node_modules/webextension-polyfill/dist/browser-polyfill.js", "kit.js", "setup.js"] + } + ], + "devtools_page": "devtools.html", + "_page_action": { "default_popup": "popup.html" } +} diff --git a/test-extension/package.json b/test-extension/package.json new file mode 100644 index 0000000..c43cf58 --- /dev/null +++ b/test-extension/package.json @@ -0,0 +1,14 @@ +{ + "name": "test-extension", + "version": "1.0.0", + "description": "", + "main": "kit.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "webextension-polyfill": "^0.6.0" + } +} diff --git a/test-extension/pnpm-lock.yaml b/test-extension/pnpm-lock.yaml new file mode 100644 index 0000000..92ce789 --- /dev/null +++ b/test-extension/pnpm-lock.yaml @@ -0,0 +1,10 @@ +dependencies: + webextension-polyfill: 0.6.0 +lockfileVersion: 5.2 +packages: + /webextension-polyfill/0.6.0: + dev: false + resolution: + integrity: sha512-PlYwiX8e4bNZrEeBFxbFFsLtm0SMPxJliLTGdNCA0Bq2XkWrAn2ejUd+89vZm+8BnfFB1BclJyCz3iKsm2atNg== +specifiers: + webextension-polyfill: ^0.6.0 diff --git a/test-extension/popup.html b/test-extension/popup.html new file mode 100644 index 0000000..0b30cad --- /dev/null +++ b/test-extension/popup.html @@ -0,0 +1,13 @@ + + + + + + Document + + + + + + + diff --git a/test-extension/setup.js b/test-extension/setup.js new file mode 100644 index 0000000..9d8c7f3 --- /dev/null +++ b/test-extension/setup.js @@ -0,0 +1,42 @@ +Object.assign(globalThis, HoloflowsKit) +globalThis.sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const output = document.body.appendChild(document.createElement('textarea')) +output.readOnly = true +output.style.display = 'display' +const input = document.body.appendChild(document.createElement('input')) +input.style.display = 'display' +const msg = new WebExtensionMessage() + +msg.events.hello.on((msg) => { + output.value = msg + '\n' + output.value + console.log(msg) +}) +function send(value, target = MessageTarget.All) { + msg.events.hello.send(target, value) +} +input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + send( + input.value, + [...select.selectedOptions].reduce((p, x) => p | x.value, 0), + ) + input.value = '' + } +}) +const select = document.body.appendChild(document.createElement('select')) +select.multiple = true +select.style.display = 'block' +select.style.height = '28em' +for (const key in Environment) { + if (!Number.isNaN(parseFloat(key))) continue + const value = select.appendChild(document.createElement('option')) + value.innerText = key + value.value = Environment[key] +} +for (const key in MessageTarget) { + if (!Number.isNaN(parseFloat(key))) continue + const value = select.appendChild(document.createElement('option')) + value.innerText = key + value.value = MessageTarget[key] +}