commit de53986bc93a21192dedca53e943d138d540f200 Author: xion Date: Mon Dec 9 12:48:56 2024 +0800 init tab leader diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..385b135 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +build +.cache +.DS_Store +*.log \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..2957d45 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +@abearxiong:registry=https://npm.pkg.github.com +//registry.npmjs.org/:_authToken=${NPM_TOKEN} +@kevisual:registry=https://npm.xiongxiao.me \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b3b6d1a --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@kevisual/tab-leader", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "build": "rm -rf dist && rollup -c", + "watch": "rollup -c -w", + "clean": "rm -rf dist" + }, + "keywords": [], + "author": "abearxiong ", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "dependencies": {}, + "devDependencies": { + "@kevisual/router": "0.0.6-alpha-2", + "@kevisual/store": "0.0.1-alpha.7", + "eventemitter3": "^5.0.1", + "nanoid": "^5.0.9", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-replace": "^6.0.1", + "@rollup/plugin-typescript": "^12.1.1", + "rollup": "^4.28.1", + "rollup-plugin-dts": "^6.1.1", + "ts-lib": "^0.0.5" + }, + "exports": { + ".": { + "import": "./dist/tab-leader.js", + "types": "./dist/tab-leader.d.ts", + "require": "./dist/tab-leader.js" + } + } +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4bd0701 --- /dev/null +++ b/readme.md @@ -0,0 +1,9 @@ +# 默认依赖模块 + +```sh +import { nanoid } from 'nanoid'; +import { QueryRouterServer } from '@kevisual/router/browser'; +import { useContextKey } from '@kevisual/store/context'; +import { useConfigKey } from '@kevisual/store/config'; +import { EventEmitter } from 'eventemitter3'; +``` \ No newline at end of file diff --git a/rollup.config.mjs b/rollup.config.mjs new file mode 100644 index 0000000..437f33b --- /dev/null +++ b/rollup.config.mjs @@ -0,0 +1,43 @@ +// rollup.config.js +import typescript from '@rollup/plugin-typescript'; +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import { dts } from 'rollup-plugin-dts'; +import replace from '@rollup/plugin-replace'; + +/** + * @type {import('rollup').RollupOptions} + */ +export default [ + { + input: 'src/index.ts', + output: { + file: 'dist/tab-leader.js', + format: 'es', + }, + plugins: [ + replace({ + preventAssignment: true, // 必须设置为 true + delimiters: ['', ''], // 确保完全匹配 + "import { nanoid } from 'nanoid'": "import { nanoid } from 'https://cdn.jsdelivr.net/npm/nanoid@4.0.0/nanoid.min.js'", + }), + , + resolve({ browser: true }), + commonjs(), + typescript(), + ], + external: [ + 'nanoid', + // + '@kevisual/router/browser', + ], + }, + { + input: 'src/index.ts', + output: { + file: 'dist/tab-leader.d.ts', + format: 'es', + }, + plugins: [dts()], + }, +]; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..045b493 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,436 @@ +import { QueryRouterServer } from '@kevisual/router/browser'; +import { useContextKey } from '@kevisual/store/config'; +import { useConfigKey } from '@kevisual/store/config'; +import { EventEmitter } from 'eventemitter3'; + +export const emitter = useContextKey('emitter', () => { + console.error('need create emitter, config.emitter'); + return {} as EventEmitter; + // return new EventEmitter(); +}); +export const app = useContextKey('app', () => { + console.error('not found app, place set context app first'); + return {} as QueryRouterServer; +}); + +const generateRandom = () => { + // return Math.random().toString(36).substring(8); + return crypto.randomUUID(); +}; +export const tabId = useConfigKey('tabId', () => { + return generateRandom(); +}); +export const openTabs = useContextKey('openTabs', () => { + return new Set(); +}); +export const tabConfig = useConfigKey('tabConfig', () => { + const random = generateRandom(); + return { + title: 'tab random' + random, + description: 'tab config', + tabId: useConfigKey('tabId', () => {}), + }; +}); + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// 控制 tab 启动 +let load = false; +let openTime = Date.now(); +let meIsMax = '1'; // 0: 未知, 1: 对比来源,我更大, 2: 不是 + +export type Msg = { + requestId?: string; + path: string; + key: string; + [key: string]: any; +}; +app + .route({ + path: 'tab', + key: 'introduce', + description: '当多个tab打开的时候,找打哦最后打开的,确信那些tabs是有效和打开的', + }) + .define(async (ctx) => { + const source = ctx.query?.source; + const sourceOpenTime = ctx.query?.openTime; + const update = ctx.query?.update; + if (!source) { + ctx.throw?.(400, 'tabId is required'); + } + openTabs.add(source); + if (update) { + localStorage.setItem('tab-' + tabId, source); + } + if (!load) { + // console.log('soucre time', sourceOpenTime, openTime, sourceOpenTime > openTime); + if (openTime < sourceOpenTime) { + meIsMax = '2'; // 来源的时间比我大 + } + } + ctx.code = 999; + ctx.body = 'ok'; + }) + .addTo(app); + +app + .route({ + path: 'tab', + key: 'close', + description: '当触发关闭事件的时候,清除tab', + }) + .define(async (ctx) => { + const source = ctx.query?.source; + if (!source) { + ctx.throw?.(400, 'tabId is required'); + } + openTabs.delete(source); + localStorage.removeItem('tab-' + source); + ctx.body = 'ok'; + }) + .addTo(app); + +app + .route({ + path: 'tab', + key: 'setTabs', + description: '已经知道最新的tabs了,通知所有的channel,设置打开的 tab', + }) + .define(async (ctx) => { + const tabs = ctx.query?.tabs || []; + openTabs.clear(); + tabs.forEach((tab) => { + openTabs.add(tab); + }); + console.log(`[${tabId}]Set open tabs: ${tabs.length}`, openTabs.keys()); + load = true; + ctx.body = 'ok'; + }) + .addTo(app); + +app + .route({ + path: 'tab', + key: 'getTabs', + description: '获取所有的打开的 tab', + }) + .define(async (ctx) => { + ctx.body = Array.from(openTabs); + }) + .addTo(app); + +// 操作私有 tab +app + .route({ + path: 'tab', + key: 'getMe', + description: '获取自己的 tab 信息,比如tabId,openTime,tabConfig', + }) + .define(async (ctx) => { + ctx.body = { tabId, openTime, tabConfig }; + }) + .addTo(app); + +app + .route({ + path: 'tab', + key: 'me', + id: 'me', + description: '验证是否是自己的标签页面,如果是自己则继续,否则返回错误', + }) + .define(async (ctx) => { + // check me + const to = ctx.query?.to; + if (to !== tabId) { + // not me + ctx.throw?.(840, 'not me'); + return; + } + ctx.body = 'ok'; + }) + .addTo(app); + +app + .route({ + path: 'channel', + key: 'postAllTabs', + description: '向所有的 tab 发送消息,如果payload有hasResponse,则等待所有的 tab 返回消息', + }) + .define(async (ctx) => { + const data = ctx.query?.data; + const hasResponse = ctx.query?.hasResponse; + if (!data?.path) { + ctx.throw?.(400, 'path is required'); + } + const res = await new Promise((resolve) => { + if (hasResponse) { + postAllTabs(data, (res) => { + resolve(res); + }); + } else { + postAllTabs(data); + resolve('ok'); + } + }); + ctx.body = res || 'ok'; + }) + .addTo(app); + +app + .route({ + path: 'channel', + key: 'postTab', + description: '向指定的 tab 发送消息,如果payload有hasResponse,则等待指定的 tab 返回消息', + }) + .define(async (ctx) => { + const data = ctx.query?.data; + const hasResponse = ctx.query?.hasResponse; + if (!data?.path) { + ctx.throw?.(400, 'path is required'); + } + const res = await new Promise((resolve) => { + if (hasResponse) { + postTab(data, (res) => { + resolve(res); + }); + } else { + postTab(data); + resolve('ok'); + } + }); + ctx.body = res || 'ok'; + }) + .addTo(app); + +app + .route({ + path: 'tab', + key: 'setConfig', + description: '设置 tab 的配置信息', + }) + .define(async (ctx) => { + const config = ctx.query?.config; + if (config) { + Object.assign(tabConfig, config); + } + ctx.body = tabConfig; + }) + .addTo(app); + +app + .route({ + path: 'tab', + key: 'getConfig', + description: '获取 tab 的配置信息', + }) + .define(async (ctx) => { + ctx.body = tabConfig; + }) + .addTo(app); + +const channel = useContextKey('channel', () => { + return new BroadcastChannel('tab-channel'); +}); + +channel.addEventListener('message', (event) => { + const { requestId, to, source, responseId } = event.data; + if (responseId && to === tabId) { + // 有 id 的消息, 这是这个模块请求返回回来的消息,不被其他模块处理。 + emitter.emit(responseId, event.data); + return; + } + + // console.log('channel message', event.data); + if (to === 'all' || to === tabId) { + const { path, key, payload, ...rest } = event.data; + if (!path) { + return; + } + app + .run({ + path, + key, + payload: { + ...rest, + ...payload, + }, + }) + .then((res) => { + const { data, message, code } = res; + if (requestId) { + if (code !== 999) { + console.log('channel response', requestId, res); + } + const msg = { + // 返回数据一般没有 requestId,如果有,在res中自己放置 + responseId: requestId, + to: source, + source: tabId, + data: data, + message, + code, + }; + if (data?.requestId) { + msg['requestId'] = data.requestId; + } + channel.postMessage(msg); + } + }) + .catch((err) => { + console.error(tabId, requestId, err); + }); + return; + } +}); + +const postAllTabs = (msg: Msg, callback?: (data: any[], error?: any[]) => any, timeout = 3 * 60 * 1000) => { + const action = { + ...msg, + to: 'all', + source: tabId, + }; + if (callback) { + const requestId = generateRandom(); + action.requestId = requestId; + const res: any = []; + const error: any = []; + emitter.on(requestId, (data) => { + res.push(data); + // console.log('postAllTabs callback', data, res.length, openTabs.size); + if (res.length >= openTabs.size - 1) { + callback(res); + emitter.off(requestId); + } + }); + setTimeout(() => { + const tabs = [...openTabs]; + tabs.forEach((tab) => { + if (tab === tabId) { + return; + } + if (!res.find((r) => r.source === tab)) { + error.push({ code: 500, message: 'timeout', tab, source: tab, to: tabId }); + } + }); + callback(res, error); + + emitter.off(requestId); + }, timeout); + } + channel.postMessage(action); +}; + +const postTab = (msg: Msg, callback?: (data: any) => any, timeout = 3 * 60 * 1000) => { + const action = { + ...msg, + to: msg.to, + source: tabId, + }; + if (callback) { + const requestId = generateRandom(); + action.requestId = requestId; + emitter.on(requestId, (data) => { + callback(data); + emitter.off(requestId); + }); + setTimeout(() => { + callback({ code: 500, message: 'timeout' }); + emitter.off(requestId); + }, timeout); + } + channel.postMessage(action); +}; +const channelFn = { + postAllTabs, + postTab, +}; + +useConfigKey('channelMsg', () => { + return channelFn; +}); + +const getTabs = (filter = true) => { + const allTabs = Object.keys(localStorage).filter((key) => key.startsWith('tab-')); + const tabs = allTabs.filter((key) => { + if (!filter) { + return true; + } + const tab = localStorage.getItem(key); + if (tab === tabId) { + return true; + } + localStorage.removeItem(key); + return false; + }); + return tabs; +}; + +const getOtherTabs = async () => { + const res = await app.run({ + path: 'channel', + key: 'postAllTabs', + payload: { + data: { + path: 'tab', + key: 'getConfig', + }, + hasResponse: true, + }, + }); + console.log('getOtherTabs', res); +}; + +// @ts-ignore +window.getOtherTabs = getOtherTabs; +const checkTabs = async () => { + postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime, update: true } }); + await sleep(1000); + const tabs = getTabs(); + postAllTabs({ path: 'tab', key: 'setTabs', payload: { tabs: tabs.map((tab) => tab.replace('tab-', '')) } }); + openTabs.clear(); + tabs.forEach((tab) => { + openTabs.add(tab.replace('tab-', '')); + }); +}; +/** + * 初始化 tab + */ +const init = async () => { + localStorage.setItem('tab-' + tabId, tabId); + // 向其他 tab 发送消息, 页面已经打开 + postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime } }); + window.addEventListener('beforeunload', () => { + // 向其他 tab 发送消息, 页面即将关闭 + localStorage.removeItem('tab-' + tabId); + console.log('close tab', tabId); + postAllTabs({ path: 'tab', key: 'close' }); + }); + introduceMe(); +}; +const introduceMe = () => { + if (meIsMax === '2') { + // 我知道我不是最大的了,我自己就不再发送消息了 + return; + } + postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime } }); + if (!load) { + const time = Date.now() - openTime; + if (time > 2100) { + return; + } + setTimeout(() => { + introduceMe(); + }, 300); + } +}; + +export const start = () => { + init(); + setTimeout(() => { + if (meIsMax === '1') { + console.log('I am max', openTime); + checkTabs(); + load = true; + } + }, 2000); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e8e7f97 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "target": "esnext", + "noImplicitAny": false, + "outDir": "./dist", + "sourceMap": false, + "allowJs": true, + "newLine": "LF", + "baseUrl": "./", + "typeRoots": [ + "node_modules/@types", + ], + "declaration": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "moduleResolution": "NodeNext", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "paths": { + "@/*": [ + "src/*" + ], + "*": [ + "types/*" + ] + } + }, + "include": [ + "typings.d.ts", + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "demo/simple/dist", + "src/**/*.test.ts", + "rollup.config.js", + ] +} \ No newline at end of file