735 lines
30 KiB
TypeScript
735 lines
30 KiB
TypeScript
/**
|
||
* OfferPie 跨标签页通信桥接组件
|
||
* 用于浏览器插件与网站页面之间的纯本地通信(不依赖任何后端接口)
|
||
*
|
||
* 【使用说明】
|
||
* 1. 引入并创建实例:
|
||
* import { createChannelBridge } from './channelBridge'
|
||
* const bridge = createChannelBridge({ domain: 'offerpai.com', isPlugin: true })
|
||
*
|
||
* 2. 发送数据(单次,同时写缓存 + 广播):
|
||
* bridge.put<MyDataType>('offerpai.com', 'resumeStatus', { ... })
|
||
*
|
||
* 3. 高频发送(节流写缓存,每次都广播):
|
||
* bridge.emit<MyDataType>('offerpai.com', 'cursorPosition', { ... })
|
||
*
|
||
* 4. 主动获取一次数据(先广播问对方,60ms超时转IndexedDB):
|
||
* const result = await bridge.get<MyDataType>('offerpai.com', 'resumeStatus')
|
||
*
|
||
* 5. 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit):
|
||
* const unwatch = bridge.watch<MyDataType>('offerpai.com', 'resumeStatus', (data) => { ... })
|
||
* // 取消监听
|
||
* unwatch()
|
||
*
|
||
* 【跨域模式 useCsl】
|
||
* 在 put/emit/get/watch 最后一个参数传 true,数据存储走 chrome.storage.local(通过 Background 中转)
|
||
* 适用于网页与插件在不同域名标签页之间的通信场景
|
||
* 示例:bridge.put<MyData>('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 extends object> = 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<T extends object> = (data: BridgeData<T> | 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<void> {
|
||
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<ChannelMessage>) => {
|
||
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<DBRecord | null> {
|
||
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<ChannelMessage>) => {
|
||
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<string, BroadcastChannel>()
|
||
|
||
/** 获取 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<string, Promise<IDBDatabase>>()
|
||
|
||
/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore,带连接缓存防并发冲突 */
|
||
function openDB(storeName: string): Promise<IDBDatabase> {
|
||
// 如果已有进行中或已完成的连接,直接复用
|
||
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<IDBDatabase>((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<void> {
|
||
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<DBRecord | null> {
|
||
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<string, BroadcastChannel>()
|
||
|
||
/** watch 订阅回调注册表:key = `${domain}::${name}` */
|
||
const watchers = new Map<string, Set<WatchCallback<any>>>()
|
||
|
||
/** emit 节流定时器:key = `${domain}::${name}` */
|
||
const emitTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||
|
||
/** emit 节流暂存最新数据:key = `${domain}::${name}` */
|
||
const emitPending = new Map<string, DBRecord>()
|
||
|
||
/** get 请求等待队列:key = requestId */
|
||
const getResolvers = new Map<string, (data: BridgeData<any> | 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<ChannelMessage>) => 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<any>
|
||
: 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<any>)
|
||
} else {
|
||
resolver(null)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化默认频道监听
|
||
getChannel(domain)
|
||
|
||
// --- CSL 代理监听(插件环境下,监听网页端的 CSL 中转请求) ---
|
||
if (isPlugin && isChromeRuntimeAvailable()) {
|
||
const cslProxyCh = new BroadcastChannel(`offerpie-bridge-csl-${domain}`)
|
||
cslProxyCh.onmessage = async (event: MessageEvent<ChannelMessage>) => {
|
||
const msg = event.data
|
||
|
||
if (msg.type === 'csl-put-request' && msg.id) {
|
||
// 代理写入 chrome.storage.local
|
||
const targetDomain = msg.domain || domain
|
||
await new Promise<void>((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<any>((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<T extends object>(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise<void> {
|
||
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<T extends object>(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise<void> {
|
||
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<T extends object>(targetDomain: string, name: string, useCsl?: boolean): Promise<BridgeData<T> | 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<BridgeData<T> | 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<T>
|
||
}
|
||
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<BridgeData<T> | 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<T>
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* watch - 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit 推送)
|
||
* @param targetDomain 目标域名频道
|
||
* @param name 数据名称
|
||
* @param callback 数据变化回调,参数为最新数据或 null
|
||
* @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转)
|
||
* @returns 取消监听的函数
|
||
*/
|
||
function watch<T extends object>(targetDomain: string, name: string, callback: WatchCallback<T>, 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<T>)
|
||
} 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<T>)
|
||
}
|
||
}
|
||
|
||
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<T>)
|
||
} 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 }
|
||
}
|