├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── .tsconfig ├── base.json ├── node.json └── react-library.json ├── LICENSE ├── README.md ├── package.json ├── packages ├── websocks-client │ ├── .swcrc │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── build-path.test.ts │ │ │ ├── clear-query.test.tsx │ │ │ └── factory.test.ts │ │ ├── events.ts │ │ ├── lib.ts │ │ ├── react.ts │ │ ├── string.ts │ │ ├── types.ts │ │ └── use-event.ts │ └── tsconfig.json └── websocks-server │ ├── .swcrc │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── create-server.ts │ ├── index.ts │ ├── string.ts │ └── types.ts │ └── tsconfig.json ├── scripts ├── apply-publish-config.js ├── check-changesets.sh ├── ensure-clean-git.js ├── force-react-18.js ├── header.js └── workspace-run.js ├── test ├── __mocks__ │ └── wss.ts └── setup.ts ├── vitest.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@nkzw"], 3 | "ignorePatterns": [ 4 | "dist", 5 | "build", 6 | "playwright-report", 7 | "test-results", 8 | "syntax-error.tsx" 9 | ], 10 | "overrides": [ 11 | { 12 | "files": ["./packages/websocks-client/**"], 13 | "rules": { 14 | "react-compiler/react-compiler": "error" 15 | } 16 | } 17 | ], 18 | "plugins": ["react-compiler", "header"], 19 | "rules": { 20 | "@nkzw/no-instanceof": "off", 21 | "@typescript-eslint/consistent-type-imports": [ 22 | "error", 23 | { 24 | "disallowTypeAnnotations": false, 25 | "fixStyle": "inline-type-imports" 26 | } 27 | ], 28 | "@typescript-eslint/no-require-imports": "off", 29 | "@typescript-eslint/no-empty-function": "off", 30 | "@typescript-eslint/no-explicit-any": "error", 31 | "@typescript-eslint/no-inferrable-types": "off", 32 | "@typescript-eslint/no-unnecessary-type-constraint": "off", 33 | "@typescript-eslint/no-unused-vars": [ 34 | "error", 35 | { "argsIgnorePattern": "^_", "ignoreRestSiblings": true } 36 | ], 37 | "brace-style": "off", 38 | "header/header": ["error", "scripts/header.js"], 39 | "import/no-extraneous-dependencies": "off", 40 | "import/no-namespace": "off", 41 | "import/no-unresolved": "off", 42 | "no-extra-parens": "off", 43 | "no-inner-declarations": "off", 44 | "no-unused-expressions": "off", 45 | "playwright/no-focused-test": "off", 46 | "playwright/no-force-option": "off", 47 | "playwright/no-standalone-expect": "off", 48 | "prefer-const": "off", 49 | "react/no-unknown-property": "off", 50 | "react/no-unescaped-entities": ["error", { "forbid": [">", "}"] }], 51 | "space-before-function-paren": "off", 52 | "unicorn/consistent-function-scoping": "off", 53 | "unicorn/prefer-ternary": "off", 54 | "unicorn/prefer-top-level-await": "off" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [itsdouges] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Packages 2 | 3 | on: 4 | pull_request: 5 | 6 | push: 7 | branches: 8 | - main 9 | 10 | env: 11 | CI: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@master 20 | 21 | - name: Setup node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version-file: "package.json" 25 | 26 | - name: Install dependencies 27 | run: yarn install 28 | 29 | - name: Build packages 30 | run: yarn build 31 | 32 | - name: Type check 33 | run: yarn typedef 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Packages 2 | 3 | on: 4 | pull_request: 5 | 6 | push: 7 | branches: 8 | - main 9 | 10 | env: 11 | CI: true 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest] 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@master 24 | 25 | - name: Setup node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: "package.json" 29 | 30 | - name: Install dependencies 31 | run: yarn install 32 | 33 | - name: Test packages 34 | run: yarn test 35 | 36 | - name: Lint packages 37 | run: yarn lint 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | dist 6 | 7 | # testing 8 | coverage 9 | 10 | # next.js 11 | .next/ 12 | out/ 13 | 14 | # misc 15 | .DS_Store 16 | *.pem 17 | .pnpm-store 18 | .npmrc 19 | tsconfig.tsbuildinfo 20 | .eslintcache 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # turbo 35 | .turbo 36 | **/*.log 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@ianvs/prettier-plugin-sort-imports", 4 | "prettier-plugin-jsdoc", 5 | "prettier-plugin-packagejson" 6 | ], 7 | "jsdocCapitalizeDescription": false, 8 | "jsdocPreferCodeFences": true, 9 | "jsdocSingleLineComment": false, 10 | "proseWrap": "never", 11 | "trailingComma": "all", 12 | "overrides": [{ "files": ["*.frag"], "options": { "parser": "glsl-parser" } }] 13 | } 14 | -------------------------------------------------------------------------------- /.tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": false, 8 | "esModuleInterop": true, 9 | "emitDeclarationOnly": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "inlineSources": false, 12 | "isolatedModules": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /.tsconfig/node.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Node", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "resolveJsonModule": true, 8 | "lib": ["dom", "dom.iterable", "es2022"], 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext", 11 | "target": "ES2022" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "lib": ["dom", "dom.iterable", "es2022"], 8 | "module": "ES2022", 9 | "moduleResolution": "Bundler", 10 | "target": "ES2022" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 — Present Michael Dougall 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Websocks 2 | 3 | [![Discord](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2FnBzRBUEs4b%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&style=flat&colorA=000000&colorB=000000&label=discord&logo=&logoColor=000000)](https://discord.gg/nBzRBUEs4b) [![GitHub Sponsors](https://img.shields.io/github/sponsors/itsdouges?style=flat&colorA=000000&colorB=000000&label=sponsor&logo=&logoColor=000000)](https://github.com/sponsors/itsdouges) 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i @triplex/websocks-server @triplex/websocks-client 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### Create the server 14 | 15 | ```ts 16 | // 1. Create the websocks server 17 | import { createWSServer } from "@triplex/websocks-server"; 18 | 19 | const wss = createWSServer(); 20 | 21 | // 2. Define routes 22 | const routes = wss.collectTypes([ 23 | wss.route( 24 | "/rng/:max", 25 | ({ max }) => Math.round(Math.random() * Number(max)), 26 | (push) => { 27 | setInterval(() => { 28 | // Every 1s push a new value to the client. 29 | push(); 30 | }, 1000); 31 | }, 32 | ), 33 | ]); 34 | 35 | // 3. Define events 36 | const events = wss.collectTypes([ 37 | tws.event<"ping", { timestamp: number }>("ping", (send) => { 38 | setInterval(() => { 39 | send({ timestamp: Date.now() }); 40 | }, 1000); 41 | }), 42 | ]); 43 | 44 | // 4. Start listening 45 | wss.listen(3000); 46 | 47 | // 5. Export types to use with the client 48 | export type Routes = typeof routes; 49 | export type Events = typeof events; 50 | ``` 51 | 52 | ### Create the client 53 | 54 | ```tsx 55 | // 1. Import the server types 56 | import { createWSEvents } from "@triplex/websocks-client/events"; 57 | import { createWSHooks } from "@triplex/websocks-client/react"; 58 | import { type Events, type Routes } from "./server"; 59 | 60 | // 2. Declare the clients 61 | const { preloadSubscription, useSubscription } = createWSHooks({ 62 | url: "ws://localhost:3000", 63 | }); 64 | 65 | const { on } = createWSEvents({ 66 | url: "ws://localhost:3000", 67 | }); 68 | 69 | // 3. Preload data 70 | preloadSubscription("/rng/:max", { max: 100 }); 71 | 72 | // 4. Subscribe to the data 73 | function Component() { 74 | const value = useSubscription("/rng/:max", { max: 100 }); 75 | return
{value}
; 76 | } 77 | 78 | on("ping", ({ timestamp }) => { 79 | console.log(timestamp); 80 | }); 81 | ``` 82 | 83 | ## API 84 | 85 | ### `createWSServer()` 86 | 87 | Creates a typed websocket server. 88 | 89 | #### Returns 90 | 91 | | Name | Description | 92 | | --- | --- | 93 | | `close()` | Closes the server. | 94 | | `collectTypes(TEvents[] \| TRoutes[])` | Collects types from `event()` and `route()`. | 95 | | `route(path: string, callback: Function, initialize: Function)` | Creates a route. | 96 | | `event(eventName: string, initialize: Function)` | Creates an event. | 97 | | `listen(port: number)` | Listens to the declared port. | 98 | 99 | ### `createWSHooks(options: Options | () => Options)` 100 | 101 | Creates a routes client using types from the server that returns React hooks. 102 | 103 | #### Returns 104 | 105 | | Name | Description | 106 | | --- | --- | 107 | | `clearQuery(path: string, args: TArgs)` | Clears the query from the cache. | 108 | | `preloadSubscription(path: string, args: TArgs)` | Preloads the subscription. | 109 | | `useSubscription(path: string, args: TArgs)` | Returns the value of a preloaded subscription. | 110 | | `useLazySubscription(path: string, args: TArgs)` | Returns the value of a subscription. | 111 | 112 | ### `createWSEvents(options: Options | () => Options)` 113 | 114 | Creates an events client using types from the server. 115 | 116 | #### Returns 117 | 118 | | Name | Description | 119 | | ------------------------------------------- | ------------------- | 120 | | `on(eventName: string, callback: Function)` | Listen to an event. | 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "websocks", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "build": "yarn clean && yarn workspaces run build", 11 | "clean": "yarn workspaces run rimraf dist", 12 | "clean:node_modules": "yarn workspaces run rimraf node_modules && rimraf node_modules", 13 | "lint": "eslint ./ --cache", 14 | "test": "vitest", 15 | "typedef": "yarn workspaces run typedef" 16 | }, 17 | "dependencies": { 18 | "@changesets/cli": "^2.27.8", 19 | "@ianvs/prettier-plugin-sort-imports": "^4.2.1", 20 | "@nkzw/eslint-config": "^1.19.0", 21 | "@swc/core": "^1.6.5", 22 | "@testing-library/react": "^14.0.0", 23 | "eslint": "^8.51.0", 24 | "eslint-plugin-header": "^3.1.1", 25 | "eslint-plugin-playwright": "^2.2.0", 26 | "eslint-plugin-react-compiler": "19.1.0-rc.1", 27 | "jsdom": "^21.1.0", 28 | "msw": "^2.7.0", 29 | "prettier": "^3.3.2", 30 | "prettier-plugin-jsdoc": "^1.3.0", 31 | "prettier-plugin-packagejson": "^2.5.0", 32 | "rimraf": "^5.0.7", 33 | "typescript": "^5.7.2", 34 | "vitest": "^3.0.4" 35 | }, 36 | "packageManager": "yarn@1.22.22", 37 | "engines": { 38 | "node": ">=20.14.0" 39 | }, 40 | "volta": { 41 | "node": "22.15.1", 42 | "yarn": "1.22.22" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/websocks-client/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://swc.rs/schema.json", 3 | "sourceMaps": false, 4 | "jsc": { 5 | "target": "es2022", 6 | "parser": { 7 | "syntax": "typescript" 8 | }, 9 | "transform": { 10 | "react": { 11 | "runtime": "automatic" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/websocks-client/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @triplex/websocks-client 2 | 3 | ## 0.0.8 4 | 5 | ### Patch Changes 6 | 7 | - ae60a8f: Upgrade typescript. 8 | 9 | ## 0.0.7 10 | 11 | ### Patch Changes 12 | 13 | - d5681d8: Add experimental_useLazySubscriptionStream. 14 | 15 | ## 0.0.6 16 | 17 | ### Patch Changes 18 | 19 | - 2301fd1: Drop unwanted files when publishing. 20 | 21 | ## 0.0.5 22 | 23 | ### Patch Changes 24 | 25 | - 863de7b: Support for optional params. E.g. the path "/scene/:path/:exportName{/:exportName1}{/:exportName2}/props" will have `path` and `exportName` as required params, and `exportName1` and `exportName2` as optional params. 26 | - 863de7b: GlobalProvider and CanvasProvider exports from the declared provider module now show up in the provider panel. 27 | 28 | ## 0.0.4 29 | 30 | ### Patch Changes 31 | 32 | - 27f5a72: Align `createWSEvents` API to `createWSHooks` API. 33 | 34 | ## 0.0.3 35 | 36 | ### Patch Changes 37 | 38 | - a5d2390: React 19 / Three Fiber 9 are now supported. 39 | 40 | ## 0.0.2 41 | 42 | ### Patch Changes 43 | 44 | - 1ba6783: Rename APIs/entrypoints. 45 | 46 | ## 0.0.1 47 | 48 | ### Patch Changes 49 | 50 | - fe84ca9: Add `clearQuery` API. 51 | -------------------------------------------------------------------------------- /packages/websocks-client/README.md: -------------------------------------------------------------------------------- 1 | # @triplex/websocks-client 2 | 3 | [![Discord](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2FnBzRBUEs4b%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&style=flat&colorA=000000&colorB=000000&label=discord&logo=&logoColor=000000)](https://discord.gg/nBzRBUEs4b) [![GitHub Sponsors](https://img.shields.io/github/sponsors/itsdouges?style=flat&colorA=000000&colorB=000000&label=sponsor&logo=&logoColor=000000)](https://github.com/sponsors/itsdouges) 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i @triplex/websocks-client 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```tsx 14 | // 1. Import the server types 15 | import { createWSEvents } from "@triplex/websocks-client/events"; 16 | import { createWSHooks } from "@triplex/websocks-client/react"; 17 | import { type Events, type Routes } from "./server"; 18 | 19 | // 2. Declare the clients 20 | const { preloadSubscription, useSubscription } = createWSHooks({ 21 | url: "ws://localhost:3000", 22 | }); 23 | 24 | const { on } = createWSEvents({ 25 | url: "ws://localhost:3000", 26 | }); 27 | 28 | // 3. Preload data 29 | preloadSubscription("/rng/:max", { max: 100 }); 30 | 31 | // 4. Subscribe to the data 32 | function Component() { 33 | const value = useSubscription("/rng/:max", { max: 100 }); 34 | return
{value}
; 35 | } 36 | 37 | on("ping", ({ timestamp }) => { 38 | console.log(timestamp); 39 | }); 40 | ``` 41 | 42 | ## API 43 | 44 | ### `createWSHooks(options: Options | () => Options)` 45 | 46 | Creates a routes client using types from the server that returns React hooks. 47 | 48 | #### Returns 49 | 50 | | Name | Description | 51 | | --- | --- | 52 | | `clearQuery(path: string, args: TArgs)` | Clears the query from the cache. | 53 | | `preloadSubscription(path: string, args: TArgs)` | Preloads the subscription. | 54 | | `useSubscription(path: string, args: TArgs)` | Returns the value of a preloaded subscription. | 55 | | `useLazySubscription(path: string, args: TArgs)` | Returns the value of a subscription. | 56 | 57 | ### `createWSEvents(options: Options | () => Options)` 58 | 59 | Creates an events client using types from the server. 60 | 61 | #### Returns 62 | 63 | | Name | Description | 64 | | ------------------------------------------- | ------------------- | 65 | | `on(eventName: string, callback: Function)` | Listen to an event. | 66 | -------------------------------------------------------------------------------- /packages/websocks-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@triplex/websocks-client", 3 | "version": "0.0.8", 4 | "description": "An end-to-end typed websocket API router and React client.", 5 | "author": { 6 | "name": "Triplex", 7 | "email": "support@triplex.dev", 8 | "url": "https://x.com/trytriplex" 9 | }, 10 | "exports": { 11 | "./events": { 12 | "types": "./src/events.ts", 13 | "module": "./src/events.ts", 14 | "default": "./src/events.ts" 15 | }, 16 | "./react": { 17 | "types": "./src/react.ts", 18 | "module": "./src/react.ts", 19 | "default": "./src/react.ts" 20 | } 21 | }, 22 | "files": [ 23 | "dist" 24 | ], 25 | "scripts": { 26 | "build": "swc ./src -d ./dist", 27 | "typedef": "tsc" 28 | }, 29 | "devDependencies": { 30 | "@swc/cli": "^0.1.59", 31 | "@swc/core": "^1.6.5", 32 | "@types/react": "^19.1.6", 33 | "react": "^19.1.0", 34 | "react-dom": "^19.1.0", 35 | "react-error-boundary": "^3.1.4", 36 | "typescript": "^5.8.3" 37 | }, 38 | "peerDependencies": { 39 | "react": ">=18.0.0" 40 | }, 41 | "optionalDependencies": { 42 | "ws": "^8.12.0" 43 | }, 44 | "publishConfig": { 45 | "exports": { 46 | "./events": { 47 | "types": "./dist/events.d.ts", 48 | "module": "./dist/events.js", 49 | "default": "./dist/events.js" 50 | }, 51 | "./react": { 52 | "types": "./dist/react.d.ts", 53 | "module": "./dist/react.js", 54 | "default": "./dist/react.js" 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/websocks-client/src/__tests__/build-path.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { describe, expect, it } from "vitest"; 8 | import { buildPath } from "../lib"; 9 | 10 | describe("buildPath()", () => { 11 | it("replaces parameters in route", () => { 12 | const route = "/users/:id"; 13 | const params = { id: "123" }; 14 | 15 | expect(buildPath(route, params)).toBe("/users/123"); 16 | }); 17 | 18 | it("replaces multiple parameters in route", () => { 19 | const route = "/users/:userId/posts/:postId"; 20 | const params = { postId: "456", userId: "123" }; 21 | 22 | expect(buildPath(route, params)).toBe("/users/123/posts/456"); 23 | }); 24 | 25 | it("handles undefined values by replacing them with empty string", () => { 26 | const route = "/users/:id"; 27 | const params = { id: undefined }; 28 | 29 | expect(buildPath(route, params)).toBe("/users/"); 30 | }); 31 | 32 | it("handles optional params", () => { 33 | const route = "/users{/:id}/more"; 34 | const params = {}; 35 | 36 | expect(buildPath(route, params)).toBe("/users/more"); 37 | }); 38 | 39 | it("URI encodes parameter values", () => { 40 | const route = "/search/:query"; 41 | const params = { query: "hello world" }; 42 | 43 | expect(buildPath(route, params)).toBe("/search/hello%20world"); 44 | }); 45 | 46 | it("collapses unreplaced optionals", () => { 47 | const route = "/users/:userId{/:postId}"; 48 | const params = { userId: "123" }; 49 | 50 | expect(buildPath(route, params)).toBe("/users/123"); 51 | }); 52 | 53 | it("removes braces from filled optional parameters", () => { 54 | const route = "/users/:userId{/:postId}"; 55 | const params = { postId: "456", userId: "123" }; 56 | 57 | expect(buildPath(route, params)).toBe("/users/123/456"); 58 | }); 59 | 60 | it("handles boolean values", () => { 61 | const route = "/feature/:enabled"; 62 | const params = { enabled: true }; 63 | 64 | expect(buildPath(route, params)).toBe("/feature/true"); 65 | }); 66 | 67 | it("handles numeric values", () => { 68 | const route = "/page/:num"; 69 | const params = { num: 42 }; 70 | 71 | expect(buildPath(route, params)).toBe("/page/42"); 72 | }); 73 | 74 | it("handles special characters in parameter values", () => { 75 | const route = "/users/:name"; 76 | const params = { name: "john&doe=test+plus" }; 77 | 78 | expect(buildPath(route, params)).toBe("/users/john%26doe%3Dtest%2Bplus"); 79 | }); 80 | 81 | it("handles empty params object", () => { 82 | const route = "/static/path"; 83 | expect(buildPath(route, {})).toBe("/static/path"); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /packages/websocks-client/src/__tests__/clear-query.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | // @vitest-environment jsdom 8 | import { render } from "@testing-library/react"; 9 | import { ErrorBoundary } from "react-error-boundary"; 10 | import { describe, expect, it } from "vitest"; 11 | import { createWSHooks } from "../react"; 12 | 13 | type StubRoutes = Record< 14 | "/folder" | "/errors" | "/errors-once", 15 | { data: { name: string }; params: never } 16 | >; 17 | 18 | const { clearQuery, preloadSubscription, useSubscription } = 19 | createWSHooks(() => ({ 20 | url: "ws://localhost:3", 21 | })); 22 | 23 | function ThrowsError() { 24 | useSubscription("/errors"); 25 | return null; 26 | } 27 | 28 | function RendersName() { 29 | const { name } = useSubscription("/errors-once"); 30 | return
{name}
; 31 | } 32 | 33 | describe("clear query", () => { 34 | it("should throw an error when receiving a message response", async () => { 35 | preloadSubscription("/errors"); 36 | 37 | const { findByTestId } = render( 38 | ( 40 |
{error.message}
41 | )} 42 | > 43 | 44 |
, 45 | ); 46 | const errorBoundary = await findByTestId("error-boundary"); 47 | 48 | expect(errorBoundary.innerHTML).toContain("Websocket server error!"); 49 | }); 50 | 51 | it("should not throw a preload error after being cleared and the second message is successful", async () => { 52 | preloadSubscription("/errors-once"); 53 | clearQuery("/errors-once"); 54 | 55 | const { findByTestId } = render(); 56 | const errorBoundary = await findByTestId("content"); 57 | 58 | expect(errorBoundary.innerHTML).toContain("bar"); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/websocks-client/src/__tests__/factory.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | // @vitest-environment jsdom 8 | import { renderHook, waitFor } from "@testing-library/react"; 9 | import { describe, expect, it, vi } from "vitest"; 10 | import { createWSHooks } from "../react"; 11 | 12 | type StubRoutes = Record<"/folder", { data: { name: string }; params: never }>; 13 | 14 | const { preloadSubscription, useLazySubscription, useSubscription } = 15 | createWSHooks(() => ({ 16 | url: "ws://localhost:3", 17 | })); 18 | 19 | describe("ws hooks", () => { 20 | it("should throw when using a subscription", () => { 21 | expect(() => { 22 | renderHook(() => { 23 | useSubscription("/folder"); 24 | }); 25 | }).toThrow("call load() first"); 26 | }); 27 | 28 | it("should subscribe to data", async () => { 29 | preloadSubscription("/folder"); 30 | const { result } = renderHook(() => useSubscription("/folder")); 31 | 32 | await waitFor(() => { 33 | expect(result.current).toEqual({ name: "bar" }); 34 | }); 35 | }); 36 | 37 | it("should lazily subscribe data", async () => { 38 | const { result } = renderHook(() => useLazySubscription("/folder")); 39 | 40 | await waitFor(() => { 41 | expect(result.current).toEqual({ name: "bar" }); 42 | }); 43 | }); 44 | 45 | it("should clean up subscriptions sometime after unmounting", async () => { 46 | preloadSubscription("/folder"); 47 | const { result, unmount } = renderHook(() => useSubscription("/folder")); 48 | await waitFor(() => { 49 | expect(result.current).toEqual({ name: "bar" }); 50 | }); 51 | 52 | vi.useFakeTimers(); 53 | unmount(); 54 | vi.runAllTimers(); 55 | // We need to wait for the websocket cleanup to happen. 56 | await new Promise((resolve) => resolve(undefined)); 57 | 58 | expect(() => { 59 | renderHook(() => useSubscription("/folder")); 60 | }).not.toThrow(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/websocks-client/src/events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { parseJSON } from "./string"; 8 | 9 | /** 10 | * **createWSEvents()** 11 | * 12 | * Returns typed event listener to be used with an instantiated websocks server. 13 | * Options can be either passed in immediately or passed in as a callback to be 14 | * called just-in-time when needed. 15 | * 16 | * ```ts 17 | * import { type Events } from "./server"; 18 | * 19 | * createWSEvents({ url: "ws://localhost:3000" }); 20 | * const { on } = createWSEvents(() => ({ 21 | * url: "ws://localhost:3000", 22 | * })); 23 | * 24 | * on("my-event", (data) => { 25 | * console.log(data); 26 | * }); 27 | * ``` 28 | */ 29 | export function createWSEvents< 30 | TWSEventDefinition extends Record, 31 | >(opts: (() => { url: string }) | { url: string }) { 32 | function on( 33 | eventName: TEventName, 34 | callback: (data: TWSEventDefinition[TEventName]["data"]) => void, 35 | ) { 36 | const WS: typeof WebSocket = 37 | typeof WebSocket === "undefined" ? require("ws") : WebSocket; 38 | const url = typeof opts === "function" ? opts().url : opts.url; 39 | const ws = new WS(url); 40 | 41 | ws.addEventListener("open", () => { 42 | ws.send(eventName); 43 | }); 44 | 45 | ws.addEventListener("message", (e) => { 46 | const parsed = parseJSON(e.data); 47 | callback(parsed); 48 | }); 49 | 50 | return () => { 51 | ws.close(); 52 | }; 53 | } 54 | 55 | return { 56 | on, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /packages/websocks-client/src/lib.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | 8 | /** 9 | * **buildPath()** 10 | * 11 | * Builds a websocks path given a route and params. 12 | */ 13 | export function buildPath( 14 | route: string, 15 | params: Record, 16 | ): string { 17 | let path = route; 18 | 19 | for (const param in params) { 20 | const rawValue = params[param]; 21 | const value = rawValue === undefined ? "" : encodeURIComponent(rawValue); 22 | path = path.replace(`:${param}`, value); 23 | } 24 | 25 | return ( 26 | path 27 | // Collapse all unreplaced optionals and return the path. 28 | .replaceAll(/{\/:\w+}/g, "") 29 | // Remove all parens from optional params that have now been filled in. 30 | .replaceAll(/{|}/g, "") 31 | ); 32 | } 33 | 34 | export function defer() { 35 | let resolve!: () => void; 36 | let reject!: (error: Error) => void; 37 | 38 | const promise = new Promise((res, rej) => { 39 | resolve = res; 40 | reject = rej; 41 | }); 42 | 43 | return { 44 | promise, 45 | reject, 46 | resolve, 47 | }; 48 | } 49 | 50 | export function noop() {} 51 | -------------------------------------------------------------------------------- /packages/websocks-client/src/react.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { useCallback, useEffect, useSyncExternalStore } from "react"; 8 | import { buildPath, defer, noop } from "./lib"; 9 | import { parseJSON } from "./string"; 10 | import { type RemapWithNumber } from "./types"; 11 | import useEvent from "./use-event"; 12 | 13 | /** 14 | * **createWSHooks()** 15 | * 16 | * Returns typed React hooks to be used with an instantiated websocks server. 17 | * Options can be either passed in immediately or passed in as a callback to be 18 | * called just-in-time when needed. 19 | * 20 | * ```ts 21 | * import { type Routes } from "./server"; 22 | * 23 | * createWSHooks({ url: "ws://localhost:3000" }); 24 | * const { preloadSubscription, useSubscription } = createWSHooks( 25 | * () => ({ url: "ws://localhost:3000" }), 26 | * ); 27 | * 28 | * preloadSubscription("/my-route"); 29 | * 30 | * function Component() { 31 | * return useSubscription("/my-route"); 32 | * } 33 | * ``` 34 | */ 35 | export function createWSHooks< 36 | TWSRouteDefinition extends Record, 37 | >(opts: (() => { url: string }) | { url: string }) { 38 | const valueCache = new Map(); 39 | const experimental_streamCache = new Map>(); 40 | const queryCache = new Map< 41 | string, 42 | { 43 | deferred: ReturnType; 44 | lazilyRefetch: boolean; 45 | subscribe: (onStoreChanged: () => void) => () => void; 46 | } 47 | >(); 48 | 49 | function createRecoverableWebSocket({ 50 | onClose, 51 | onError, 52 | onMessage, 53 | path, 54 | url, 55 | }: { 56 | onClose: () => void; 57 | onError: () => void; 58 | onMessage: (data: unknown) => void; 59 | path: string; 60 | url: string; 61 | }) { 62 | let ws = recreate(); 63 | 64 | function recreate() { 65 | const ws = new WebSocket(url); 66 | 67 | ws.addEventListener("open", () => { 68 | ws.send(path); 69 | }); 70 | 71 | ws.addEventListener("message", (e) => { 72 | const parsed = parseJSON(e.data); 73 | onMessage(parsed); 74 | }); 75 | 76 | ws.addEventListener("error", () => { 77 | onError(); 78 | }); 79 | 80 | ws.addEventListener("close", ({ code }) => { 81 | if (code === 1006) { 82 | // The websocket connection closed from some local browser related issue. 83 | // Try and reconnect in a second. 84 | setTimeout(() => { 85 | if (ws.readyState === WebSocket.CLOSED) { 86 | recreate(); 87 | } 88 | }, 1000); 89 | } else { 90 | onClose(); 91 | } 92 | }); 93 | 94 | return ws; 95 | } 96 | 97 | return { 98 | close: () => ws.close(), 99 | }; 100 | } 101 | 102 | function wsQuery(path: string) { 103 | function load() { 104 | const query = queryCache.get(path); 105 | if (query && !query.lazilyRefetch) { 106 | return; 107 | } 108 | 109 | if (query) { 110 | queryCache.set(path, { 111 | ...query, 112 | lazilyRefetch: false, 113 | }); 114 | } 115 | 116 | const resolvedOpts = typeof opts === "function" ? opts() : opts; 117 | const deferred = defer(); 118 | const subscriptions: (() => void)[] = []; 119 | 120 | let cleanupTimeoutId: number; 121 | 122 | const ws = createRecoverableWebSocket({ 123 | onClose: () => { 124 | const query = queryCache.get(path); 125 | if (query) { 126 | /** 127 | * Once a query has been closed we mark it as needing a refetch if 128 | * it's accessed again in the future. This means it can transition 129 | * from a standard subscription to a lazy subscription later in its 130 | * life. 131 | */ 132 | valueCache.delete(path); 133 | experimental_streamCache.delete(path); 134 | queryCache.set(path, { ...query, lazilyRefetch: true }); 135 | } 136 | }, 137 | onError: () => { 138 | deferred.reject(new Error("Error connecting to websocket.")); 139 | }, 140 | onMessage: (data) => { 141 | const streamValue = experimental_streamCache.get(path) || []; 142 | streamValue.push(data); 143 | experimental_streamCache.set(path, streamValue); 144 | 145 | valueCache.set(path, data); 146 | subscriptions.forEach((cb) => cb()); 147 | deferred.resolve(); 148 | }, 149 | path, 150 | url: resolvedOpts.url, 151 | }); 152 | 153 | function subscribe(onStoreChanged: () => void) { 154 | subscriptions.push(onStoreChanged); 155 | 156 | return () => { 157 | // Cleanup 158 | window.clearTimeout(cleanupTimeoutId); 159 | 160 | const index = subscriptions.indexOf(onStoreChanged); 161 | subscriptions.splice(index, 1); 162 | 163 | cleanupTimeoutId = window.setTimeout(() => { 164 | if (subscriptions.length === 0) { 165 | // After a period of time if there are no more active connections 166 | // Close websocket and clear the cache. It'll be loaded fresh from 167 | // The server next time a component tries to access the data. 168 | ws.close(); 169 | } 170 | }, 9999); 171 | }; 172 | } 173 | 174 | queryCache.set(path, { 175 | deferred, 176 | lazilyRefetch: false, 177 | subscribe, 178 | }); 179 | } 180 | 181 | function read({ 182 | stream, 183 | suspend = true, 184 | }: { stream?: "all" | "last"; suspend?: boolean } = {}) { 185 | const query = queryCache.get(path); 186 | if (!query) { 187 | throw new Error(`invariant: call load() first for ${path}`); 188 | } 189 | 190 | if (query.lazilyRefetch) { 191 | load(); 192 | } 193 | 194 | const value = stream 195 | ? experimental_streamCache.get(path) 196 | : valueCache.get(path); 197 | 198 | if (!value) { 199 | if (suspend) { 200 | throw query.deferred.promise; 201 | } else { 202 | return null as TValue; 203 | } 204 | } 205 | 206 | if (typeof value === "object" && "error" in value) { 207 | throw new Error( 208 | `Error reading "${decodeURIComponent(path)}" - [${value.error}]`, 209 | ); 210 | } 211 | 212 | if (stream === "all" && Array.isArray(value)) { 213 | return value; 214 | } else if (stream === "last" && Array.isArray(value)) { 215 | return value.at(-1); 216 | } 217 | 218 | return value as TValue; 219 | } 220 | 221 | function subscribe(onStoreChanged: () => void) { 222 | const query = queryCache.get(path); 223 | if (!query) { 224 | throw new Error(`invariant: call load() first for ${path}`); 225 | } 226 | 227 | return query.subscribe(onStoreChanged); 228 | } 229 | 230 | return { 231 | load, 232 | read, 233 | subscribe, 234 | }; 235 | } 236 | 237 | /** 238 | * **preloadSubscription()** 239 | * 240 | * Preloads a subscription. This should be called as early as possible in your 241 | * application. 242 | */ 243 | function preloadSubscription< 244 | TRoute extends string & keyof TWSRouteDefinition, 245 | >( 246 | ...args: TWSRouteDefinition[TRoute]["params"] extends never 247 | ? [route: TRoute] 248 | : [ 249 | route: TRoute, 250 | params: RemapWithNumber, 251 | ] 252 | ): void { 253 | const [route, params = {}] = args; 254 | const query = wsQuery(buildPath(route, params)); 255 | query.load(); 256 | } 257 | 258 | /** 259 | * **useLazySubscription()** 260 | * 261 | * Lazily subscribes to a route. This hook does not need to be preloaded and 262 | * so is at risk of causing data loading waterfalls. 263 | * 264 | * Prefer preloading as early as possible and using `useSubscription()` 265 | * instead. 266 | */ 267 | function useLazySubscription< 268 | TRoute extends string & keyof TWSRouteDefinition, 269 | >( 270 | ...args: TWSRouteDefinition[TRoute]["params"] extends never 271 | ? [route: TRoute] 272 | : [ 273 | route: TRoute, 274 | params: RemapWithNumber, 275 | ] 276 | ): TWSRouteDefinition[TRoute]["data"] { 277 | const [route, params = {}] = args; 278 | const query = wsQuery( 279 | buildPath(route, params), 280 | ); 281 | 282 | query.load(); 283 | 284 | const data = useSyncExternalStore( 285 | useCallback((onStoreChanged) => query.subscribe(onStoreChanged), [query]), 286 | query.read, 287 | ); 288 | 289 | return data; 290 | } 291 | 292 | /** 293 | * **useSubscription()** 294 | * 295 | * Returns the value of a preloaded subscription. You must call 296 | * `preloadSubscription()` prior to calling this hook otherwise an error will 297 | * be thrown. 298 | */ 299 | function useSubscription( 300 | ...args: TWSRouteDefinition[TRoute]["params"] extends never 301 | ? [route: TRoute] 302 | : [ 303 | route: TRoute, 304 | params: RemapWithNumber, 305 | ] 306 | ): TWSRouteDefinition[TRoute]["data"] { 307 | const [route, params = {}] = args; 308 | const query = wsQuery( 309 | buildPath(route, params), 310 | ); 311 | 312 | const data = useSyncExternalStore( 313 | useCallback((onStoreChanged) => query.subscribe(onStoreChanged), [query]), 314 | query.read, 315 | ); 316 | 317 | return data; 318 | } 319 | 320 | /** 321 | * **clearQuery()** 322 | * 323 | * Clears a path from the query cache. When accessing the path again it will 324 | * be lazily fetched from the server, meaning it doesn't need to be 325 | * preloaded. 326 | */ 327 | function clearQuery( 328 | ...args: TWSRouteDefinition[TRoute]["params"] extends never 329 | ? [route: TRoute] 330 | : [ 331 | route: TRoute, 332 | params: RemapWithNumber, 333 | ] 334 | ): void { 335 | const [route, params = {}] = args; 336 | const path = buildPath(route, params); 337 | const query = queryCache.get(path); 338 | 339 | valueCache.delete(path); 340 | 341 | if (query) { 342 | /** 343 | * To prevent an invariant being thrown we set the query to lazily refresh 344 | * the next time it's accessed, transforming the query to a lazy 345 | * subscription. 346 | */ 347 | queryCache.set(path, { ...query, lazilyRefetch: true }); 348 | } 349 | } 350 | 351 | /** @experimental */ 352 | function useLazySubscriptionStream< 353 | TRoute extends string & keyof TWSRouteDefinition, 354 | >( 355 | ...args: TWSRouteDefinition[TRoute]["params"] extends never 356 | ? [ 357 | route: TRoute, 358 | callback: ( 359 | data: TWSRouteDefinition[TRoute]["data"], 360 | type: "chunk" | "all", 361 | ) => void, 362 | ] 363 | : [ 364 | route: TRoute, 365 | params: RemapWithNumber, 366 | callback: ( 367 | data: TWSRouteDefinition[TRoute]["data"], 368 | type: "chunk" | "all", 369 | ) => void, 370 | ] 371 | ) { 372 | const [route, paramsOrCallback, maybeCallback = noop] = args; 373 | const params = 374 | typeof paramsOrCallback === "function" ? {} : paramsOrCallback; 375 | const callback = 376 | typeof paramsOrCallback === "function" ? paramsOrCallback : maybeCallback; 377 | const callbackEvent = useEvent(callback); 378 | const query = wsQuery( 379 | buildPath(route, params), 380 | ); 381 | 382 | query.load(); 383 | 384 | useEffect(() => { 385 | const latestValue = query.read({ stream: "all", suspend: false }); 386 | if (latestValue?.length > 0) { 387 | callbackEvent(latestValue, "all"); 388 | } 389 | 390 | return query.subscribe(() => { 391 | callbackEvent(query.read({ stream: "last" }), "chunk"); 392 | }); 393 | }, [callbackEvent, query]); 394 | } 395 | 396 | return { 397 | clearQuery, 398 | experimental_useLazySubscriptionStream: useLazySubscriptionStream, 399 | preloadSubscription, 400 | useLazySubscription, 401 | useSubscription, 402 | }; 403 | } 404 | 405 | export { type RemapWithNumber, buildPath }; 406 | -------------------------------------------------------------------------------- /packages/websocks-client/src/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | 8 | /** Parses JSON while handling preserved undefined values. */ 9 | export function parseJSON(value: string) { 10 | return JSON.parse(value, (_k, v) => (v === "__UNDEFINED__" ? undefined : v)); 11 | } 12 | -------------------------------------------------------------------------------- /packages/websocks-client/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | 8 | export type RemapWithNumber = { 9 | [P in keyof TObject]: string | number | undefined; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/websocks-client/src/use-event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | /* eslint-disable @typescript-eslint/no-explicit-any */ 8 | import { useInsertionEffect, useRef } from "react"; 9 | 10 | type AnyFunction = (...args: any[]) => any; 11 | 12 | /** 13 | * Similar to useCallback, with a few subtle differences: 14 | * 15 | * - The returned function is a stable reference, and will always be the same 16 | * between renders 17 | * - No dependency lists required 18 | * - Properties or state accessed within the callback will always be "current" 19 | */ 20 | export function useEvent( 21 | callback: TCallback, 22 | ): TCallback { 23 | // Keep track of the latest callback: 24 | const latestRef = useRef( 25 | invariant_shouldNotBeInvokedBeforeMount as any, 26 | ); 27 | 28 | useInsertionEffect(() => { 29 | latestRef.current = callback; 30 | }, [callback]); 31 | 32 | const stableRef = useRef(null as any); 33 | // eslint-disable-next-line react-compiler/react-compiler 34 | if (!stableRef.current) { 35 | // eslint-disable-next-line react-compiler/react-compiler 36 | stableRef.current = ((...args: any[]) => { 37 | return latestRef.current(...args); 38 | }) as TCallback; 39 | } 40 | 41 | // eslint-disable-next-line react-compiler/react-compiler 42 | return stableRef.current; 43 | } 44 | 45 | /** 46 | * Render methods should be pure, especially when concurrency is used, so we 47 | * will throw this error if the callback is called while rendering. 48 | */ 49 | function invariant_shouldNotBeInvokedBeforeMount() { 50 | throw new Error( 51 | "INVALID_USEEVENT_INVOCATION: the callback from useEvent cannot be invoked before the component has mounted.", 52 | ); 53 | } 54 | 55 | export default useEvent; 56 | -------------------------------------------------------------------------------- /packages/websocks-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.tsconfig/react-library.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/websocks-server/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://swc.rs/schema.json", 3 | "sourceMaps": false, 4 | "jsc": { 5 | "target": "es2022", 6 | "parser": { 7 | "syntax": "typescript" 8 | }, 9 | "transform": { 10 | "react": { 11 | "runtime": "automatic" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/websocks-server/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @triplex/websocks-server 2 | 3 | ## 0.0.7 4 | 5 | ### Patch Changes 6 | 7 | - 030ed16: Add `UNSAFE_use` api that is called for every message recieved. 8 | 9 | ## 0.0.6 10 | 11 | ### Patch Changes 12 | 13 | - ae60a8f: Upgrade typescript. 14 | 15 | ## 0.0.5 16 | 17 | ### Patch Changes 18 | 19 | - b97cd77: .listen() now takes a second arg for hostname. 20 | 21 | ## 0.0.4 22 | 23 | ### Patch Changes 24 | 25 | - 2301fd1: Drop unwanted files when publishing. 26 | 27 | ## 0.0.3 28 | 29 | ### Patch Changes 30 | 31 | - 863de7b: Support for optional params. E.g. the path "/scene/:path/:exportName{/:exportName1}{/:exportName2}/props" will have `path` and `exportName` as required params, and `exportName1` and `exportName2` as optional params. 32 | - 863de7b: GlobalProvider and CanvasProvider exports from the declared provider module now show up in the provider panel. 33 | 34 | ## 0.0.2 35 | 36 | ### Patch Changes 37 | 38 | - 27f5a72: Rename `createEvent` to `event`. 39 | 40 | ## 0.0.1 41 | 42 | ### Patch Changes 43 | 44 | - 1ba6783: Initial release. 45 | -------------------------------------------------------------------------------- /packages/websocks-server/README.md: -------------------------------------------------------------------------------- 1 | # @triplex/websocks-server 2 | 3 | [![Discord](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2FnBzRBUEs4b%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&style=flat&colorA=000000&colorB=000000&label=discord&logo=&logoColor=000000)](https://discord.gg/nBzRBUEs4b) [![GitHub Sponsors](https://img.shields.io/github/sponsors/itsdouges?style=flat&colorA=000000&colorB=000000&label=sponsor&logo=&logoColor=000000)](https://github.com/sponsors/itsdouges) 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i @triplex/websocks-server 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```ts 14 | // 1. Create the websocks server 15 | import { createWSServer } from "@triplex/websocks-server"; 16 | 17 | const wss = createWSServer(); 18 | 19 | // 2. Define routes 20 | const routes = wss.collectTypes([ 21 | wss.route( 22 | "/rng/:max", 23 | ({ max }) => Math.round(Math.random() * Number(max)), 24 | (push) => { 25 | setInterval(() => { 26 | // Every 1s push a new value to the client. 27 | push(); 28 | }, 1000); 29 | }, 30 | ), 31 | ]); 32 | 33 | // 3. Define events 34 | const events = wss.collectTypes([ 35 | tws.event<"ping", { timestamp: number }>("ping", (send) => { 36 | setInterval(() => { 37 | send({ timestamp: Date.now() }); 38 | }, 1000); 39 | }), 40 | ]); 41 | 42 | // 4. Start listening 43 | wss.listen(3000); 44 | 45 | // 5. Export types to use with the client 46 | export type Routes = typeof routes; 47 | export type Events = typeof events; 48 | ``` 49 | 50 | ## API 51 | 52 | ### `createWSServer()` 53 | 54 | Creates a typed websocket server. 55 | 56 | #### Returns 57 | 58 | | Name | Description | 59 | | --- | --- | 60 | | `close()` | Closes the server. | 61 | | `collectTypes(TEvents[] \| TRoutes[])` | Collects types from `event()` and `route()`. | 62 | | `route(path: string, callback: Function, initialize: Function)` | Creates a route. | 63 | | `event(eventName: string, initialize: Function)` | Creates an event. | 64 | | `listen(port: number)` | Listens to the declared port. | 65 | -------------------------------------------------------------------------------- /packages/websocks-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@triplex/websocks-server", 3 | "version": "0.0.7", 4 | "description": "An end-to-end typed websocket API router and React client.", 5 | "author": { 6 | "name": "Triplex", 7 | "email": "support@triplex.dev", 8 | "url": "https://x.com/trytriplex" 9 | }, 10 | "exports": { 11 | ".": { 12 | "types": "./src/index.ts", 13 | "module": "./src/index.ts", 14 | "default": "./src/index.ts" 15 | } 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build": "swc ./src -d ./dist", 22 | "typedef": "tsc" 23 | }, 24 | "dependencies": { 25 | "path-to-regexp": "^8.2.0", 26 | "ws": "^8.12.0" 27 | }, 28 | "devDependencies": { 29 | "@swc/cli": "^0.1.59", 30 | "@swc/core": "^1.6.5", 31 | "@types/ws": "8.5.4", 32 | "typescript": "^5.8.3" 33 | }, 34 | "publishConfig": { 35 | "exports": { 36 | ".": { 37 | "types": "./dist/index.d.ts", 38 | "module": "./dist/index.js", 39 | "default": "./dist/index.js" 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/websocks-server/src/create-server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { createServer } from "node:http"; 8 | import { match } from "path-to-regexp"; 9 | import { WebSocketServer, type WebSocket } from "ws"; 10 | import { decodeParams, stringifyJSON } from "./string"; 11 | import { 12 | type AliveWebSocket, 13 | type MessageContext, 14 | type MiddlewareHandler, 15 | type RouteHandler, 16 | type RouteOpts, 17 | type RouteParams, 18 | type UnionToIntersection, 19 | } from "./types"; 20 | 21 | /** 22 | * **collectTypes()** 23 | * 24 | * Collects the types of all routes or events passed to it. This result of this 25 | * function can be used on the client for end-to-end type safety. 26 | */ 27 | function collectTypes>>( 28 | _: TRoutes, 29 | ): UnionToIntersection { 30 | // This is opaque, purely used to return what the types are. 31 | // Accessing it at runtime won't do anything. 32 | return {} as UnionToIntersection; 33 | } 34 | 35 | /** 36 | * **createWSServer()** 37 | * 38 | * Creates a typed websocket server. 39 | * 40 | * ```ts 41 | * // 1. Create the websocks server 42 | * import { createWSServer } from "@triplex/websocks-server"; 43 | * 44 | * const wss = createWSServer(); 45 | * 46 | * // 2. Define routes 47 | * const routes = wss.collectTypes([ 48 | * wss.route( 49 | * "/rng/:max", 50 | * ({ max }) => Math.round(Math.random() * Number(max)), 51 | * (push) => { 52 | * setInterval(() => { 53 | * // Every 1s push a new value to the client. 54 | * push(); 55 | * }, 1000); 56 | * }, 57 | * ), 58 | * ]); 59 | * 60 | * // 3. Define events 61 | * const events = wss.collectTypes([ 62 | * tws.event<"ping", { timestamp: number }>("ping", (send) => { 63 | * setInterval(() => { 64 | * send({ timestamp: Date.now() }); 65 | * }, 1000); 66 | * }), 67 | * ]); 68 | * 69 | * // 4. Start listening 70 | * wss.listen(3000); 71 | * 72 | * // 5. Export types to use with the client 73 | * export type Routes = typeof routes; 74 | * export type Events = typeof events; 75 | * ``` 76 | */ 77 | export function createWSServer() { 78 | const server = createServer(); 79 | const eventHandlers: Record void> = {}; 80 | const wss = new WebSocketServer({ server }); 81 | const routeHandlers: RouteHandler[] = []; 82 | const middleware: MiddlewareHandler[] = []; 83 | 84 | /** 85 | * Processes each middleware sequentially. Each middleware must call `next` to 86 | * then continue to each middleware and then the root one. 87 | * 88 | * Only calling `next` once is valid, calling it multiple times will no-op. 89 | */ 90 | function processMiddleware( 91 | context: MessageContext, 92 | root: () => void | Promise, 93 | ) { 94 | const processed: number[] = []; 95 | let index = 0; 96 | 97 | function next() { 98 | if (processed.includes(index)) { 99 | // We've already processed this so skip it. 100 | return; 101 | } 102 | 103 | if (index >= middleware.length) { 104 | return root(); 105 | } 106 | 107 | const nextMiddleware = middleware[index]; 108 | processed.push(index); 109 | index += 1; 110 | 111 | return Promise.resolve(nextMiddleware(context, next)); 112 | } 113 | 114 | return next(); 115 | } 116 | 117 | function ping() { 118 | wss.clients.forEach((ws) => { 119 | if (ws.isAlive === false) { 120 | return ws.terminate(); 121 | } 122 | 123 | ws.isAlive = false; 124 | ws.ping(); 125 | }); 126 | } 127 | 128 | // Every 30s ping all connected clients to make sure they are alive. 129 | setInterval(ping, 30_000); 130 | 131 | wss.on("connection", (ws) => { 132 | ws.on("pong", () => { 133 | ws.isAlive = true; 134 | }); 135 | 136 | ws.on("message", (rawData) => { 137 | const path = rawData.toString(); 138 | 139 | if (path.startsWith("/")) { 140 | const context: MessageContext = { path, type: "route" }; 141 | 142 | return processMiddleware(context, () => { 143 | for (let i = 0; i < routeHandlers.length; i++) { 144 | const handler = routeHandlers[i](path); 145 | if (handler) { 146 | handler(ws); 147 | return; 148 | } 149 | } 150 | }); 151 | } else { 152 | const context: MessageContext = { name: path, type: "event" }; 153 | 154 | return processMiddleware(context, () => { 155 | const handler = eventHandlers[path]; 156 | if (handler) { 157 | handler(ws); 158 | } 159 | }); 160 | } 161 | }); 162 | }); 163 | 164 | /** 165 | * **route()** 166 | * 167 | * Declare a route to be passed to `collectTypes()`. 168 | */ 169 | function route< 170 | TData, 171 | TRoute extends `/${string}`, 172 | TRouteParams extends RouteParams, 173 | >( 174 | opts: (RouteOpts & { path: TRoute }) | TRoute, 175 | cb: ( 176 | params: TRouteParams, 177 | state: { type: "push" | "pull" }, 178 | ) => Promise | TData, 179 | pushConstructor?: ( 180 | push: () => void, 181 | params: TRouteParams, 182 | ) => Promise | void, 183 | ): Record { 184 | const handler = (path: string) => { 185 | const route = typeof opts === "string" ? opts : opts.path; 186 | const config: RouteOpts = typeof opts === "string" ? {} : opts; 187 | const fn = match(route); 188 | const matches = fn(path); 189 | 190 | if (matches) { 191 | return async (ws: WebSocket) => { 192 | const params: TRouteParams = decodeParams( 193 | matches.params, 194 | ) as TRouteParams; 195 | 196 | async function sendMessage(type: "push" | "pull") { 197 | let data; 198 | 199 | try { 200 | if (config.defer) { 201 | await new Promise((resolve) => { 202 | setImmediate(resolve); 203 | }); 204 | } 205 | 206 | data = await cb(params, { type }); 207 | } catch (error) { 208 | if (error instanceof Error) { 209 | ws.send( 210 | JSON.stringify({ error: error.stack || error.message }), 211 | ); 212 | } else { 213 | ws.send(JSON.stringify({ error })); 214 | } 215 | 216 | return ws.terminate(); 217 | } 218 | 219 | ws.send(stringifyJSON(data)); 220 | } 221 | 222 | sendMessage("pull"); 223 | 224 | if (pushConstructor) { 225 | try { 226 | await pushConstructor(() => sendMessage("push"), params); 227 | } catch (error) { 228 | if (error instanceof Error) { 229 | ws.send( 230 | JSON.stringify({ error: error.stack || error.message }), 231 | ); 232 | } else { 233 | ws.send(JSON.stringify({ error })); 234 | } 235 | 236 | return ws.terminate(); 237 | } 238 | } 239 | }; 240 | } 241 | 242 | return false; 243 | }; 244 | 245 | routeHandlers.push(handler); 246 | 247 | // This is opaque, purely used to return what the types are. 248 | // Accessing it at runtime won't do anything. 249 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 250 | return {} as any; 251 | } 252 | 253 | /** 254 | * **event()** 255 | * 256 | * Declares an event to be passed to `collectTypes()`. 257 | */ 258 | function event( 259 | eventName: TRoute, 260 | init: (sendEvent: (data: TData) => void) => Promise | void, 261 | ): Record { 262 | const handler = (ws: WebSocket) => { 263 | async function sendEvent(data: TData) { 264 | ws.send(stringifyJSON(data)); 265 | } 266 | 267 | init(sendEvent); 268 | }; 269 | 270 | if (eventHandlers[eventName]) { 271 | throw new Error(`invariant: ${eventName} already declared`); 272 | } 273 | 274 | eventHandlers[eventName] = handler; 275 | 276 | // This is opaque, purely used to return what the types are. 277 | // Accessing it at runtime won't do anything. 278 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 279 | return {} as any; 280 | } 281 | 282 | /** 283 | * **use()** 284 | * 285 | * Registers a middleware handler that will be called when messages are 286 | * received but before they've matched with a route handler or event. You must 287 | * call `next` to continue processing the message. 288 | * 289 | * @experimental 290 | */ 291 | function UNSAFE_use(callback: MiddlewareHandler) { 292 | middleware.push(callback); 293 | 294 | return () => { 295 | const index = middleware.indexOf(callback); 296 | if (index !== -1) { 297 | middleware.splice(index, 1); 298 | } 299 | }; 300 | } 301 | 302 | /** 303 | * **close()** 304 | * 305 | * Stops the server and closes all connections. 306 | */ 307 | function close() { 308 | wss.close(); 309 | server.close(); 310 | } 311 | 312 | /** 313 | * **listen()** 314 | * 315 | * Listens on the specified port. 316 | */ 317 | function listen(port: number, hostname?: string) { 318 | server.listen(port, hostname); 319 | } 320 | 321 | return { 322 | UNSAFE_use, 323 | close, 324 | collectTypes, 325 | event, 326 | listen, 327 | route, 328 | }; 329 | } 330 | -------------------------------------------------------------------------------- /packages/websocks-server/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | export { createWSServer } from "./create-server"; 8 | -------------------------------------------------------------------------------- /packages/websocks-server/src/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | 8 | /** Converts an object to JSON while ensuring undefined values are preserved. */ 9 | export function stringifyJSON(value: unknown): string { 10 | return JSON.stringify(value, (_k, v) => 11 | v === undefined ? "__UNDEFINED__" : v, 12 | ); 13 | } 14 | 15 | export function decodeParams(params?: Record | null) { 16 | if (!params) { 17 | return {}; 18 | } 19 | 20 | const newParams = { ...params }; 21 | 22 | for (const key in newParams) { 23 | newParams[key] = decodeURIComponent(newParams[key]); 24 | } 25 | 26 | return newParams; 27 | } 28 | -------------------------------------------------------------------------------- /packages/websocks-server/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { type WebSocket } from "ws"; 8 | 9 | export interface AliveWebSocket extends WebSocket { 10 | isAlive: boolean; 11 | } 12 | 13 | export type RouteHandler = (path: string) => RouteCallback | false; 14 | 15 | export type RouteCallback = (ws: WebSocket) => Promise; 16 | 17 | export type UnionToIntersection = ( 18 | U extends unknown ? (k: U) => void : never 19 | ) extends (k: infer I) => void 20 | ? I 21 | : never; 22 | 23 | export interface RouteOpts { 24 | defer?: boolean; 25 | } 26 | 27 | export type ValidateShape = T extends Shape 28 | ? Exclude extends never 29 | ? T 30 | : never 31 | : never; 32 | 33 | export type ExtractParams = 34 | TRoute extends `${infer TStart}{/:${infer TOptionalParam}}${infer TEnd}` 35 | ? { [P in TOptionalParam]?: string } & ExtractParams<`${TStart}${TEnd}`> 36 | : TRoute extends `${infer TStart}/${infer TEnd}` 37 | ? ExtractParams & ExtractParams 38 | : TRoute extends `:${infer TParam}` 39 | ? { [P in TParam]: string } 40 | : // eslint-disable-next-line @typescript-eslint/no-empty-object-type 41 | {}; 42 | 43 | export type RouteParams = 44 | ValidateShape, object> extends never 45 | ? ExtractParams 46 | : never; 47 | 48 | export type MessageContext = 49 | | { 50 | path: string; 51 | type: "route"; 52 | } 53 | | { 54 | name: string; 55 | type: "event"; 56 | }; 57 | 58 | export type MiddlewareHandler = ( 59 | context: MessageContext, 60 | next: () => unknown | Promise, 61 | ) => unknown | Promise; 62 | -------------------------------------------------------------------------------- /packages/websocks-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.tsconfig/node.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /scripts/apply-publish-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | const { readFile, writeFile } = require("node:fs/promises"); 8 | const { join } = require("node:path"); 9 | 10 | async function main() { 11 | const filename = join(process.cwd(), "package.json"); 12 | const file = await readFile(filename, "utf8"); 13 | const data = JSON.parse(file); 14 | 15 | if (!data.publishConfig) { 16 | return; 17 | } 18 | 19 | const publishConfig = data.publishConfig; 20 | 21 | delete data.publishConfig; 22 | 23 | for (const key in publishConfig) { 24 | const value = publishConfig[key]; 25 | data[key] = value; 26 | } 27 | 28 | await writeFile(filename, JSON.stringify(data, null, 2) + "\n"); 29 | } 30 | 31 | main(); 32 | -------------------------------------------------------------------------------- /scripts/check-changesets.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR=./.changeset 3 | FILES=( $(find "$DIR" -name "*.md") ) 4 | 5 | if [ ${#FILES[@]} -gt 0 ]; then # if the length of the array is more than 0 6 | echo "Changesets exist, aborting release..." 7 | exit 1 8 | else 9 | echo "Changesets do not exist, releasing..." 10 | exit 0 11 | fi 12 | -------------------------------------------------------------------------------- /scripts/ensure-clean-git.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | const { spawnSync } = require("node:child_process"); 8 | 9 | const { output, status } = spawnSync("git diff --exit-code", { 10 | shell: true, 11 | }); 12 | 13 | if (status !== null && status !== 0) { 14 | // eslint-disable-next-line no-console 15 | console.error( 16 | ` 17 | ===== Unexpected Uncommitted Changes ===== 18 | ${output.join("\n").trim()} 19 | ========================================== 20 | `, 21 | ); 22 | process.exit(1); 23 | } 24 | -------------------------------------------------------------------------------- /scripts/force-react-18.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | const { join } = require("node:path"); 8 | const { readFileSync, writeFileSync } = require("node:fs"); 9 | 10 | const pkgJSON = JSON.parse(readFileSync(join(process.cwd(), "package.json"))); 11 | 12 | pkgJSON.pnpm.overrides = { 13 | ...pkgJSON.pnpm.overrides, 14 | "@react-three/drei": "9.121.4", 15 | "@react-three/fiber": "8.17.14", 16 | "@types/react": "18.3.12", 17 | "@types/react-dom": "18.3.1", 18 | react: "18.3.1", 19 | "react-dom": "18.3.1", 20 | }; 21 | 22 | writeFileSync( 23 | join(process.cwd(), "package.json"), 24 | JSON.stringify(pkgJSON, null, 2), 25 | ); 26 | -------------------------------------------------------------------------------- /scripts/header.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | -------------------------------------------------------------------------------- /scripts/workspace-run.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | // eslint-disable-next-line @typescript-eslint/no-require-imports 8 | const { exec } = require("node:child_process"); 9 | 10 | const script = process.argv[2]; 11 | const path = require.resolve(script); 12 | 13 | exec(`pnpm -r exec node ${path}`); 14 | -------------------------------------------------------------------------------- /test/__mocks__/wss.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { ws } from "msw"; 8 | 9 | const wssURL = `ws://localhost:3`; 10 | const mockWSS = ws.link(wssURL); 11 | 12 | export const handlers = [ 13 | mockWSS.addEventListener("connection", ({ client }) => { 14 | let errorCount = 0; 15 | 16 | client.addEventListener("message", (e) => { 17 | switch (e.data) { 18 | case "/errors": 19 | client.send(JSON.stringify({ error: "Websocket server error!" })); 20 | break; 21 | 22 | case "/errors-once": { 23 | errorCount += 1; 24 | 25 | if (errorCount >= 1) { 26 | client.send(JSON.stringify({ name: "bar" })); 27 | } else { 28 | client.send(JSON.stringify({ error: "Websocket server error!" })); 29 | } 30 | 31 | break; 32 | } 33 | 34 | default: 35 | client.send(JSON.stringify({ name: "bar" })); 36 | } 37 | }); 38 | }), 39 | ]; 40 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { cleanup } from "@testing-library/react"; 8 | import { setupServer } from "msw/node"; 9 | import { afterAll, afterEach, beforeAll, vi } from "vitest"; 10 | import { handlers } from "./__mocks__/wss"; 11 | 12 | const server = setupServer(...handlers); 13 | 14 | beforeAll(() => server.listen()); 15 | 16 | afterEach(() => { 17 | server.resetHandlers(); 18 | vi.useRealTimers(); 19 | cleanup(); 20 | }); 21 | 22 | afterAll(() => server.close()); 23 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022—present Michael Dougall. All rights reserved. 3 | * 4 | * This repository utilizes multiple licenses across different directories. To 5 | * see this files license find the nearest LICENSE file up the source tree. 6 | */ 7 | import { defineConfig } from "vitest/config"; 8 | 9 | export default defineConfig(async () => { 10 | return { 11 | test: { 12 | expect: { 13 | requireAssertions: true, 14 | }, 15 | setupFiles: "./test/setup.ts", 16 | }, 17 | }; 18 | }); 19 | --------------------------------------------------------------------------------