/** * OfferPie 跨标签页通信桥接组件 * 用于浏览器插件与网站页面之间的纯本地通信(不依赖任何后端接口) * * 【使用说明】 * 1. 引入并创建实例: * import { createChannelBridge } from './channelBridge' * const bridge = createChannelBridge({ domain: 'offerpai.com', isPlugin: true }) * * 2. 发送数据(单次,同时写缓存 + 广播): * bridge.put('offerpai.com', 'resumeStatus', { ... }) * * 3. 高频发送(节流写缓存,每次都广播): * bridge.emit('offerpai.com', 'cursorPosition', { ... }) * * 4. 主动获取一次数据(先广播问对方,60ms超时转IndexedDB): * const result = await bridge.get('offerpai.com', 'resumeStatus') * * 5. 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit): * const unwatch = bridge.watch('offerpai.com', 'resumeStatus', (data) => { ... }) * // 取消监听 * unwatch() * * 【跨域模式 useCsl】 * 在 put/emit/get/watch 最后一个参数传 true,数据存储走 chrome.storage.local(通过 Background 中转) * 适用于网页与插件在不同域名标签页之间的通信场景 * 示例:bridge.put('offerpai.com', 'someData', { ... }, true) * 注意:useCsl 模式依赖插件 Background Service Worker 在线 * * 6. 销毁实例(页面卸载时调用): * bridge.destroy() * * 【参数说明】 * - domain: 频道域名标识,不同网站项目用不同域名隔离 * - isPlugin: 是否为插件环境,默认 false(非插件时会启用 Web Lock 保活) * * 【数据类型要求】 * - 传递的数据必须有 TypeScript 类型定义(泛型约束 extends object) * - 组件自动为每条数据添加 createTime 和 updateTime 字段 * * ============================================================ * 【通信数据名称注册表】 * 在此区域记录所有通信数据的名称、类型和用途说明 * 新增数据时在此补充,删除时移除对应行 * ------------------------------------------------------------ * | 数据名(name) | 中文名称 | 说明 | * | ----------------------------- | -------------- | --------------------------------- | * | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } | * | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] } | * ============================================================ */ // ============ 类型定义 ============ /** 组件自动附加的时间戳字段 */ export interface BridgeTimestamp { /** 数据首次创建时间(格式:yyyy-MM-dd HH:mm:ss) */ createTime: string /** 数据最近更新时间(格式:yyyy-MM-dd HH:mm:ss) */ updateTime: string } /** 带时间戳的完整数据包装 */ export type BridgeData = T & BridgeTimestamp /** IndexedDB 存储记录结构 */ interface DBRecord { /** 数据名称(唯一索引) */ name: string /** JSON 数据本体 */ data: object /** 创建时间 */ createTime: string /** 更新时间 */ updateTime: string } /** BroadcastChannel 消息结构 */ interface ChannelMessage { /** 消息类型 */ type: 'put' | 'emit' | 'get-request' | 'get-response' | 'csl-put-request' | 'csl-put-response' | 'csl-get-request' | 'csl-get-response' /** 数据名称 */ name: string /** 请求唯一ID(用于 get 请求-响应匹配) */ id?: string /** 数据本体(put/emit/get-response 携带) */ data?: object /** 创建时间 */ createTime?: string /** 更新时间 */ updateTime?: string /** 目标域名(csl 中转消息使用) */ domain?: string } /** watch 回调函数类型 */ type WatchCallback = (data: BridgeData | null) => void /** 创建实例的配置参数 */ export interface ChannelBridgeOptions { /** 默认域名(可在方法调用时覆盖) */ domain: string /** 是否为插件环境,默认 false。非插件时启用 Web Lock 保活 */ isPlugin?: boolean } // ============ 常量 ============ /** IndexedDB 数据库名称(含固定雪花ID保证唯一性) */ const DB_NAME = 'offerpie_comm_7394028156183472' /** get 方法 BroadcastChannel 超时时间(毫秒) */ const GET_TIMEOUT_MS = 60 /** emit 方法节流写库间隔(毫秒) */ const EMIT_THROTTLE_MS = 500 /** chrome.storage.local 存储总 key */ const CSL_STORAGE_KEY = 'offerpie_comm_7394028156183472' /** Background 消息类型前缀(用于 useCsl 模式) */ const CSL_MSG_PREFIX = 'OFFERPIE_CSL' // ============ 工具函数 ============ /** 获取当前时间的 localDateTime 格式字符串:yyyy-MM-dd HH:mm:ss */ function nowLocalDateTime(): string { const d = new Date() const pad = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } /** 生成简单唯一ID(用于 get 请求匹配) */ function uid(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) } // ============ chrome.storage.local 操作(useCsl 模式) ============ /** 检测 chrome.runtime.sendMessage 是否可用(插件环境) */ function isChromeRuntimeAvailable(): boolean { return typeof chrome !== 'undefined' && !!chrome.runtime && !!chrome.runtime.sendMessage } /** * 通过 Background 写入 chrome.storage.local * 插件环境直接发 chrome.runtime.sendMessage * 网页环境自动走 BroadcastChannel 中转给同域 Content Script 代理 */ function cslPut(domain: string, name: string, data: object, createTime: string, updateTime: string): Promise { return new Promise((resolve, reject) => { if (isChromeRuntimeAvailable()) { // 插件环境:直接发给 Background chrome.runtime.sendMessage( { type: `${CSL_MSG_PREFIX}_PUT`, domain, name, data, createTime, updateTime }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)) } else { resolve() } } ) } else { // 网页环境:通过 BroadcastChannel 请求同域 Content Script 代理 const requestId = uid() const ch = _getCslProxyChannel(domain) const handler = (event: MessageEvent) => { if (event.data.type === 'csl-put-response' && event.data.id === requestId) { resolve() } } ch.addEventListener('message', handler) ch.postMessage({ type: 'csl-put-request', id: requestId, name, data, createTime, updateTime, domain } as ChannelMessage) // 超时兜底 setTimeout(() => { ch.removeEventListener('message', handler) resolve() // 即使超时也不阻塞 }, 3000) } }) } /** * 通过 Background 从 chrome.storage.local 读取数据 * 插件环境直接发 chrome.runtime.sendMessage * 网页环境自动走 BroadcastChannel 中转给同域 Content Script 代理 */ function cslGet(domain: string, name: string): Promise { return new Promise((resolve, reject) => { if (isChromeRuntimeAvailable()) { // 插件环境:直接发给 Background chrome.runtime.sendMessage( { type: `${CSL_MSG_PREFIX}_GET`, domain, name }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)) } else { resolve(response?.record || null) } } ) } else { // 网页环境:通过 BroadcastChannel 请求同域 Content Script 代理 const requestId = uid() const ch = _getCslProxyChannel(domain) const handler = (event: MessageEvent) => { if (event.data.type === 'csl-get-response' && event.data.id === requestId) { ch.removeEventListener('message', handler) const record = event.data.data && event.data.createTime && event.data.updateTime ? { name: event.data.name, data: event.data.data, createTime: event.data.createTime, updateTime: event.data.updateTime } : null resolve(record) } } ch.addEventListener('message', handler) ch.postMessage({ type: 'csl-get-request', id: requestId, name, domain } as ChannelMessage) // 超时兜底 setTimeout(() => { ch.removeEventListener('message', handler) resolve(null) }, 3000) } }) } /** CSL 代理频道缓存 */ const _cslProxyChannels = new Map() /** 获取 CSL 代理用的 BroadcastChannel(与主频道分开,避免消息混淆) */ function _getCslProxyChannel(domain: string): BroadcastChannel { if (_cslProxyChannels.has(domain)) return _cslProxyChannels.get(domain)! const ch = new BroadcastChannel(`offerpie-bridge-csl-${domain}`) _cslProxyChannels.set(domain, ch) return ch } // ============ IndexedDB 操作 ============ /** 数据库连接缓存(避免重复打开和版本冲突) */ const _dbCache = new Map>() /** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore,带连接缓存防并发冲突 */ function openDB(storeName: string): Promise { // 如果已有进行中或已完成的连接,直接复用 if (_dbCache.has(storeName)) { return _dbCache.get(storeName)!.then(db => { // 检查连接是否还有效(可能被 close 了) try { // 尝试创建事务验证连接有效性 if (db.objectStoreNames.contains(storeName)) { return db } } catch { // 连接已关闭,清除缓存重新打开 _dbCache.delete(storeName) } return openDB(storeName) }) } const promise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME) request.onupgradeneeded = () => { const db = request.result if (!db.objectStoreNames.contains(storeName)) { const store = db.createObjectStore(storeName, { keyPath: 'name' }) store.createIndex('name', 'name', { unique: true }) } } request.onsuccess = () => { const db = request.result if (!db.objectStoreNames.contains(storeName)) { // store 不存在,需要升版本。关闭当前连接再重开 db.close() _dbCache.delete(storeName) const version = db.version + 1 const req2 = indexedDB.open(DB_NAME, version) req2.onupgradeneeded = () => { const db2 = req2.result if (!db2.objectStoreNames.contains(storeName)) { const store = db2.createObjectStore(storeName, { keyPath: 'name' }) store.createIndex('name', 'name', { unique: true }) } } req2.onsuccess = () => resolve(req2.result) req2.onerror = () => { _dbCache.delete(storeName); reject(req2.error) } req2.onblocked = () => { // 其他标签页占用数据库导致升级被阻塞,超时后放弃 console.warn('[ChannelBridge] IndexedDB upgrade blocked, retrying...') _dbCache.delete(storeName) reject(new Error('IndexedDB upgrade blocked')) } } else { resolve(db) } } request.onerror = () => { _dbCache.delete(storeName); reject(request.error) } request.onblocked = () => { console.warn('[ChannelBridge] IndexedDB open blocked') _dbCache.delete(storeName) reject(new Error('IndexedDB open blocked')) } }) _dbCache.set(storeName, promise) return promise } /** 写入或更新一条记录到 IndexedDB */ async function dbPut(storeName: string, record: DBRecord): Promise { try { const db = await openDB(storeName) return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite') const store = tx.objectStore(storeName) store.put(record) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) } catch (err) { console.warn('[ChannelBridge] dbPut failed:', err) } } /** 从 IndexedDB 读取一条记录 */ async function dbGet(storeName: string, name: string): Promise { try { const db = await openDB(storeName) return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly') const store = tx.objectStore(storeName) const req = store.get(name) req.onsuccess = () => resolve(req.result || null) req.onerror = () => reject(req.error) }) } catch (err) { console.warn('[ChannelBridge] dbGet failed:', err) return null } } // ============ 核心:创建通信桥接实例 ============ export function createChannelBridge(options: ChannelBridgeOptions) { const { domain, isPlugin = false } = options /** BroadcastChannel 实例缓存(按 domain 隔离频道) */ const channels = new Map() /** watch 订阅回调注册表:key = `${domain}::${name}` */ const watchers = new Map>>() /** emit 节流定时器:key = `${domain}::${name}` */ const emitTimers = new Map>() /** emit 节流暂存最新数据:key = `${domain}::${name}` */ const emitPending = new Map() /** get 请求等待队列:key = requestId */ const getResolvers = new Map | null) => void>() // --- Web Lock 保活(非插件环境) --- if (!isPlugin && typeof navigator !== 'undefined' && navigator.locks) { navigator.locks.request('offerpie-keep-alive', () => new Promise(() => {})) } /** 获取或创建指定 domain 的 BroadcastChannel */ function getChannel(channelDomain: string): BroadcastChannel { if (channels.has(channelDomain)) return channels.get(channelDomain)! const ch = new BroadcastChannel(`offerpie-bridge-${channelDomain}`) ch.onmessage = (event: MessageEvent) => handleMessage(channelDomain, event.data) channels.set(channelDomain, ch) return ch } /** 处理收到的 BroadcastChannel 消息 */ async function handleMessage(channelDomain: string, msg: ChannelMessage) { const watchKey = `${channelDomain}::${msg.name}` if (msg.type === 'put' || msg.type === 'emit') { // 收到对方的数据推送,触发本地 watch 回调 const wrapped = msg.data && msg.createTime && msg.updateTime ? { ...msg.data, createTime: msg.createTime, updateTime: msg.updateTime } as BridgeData : null const callbacks = watchers.get(watchKey) if (callbacks) { callbacks.forEach(cb => cb(wrapped)) } } if (msg.type === 'get-request' && msg.id) { // 收到对方的 get 请求,从本地 IndexedDB 读取数据并回复 const record = await dbGet(channelDomain, msg.name) const ch = getChannel(channelDomain) const response: ChannelMessage = { type: 'get-response', name: msg.name, id: msg.id, data: record?.data, createTime: record?.createTime, updateTime: record?.updateTime, } ch.postMessage(response) } if (msg.type === 'get-response' && msg.id) { // 收到对方对 get 请求的回复 const resolver = getResolvers.get(msg.id) if (resolver) { getResolvers.delete(msg.id) if (msg.data && msg.createTime && msg.updateTime) { resolver({ ...msg.data, createTime: msg.createTime, updateTime: msg.updateTime } as BridgeData) } else { resolver(null) } } } } // 初始化默认频道监听 getChannel(domain) // --- CSL 代理监听(插件环境下,监听网页端的 CSL 中转请求) --- if (isPlugin && isChromeRuntimeAvailable()) { const cslProxyCh = new BroadcastChannel(`offerpie-bridge-csl-${domain}`) cslProxyCh.onmessage = async (event: MessageEvent) => { const msg = event.data if (msg.type === 'csl-put-request' && msg.id) { // 代理写入 chrome.storage.local const targetDomain = msg.domain || domain await new Promise((resolve) => { chrome.runtime.sendMessage( { type: `${CSL_MSG_PREFIX}_PUT`, domain: targetDomain, name: msg.name, data: msg.data, createTime: msg.createTime, updateTime: msg.updateTime }, () => resolve() ) }) cslProxyCh.postMessage({ type: 'csl-put-response', id: msg.id, name: msg.name } as ChannelMessage) } if (msg.type === 'csl-get-request' && msg.id) { // 代理读取 chrome.storage.local const targetDomain = msg.domain || domain const response = await new Promise((resolve) => { chrome.runtime.sendMessage( { type: `${CSL_MSG_PREFIX}_GET`, domain: targetDomain, name: msg.name }, (res) => resolve(res) ) }) const record = response?.record cslProxyCh.postMessage({ type: 'csl-get-response', id: msg.id, name: msg.name, data: record?.data, createTime: record?.createTime, updateTime: record?.updateTime, } as ChannelMessage) } } } // --- 公开方法 --- /** * put - 单次发送数据(写入 IndexedDB + BroadcastChannel 广播) * @param targetDomain 目标域名频道 * @param name 数据名称 * @param data 数据本体(需有 TS 类型定义) * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) */ async function put(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise { const now = nowLocalDateTime() if (useCsl) { // 跨域模式:通过 Background 读写 chrome.storage.local const existing = await cslGet(targetDomain, name).catch(() => null) const createTime = existing?.createTime || now const updateTime = now await cslPut(targetDomain, name, data, createTime, updateTime) // 同时广播(同域标签页也能收到) const ch = getChannel(targetDomain) const msg: ChannelMessage = { type: 'put', name, data, createTime, updateTime } ch.postMessage(msg) return } // 默认模式:IndexedDB const existing = await dbGet(targetDomain, name) const createTime = existing?.createTime || now const updateTime = now const record: DBRecord = { name, data, createTime, updateTime } // 写入 IndexedDB await dbPut(targetDomain, record) // BroadcastChannel 广播 const ch = getChannel(targetDomain) const msg: ChannelMessage = { type: 'put', name, data, createTime, updateTime } ch.postMessage(msg) } /** * emit - 高频发送数据(每次都广播,节流 500ms 写一次 IndexedDB) * @param targetDomain 目标域名频道 * @param name 数据名称 * @param data 数据本体(需有 TS 类型定义) * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) */ async function emit(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise { const now = nowLocalDateTime() const key = `${targetDomain}::${name}` if (useCsl) { // 跨域模式:每次广播,节流写 chrome.storage.local let createTime: string const pending = emitPending.get(key) if (pending) { createTime = pending.createTime } else { const existing = await cslGet(targetDomain, name).catch(() => null) createTime = existing?.createTime || now } const updateTime = now const record: DBRecord = { name, data, createTime, updateTime } emitPending.set(key, record) // BroadcastChannel 每次都广播 const ch = getChannel(targetDomain) const msg: ChannelMessage = { type: 'emit', name, data, createTime, updateTime } ch.postMessage(msg) // 节流写 chrome.storage.local if (!emitTimers.has(key)) { emitTimers.set(key, setTimeout(async () => { emitTimers.delete(key) const latestRecord = emitPending.get(key) if (latestRecord) { emitPending.delete(key) await cslPut(targetDomain, latestRecord.name, latestRecord.data, latestRecord.createTime, latestRecord.updateTime).catch(() => {}) } }, EMIT_THROTTLE_MS)) } return } // 默认模式:IndexedDB // 先尝试获取 createTime(从暂存或内存缓存) let createTime: string const pending = emitPending.get(key) if (pending) { createTime = pending.createTime } else { // 只在首次查库获取 createTime,失败就用当前时间(不阻塞后续 emit) let existing: DBRecord | null = null try { existing = await dbGet(targetDomain, name) } catch {} createTime = existing?.createTime || now } const updateTime = now const record: DBRecord = { name, data, createTime, updateTime } // 暂存最新数据(不再 delete,保证后续 emit 总能命中缓存不走 dbGet) emitPending.set(key, record) // BroadcastChannel 每次都广播(保证 watch 实时性) const ch = getChannel(targetDomain) const msg: ChannelMessage = { type: 'emit', name, data, createTime, updateTime } ch.postMessage(msg) // 节流写库 if (!emitTimers.has(key)) { emitTimers.set(key, setTimeout(async () => { emitTimers.delete(key) const latestRecord = emitPending.get(key) if (latestRecord) { // 注意:不再 delete emitPending,让下次 emit 继续命中缓存 await dbPut(targetDomain, latestRecord) } }, EMIT_THROTTLE_MS)) } } /** * get - 主动获取一次数据(先 BroadcastChannel 请求,60ms 超时转 IndexedDB) * @param targetDomain 目标域名频道 * @param name 数据名称 * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) * @returns 数据本体(含 createTime/updateTime),无数据返回 null */ async function get(targetDomain: string, name: string, useCsl?: boolean): Promise | null> { if (useCsl) { // 跨域模式:先广播请求,超时读 chrome.storage.local const ch = getChannel(targetDomain) const requestId = uid() const msg: ChannelMessage = { type: 'get-request', name, id: requestId } ch.postMessage(msg) const result = await new Promise | null>((resolve) => { getResolvers.set(requestId, resolve as any) setTimeout(() => { if (getResolvers.has(requestId)) { getResolvers.delete(requestId) resolve(null) } }, GET_TIMEOUT_MS) }) if (result) return result // 超时降级读 chrome.storage.local const record = await cslGet(targetDomain, name).catch(() => null) if (record) { return { ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData } return null } // 默认模式:IndexedDB const ch = getChannel(targetDomain) const requestId = uid() // 发起广播请求 const msg: ChannelMessage = { type: 'get-request', name, id: requestId } ch.postMessage(msg) // 等待响应或超时 const result = await new Promise | null>((resolve) => { getResolvers.set(requestId, resolve as any) setTimeout(() => { if (getResolvers.has(requestId)) { getResolvers.delete(requestId) resolve(null) // 超时,标记为未收到响应 } }, GET_TIMEOUT_MS) }) // 如果广播拿到了数据直接返回 if (result) return result // 超时降级读 IndexedDB const record = await dbGet(targetDomain, name) if (record) { return { ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData } return null } /** * watch - 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit 推送) * @param targetDomain 目标域名频道 * @param name 数据名称 * @param callback 数据变化回调,参数为最新数据或 null * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) * @returns 取消监听的函数 */ function watch(targetDomain: string, name: string, callback: WatchCallback, useCsl?: boolean): () => void { const key = `${targetDomain}::${name}` // 确保频道已初始化 getChannel(targetDomain) // 注册回调(BroadcastChannel 广播也能触发,不管 useCsl 与否) if (!watchers.has(key)) watchers.set(key, new Set()) watchers.get(key)!.add(callback) if (useCsl) { // 跨域模式:初始读 chrome.storage.local cslGet(targetDomain, name).then((record) => { if (record) { callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData) } else { callback(null) } }).catch(() => callback(null)) // 监听 chrome.storage.onChanged 事件 const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { if (areaName !== 'local' || !changes[CSL_STORAGE_KEY]) return const newValue = changes[CSL_STORAGE_KEY].newValue as Array<{ domain: string; value: DBRecord }> | undefined if (!newValue) return const item = newValue.find(i => i.domain === targetDomain && i.value?.name === name) if (item && item.value) { callback({ ...item.value.data, createTime: item.value.createTime, updateTime: item.value.updateTime } as BridgeData) } } if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) { chrome.storage.onChanged.addListener(storageListener) } // 返回取消监听函数 return () => { const set = watchers.get(key) if (set) { set.delete(callback) if (set.size === 0) watchers.delete(key) } if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) { chrome.storage.onChanged.removeListener(storageListener) } } } // 默认模式:初始读取 IndexedDB dbGet(targetDomain, name).then((record) => { if (record) { callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData) } else { callback(null) } }) // 返回取消监听函数 return () => { const set = watchers.get(key) if (set) { set.delete(callback) if (set.size === 0) watchers.delete(key) } } } /** * destroy - 销毁实例,关闭所有频道,清除定时器 */ function destroy(): void { channels.forEach(ch => ch.close()) channels.clear() watchers.clear() emitTimers.forEach(timer => clearTimeout(timer)) emitTimers.clear() emitPending.clear() getResolvers.clear() } return { put, emit, get, watch, destroy } }