From ed178ee4c67cc5888aa982cf0ad6d62d351ce0e5 Mon Sep 17 00:00:00 2001 From: xion Date: Sat, 28 Sep 2024 18:35:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0query=20WS=E7=9A=84?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- demo/package-lock.json | 7 +- demo/package.json | 2 +- demo/src/index.ts | 10 +++ demo/vite.config.ts | 11 +++- package.json | 23 +++++-- rollup.config.js | 14 ++++ src/index.ts | 2 +- src/utils.ts | 24 +++++++ src/ws.ts | 143 +++++++++++++++++++++++++++++++++++++++++ test/ws.test.ts | 13 ++++ 11 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 src/utils.ts create mode 100644 src/ws.ts create mode 100644 test/ws.test.ts diff --git a/.gitignore b/.gitignore index 40b878d..76add87 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules/ \ No newline at end of file +node_modules +dist \ No newline at end of file diff --git a/demo/package-lock.json b/demo/package-lock.json index c33e07c..620b052 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -16,9 +16,12 @@ } }, "..": { - "name": "@abearxiong/query", - "version": "0.0.1", + "name": "@kevisual/query", + "version": "0.0.2-alpha.0", "license": "ISC", + "dependencies": { + "zustand": "^4.5.5" + }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", diff --git a/demo/package.json b/demo/package.json index b16889c..fc6b079 100644 --- a/demo/package.json +++ b/demo/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dev": "vite" }, "keywords": [], "author": "", diff --git a/demo/src/index.ts b/demo/src/index.ts index 25eaec0..b767f2e 100644 --- a/demo/src/index.ts +++ b/demo/src/index.ts @@ -1,5 +1,6 @@ // console.log('Hello World'); import { adapter, Query } from '@abearxiong/query'; +import { QueryWs } from '@abearxiong/query/ws'; window.onload = async () => { // const res = await adapter({ @@ -27,4 +28,13 @@ window.onload = async () => { }, ); console.log(res); + + const queryWs = new QueryWs({ url: '/api/router' }); + // queryWs.conn + queryWs.listenConnect(() => { + console.log('Connected'); + }); + queryWs.listenConnect(() => { + console.log('Connected2'); + }); }; diff --git a/demo/vite.config.ts b/demo/vite.config.ts index f59dc11..c04aeac 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -9,10 +9,17 @@ export default defineConfig({ port: 6102, // host: '::', proxy: { - '/api/router': { - target: 'http://127.0.0.1:3003', + '/api': { + target: 'http://127.0.0.1:4000', changeOrigin: true, }, + '/api/router': { + target: 'ws://localhost:4000', + changeOrigin: true, + ws: true, + rewriteWsOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '/api'), + }, }, }, // define: { diff --git a/package.json b/package.json index 0e53295..d69213c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kevisual/query", - "version": "0.0.2-alpha.0", + "version": "0.0.3", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", @@ -21,17 +21,28 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", - "@types/jest": "^29.5.12", - "jest": "^29.7.0", - "jest-config": "^29.7.0", "rollup": "^4.21.2", - "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tslib": "^2.7.0", + "zustand": "^4.5.5", "typescript": "^5.5.4" }, "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447", "publishConfig": { "access": "public" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./node": { + "import": "./dist/node-adapter.js", + "require": "./dist/node-adapter.js" + }, + "./ws": { + "import": "./dist/ws.js", + "require": "./dist/ws.js" + } } -} +} \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js index 92c403e..73110df 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -35,4 +35,18 @@ export default [ }), // 使用 @rollup/plugin-typescript 处理 TypeScript 文件 ], }, + { + input: 'src/ws.ts', // TypeScript 入口文件 + output: { + file: 'dist/ws.js', // 输出文件 + format: 'es', // 输出格式设置为 ES 模块 + }, + plugins: [ + resolve(), // 使用 @rollup/plugin-node-resolve 解析 node_modules 中的模块 + typescript({ + allowImportingTsExtensions: true, + noEmit: true, + }), // 使用 @rollup/plugin-typescript 处理 TypeScript 文件 + ], + }, ]; diff --git a/src/index.ts b/src/index.ts index 5d3f216..ef49148 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { adapter } from './adapter.ts'; - +export {QueryWs} from './ws.ts' type Fn = (opts: { url?: string; headers?: Record; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..6560093 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,24 @@ +export const parseUrl = (url: string) => { + try { + new URL(url); + } catch (e) { + const _url = new URL(url, location.origin); + return _url.href; + } +}; + +export const parseWsUrl = (url: string) => { + try { + new URL(url); + return url; + } catch (e) { + const _url = new URL(url, location.origin); + if (_url.protocol === 'http:') { + _url.protocol = 'ws:'; + } + if (_url.protocol === 'https:') { + _url.protocol = 'wss:'; + } + return _url.href; + } +}; diff --git a/src/ws.ts b/src/ws.ts new file mode 100644 index 0000000..3b79a6d --- /dev/null +++ b/src/ws.ts @@ -0,0 +1,143 @@ +import { createStore, StoreApi } from 'zustand/vanilla'; +import { parseWsUrl } from './utils.ts'; + +type QueryWsStore = { + connected: boolean; + status: 'connecting' | 'connected' | 'disconnected'; + setConnected: (connected: boolean) => void; + setStatus: (status: QuerySelectState) => void; +}; +export type QuerySelectState = 'connecting' | 'connected' | 'disconnected'; +export type QueryWsStoreListener = (newState: QueryWsStore, oldState: QueryWsStore) => void; +type QueryWsOpts = { + url?: string; + store?: StoreApi; + ws?: WebSocket; +}; +export type WsSend = (data: T, opts?: { isJson?: boolean; wrapper?: (data: T) => U }) => any; +export type WsOnMessage = (fn: (data: U, event: MessageEvent) => void, opts?: { isJson?: boolean; selector?: (data: T) => U }) => any; + +export class QueryWs { + url: string; + store: StoreApi; + ws: WebSocket; + constructor(opts?: QueryWsOpts) { + const url = opts?.url || '/api/router'; + if (opts?.store) { + this.store = opts.store; + } else { + const store = createStore((set) => ({ + connected: false, + status: 'connecting', + setConnected: (connected) => set({ connected }), + setStatus: (status) => set({ status }), + })); + this.store = store; + } + const wsUrl = parseWsUrl(url); + if (opts?.ws && opts.ws instanceof WebSocket) { + this.ws = opts.ws; + } else { + this.ws = new WebSocket(wsUrl); + } + this.connect(); + } + /** + * 连接 WebSocket + */ + connect() { + const store = this.store; + const connected = store.getState().connected; + if (connected) { + return; + } + const ws = this.ws || new WebSocket(this.url); + ws.onopen = () => { + store.getState().setConnected(true); + store.getState().setStatus('connected'); + }; + ws.onclose = () => { + store.getState().setConnected(false); + this.ws = null; + }; + } + + listenConnect(callback: () => void) { + const store = this.store; + const { connected } = store.getState(); + if (connected) { + callback(); + return; + } + const subscriptionOne = (selector: (state: QueryWsStore) => QueryWsStore['connected'], listener: QueryWsStoreListener) => { + const unsubscribe = store.subscribe((newState: any, oldState: any) => { + if (selector(newState) !== selector(oldState)) { + listener(newState, oldState); + unsubscribe(); + } + }); + return unsubscribe; + }; + const cancel = subscriptionOne( + (state) => state.connected, + () => { + callback(); + }, + ); + return cancel; + } + onMessage( + fn: (data: U, event: MessageEvent) => void, + opts?: { + isJson?: boolean; + selector?: (data: T) => U; + }, + ) { + const ws = this.ws; + const isJson = opts?.isJson ?? true; + const selector = opts?.selector; + const parseIfJson = (data: string) => { + try { + return JSON.parse(data); + } catch (e) { + return data; + } + }; + const listener = (event: MessageEvent) => { + const received = parseIfJson(event.data); + if (typeof received === 'string' && !isJson) { + fn(received as any, event); + } else if (typeof received === 'object' && isJson) { + fn(selector ? selector(received) : received, event); + } else { + // 过滤掉的数据 + } + }; + ws.addEventListener('message', listener); + return () => { + ws.removeEventListener('message', listener); + }; + } + close() { + const ws = this.ws; + const store = this.store; + ws?.close?.(); + this.ws = null; + store.getState().setConnected(false); + store.getState().setStatus('disconnected'); + } + send(data: T, opts?: { isJson?: boolean; wrapper?: (data: T) => U }) { + const ws = this.ws; + const isJson = opts?.isJson ?? true; + const wrapper = opts?.wrapper; + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.error('WebSocket is not open'); + return; + } + if (isJson) { + ws.send(JSON.stringify(wrapper ? wrapper(data) : data)); + } else { + ws.send(data as string); + } + } +} diff --git a/test/ws.test.ts b/test/ws.test.ts new file mode 100644 index 0000000..c0d709d --- /dev/null +++ b/test/ws.test.ts @@ -0,0 +1,13 @@ +import { QueryWs } from '../src/ws'; + +const queryWs = new QueryWs({ url: '/api/ws' }); + +queryWs.listenConnect(() => { + console.log('Connected'); +}); + +queryWs.store.getState().setConnected(true); +queryWs.store.getState().setConnected(false); +setTimeout(() => { + queryWs.store.getState().setConnected(true); +}, 1000);