From 474deb1fa1709951b3934b04a0607865fd00c443 Mon Sep 17 00:00:00 2001 From: brandon-schabel Date: Sat, 11 Jan 2025 09:57:57 -0700 Subject: [PATCH] move out packages into seperate repos --- LICENSE | 2 +- bun.lockb | Bin 344768 -> 307280 bytes instructions.md | 192 --------- package.json | 8 +- packages/server/package.json | 4 +- .../server/src/websocket/websocket-config.ts | 102 +++++ packages/streaming-engine/LICENSE | 21 - packages/streaming-engine/README.md | 198 --------- packages/streaming-engine/package.json | 20 - .../src/constants/provider-defauls.ts | 9 - .../src/create-mock-sse-stream.ts | 42 -- packages/streaming-engine/src/index.ts | 17 - .../src/models/model-fetcher-service.ts | 328 -------------- .../src/models/model-types.ts | 133 ------ .../src/plugins/anthropic-plugin.test.ts | 100 ----- .../src/plugins/anthropic-plugin.ts | 92 ---- .../src/plugins/gemini-plugin.test.ts | 107 ----- .../src/plugins/gemini-plugin.ts | 105 ----- .../src/plugins/groq-plugin.test.ts | 109 ----- .../src/plugins/groq-plugin.ts | 61 --- .../src/plugins/ollama-plugin.test.ts | 103 ----- .../src/plugins/ollama-plugin.ts | 46 -- .../src/plugins/open-ai-like-plugin.test.ts | 134 ------ .../src/plugins/open-ai-like-plugin.ts | 97 ----- .../src/plugins/open-router-plugin.test.ts | 106 ----- .../src/plugins/open-router-plugin.ts | 70 --- .../src/plugins/together-plugin.test.ts | 106 ----- .../src/plugins/together-plugin.ts | 61 --- .../streaming-engine/src/provider-plugin.ts | 17 - .../src/streaming-engine.test.ts | 143 ------- .../streaming-engine/src/streaming-engine.ts | 194 --------- .../streaming-engine/src/streaming-types.ts | 67 --- packages/streaming-engine/tsconfig.json | 30 -- packages/websocket-manager-react/.gitignore | 1 - packages/websocket-manager-react/README.md | 403 ------------------ packages/websocket-manager-react/package.json | 34 -- .../src/client-websocket-context.tsx | 130 ------ .../src/client-websocket-manager.ts | 164 ------- .../src/client-websocket-types.ts | 46 -- packages/websocket-manager-react/src/index.ts | 13 - .../websocket-manager-react/tsconfig.app.json | 25 -- .../tsconfig.build.json | 27 -- .../websocket-manager-react/tsconfig.json | 7 - .../tsconfig.node.json | 24 -- .../websocket-manager-react/vite.config.ts | 34 -- packages/websocket-manager/.gitignore | 1 - packages/websocket-manager/README.md | 208 --------- packages/websocket-manager/package.json | 18 - .../src/example/counter-handlers.ts | 37 -- .../websocket-manager/src/example/database.ts | 44 -- .../src/example/example-bun-server.ts | 111 ----- .../websocket-manager/src/example/index.html | 54 --- .../src/example/project-tab-handlers.ts | 61 --- .../src/generic-websocket-manager.test.ts | 144 ------- .../src/generic-websocket-manager.ts | 369 ---------------- packages/websocket-manager/src/index.ts | 2 - .../websocket-manager/src/websocket-types.ts | 37 -- packages/websocket-manager/tsconfig.json | 30 -- .../websocket-react-example/client/.gitignore | 24 -- .../websocket-react-example/client/README.md | 50 --- .../websocket-react-example/client/bun.lockb | Bin 44966 -> 0 bytes .../websocket-react-example/client/index.html | 13 - .../client/package.json | 25 -- .../client/public/vite.svg | 1 - .../client/src/app.css | 42 -- .../client/src/app.tsx | 98 ----- .../client/src/assets/react.svg | 1 - .../client/src/chat-web-socket-provider.tsx | 77 ---- .../client/src/index.css | 68 --- .../client/src/main.tsx | 13 - .../client/src/vite-env.d.ts | 1 - .../client/tsconfig.app.json | 25 -- .../client/tsconfig.json | 7 - .../client/tsconfig.node.json | 23 - .../client/vite.config.ts | 10 - .../server/package.json | 13 - .../server/src/index.html | 1 - .../server/src/index.ts | 103 ----- .../server/tsconfig.json | 31 -- prompts/bnk-websocket-manager.md | 262 ++++++++++++ prompts/bun-typescript-coder.md | 32 ++ components.md => prompts/components.md | 0 .../drizzle-sqlite.md | 0 prompts/readme-meta-prompt.md | 77 ++++ server-summary.md | 49 --- 85 files changed, 479 insertions(+), 5415 deletions(-) delete mode 100644 instructions.md create mode 100644 packages/server/src/websocket/websocket-config.ts delete mode 100644 packages/streaming-engine/LICENSE delete mode 100644 packages/streaming-engine/README.md delete mode 100644 packages/streaming-engine/package.json delete mode 100644 packages/streaming-engine/src/constants/provider-defauls.ts delete mode 100644 packages/streaming-engine/src/create-mock-sse-stream.ts delete mode 100644 packages/streaming-engine/src/index.ts delete mode 100644 packages/streaming-engine/src/models/model-fetcher-service.ts delete mode 100644 packages/streaming-engine/src/models/model-types.ts delete mode 100644 packages/streaming-engine/src/plugins/anthropic-plugin.test.ts delete mode 100644 packages/streaming-engine/src/plugins/anthropic-plugin.ts delete mode 100644 packages/streaming-engine/src/plugins/gemini-plugin.test.ts delete mode 100644 packages/streaming-engine/src/plugins/gemini-plugin.ts delete mode 100644 packages/streaming-engine/src/plugins/groq-plugin.test.ts delete mode 100644 packages/streaming-engine/src/plugins/groq-plugin.ts delete mode 100644 packages/streaming-engine/src/plugins/ollama-plugin.test.ts delete mode 100644 packages/streaming-engine/src/plugins/ollama-plugin.ts delete mode 100644 packages/streaming-engine/src/plugins/open-ai-like-plugin.test.ts delete mode 100644 packages/streaming-engine/src/plugins/open-ai-like-plugin.ts delete mode 100644 packages/streaming-engine/src/plugins/open-router-plugin.test.ts delete mode 100644 packages/streaming-engine/src/plugins/open-router-plugin.ts delete mode 100644 packages/streaming-engine/src/plugins/together-plugin.test.ts delete mode 100644 packages/streaming-engine/src/plugins/together-plugin.ts delete mode 100644 packages/streaming-engine/src/provider-plugin.ts delete mode 100644 packages/streaming-engine/src/streaming-engine.test.ts delete mode 100644 packages/streaming-engine/src/streaming-engine.ts delete mode 100644 packages/streaming-engine/src/streaming-types.ts delete mode 100644 packages/streaming-engine/tsconfig.json delete mode 100644 packages/websocket-manager-react/.gitignore delete mode 100644 packages/websocket-manager-react/README.md delete mode 100644 packages/websocket-manager-react/package.json delete mode 100644 packages/websocket-manager-react/src/client-websocket-context.tsx delete mode 100644 packages/websocket-manager-react/src/client-websocket-manager.ts delete mode 100644 packages/websocket-manager-react/src/client-websocket-types.ts delete mode 100644 packages/websocket-manager-react/src/index.ts delete mode 100644 packages/websocket-manager-react/tsconfig.app.json delete mode 100644 packages/websocket-manager-react/tsconfig.build.json delete mode 100644 packages/websocket-manager-react/tsconfig.json delete mode 100644 packages/websocket-manager-react/tsconfig.node.json delete mode 100644 packages/websocket-manager-react/vite.config.ts delete mode 100644 packages/websocket-manager/.gitignore delete mode 100644 packages/websocket-manager/README.md delete mode 100644 packages/websocket-manager/package.json delete mode 100644 packages/websocket-manager/src/example/counter-handlers.ts delete mode 100644 packages/websocket-manager/src/example/database.ts delete mode 100644 packages/websocket-manager/src/example/example-bun-server.ts delete mode 100644 packages/websocket-manager/src/example/index.html delete mode 100644 packages/websocket-manager/src/example/project-tab-handlers.ts delete mode 100644 packages/websocket-manager/src/generic-websocket-manager.test.ts delete mode 100644 packages/websocket-manager/src/generic-websocket-manager.ts delete mode 100644 packages/websocket-manager/src/index.ts delete mode 100644 packages/websocket-manager/src/websocket-types.ts delete mode 100644 packages/websocket-manager/tsconfig.json delete mode 100644 packages/websocket-react-example/client/.gitignore delete mode 100644 packages/websocket-react-example/client/README.md delete mode 100755 packages/websocket-react-example/client/bun.lockb delete mode 100644 packages/websocket-react-example/client/index.html delete mode 100644 packages/websocket-react-example/client/package.json delete mode 100644 packages/websocket-react-example/client/public/vite.svg delete mode 100644 packages/websocket-react-example/client/src/app.css delete mode 100644 packages/websocket-react-example/client/src/app.tsx delete mode 100644 packages/websocket-react-example/client/src/assets/react.svg delete mode 100644 packages/websocket-react-example/client/src/chat-web-socket-provider.tsx delete mode 100644 packages/websocket-react-example/client/src/index.css delete mode 100644 packages/websocket-react-example/client/src/main.tsx delete mode 100644 packages/websocket-react-example/client/src/vite-env.d.ts delete mode 100644 packages/websocket-react-example/client/tsconfig.app.json delete mode 100644 packages/websocket-react-example/client/tsconfig.json delete mode 100644 packages/websocket-react-example/client/tsconfig.node.json delete mode 100644 packages/websocket-react-example/client/vite.config.ts delete mode 100644 packages/websocket-react-example/server/package.json delete mode 100644 packages/websocket-react-example/server/src/index.html delete mode 100644 packages/websocket-react-example/server/src/index.ts delete mode 100644 packages/websocket-react-example/server/tsconfig.json create mode 100644 prompts/bnk-websocket-manager.md create mode 100644 prompts/bun-typescript-coder.md rename components.md => prompts/components.md (100%) rename drizzle-sqlite.md => prompts/drizzle-sqlite.md (100%) create mode 100644 prompts/readme-meta-prompt.md delete mode 100644 server-summary.md diff --git a/LICENSE b/LICENSE index 7aea2e6..6402eea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Brandon Schabel +Copyright (c) 2025 Brandon Schabel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/bun.lockb b/bun.lockb index ed0c43788cb631070d91b0219a7144a8eec1757a..cf13c617e03c45eebf245798c84efff228a101cb 100755 GIT binary patch delta 66881 zcmeFadz?*W|Np=Dn%V5_OvyCJDTgpZm|@uDIL_zOWMnYRV9b~qjN=TY zjwDezR!T~x5F?3FC`zTMn|{yNT5C^JKllB;KlkVR`F(zWv>smby56tr{JswBTFdM` zJLi=D@bhwu8q}Q{i0@dc+gA}`Wgm)IvMXxEPpdxd*W;0#QNeKE&DAG#Yqs-+aF4F9 zjm3j*%6a!Q!?v6s#XX+f(FDa)(?;6jGyDu-C&0zv9$v>@2`k;_a0z&ctM_%&WhN(P zBvHhhRG|E!azpuFLNAj;#5w{hsD+y`Ej?*`Vv5Ie#^>>rML+EFUn`tj#3{I6QKw<4 zDXB?OBZqrBV3#Iegx}*S1t(@s89!odO4c6ADT93$UFE)toudTz(L6OEHIo)_Jx4@I z>;$@dJ=_LXhEN0Yh?ghkl1cnNTn^64Xwppi+v8xByBfEs!X6YR59PtCCo3@{Y7})< zDCN1;E?^R=)#4+tGQR6_%J}4@jFha*G1w{~p_IAT-yr84h03Gp6I0VwUoFa2wh_r` zlgDRJKepPHK%)rec7d;he}$DUV}x>fJa-{VPfZ!0lA4m41=)HwqS`q=ZDdkZ;_#Hr zq|`}Cl-q>DsJdP5bp(_#99Fw8(>P@qo-%%9%J|Wl{cdnNo|rKrIfbmnDmV@51#r zs)<8HK+KIu*M;wx|6;ktnu;JaQF)G zYVQhcx}JMKtonA54$jG)eX~=*e%Kz^|3g86T24d$Qcx}v&d#5im64Q~n(axc?NpQi zs{zBCB+5UYzSyegIym6T@#J2`T}lw@@ei(TFD)_*U`1#m?7GF77;T1+AA2inxGTDP z@d7?FkDI?fr=oc9T6oi}s2 zQU_=3ws&zR&3#>+*_)L)E@e0w{oS19Hw9f|KN8j?t<~Krr#rUR`9|^=hZn-E*xUzt zcsy0%Djl7XO{B+mo=)ezQydP+uOR#K)X zb*hqkJmUwrJx)#4L zY2zj(d2WmMc-U-nZ-I+x{Z}MV3QkVU{2u<#E3P@LXxvFAc zPsWub&=@XuGlot(!!r^`;4sh0VNSj}iB7|&!m4mgN>+->Ny&_|Z%el_an!&WZh2X- z(xoP4fZYCFAE4{o40+IWV;+Jeuy@uLzmvnEhMM$*JgwRz;YNvh8? z#%(Bvg|R6k6Ej9-CQV8jpOxtyh0g%?cpc8~c5&<-uDuB^h8>*l@o><|P0Gkf%ZN(K zzKE@KV=|-cWssOLIz2HXGie>^<*A}GSpy3EI>T|{QiK3@=p5XT)~N@zU{w%0H(!sf zF@K7ND1B%*c-XbGGNV${GSZVDo#o_T1gqzhanN=11jfwH36O&Nf&BHylmsZ@Wd3m z=h5U-gKmV&!B-!2{QDEE{vLrV!nKw;4Jd0fYwhwg1HH5xD$wY!¬f@5+)>wY`nB(#U^2z0kpK`e9TBjr5 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�S2#z|z$sX)&^57v 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

VPON;dxwcq6-|5<7H6Z6r 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 delta 87929 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 diff --git a/instructions.md b/instructions.md deleted file mode 100644 index d601111..0000000 --- a/instructions.md +++ /dev/null @@ -1,192 +0,0 @@ -# OctoPrompt Usage Guide - -Welcome to OctoPrompt, your prompt-building and chat companion designed to streamline and enhance your development workflow. This guide will walk you through every feature, from setting up projects, syncing files, managing prompts, and engaging with chat capabilities via local models or external APIs. - -## Overview - -OctoPrompt helps you: - -- Load and manage projects, including syncing and exploring your code files -- Create, save, and reuse custom prompts -- Interact with a variety of Large Language Models (LLMs) through a chat interface -- Fork chats from specific messages or exclude certain messages to refine context -- Optimize your prompt tokens and keep track of token usage - -Use OctoPrompt to streamline code analysis, generate solutions, and integrate intelligence into your development workflow. - ---- - -## Getting Started - -### Running the App - -Please ensure you have downloaded the correct binary for you platform. -M Series mac need to be using the "macos-arm64" version. -For Linux and Mac: Open a terminal in the root of the directory directory for your platform, run the file for the current version you downloaded. - -```bash -./octoprompt-v1.0.6 -``` - -This will start the server at [http://localhost:3579](http://localhost:3579) - -For Windows: -I haven't had a chance to try it yet, but you should be able to just run the .exe files in the version compiled for Windows and it should spin up the server on [http://localhost:3579](http://localhost:3579) - -## Key Concepts - -### Projects & Files - -**Projects** represent local codebases or folders that you want to integrate with OctoPrompt. By selecting a project path, you can sync the project to load and view its files within the app. This allows you to quickly reference code segments when building prompts or discussing them with the chat AI. - -- **Setting a Project Path**: - Open the “Projects” page, click "New Project" (or press `⌘N`), enter the project’s directory path on your local machine. - *Note: The path should be accessible to the server.* - -- **Syncing a Project**: - Once a project is created, select it and click "Sync Files" to load or update the project’s files. Syncing ensures OctoPrompt has the latest version of your code. The app will parse supported file types (`.ts`, `.tsx`, `.js`, `.jsx`, `.md`, `.txt`) and list them, allowing you to select which files to include in your prompts. - -- **Selecting Files for Prompts**: - After syncing, you can browse the file tree or a tabular view. Select files by checking the box next to them. Selected files can be integrated directly into prompts. - -### Prompts - -**Prompts** are reusable templates or instructions you craft for the AI. You can create, save, and manage prompts within each project. - -- **Creating a Prompt**: - Navigate to the "Prompts" section in your selected project. Click "New" and provide a name and content. - For example: - **Name**: "Summarize Code" - **Content**: "Please summarize the functionality of the selected files." - -- **Editing & Deleting Prompts**: - Use the edit (pencil) or delete (trash) icons next to a prompt. Editing allows you to refine the instructions; deleting removes it from your project. - -- **Using Prompts in Chat**: - You can select multiple saved prompts and easily include them in your chat context. This is helpful for consistently applying specific instructions or formatting guidance. - ---- - -## Chat Capabilities - -### Overview - -The chat interface allows you to interact with LLMs to: - -- Discuss your code -- Brainstorm feature implementations -- Debug complex issues -- Generate new code snippets - -You can supply the chat with: - -- **Selected Files**: The chat can read and reason about your chosen code files. -- **Saved Prompts**: Reuse your prompts as part of the conversation context. -- **User Prompt**: Provide an additional custom query or instructions. - -All combined, the chat’s context can be prepared as a well-structured message that the AI can process and respond to. - -### Choosing a Provider - -To use chat, you must set up a provider. There are two main categories: - -1. **Local Providers**: - - **LM Studio**: A local inference server for running LLMs on your machine. - *Setup*: Download and run LM Studio locally. Provide the correct model name in the chat settings. - - **Ollama**: A local LLM inference tool that allows you to run models like Llama locally. - *Setup*: Install and run Ollama. Configure the model name in chat settings. - - Using a local provider means no external API calls. It’s ideal for privacy and avoiding API costs. - -2. **Remote Providers (API-based)**: - - **OpenAI**: Use keys from the OpenAI platform (such as GPT-4). - - **OpenRouter**: Use a single API key to access multiple providers and models via OpenRouter. - - To use these: - - Acquire an API key from OpenAI or OpenRouter. - - Go to the “Keys” page in OctoPrompt and add the key. - - Select the appropriate provider and model in the chat UI. - -### Setting Up Keys - -If you choose OpenAI or OpenRouter: - -- Navigate to the “Keys” page. -- Select the provider from the dropdown. -- Enter your API key. -- Click “Add” to store it securely. - -Once added, you can select the provider and model in the chat section. - -### Customizing the Chat - -**Providers & Models**: -At the left side panel of the chat page, you’ll see options to select your provider (OpenAI, OpenRouter, LM Studio, Ollama) and choose a model. For LM Studio and Ollama, pick from local models. For OpenAI/OpenRouter, pick a hosted model (like GPT-4). - -**Excluding Messages**: -Sometimes the conversation may drift off-topic. You can exclude certain past messages from the chat’s context to refocus. Toggle “Exclude” on a message, and it won’t be sent to the model in the next request. - -**Forking Chats**: -If you want to explore an alternate conversation path, use the “Fork Chat” feature. This duplicates the conversation up to a certain point or from a certain message, allowing you to try a different approach without losing the original thread. - ---- - -## Token Estimation - -For prompts and file contents, OctoPrompt estimates token usage. Tokens roughly represent chunks of text; more tokens mean higher API costs (for OpenAI/OpenRouter) or longer inference times. Keep an eye on token counts to optimize your prompts. - ---- - -## Page-by-Page Functionality - -- **Landing Page** (`/`): - Introduces the app’s capabilities and lets you quickly jump to “Chat” or “Projects.” - -- **Projects Page** (`/projects`): - Manage all your projects here: - - **Create New Project**: Press the "New" button or `⌘N`. - - **Open Project**: Press `⌘O` or click the project in the list. - - **Sync Files**: Once a project is open, click "Sync Files" to update the file list. - - **Select Files**: Choose files you want to include in your prompt context. - -- **Keys Page** (`/keys`): - Add or remove API keys for OpenAI or OpenRouter. Once keys are added, they can be selected in chat. - -- **Chat Page** (`/chat`): - Engage with the chosen LLM: - - **Select Provider & Model**: On the left sidebar, pick your provider (local or API-based) and model. - - **Load Prompt & Files**: Add saved prompts and selected project files into the context. - - **User Prompt**: Type your question or instructions. - - **Copy Prompt**: Copy the entire context to clipboard if you need it elsewhere. - - **Transfer to Chat**: Move prepared context directly into the chat input. - - **Fork Chat**: Create a new chat branch from the current state or a specific message. - - **Exclude Messages**: Toggle message exclusion to refine context. - ---- - -## Local Models: LM Studio & Ollama - -**LM Studio**: -A desktop application that runs LLMs locally. Once LM Studio is running, configure the same model name in the chat settings. No external API calls are made; it all runs on your machine. - -**Ollama**: -A local inference tool for LLMs. Install Ollama and run it. Configure the model name (e.g., “llama2”). Now your chat requests will be served locally, maintaining full privacy and control over the model. - ---- - -## Troubleshooting - -- **No Files Listed After Sync**: - Ensure you’ve entered a correct and accessible path. Only supported file types are shown. -- **No Keys for OpenAI/OpenRouter**: - Make sure you’ve added an API key in the “Keys” section. -- **Local Providers Not Working**: - Verify LM Studio or Ollama is running locally and the model name is correct. - ---- - -## Conclusion - -OctoPrompt brings together code context, custom prompts, and flexible chat capabilities, whether through local models or remote APIs. With careful prompt preparation, file inclusion, and provider selection, you can supercharge your development process. - -Enjoy building smarter and faster with OctoPrompt! diff --git a/package.json b/package.json index 563205c..abcceee 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { - "name": "octo-prompt", + "name": "octoprompt", "module": "index.ts", "type": "module", "workspaces": [ - "packages/*", - "packages/websocket-react-example/*" + "packages/*" ], "devDependencies": { "@types/bun": "latest" @@ -13,7 +12,6 @@ "typescript": "^5.7.2" }, "dependencies": { - "@bnk/router": "1.0.5", - "@bnk/websocket-manager-react": "workspace:*" + "@bnk/router": "1.0.5" } } \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 373d955..af779bc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -25,13 +25,13 @@ "drizzle-orm": "^0.36.3" }, "dependencies": { + "@bnk/ai": "^1.0.0", "@bnk/router": "1.0.6", "@types/archiver": "^6.0.3", "archiver": "^7.0.1", "better-sqlite3": "^11.7.0", "openai": "^4.73.1", "shared": "workspace:*", - "zod": "^3.23.8", - "@bnk/ai": "workspace:*" + "zod": "^3.23.8" } } diff --git a/packages/server/src/websocket/websocket-config.ts b/packages/server/src/websocket/websocket-config.ts new file mode 100644 index 0000000..471e1be --- /dev/null +++ b/packages/server/src/websocket/websocket-config.ts @@ -0,0 +1,102 @@ +// websocket-config.ts +import { globalStateSchema, createInitialGlobalState, type GlobalState } from "shared"; +import { db } from "shared/database"; +import { globalStateTable, eq } from "shared"; +import { ZodError } from "zod"; + +// -------------------------------------------------- +// 1. In-Memory Cache +// -------------------------------------------------- +// This holds your state in memory for fast reads/writes. +let inMemoryGlobalState: GlobalState | null = null; + +// -------------------------------------------------- +// 2. Fetch from Database +// -------------------------------------------------- +async function loadFromDb(): Promise { + const row = await db + .select() + .from(globalStateTable) + .where(eq(globalStateTable.id, "main")) + .get(); + + if (!row) { + // No stored state in DB; create a fresh one and persist + const initialState = createInitialGlobalState(); + await db + .insert(globalStateTable) + .values({ id: "main", state_json: JSON.stringify(initialState) }) + .run(); + return initialState; + } + + // Validate or fall back if the schema fails + try { + const parsed = JSON.parse(row.state_json); + return globalStateSchema.parse(parsed); + } catch (err) { + if (err instanceof ZodError) { + // If DB state is invalid, reset to a known good state + const fallback = createInitialGlobalState(); + await saveToDb(fallback); + return fallback; + } + throw err; + } +} + +// -------------------------------------------------- +// 3. Save to Database +// -------------------------------------------------- +async function saveToDb(newState: GlobalState): Promise { + const exists = await db + .select() + .from(globalStateTable) + .where(eq(globalStateTable.id, "main")) + .get(); + + if (!exists) { + await db + .insert(globalStateTable) + .values({ id: "main", state_json: JSON.stringify(newState) }) + .run(); + } else { + await db + .update(globalStateTable) + .set({ state_json: JSON.stringify(newState) }) + .where(eq(globalStateTable.id, "main")) + .run(); + } +} + +// -------------------------------------------------- +// 4. getState / setState for BNK manager +// -------------------------------------------------- +export async function getState(): Promise { + // If our in-memory copy is null, load from DB once + if (!inMemoryGlobalState) { + inMemoryGlobalState = await loadFromDb(); + } + // Return a clone (structuredClone or deep copy) to avoid accidental mutations + return structuredClone(inMemoryGlobalState); +} + +export async function setState(newState: GlobalState): Promise { + // Update in-memory copy + inMemoryGlobalState = structuredClone(newState); + + // Optionally, write to DB immediately. + // If you'd rather do periodic saves, comment this out or queue it in an interval. + await saveToDb(inMemoryGlobalState); +} + +// -------------------------------------------------- +// 5. Optional: Periodic DB Save Interval +// -------------------------------------------------- +const SAVE_INTERVAL_MS = 10_000; // e.g. 10 seconds +setInterval(async () => { + if (inMemoryGlobalState) { + await saveToDb(inMemoryGlobalState); + // console.log("[PeriodicSync] State saved to DB"); + } +}, SAVE_INTERVAL_MS); \ No newline at end of file diff --git a/packages/streaming-engine/LICENSE b/packages/streaming-engine/LICENSE deleted file mode 100644 index 7aea2e6..0000000 --- a/packages/streaming-engine/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Brandon Schabel - -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: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -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. diff --git a/packages/streaming-engine/README.md b/packages/streaming-engine/README.md deleted file mode 100644 index 527a5ff..0000000 --- a/packages/streaming-engine/README.md +++ /dev/null @@ -1,198 +0,0 @@ -# AI Streaming Engine - -A powerful abstraction layer for streaming text completions from multiple AI providers through a single unified toolkit. - -## Overview - -`@bnk/ai` (AI Streaming Engine) provides a flexible, plugin-based abstraction for streaming AI-generated text from multiple providers such as OpenAI, Anthropic, Ollama, etc. The goal is for your application to integrate once, then swap or combine providers at will without modifying your client-side streaming logic. - -**Key features**: - -- **Unified streaming abstraction**: use one engine in your client code to handle partial message streaming, done signals, and error handling. -- **Plugin-based**: each AI provider is implemented as a plugin that knows how to prepare the request and parse the stream. -- **Simple SSE-based interface**: your app interacts with the engine through a set of handlers (e.g. `onPartial`, `onDone`). -- **Extensible**: easily add new providers by implementing a `ProviderPlugin`. - -## How It Works - -### Architecture - -1. **Your application** calls `createSSEStream(params)` with: - - **`userMessage`** and optionally a **`systemMessage`** - - The desired **plugin** (provider) - - **Options** you want to pass to the provider (model, temperature, etc.) - - **Handlers** for partial chunks, done signal, errors, etc. - -2. **Inside `createSSEStream`**: - - The plugin’s `prepareRequest()` method is called, returning a readable stream or reader containing the SSE data from the AI provider. - - The engine reads SSE lines, calls the plugin’s `parseServerSentEvent()` to extract text chunks, and forwards them to your handlers (`onPartial`, `onDone`, etc.). - - The engine also returns a **`ReadableStream`** which you could pipe back to your clients if desired (for real-time text updates in a browser). - -3. **Plugins** (e.g. `OpenAiLikePlugin`, `AnthropicPlugin`) each implement: - ```ts - interface ProviderPlugin { - prepareRequest(params: SSEEngineParams): Promise | ReadableStreamDefaultReader>; - parseServerSentEvent(line: string): string | null; - } - ``` - - **`prepareRequest()`** handles how to call the provider’s API and get back a streaming SSE. - - **`parseServerSentEvent()`** extracts only the text from each SSE chunk, returning `[DONE]` or `null` when appropriate. - -### Typical Usage - -1. **Install** the package (private or local reference as appropriate): - ```bash - # e.g. if you're using bun or npm - bun add @bnk/ai - # or - npm install @bnk/ai - ``` - -2. **Import** the engine and plugin(s) you want: - ```ts - import { createSSEStream, OpenAiLikePlugin } from '@bnk/ai'; - - // Initialize provider - const plugin = new OpenAiLikePlugin(myOpenAiClient, 'gpt-4'); - ``` - -3. **Create the stream** and provide handlers: - ```ts - const userMessage = "Explain the concept of neural networks."; - const handlers = { - onPartial: (chunk) => { - console.log("Partial chunk:", chunk.content); - }, - onDone: (final) => { - console.log("All done:", final.content); - }, - onError: (error) => { - console.error("Stream error:", error); - }, - }; - - const stream = await createSSEStream({ - userMessage, - plugin, // e.g. OpenAiLikePlugin - handlers, - options: { model: "gpt-4", temperature: 0.5 }, // any provider-specific options - }); - - // You can also return or pipe this ReadableStream back to your client - // for in-browser SSE consumption, or handle it server-side. - ``` - -## API - -### `createSSEStream(params: SSEEngineParams): Promise>` - -Creates a new SSE stream. The `SSEEngineParams` object includes: - -- `userMessage: string` – the user’s prompt -- `systemMessage?: string` – an optional system-level instruction -- `plugin: ProviderPlugin` – the provider plugin handling the request -- `options?: Record` – any provider-specific options (model, temperature, etc.) -- `handlers: SSEEngineHandlers` – callbacks for partial, done, error, etc. - -Example usage: -```ts -await createSSEStream({ - userMessage: "Hello world", - systemMessage: "You are a helpful assistant", - plugin: myPlugin, - options: { model: "myModel", temperature: 0.9 }, - handlers: { - onPartial: (chunk) => { - console.log("Partial:", chunk.content); - }, - onDone: (message) => { - console.log("Complete:", message.content); - }, - }, -}); -``` - -### Handlers - -```ts -interface SSEEngineHandlers { - onSystemMessage?: (message: SSEMessage) => void; - onUserMessage?: (message: SSEMessage) => void; - onPartial?: (partial: SSEMessage) => void; - onDone?: (fullContent: SSEMessage) => void; - onError?: (error: unknown, partialSoFar: SSEMessage) => void; -} -``` - -- **`onSystemMessage`**: Invoked once if a system message is provided. -- **`onUserMessage`**: Invoked for the user's message. -- **`onPartial`**: Invoked for each streamed chunk of assistant text. -- **`onDone`**: Final callback when the stream is complete or `[DONE]` is encountered. -- **`onError`**: If an error occurs, you get the error plus the text accumulated so far. - -### Providers & Plugins - -#### Creating a Plugin - -To add a new provider, implement: -```ts -import { ProviderPlugin } from "@bnk/ai"; - -export class MyProviderPlugin implements ProviderPlugin { - async prepareRequest(params: SSEEngineParams) { - // 1) Call your provider's SSE or streaming API - // 2) Return a ReadableStream or the reader - } - - parseServerSentEvent(line: string): string | null { - // Convert SSE line to text chunk or "[DONE]" if the stream ended - } -} -``` -Then pass an instance to the engine via `plugin: new MyProviderPlugin(...)`. - -#### Example: `OpenAiLikePlugin` -- Calls OpenAI’s streaming Chat Completions API. -- Returns a stream of SSE lines that the engine processes. - -#### Example: `AnthropicPlugin` -- Calls Anthropic’s API. -- Parses its SSE format to gather partial text. - -### Extending - -You can build your own higher-level logic on top of this engine: -- Save partial responses to a database -- Stream updates to websockets or SSE endpoints -- Provide multiple fallback plugins (e.g., if one fails, switch to another) - -## Repository Structure - -- **`src/`** – main code for the streaming engine, plugins, and types -- **`src/streaming-engine.ts`** – core `createSSEStream` function -- **`src/streaming-types.ts`** – shared SSE engine types (`SSEEngineParams`, `SSEMessage`, etc.) -- **`src/plugins/`** – plugin implementations for different AI providers -- **`package.json`** – package metadata - -## Getting Started - -1. Install dependencies: - ```bash - bun install - # or npm install - ``` -2. Build or run tests: - ```bash - bun test - ``` - -## Contributing - -Contributions are welcome! To add a new provider: -1. Create a new plugin in `src/plugins/`. -2. Implement `prepareRequest()` and `parseServerSentEvent()`. -3. Export it in `src/index.ts`. - -## License - -MIT License (or appropriate license text here) \ No newline at end of file diff --git a/packages/streaming-engine/package.json b/packages/streaming-engine/package.json deleted file mode 100644 index 34c9eb4..0000000 --- a/packages/streaming-engine/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@bnk/ai", - "module": "src/index.ts", - "main": "src/index.ts", - "type": "module", - "version": "1.0.0", - "scripts": { - "test": "bun test src/", - "test:watch": "bun test --watch src/", - "prepublishOnly": "bun test", - "publish:streaming-engine": "bun publish --access public" - }, - "devDependencies": { - "bun-types": "latest", - "typescript": "^5.7.2" - }, - "dependencies": { - "openai": "^4.73.1" - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/constants/provider-defauls.ts b/packages/streaming-engine/src/constants/provider-defauls.ts deleted file mode 100644 index b139b01..0000000 --- a/packages/streaming-engine/src/constants/provider-defauls.ts +++ /dev/null @@ -1,9 +0,0 @@ -// ollama and lmstudio should be adjustable eventually -export const OLLAMA_BASE_URL = "http://localhost:11434"; -export const LMSTUDIO_BASE_URL = "http://localhost:1234/v1"; -export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; -export const OPENAI_BASE_URL = "https://api.openai.com/v1"; -export const XAI_BASE_URL = "https://api.x.ai/v1"; -export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; -export const GROQ_BASE_URL = "https://api.groq.com/openai/v1"; -export const TOGETHER_BASE_URL = "https://api.together.xyz/v1"; diff --git a/packages/streaming-engine/src/create-mock-sse-stream.ts b/packages/streaming-engine/src/create-mock-sse-stream.ts deleted file mode 100644 index a89a100..0000000 --- a/packages/streaming-engine/src/create-mock-sse-stream.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * createMockSSEStream simulates a streaming SSE response by emitting lines one at a time. - * - * @param chunks Each array entry will be returned as an SSE data line: `data: \n\n`. - * @param options.endWithDone If true, appends a `data: [DONE]\n\n` at the end. Default: true. - * @param options.delayMs Milliseconds to wait between emitting each chunk. Default: 100. - */ -export function createMockSSEStream( - chunks: string[], - options: { endWithDone?: boolean; delayMs?: number } = {} - ): ReadableStream { - const { endWithDone = true, delayMs = 100 } = options; - - const encoder = new TextEncoder(); - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - - // Emit lines one by one, optionally appending a DONE signal - (async () => { - try { - for (const chunk of chunks) { - // SSE line format: data: \n\n - const dataLine = `data: ${chunk}\n\n`; - await writer.write(encoder.encode(dataLine)); - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - - // Optionally append the SSE "[DONE]" line - if (endWithDone) { - await writer.write(encoder.encode("data: [DONE]\n\n")); - } - - writer.close(); - } catch (error) { - writer.abort(error); - } - })(); - - return readable; - } \ No newline at end of file diff --git a/packages/streaming-engine/src/index.ts b/packages/streaming-engine/src/index.ts deleted file mode 100644 index f150d73..0000000 --- a/packages/streaming-engine/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { AnthropicPlugin } from './plugins/anthropic-plugin' -export { GeminiPlugin } from './plugins/gemini-plugin' -export { GroqPlugin } from './plugins/groq-plugin' -export { OllamaPlugin } from './plugins/ollama-plugin' -export { OpenAiLikePlugin } from './plugins/open-ai-like-plugin' -export { OpenRouterPlugin } from './plugins/open-router-plugin' -export { TogetherPlugin } from './plugins/together-plugin' - -export { createSSEStream } from './streaming-engine' - -export type { SSEEngineParams, SSEEngineHandlers, SSEMessage } from './streaming-types' - -export type { ProviderPlugin } from './provider-plugin' - -export * from './constants/provider-defauls' -export { ModelFetcherService, type ProviderConfig } from './models/model-fetcher-service' -export * from './models/model-types' \ No newline at end of file diff --git a/packages/streaming-engine/src/models/model-fetcher-service.ts b/packages/streaming-engine/src/models/model-fetcher-service.ts deleted file mode 100644 index 79e0180..0000000 --- a/packages/streaming-engine/src/models/model-fetcher-service.ts +++ /dev/null @@ -1,328 +0,0 @@ -// model-fetcher-service.ts -import type { APIProviders } from "shared"; -import { - GEMINI_BASE_URL, - GROQ_BASE_URL, - TOGETHER_BASE_URL, - LMSTUDIO_BASE_URL, - OLLAMA_BASE_URL, OPENAI_BASE_URL, - OPENROUTER_BASE_URL, - XAI_BASE_URL -} from "../constants/provider-defauls"; // or wherever you store these -import type { - UnifiedModel, - GeminiAPIModel, - TogetherModel, - AnthropicModel, - AnthropicModelsResponse, - OpenAIModelObject, - OpenAIModelsListResponse, - OpenRouterModel, - OpenRouterModelsResponse, - XAIModel, - -} from "./model-types"; - - -// provider-config.ts -export interface ProviderConfig { - openaiKey?: string; - anthropicKey?: string; - googleGeminiKey?: string; - groqKey?: string; - togetherKey?: string; - xaiKey?: string; - openRouterKey?: string; -} - -export class ModelFetcherService { - // store your config - constructor(private config: ProviderConfig) { } - - // Helper: throw if missing - private ensure(key?: string, providerName = "unknown") { - if (!key) throw new Error(`${providerName} API key not found in config`); - return key; - } - - // ----------------------------- - // GEMINI EXAMPLE - // ----------------------------- - async listGeminiModels(): Promise { - const apiKey = this.ensure(this.config.googleGeminiKey, "Google Gemini"); - const response = await fetch(`${GEMINI_BASE_URL}/models?key=${apiKey}`); - - if (!response.ok) { - throw new Error(`Failed to fetch Gemini models: ${response.statusText}`); - } - const data = (await response.json()) as { models: GeminiAPIModel[] }; - return data.models; - } - - // ----------------------------- - // GROQ EXAMPLE - // ----------------------------- - async listGroqModels(): Promise { - const groqApiKey = this.ensure(this.config.groqKey, "Groq"); - const response = await fetch(`${GROQ_BASE_URL}/models`, { - method: "GET", - headers: { - Authorization: `Bearer ${groqApiKey}`, - "Content-Type": "application/json", - }, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Groq models API error: ${response.status} - ${errorText}`); - } - const data = await response.json() as { - object: string; - data: Array<{ - id: string; - object: string; - created: number; - owned_by: string; - active: boolean; - context_window: number; - }>; - }; - return data.data.map((m) => ({ - id: m.id, - name: m.id, - description: `Groq model owned by ${m.owned_by}`, - })); - } - - // ----------------------------- - // TOGETHER EXAMPLE - // ----------------------------- - async listTogetherModels(): Promise { - const togetherApiKey = this.ensure(this.config.togetherKey, "Together"); - const response = await fetch(`${TOGETHER_BASE_URL}/models`, { - method: "GET", - headers: { - Authorization: `Bearer ${togetherApiKey}`, - "Content-Type": "application/json", - }, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Together models API error: ${response.status} - ${errorText}`); - } - const data = await response.json() as TogetherModel[]; - return data.map((m) => ({ - id: m.id, - name: m.display_name || m.id, - description: `${m.organization} model - ${m.display_name || m.id} | Context: ${m.context_length} tokens | License: ${m.license}`, - })); - } - - // ----------------------------- - // OPENAI EXAMPLE - // ----------------------------- - async listOpenAiModels(): Promise { - const openAIKey = this.ensure(this.config.openaiKey, "OpenAI"); - const response = await fetch("https://api.openai.com/v1/models", { - method: "GET", - headers: { - Authorization: `Bearer ${openAIKey}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`OpenAI list models error: ${response.status} - ${errorText}`); - } - - const data = await response.json() as OpenAIModelsListResponse; - return data.data; - } - - // ----------------------------- - // ANTHROPIC EXAMPLE - // ----------------------------- - async listAnthropicModels(): Promise { - const anthropicKey = this.ensure(this.config.anthropicKey, "Anthropic"); - const response = await fetch("https://api.anthropic.com/v1/models", { - method: "GET", - headers: { - "x-api-key": anthropicKey, - "anthropic-version": "2023-06-01", - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Anthropic Models API error: ${response.status} - ${errorText}`); - } - - const data = await response.json() as AnthropicModelsResponse; - return data.data; - } - - // ----------------------------- - // OPENROUTER EXAMPLE - // ----------------------------- - async listOpenRouterModels(): Promise { - const openRouterKey = this.ensure(this.config.openRouterKey, "OpenRouter"); - const response = await fetch("https://openrouter.ai/api/v1/models", { - method: "GET", - headers: { - Authorization: `Bearer ${openRouterKey}`, - "HTTP-Referer": "http://localhost:3579", - "X-Title": "OctoPrompt", - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`); - } - - const data = await response.json() as OpenRouterModelsResponse; - return data.data; - } - - // ----------------------------- - // XAI EXAMPLE - // ----------------------------- - async listXAIModels(): Promise { - const xaiKey = this.ensure(this.config.xaiKey, "XAI"); - const response = await fetch("https://api.x.ai/v1/models", { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${xaiKey}`, - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`XAI API error: ${response.status} - ${errorText}`); - } - - const data = await response.json() as { data: OpenAIModelObject[] }; - return data.data; - } - - // ----------------------------- - // OLLAMA EXAMPLE - // ----------------------------- - async listOllamaModels(): Promise { - const response = await fetch(`${process.env.OLLAMA_BASE_URL || "http://localhost:11434"}/api/tags`); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Ollama error: ${response.statusText} - ${errorText}`); - } - - const data = await response.json() as string[]; - return data.map((modelName) => ({ - id: modelName, - name: modelName, - description: `Ollama model: ${modelName}`, - })); - } - - // ----------------------------- - // LMSTUDIO EXAMPLE - // ----------------------------- - async listLMStudioModels(): Promise { - const response = await fetch(`${process.env.LMSTUDIO_BASE_URL || "http://localhost:1234"}/models`); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`LM Studio error: ${response.statusText} - ${errorText}`); - } - - const data = await response.json() as { data: any[] }; - return data.data.map((m) => ({ - id: m.id, - name: m.id, - description: `LM Studio model: ${m.id}`, - })); - } - - /** - * A unified method to list models for a given provider - */ - async listModels(provider: APIProviders): Promise { - switch (provider) { - case "openrouter": { - const models = await this.listOpenRouterModels(); - return models.map((m) => ({ - id: m.id, - name: m.name, - description: m.description, - })); - } - - case "lmstudio": { - return this.listLMStudioModels(); - } - - case "ollama": { - return this.listOllamaModels(); - } - - case "xai": { - const models = await this.listXAIModels(); - return models.map((m) => ({ - id: m.id, - name: m.id, - description: `XAI model owned by ${m.owned_by}`, - })); - } - - case "google_gemini": { - const models = await this.listGeminiModels(); - return models.map((m) => ({ - id: m.name, - name: m.displayName, - description: m.description, - })); - } - - case "anthropic": { - const models = await this.listAnthropicModels(); - return models.map(m => ({ - id: m.id, - name: m.display_name, - description: `Anthropic model: ${m.id}`, - })); - } - - case "groq": { - const models = await this.listGroqModels(); - return models.map(m => ({ - id: m.id, - name: m.name, - description: `Groq model: ${m.id}`, - })); - } - - case "together": { - const models = await this.listTogetherModels(); - return models.map(m => ({ - id: m.id, - name: m.id, - description: `Together model: ${m.id}`, - })); - } - - case "openai": - default: { - try { - const models = await this.listOpenAiModels(); - return models.map(m => ({ - id: m.id, - name: m.id, - description: `OpenAI model owned by ${m.owned_by}`, - })); - } catch (error) { - console.warn("Failed to fetch OpenAI models", error); - return []; - } - } - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/models/model-types.ts b/packages/streaming-engine/src/models/model-types.ts deleted file mode 100644 index 5a29215..0000000 --- a/packages/streaming-engine/src/models/model-types.ts +++ /dev/null @@ -1,133 +0,0 @@ -export type UnifiedModel = { - id: string; - name: string; - description: string; - // context: number; - // pricing: number; - // top_provider?: string; - // architecture?: string; -}; - -/** For your SSE event chunk shape */ -export type OpenRouterStreamResponse = { - choices: { - delta?: { content?: string }; - content?: string; - }[]; -}; - -/** Example shape of OpenRouter model data */ -export type OpenRouterModelContext = { - description: string; - tokens: number; - mode?: string; - formats?: string[]; -}; - -export type OpenRouterModelPricing = { - prompt: string; - completion: string; - rateLimit?: number; -}; - -export type OpenRouterModel = { - id: string; - name: string; - description: string; - context: OpenRouterModelContext; - pricing: OpenRouterModelPricing; - top_provider?: string; - architecture?: string; - per_request_limits?: { - prompt_tokens?: number; - completion_tokens?: number; - }; -}; - -export type OpenRouterModelsResponse = { - data: OpenRouterModel[]; -}; - -/** Gemini API model types */ -export type GeminiAPIModel = { - name: string; - baseModelId: string; - version: string; - displayName: string; - description: string; - inputTokenLimit: number; - outputTokenLimit: number; - supportedGenerationMethods: string[]; - temperature: number; - maxTemperature: number; - topP: number; - topK: number; -}; - -export type ListModelsResponse = { - models: GeminiAPIModel[]; -}; - -export type AnthropicModel = { - type: string; - id: string; - display_name: string; - created_at: string; -}; - -export type AnthropicModelsResponse = { - data: AnthropicModel[]; - has_more: boolean; - first_id: string | null; - last_id: string | null; -}; - -export type OpenAIModelObject = { - id: string; - object: string; - created: number; - owned_by: string; -}; - -export type OpenAIModelsListResponse = { - object: string; - data: OpenAIModelObject[]; -}; - -export type TogetherModelConfig = { - chat_template: string; - stop: string[]; - bos_token: string; - eos_token: string; -}; - -export type TogetherModelPricing = { - hourly: number; - input: number; - output: number; - base: number; - finetune: number; -}; - -export type TogetherModel = { - id: string; - object: string; - created: number; - type: string; - running: boolean; - display_name: string; - organization: string; - link: string; - license: string; - context_length: number; - config: TogetherModelConfig; - pricing: TogetherModelPricing; -}; - -export type XAIModel = { - id: string - // created at unix timestamp - created: number - object: string, - owned_by: string -} diff --git a/packages/streaming-engine/src/plugins/anthropic-plugin.test.ts b/packages/streaming-engine/src/plugins/anthropic-plugin.test.ts deleted file mode 100644 index 8a51672..0000000 --- a/packages/streaming-engine/src/plugins/anthropic-plugin.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { AnthropicPlugin } from "./anthropic-plugin"; -import type { SSEEngineParams } from "../streaming-types"; - -// Suppose we have a shared test utility in ./test-utils.ts: -import { createMockSSEStream } from "../create-mock-sse-stream"; // adjust path as needed - -describe("AnthropicPlugin", () => { - it("should parse SSE lines correctly and return partial text + [DONE]", async () => { - // 1) Spy on fetch to return a mock SSE stream using createMockSSEStream - const fakeFetch = () => - Promise.resolve({ - ok: true, - status: 200, - body: createMockSSEStream( - [ - // Each entry becomes `data: \n\n` - JSON.stringify({ type: "content_block_delta", delta: { text: "Hello " } }), - JSON.stringify({ type: "content_block_delta", delta: { text: "world!" } }) - ], - { - endWithDone: true, - delayMs: 0, // emit instantly, or adjust for slower simulation - } - ), - } as Response); - - // 2) Replace global fetch with our fake - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new AnthropicPlugin("fakeKey", "2023-06-01"); - const params: SSEEngineParams = { - userMessage: "Test user message", - plugin, - handlers: {}, - }; - - // 3) Prepare request - const reader = await plugin.prepareRequest(params); - - // 4) Manually read the SSE lines from the plugin’s returned reader - const decoder = new TextDecoder(); - let fullString = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - fullString += decoder.decode(value); - } - - // Validate parseServerSentEvent logic - const partial1 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ type: "content_block_delta", delta: { text: "Hello " } })}` - ); - const partial2 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ type: "content_block_delta", delta: { text: "world!" } })}` - ); - const doneSignal = plugin.parseServerSentEvent(`data: [DONE]`); - - expect(partial1).toBe("Hello "); - expect(partial2).toBe("world!"); - expect(doneSignal).toBe("[DONE]"); - - // Optionally, verify the raw SSE lines we read - expect(fullString).toContain("data: {\"type\":\"content_block_delta\""); - expect(fullString).toContain("[DONE]"); - } finally { - // Restore original fetch - globalThis.fetch = originalFetch; - } - }); - - it("should throw if the fetch fails", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: false, - status: 500, - body: null, - text: () => Promise.resolve("Server error"), - statusText: "Internal Server Error", - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new AnthropicPlugin("fakeKey", "2023-06-01"); - const params: SSEEngineParams = { - userMessage: "Test user message", - plugin, - handlers: {}, - }; - - await expect(plugin.prepareRequest(params)).rejects.toThrow(/Anthropic API error/); - } finally { - globalThis.fetch = originalFetch; - } - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/anthropic-plugin.ts b/packages/streaming-engine/src/plugins/anthropic-plugin.ts deleted file mode 100644 index 43cdbbb..0000000 --- a/packages/streaming-engine/src/plugins/anthropic-plugin.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ProviderPlugin } from "../provider-plugin"; -import type { SSEEngineParams } from "../streaming-types"; - -interface AnthropicStreamResponse { - type: string; - message?: { - content?: Array<{ text?: string }>; - stop_reason?: string; - }; - delta?: { - text?: string; - }; - error?: { message: string }; -} - -/** - * The new Anthropic plugin - */ -export class AnthropicPlugin implements ProviderPlugin { - private apiKey: string; - private version: string; - private beta?: string; - - constructor(apiKey: string, version: string, beta?: string) { - this.apiKey = apiKey; - this.version = version; - this.beta = beta; - } - - async prepareRequest(params: SSEEngineParams) { - const { userMessage, options } = params; - - const body = JSON.stringify({ - model: options?.model || "claude-2", - messages: [ - { - role: "user", - content: userMessage, - }, - ], - max_tokens: options?.max_tokens ?? 1024, - temperature: typeof options?.temperature === "number" ? options?.temperature : 1.0, - top_p: options?.top_p ?? 1, - top_k: options?.top_k ?? 0, - stream: true, - }); - - const headers: Record = { - "Content-Type": "application/json", - "anthropic-version": this.version, - "x-api-key": this.apiKey, - }; - if (this.beta) { - headers["anthropic-beta"] = this.beta; - } - - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers, - body, - }); - - if (!response.ok || !response.body) { - const errorText = await response.text(); - throw new Error(`Anthropic API error: ${response.status} - ${errorText}`); - } - - return response.body.getReader() as ReadableStreamDefaultReader - } - - parseServerSentEvent(line: string): string | null { - // SSE lines are prefixed with "data:" ... - if (!line.startsWith("data:")) return null; - const jsonString = line.replace(/^data:\s*/, "").trim(); - - // Anthropic uses "[DONE]" to signal the end - if (jsonString === "[DONE]") return "[DONE]"; - - try { - const parsed = JSON.parse(jsonString) as AnthropicStreamResponse; - if (parsed.error) { - throw new Error(`Anthropic SSE error: ${parsed.error.message}`); - } - if (parsed.type === "content_block_delta" && parsed.delta?.text) { - return parsed.delta.text; - } - return null; - } catch { - return null; - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/gemini-plugin.test.ts b/packages/streaming-engine/src/plugins/gemini-plugin.test.ts deleted file mode 100644 index 410ffaa..0000000 --- a/packages/streaming-engine/src/plugins/gemini-plugin.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { GeminiPlugin } from "./gemini-plugin"; -import type { SSEEngineParams } from "../streaming-types"; -import { createMockSSEStream } from "../create-mock-sse-stream"; - -describe("GeminiPlugin", () => { - it("should parse SSE lines correctly and return content", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: true, - status: 200, - body: createMockSSEStream( - [ - JSON.stringify({ candidates: [{ content: { parts: [{ text: "Hello " }] } }] }), - JSON.stringify({ candidates: [{ content: { parts: [{ text: "world!" }] } }] }) - ], - { - endWithDone: true, - delayMs: 0, - } - ), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new GeminiPlugin("fakeKey", "http://localhost:3000", "modelId"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - const readable = await plugin.prepareRequest(params); - const reader = readable.getReader() - - const decoder = new TextDecoder(); - let fullString = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - fullString += decoder.decode(value); - } - - // Test parseServerSentEvent logic - const partial1 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ candidates: [{ content: { parts: [{ text: "Hello " }] } }] })}` - ); - const partial2 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ candidates: [{ content: { parts: [{ text: "world!" }] } }] })}` - ); - const doneSignal = plugin.parseServerSentEvent(`data: [DONE]`); - - expect(partial1).toBe("Hello "); - expect(partial2).toBe("world!"); - expect(doneSignal).toBe("[DONE]"); - - // Verify the raw SSE output - expect(fullString).toContain("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello "); - expect(fullString).toContain("[DONE]"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should throw if the fetch fails", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: false, - status: 500, - body: null, - statusText: "Internal Server Error", - text: () => Promise.resolve("Server error"), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new GeminiPlugin("fakeKey", "http://localhost:3000", "modelId"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - await expect(plugin.prepareRequest(params)).rejects.toThrow(/Gemini API error/); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should handle invalid JSON in SSE data", () => { - const plugin = new GeminiPlugin("fakeKey", "http://localhost:3000", "modelId"); - const result = plugin.parseServerSentEvent("data: {invalid json}"); - expect(result).toBeNull(); - }); - - it("should handle empty content parts in SSE data", () => { - const plugin = new GeminiPlugin("fakeKey", "http://localhost:3000", "modelId"); - const result = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ candidates: [{ content: { parts: [] } }] })}` - ); - expect(result).toBeNull(); - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/gemini-plugin.ts b/packages/streaming-engine/src/plugins/gemini-plugin.ts deleted file mode 100644 index c07dc2a..0000000 --- a/packages/streaming-engine/src/plugins/gemini-plugin.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { ProviderPlugin } from "../provider-plugin"; -import type { SSEEngineParams } from "../streaming-types"; - -export class GeminiPlugin implements ProviderPlugin { - private geminiApiKey: string; - private geminiBaseUrl: string; - private modelId: string; - - constructor( - geminiApiKey: string, - geminiBaseUrl: string, - modelId: string - ) { - this.geminiApiKey = geminiApiKey; - this.geminiBaseUrl = geminiBaseUrl; - this.modelId = modelId; - } - - /** - * Prepare the SSE request. Return a ReadableStream so the test - * can do `const reader = (await plugin.prepareRequest(...)).getReader()`. - */ - async prepareRequest(params: SSEEngineParams): Promise> { - const { userMessage, options } = params; - - // NOTE: remove "role" from parts[]. Gemini doesn't accept it. - const payload = { - contents: [ - { - parts: [{ text: userMessage }], - }, - ], - generationConfig: { - temperature: - typeof options?.temperature === "number" ? options?.temperature : 0.7, - maxOutputTokens: options?.max_tokens ?? 1024, - topP: options?.top_p ?? 0.9, - topK: options?.top_k ?? 40, - }, - }; - - const endpoint = `${this.geminiBaseUrl}/${this.modelId}:streamGenerateContent?alt=sse&key=${this.geminiApiKey}`; - if (options?.debug) { - console.debug("[GeminiPlugin] Sending request:", { endpoint, payload }); - } - - const response = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - if (!response.ok || !response.body) { - console.error("Gemini API error response:", await response.text()); - throw new Error(`Gemini API error: ${response.statusText}`); - } - - // Return a ReadableStream that passes through the SSE chunks from Gemini. - return new ReadableStream({ - start: async (controller) => { - try { - const reader = response.body?.getReader(); - if (!reader) { - throw new Error("Failed to get reader from response body"); - } - while (true) { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - break; - } - controller.enqueue(value!); - } - } catch (err) { - controller.error(err); - } - }, - }); - } - - /** - * parseServerSentEvent: for each SSE line like: - * data: {"candidates":[{"content":{"parts":[{"text":"Hello "}]}}]} - * we JSON-parse, then return the aggregated text. - */ - parseServerSentEvent(line: string): string | null { - if (!line.startsWith("data:")) return null; - const jsonString = line.replace(/^data:\s*/, "").trim(); - - if (jsonString === "[DONE]") { - return "[DONE]"; - } - - try { - const parsed = JSON.parse(jsonString); - const parts = parsed.candidates?.[0]?.content?.parts; - if (!parts || parts.length === 0) return null; - - const chunkText = parts.map((p: any) => p.text).join(""); - return chunkText || null; - } catch { - return null; // invalid JSON => test expects null - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/groq-plugin.test.ts b/packages/streaming-engine/src/plugins/groq-plugin.test.ts deleted file mode 100644 index 3cbddea..0000000 --- a/packages/streaming-engine/src/plugins/groq-plugin.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { GroqPlugin } from "./groq-plugin"; -import type { SSEEngineParams } from "../streaming-types"; -import { createMockSSEStream } from "../create-mock-sse-stream"; - -describe("GroqPlugin", () => { - it("should parse SSE lines correctly and return partial text + [DONE]", async () => { - // Mock fetch to return a mock SSE stream - const fakeFetch = () => - Promise.resolve({ - ok: true, - status: 200, - body: createMockSSEStream( - [ - JSON.stringify({ choices: [{ delta: { content: "Hello " } }] }), - JSON.stringify({ choices: [{ delta: { content: "world!" } }] }) - ], - { - endWithDone: true, - delayMs: 0, - } - ), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new GroqPlugin("fakeKey", "https://api.groq.com"); - const params: SSEEngineParams = { - userMessage: "Test user message", - plugin, - handlers: {}, - }; - - // Prepare request - const reader = await plugin.prepareRequest(params); - - // Read the SSE lines from the plugin's returned reader - const decoder = new TextDecoder(); - let fullString = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - fullString += decoder.decode(value); - } - - // Test parseServerSentEvent logic - const partial1 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: { content: "Hello " } }] })}` - ); - const partial2 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: { content: "world!" } }] })}` - ); - const doneSignal = plugin.parseServerSentEvent(`data: [DONE]`); - - expect(partial1).toBe("Hello "); - expect(partial2).toBe("world!"); - expect(doneSignal).toBe("[DONE]"); - - // Verify the raw SSE lines - expect(fullString).toContain("data: {\"choices\":[{\"delta\":{\"content\":\"Hello "); - expect(fullString).toContain("[DONE]"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should throw if the fetch fails", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: false, - status: 500, - body: null, - text: () => Promise.resolve("Server error"), - statusText: "Internal Server Error", - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new GroqPlugin("fakeKey", "https://api.groq.com"); - const params: SSEEngineParams = { - userMessage: "Test user message", - plugin, - handlers: {}, - }; - - await expect(plugin.prepareRequest(params)).rejects.toThrow(/Groq API error/); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should handle invalid JSON in SSE data", () => { - const plugin = new GroqPlugin("fakeKey", "https://api.groq.com"); - const result = plugin.parseServerSentEvent("data: {invalid json}"); - expect(result).toBeNull(); - }); - - it("should handle empty delta content in SSE data", () => { - const plugin = new GroqPlugin("fakeKey", "https://api.groq.com"); - const result = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: {} }] })}` - ); - expect(result).toBeNull(); - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/groq-plugin.ts b/packages/streaming-engine/src/plugins/groq-plugin.ts deleted file mode 100644 index fddeb89..0000000 --- a/packages/streaming-engine/src/plugins/groq-plugin.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { GROQ_BASE_URL } from "../constants/provider-defauls"; -import type { ProviderPlugin } from "../provider-plugin"; -import type { SSEEngineParams } from "../streaming-types"; - -export class GroqPlugin implements ProviderPlugin { - private apiKey: string; - private baseUrl: string; - - constructor(apiKey: string, baseUrl?: string) { - this.apiKey = apiKey; - this.baseUrl = baseUrl || GROQ_BASE_URL; - } - - async prepareRequest(params: SSEEngineParams) { - const { userMessage, options } = params; - - const payload = { - model: options?.model || "llama-3.1-70b-versatile", - messages: [{ role: "user", content: userMessage }], - stream: true, - max_tokens: options?.max_tokens ?? 1024, - temperature: typeof options?.temperature === "number" ? options?.temperature : 0.7, - top_p: options?.top_p ?? 1, - frequency_penalty: options?.frequency_penalty ?? 0, - presence_penalty: options?.presence_penalty ?? 0, - }; - - const endpoint = `${this.baseUrl}/chat/completions`; - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok || !response.body) { - const errorText = await response.text(); - throw new Error(`Groq API error: ${response.statusText} - ${errorText}`); - } - - return response.body.getReader() as ReadableStreamDefaultReader - } - - parseServerSentEvent(line: string): string | null { - if (!line.startsWith("data:")) return null; - const jsonString = line.replace(/^data:\s*/, "").trim(); - - if (jsonString === "[DONE]") return "[DONE]"; - - try { - const parsed = JSON.parse(jsonString); - // e.g. parsed.choices[0].delta.content - const content = parsed.choices?.[0]?.delta?.content; - return content || null; - } catch { - return null; - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/ollama-plugin.test.ts b/packages/streaming-engine/src/plugins/ollama-plugin.test.ts deleted file mode 100644 index 8d2088b..0000000 --- a/packages/streaming-engine/src/plugins/ollama-plugin.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { OllamaPlugin } from "./ollama-plugin"; -import type { SSEEngineParams } from "../streaming-types"; -import { createMockSSEStream } from "../create-mock-sse-stream"; - -describe("OllamaPlugin", () => { - it("should parse SSE lines correctly and return content", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: true, - status: 200, - body: createMockSSEStream( - [ - JSON.stringify({ message: { content: "Hello " } }), - JSON.stringify({ message: { content: "world!" } }) - ], - { - endWithDone: true, - delayMs: 0, - } - ), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new OllamaPlugin("http://localhost:11434"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - const reader = await plugin.prepareRequest(params); - - const decoder = new TextDecoder(); - let fullString = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - fullString += decoder.decode(value); - } - - // Test parseServerSentEvent logic - const partial1 = plugin.parseServerSentEvent( - JSON.stringify({ message: { content: "Hello " } }) - ); - const partial2 = plugin.parseServerSentEvent( - JSON.stringify({ message: { content: "world!" } }) - ); - - expect(partial1).toBe("Hello "); - expect(partial2).toBe("world!"); - - // Verify the raw SSE output - expect(fullString).toContain("data: {\"message\":{\"content\":\"Hello "); - expect(fullString).toContain("world!"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should throw if the fetch fails", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: false, - status: 500, - body: null, - statusText: "Internal Server Error", - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new OllamaPlugin("http://localhost:11434"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - await expect(plugin.prepareRequest(params)).rejects.toThrow(/Ollama API error/); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should handle invalid JSON in SSE data", () => { - const plugin = new OllamaPlugin("http://localhost:11434"); - const result = plugin.parseServerSentEvent("{invalid json}"); - expect(result).toBeNull(); - }); - - it("should handle empty message content", () => { - const plugin = new OllamaPlugin("http://localhost:11434"); - const result = plugin.parseServerSentEvent( - JSON.stringify({ message: { } }) - ); - expect(result).toBeNull(); - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/ollama-plugin.ts b/packages/streaming-engine/src/plugins/ollama-plugin.ts deleted file mode 100644 index 4e0cb3d..0000000 --- a/packages/streaming-engine/src/plugins/ollama-plugin.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { ProviderPlugin } from "../provider-plugin"; -import type { SSEEngineParams } from "../streaming-types"; - - -export class OllamaPlugin implements ProviderPlugin { - private baseUrl: string; - - constructor(baseUrl: string) { - this.baseUrl = baseUrl; - } - - async prepareRequest(params: SSEEngineParams) { - const { userMessage, options } = params; - - const response = await fetch(`${this.baseUrl}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: options?.model || "llama3:latest", - messages: [{ role: "user", content: userMessage }], - stream: true, - ...options, // pass along other config - }), - }); - - if (!response.ok || !response.body) { - throw new Error(`Ollama API error: ${response.statusText}`); - } - - return response.body.getReader() as ReadableStreamDefaultReader - } - - parseServerSentEvent(line: string): string | null { - // Each line is JSON - // We can ignore lines that don't parse. - // Return "[DONE]" if there's some condition for done (if needed). - try { - const data = JSON.parse(line); - const chunk = data?.message?.content || ""; - return chunk || null; - } catch { - // If partial or invalid JSON - return null; - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/open-ai-like-plugin.test.ts b/packages/streaming-engine/src/plugins/open-ai-like-plugin.test.ts deleted file mode 100644 index 39003e0..0000000 --- a/packages/streaming-engine/src/plugins/open-ai-like-plugin.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { OpenAiLikePlugin } from "./open-ai-like-plugin"; -import type { SSEEngineParams } from "../streaming-types"; -import OpenAI from "openai"; - -describe("OpenAiLikePlugin", () => { - it("should parse SSE lines correctly and handle streaming", async () => { - // Mock OpenAI client with a fake streaming response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { choices: [{ delta: { content: "Hello " } }] }; - yield { choices: [{ delta: { content: "world!" } }] }; - }, - }; - - const mockOpenAI = { - chat: { - completions: { - create: async () => mockStream, - }, - }, - } as unknown as OpenAI; - - const plugin = new OpenAiLikePlugin(mockOpenAI, "gpt-3.5-turbo"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - const readable = await plugin.prepareRequest(params); - const reader = readable.getReader(); - - const decoder = new TextDecoder(); - let fullString = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - fullString += decoder.decode(value); - } - - // Test parseServerSentEvent logic - const partial1 = plugin.parseServerSentEvent('data: "Hello "'); - const partial2 = plugin.parseServerSentEvent('data: "world!"'); - const doneSignal = plugin.parseServerSentEvent("data: [DONE]"); - - expect(partial1).toBe("Hello "); - expect(partial2).toBe("world!"); - expect(doneSignal).toBe("[DONE]"); - - // Verify the raw SSE output contains our streamed content - expect(fullString).toContain('"Hello "'); - expect(fullString).toContain('"world!"'); - expect(fullString).toContain("[DONE]"); - }); - - it("should handle streaming errors gracefully", async () => { - const expectedError = new Error("Stream error"); - const mockStream = { - async *[Symbol.asyncIterator]() { - throw expectedError; - }, - }; - - const mockOpenAI = { - chat: { - completions: { - create: async () => mockStream, - }, - }, - } as unknown as OpenAI; - - const plugin = new OpenAiLikePlugin(mockOpenAI, "gpt-3.5-turbo"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - const readable = await plugin.prepareRequest(params); - const reader = readable.getReader(); - - try { - await reader.read(); - throw new Error("Expected stream to throw"); - } catch (error) { - expect(error).toBe(expectedError); - } - }); - - it("should handle non-string SSE data", () => { - const plugin = new OpenAiLikePlugin({} as OpenAI, "gpt-3.5-turbo"); - const result = plugin.parseServerSentEvent("data: 123"); - expect(result).toBe("123"); // The payload should be converted to string - }); - - it("should handle invalid SSE format", () => { - const plugin = new OpenAiLikePlugin({} as OpenAI, "gpt-3.5-turbo"); - const result = plugin.parseServerSentEvent("invalid line"); - expect(result).toBeNull(); - }); - - it("should respect debug option", async () => { - let debugCalled = false; - const originalDebug = console.debug; - console.debug = () => { debugCalled = true; }; - - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { choices: [{ delta: { content: "test" } }] }; - }, - }; - - const mockOpenAI = { - chat: { - completions: { - create: async () => mockStream, - }, - }, - } as unknown as OpenAI; - - const plugin = new OpenAiLikePlugin(mockOpenAI, "gpt-3.5-turbo"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - options: { debug: true }, - }; - - await plugin.prepareRequest(params); - expect(debugCalled).toBe(true); - console.debug = originalDebug; - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/open-ai-like-plugin.ts b/packages/streaming-engine/src/plugins/open-ai-like-plugin.ts deleted file mode 100644 index 8b010b3..0000000 --- a/packages/streaming-engine/src/plugins/open-ai-like-plugin.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ProviderPlugin } from "../provider-plugin"; -import OpenAI from "openai"; -import type { SSEEngineParams } from "../streaming-types"; - -export class OpenAiLikePlugin implements ProviderPlugin { - private client: OpenAI; - private defaultModel: string; - - constructor(client: OpenAI, defaultModel: string) { - this.client = client; - this.defaultModel = defaultModel; - } - - async prepareRequest(params: SSEEngineParams) { - const { userMessage, options } = params; - - const model = options?.model || this.defaultModel; - const temperature = typeof options?.temperature === "number" ? options?.temperature : 0.7; - - if (options?.debug) { - console.debug("[OpenAiLikePlugin] Sending request:", { userMessage, options }); - } - - // Call OpenAI with streaming - const openaiStream = await this.client.chat.completions.create({ - model, - messages: [ - { role: "user", content: userMessage }, - ], - stream: true, - temperature, - max_tokens: options?.max_tokens, - top_p: options?.top_p, - frequency_penalty: options?.frequency_penalty, - presence_penalty: options?.presence_penalty, - }); - - // Turn the async iterator into a TransformStream that emits SSE lines - const { readable, writable } = new TransformStream(); - (async () => { - try { - const encoder = new TextEncoder(); - - for await (const chunk of openaiStream) { - if (options?.debug) { - console.debug("[OpenAiLikePlugin] SSE chunk:", chunk); - } - - const content = chunk.choices[0]?.delta?.content || ""; - if (content) { - // JSON-encode so we don't break on embedded newlines - const jsonPayload = JSON.stringify(content); - // SSE event lines: "data: ...\n\n" - const sseLine = `data: ${jsonPayload}\n\n`; - - const writer = writable.getWriter(); - await writer.write(encoder.encode(sseLine)); - writer.releaseLock(); - } - } - - // Done signal - const writer = writable.getWriter(); - await writer.write(encoder.encode("data: [DONE]\n\n")); - writer.releaseLock(); - - writable.close(); - } catch (err) { - console.error("OpenAiLike streaming error:", err); - writable.abort(err); - } - })(); - - return readable; - } - - parseServerSentEvent(line: string): string | null { - // Same SSE approach as XAIPlugin - if (!line.startsWith("data: ")) return null; - - const payload = line.slice("data: ".length).trim(); - - if (payload === "[DONE]") { - return "[DONE]"; - } - - // Otherwise, parse the JSON text - try { - const parsed = JSON.parse(payload); - // Convert non-string values to string - return typeof parsed === 'string' ? parsed : String(parsed); - } catch { - // If JSON parse fails, return the raw payload - return payload; - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/open-router-plugin.test.ts b/packages/streaming-engine/src/plugins/open-router-plugin.test.ts deleted file mode 100644 index 6258674..0000000 --- a/packages/streaming-engine/src/plugins/open-router-plugin.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { OpenRouterPlugin } from "./open-router-plugin"; -import type { SSEEngineParams } from "../streaming-types"; -import { createMockSSEStream } from "../create-mock-sse-stream"; - -describe("OpenRouterPlugin", () => { - it("should parse SSE lines correctly and return content", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: true, - status: 200, - body: createMockSSEStream( - [ - JSON.stringify({ choices: [{ delta: { content: "Hello " } }] }), - JSON.stringify({ choices: [{ delta: { content: "world!" } }] }) - ], - { - endWithDone: true, - delayMs: 0, - } - ), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new OpenRouterPlugin("fakeKey"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - const reader = await plugin.prepareRequest(params); - - const decoder = new TextDecoder(); - let fullString = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - fullString += decoder.decode(value); - } - - // Test parseServerSentEvent logic - const partial1 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: { content: "Hello " } }] })}` - ); - const partial2 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: { content: "world!" } }] })}` - ); - const doneSignal = plugin.parseServerSentEvent(`data: [DONE]`); - - expect(partial1).toBe("Hello "); - expect(partial2).toBe("world!"); - expect(doneSignal).toBe("[DONE]"); - - // Verify the raw SSE output - expect(fullString).toContain("data: {\"choices\":[{\"delta\":{\"content\":\"Hello "); - expect(fullString).toContain("[DONE]"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should throw if the fetch fails", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: false, - status: 500, - body: null, - statusText: "Internal Server Error", - text: () => Promise.resolve("Server error"), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new OpenRouterPlugin("fakeKey"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - await expect(plugin.prepareRequest(params)).rejects.toThrow(/OpenRouter API error/); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should handle invalid JSON in SSE data", () => { - const plugin = new OpenRouterPlugin("fakeKey"); - const result = plugin.parseServerSentEvent("data: {invalid json}"); - expect(result).toBeNull(); - }); - - it("should handle empty delta content in SSE data", () => { - const plugin = new OpenRouterPlugin("fakeKey"); - const result = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: {} }] })}` - ); - expect(result).toBeNull(); - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/open-router-plugin.ts b/packages/streaming-engine/src/plugins/open-router-plugin.ts deleted file mode 100644 index ebf6471..0000000 --- a/packages/streaming-engine/src/plugins/open-router-plugin.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ProviderPlugin } from "../provider-plugin"; -import type { SSEEngineParams } from "../streaming-types"; - -type OpenRouterStreamResponse = { - choices: { - delta?: { content?: string }; - content?: string; - }[]; -}; - -export class OpenRouterPlugin implements ProviderPlugin { - private apiKey: string; - private systemMessage?: string; - - constructor(apiKey: string, systemMessage?: string) { - this.apiKey = apiKey; - this.systemMessage = systemMessage; - } - - async prepareRequest(params: SSEEngineParams) { - const { userMessage, options } = params; - - // Build messages array - const messages = []; - if (this.systemMessage) { - messages.push({ role: "system", content: this.systemMessage }); - } - messages.push({ role: "user", content: userMessage }); - - const model = options?.model || "deepseek/deepseek-chat"; - - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - Authorization: `Bearer ${this.apiKey}`, - "HTTP-Referer": "http://localhost:3579", - "X-Title": "OctoPrompt", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model, - messages, - stream: true, - ...options, - }), - }); - - if (!response.ok || !response.body) { - const errorText = await response.text(); - throw new Error(`OpenRouter API error: ${response.statusText} - ${errorText}`); - } - - return response.body.getReader() as ReadableStreamDefaultReader - } - - parseServerSentEvent(line: string): string | null { - if (!line.startsWith("data:")) return null; - const jsonString = line.replace(/^data:\s*/, "").trim(); - - if (jsonString === "[DONE]") return "[DONE]"; - - try { - const parsed = JSON.parse(jsonString) as OpenRouterStreamResponse; - const content = parsed.choices?.[0]?.delta?.content || ""; - return content || null; - } catch { - return null; - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/together-plugin.test.ts b/packages/streaming-engine/src/plugins/together-plugin.test.ts deleted file mode 100644 index 364f565..0000000 --- a/packages/streaming-engine/src/plugins/together-plugin.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { TogetherPlugin } from "./together-plugin"; -import type { SSEEngineParams } from "../streaming-types"; -import { createMockSSEStream } from "../create-mock-sse-stream"; - -describe("TogetherPlugin", () => { - it("should parse SSE lines correctly and return content", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: true, - status: 200, - body: createMockSSEStream( - [ - JSON.stringify({ choices: [{ delta: { content: "Hello " } }] }), - JSON.stringify({ choices: [{ delta: { content: "world!" } }] }) - ], - { - endWithDone: true, - delayMs: 0, - } - ), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new TogetherPlugin("fakeKey", "http://localhost:3000"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - const reader = await plugin.prepareRequest(params); - - const decoder = new TextDecoder(); - let fullString = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - fullString += decoder.decode(value); - } - - // Test parseServerSentEvent logic - const partial1 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: { content: "Hello " } }] })}` - ); - const partial2 = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: { content: "world!" } }] })}` - ); - const doneSignal = plugin.parseServerSentEvent(`data: [DONE]`); - - expect(partial1).toBe("Hello "); - expect(partial2).toBe("world!"); - expect(doneSignal).toBe("[DONE]"); - - // Verify the raw SSE output - expect(fullString).toContain("data: {\"choices\":[{\"delta\":{\"content\":\"Hello "); - expect(fullString).toContain("[DONE]"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should throw if the fetch fails", async () => { - const fakeFetch = () => - Promise.resolve({ - ok: false, - status: 500, - body: null, - statusText: "Internal Server Error", - text: () => Promise.resolve("Server error"), - } as Response); - - const originalFetch = globalThis.fetch; - (globalThis.fetch as unknown) = fakeFetch; - - try { - const plugin = new TogetherPlugin("fakeKey", "http://localhost:3000"); - const params: SSEEngineParams = { - userMessage: "Test message", - plugin, - handlers: {}, - }; - - await expect(plugin.prepareRequest(params)).rejects.toThrow(/Together API error/); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should handle invalid JSON in SSE data", () => { - const plugin = new TogetherPlugin("fakeKey", "http://localhost:3000"); - const result = plugin.parseServerSentEvent("data: {invalid json}"); - expect(result).toBeNull(); - }); - - it("should handle empty delta content in SSE data", () => { - const plugin = new TogetherPlugin("fakeKey", "http://localhost:3000"); - const result = plugin.parseServerSentEvent( - `data: ${JSON.stringify({ choices: [{ delta: {} }] })}` - ); - expect(result).toBeNull(); - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/plugins/together-plugin.ts b/packages/streaming-engine/src/plugins/together-plugin.ts deleted file mode 100644 index 2488678..0000000 --- a/packages/streaming-engine/src/plugins/together-plugin.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { TOGETHER_BASE_URL } from "../constants/provider-defauls"; -import type { ProviderPlugin } from "../provider-plugin"; -import type { SSEEngineParams } from "../streaming-types"; - -export class TogetherPlugin implements ProviderPlugin { - private apiKey: string; - private baseUrl: string; - - constructor(apiKey: string, baseUrl?: string) { - this.apiKey = apiKey; - this.baseUrl = baseUrl || TOGETHER_BASE_URL; - } - - async prepareRequest(params: SSEEngineParams) { - const { userMessage, options } = params; - - const payload = { - model: options?.model || "Qwen/Qwen2.5-72B-Instruct-Turbo", - messages: [{ role: "user", content: userMessage }], - stream: true, - max_tokens: options?.max_tokens ?? 1024, - temperature: typeof options?.temperature === "number" ? options?.temperature : 0.7, - top_p: options?.top_p ?? 1, - top_k: options?.top_k ?? 50, - presence_penalty: options?.presence_penalty ?? 0, - frequency_penalty: options?.frequency_penalty ?? 0, - }; - - const endpoint = `${this.baseUrl}/chat/completions`; - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok || !response.body) { - const errorText = await response.text(); - throw new Error(`Together API error: ${response.statusText} - ${errorText}`); - } - - return response.body.getReader() as ReadableStreamDefaultReader - } - - parseServerSentEvent(line: string): string | null { - if (!line.startsWith("data:")) return null; - const jsonString = line.replace(/^data:\s*/, "").trim(); - - if (jsonString === "[DONE]") return "[DONE]"; - - try { - const parsed = JSON.parse(jsonString); - const delta = parsed.choices?.[0]?.delta?.content; - return delta || null; - } catch { - return null; - } - } -} \ No newline at end of file diff --git a/packages/streaming-engine/src/provider-plugin.ts b/packages/streaming-engine/src/provider-plugin.ts deleted file mode 100644 index 1f6d87a..0000000 --- a/packages/streaming-engine/src/provider-plugin.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SSEEngineParams } from ".//streaming-types"; - -export interface ProviderPlugin { - /** - * Prepare the SSE request to the provider (fetch or client call). - * Return a raw ReadableStream or the underlying reader for SSE. - */ - prepareRequest(params: SSEEngineParams): Promise | ReadableStreamDefaultReader>; - - /** - * Given a line or chunk from the SSE, parse out the relevant text - * to be appended to the user’s message. - * Return null or empty string if the line doesn't contain displayable text. - * Return the special string `[DONE]` or something equivalent if this chunk signals the end. - */ - parseServerSentEvent(line: string): string | null; -} \ No newline at end of file diff --git a/packages/streaming-engine/src/streaming-engine.test.ts b/packages/streaming-engine/src/streaming-engine.test.ts deleted file mode 100644 index 1a04478..0000000 --- a/packages/streaming-engine/src/streaming-engine.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { createSSEStream } from "./streaming-engine"; -import type { SSEEngineParams, SSEEngineHandlers } from "./streaming-types"; -import type { ProviderPlugin } from "./provider-plugin"; - -/** - * A mock plugin that simulates a streaming response with SSE data. - * We'll create a fake SSE sequence that sends two partial chunks and then a [DONE] line. - */ -class MockPlugin implements ProviderPlugin { - async prepareRequest(): Promise> { - // Create an artificial SSE stream that sends two chunks and a [DONE] signal - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - - // Simulate SSE lines - // - data: "Hello" - // - data: " world!" - // - data: [DONE] - (async () => { - await writer.write(encoder.encode("data: \"Hello\"\n\n")); - await writer.write(encoder.encode("data: \" world!\"\n\n")); - await writer.write(encoder.encode("data: [DONE]\n\n")); - writer.close(); - })(); - - return readable; - } - - parseServerSentEvent(line: string): string | null { - if (!line.startsWith("data: ")) return null; - const payload = line.slice("data: ".length).trim(); - - if (payload === "[DONE]") { - return "[DONE]"; - } - - // Convert from JSON string -> actual string - try { - return JSON.parse(payload); - } catch { - return payload; - } - } -} - -describe("createSSEStream", () => { - it("should call onPartial and onDone handlers correctly", async () => { - let partialCalls: string[] = []; - let doneCall: string | null = null; - - const handlers: SSEEngineHandlers = { - onPartial: (msg) => { - partialCalls.push(msg.content); - }, - onDone: (msg) => { - doneCall = msg.content; - }, - }; - - const params: SSEEngineParams = { - userMessage: "Test message", - plugin: new MockPlugin(), - handlers, - }; - - // Run the SSE stream - const stream = await createSSEStream(params); - - // We need to consume the stream to trigger the reading loop - const reader = stream.getReader(); - // Read until done - let s: ReadableStreamDefaultReadResult; - do { - s = await reader.read(); - } while (!s.done); - - expect(partialCalls).toEqual(["Hello", " world!"]); - // The final (done) call should be the concatenation of partial responses - expect(doneCall).toBe("Hello world!"); - }); - - it("should call onUserMessage and onSystemMessage if provided", async () => { - let systemHandlerCalled = false; - let userHandlerCalled = false; - - const handlers: SSEEngineHandlers = { - onSystemMessage: () => { - systemHandlerCalled = true; - }, - onUserMessage: () => { - userHandlerCalled = true; - }, - }; - - const params: SSEEngineParams = { - userMessage: "Test user message", - systemMessage: "Test system message", - plugin: new MockPlugin(), - handlers, - }; - - const stream = await createSSEStream(params); - const reader = stream.getReader(); - await reader.cancel(); // We don't need to fully read for this test - - expect(systemHandlerCalled).toBe(true); - expect(userHandlerCalled).toBe(true); - }); - - it("should call onError if the plugin fails immediately", async () => { - class ErrorPlugin implements ProviderPlugin { - async prepareRequest() { - throw new Error("Simulated stream error"); - } - parseServerSentEvent() { return null; } - } - - let onErrorCalled = false; - let partialSoFar = ""; - const handlers: SSEEngineHandlers = { - onError: (err, partial) => { - onErrorCalled = true; - partialSoFar = partial.content; - }, - }; - - const params: SSEEngineParams = { - userMessage: "Test user message", - plugin: new ErrorPlugin(), - handlers, - }; - - // createSSEStream now catches immediate plugin errors - const stream = await createSSEStream(params); - const reader = stream.getReader(); - await reader.read(); // shouldn't throw because we returned an empty stream - - expect(onErrorCalled).toBe(true); - expect(partialSoFar).toBe(""); - }); -}); \ No newline at end of file diff --git a/packages/streaming-engine/src/streaming-engine.ts b/packages/streaming-engine/src/streaming-engine.ts deleted file mode 100644 index 52b6d26..0000000 --- a/packages/streaming-engine/src/streaming-engine.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { SSEEngineParams } from "./streaming-types"; - -/** - * Create a streaming SSE with the given plugin + params + handlers. - * This function does NOT know anything about ChatService or other external concerns. - * All updates go out via the handlers/callbacks. - */ -export async function createSSEStream(params: SSEEngineParams): Promise> { - const { plugin, systemMessage, userMessage, handlers, options } = params; - - let streamOrReader: ReadableStream | ReadableStreamDefaultReader; - try { - streamOrReader = await plugin.prepareRequest({ - userMessage, - systemMessage, - options, - handlers, - plugin, - }); - } catch (error) { - // If the plugin fails immediately, call onError - if (handlers.onError) { - handlers.onError(error, { - role: "assistant", - content: "", // or partial content if any - }); - } - // Return an empty closed stream so the caller can still consume something - return new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - } - - // Fire off system message handler if present - if (systemMessage && handlers.onSystemMessage) { - handlers.onSystemMessage({ - role: "system", - content: systemMessage, - }); - } - - // Fire off user message handler if present - if (handlers.onUserMessage) { - handlers.onUserMessage({ - role: "user", - content: userMessage, - }); - } - - // Prepare the SSE request with the plugin - // const streamOrReader = await plugin.prepareRequest({ - // userMessage, - // options: options ?? {}, - // plugin, - // handlers, - // systemMessage - // // If your plugin needs more, pass them here - // // chatId: "", - // // assistantMessageId: "", - // }); - - // unify the reader (some plugins return a ReadableStream, others return a Reader directly) - const reader = - streamOrReader instanceof ReadableStream - ? streamOrReader.getReader() - : streamOrReader; - - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let fullResponse = ""; // accumulate all assistant text for onDone - let buffer = ""; - - return new ReadableStream({ - async start(controller) { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - // Decode chunk - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; - - // Split SSE events by double-newline - const events = buffer.split("\n\n"); - buffer = events.pop() ?? ""; // leftover partial event - - // Process each SSE event - for (const event of events) { - // Split each event by newline, ignoring comment lines - const lines = event - .split("\n") - .map(line => line.trim()) - .filter(line => !!line && !line.startsWith(":")); - - if (lines.length === 0) { - continue; - } - - let eventText = ""; - for (const line of lines) { - const parsedText = plugin.parseServerSentEvent(line); - - if (parsedText === "[DONE]") { - // SSE end marker - if (handlers.onDone) { - handlers.onDone({ - role: "assistant", - content: fullResponse, - }); - } - controller.close(); - return; - } - if (parsedText) { - eventText += parsedText; - } - } - - // If there was text in this event, append to full and call partial - if (eventText) { - fullResponse += eventText; - controller.enqueue(encoder.encode(eventText)); - - if (handlers.onPartial) { - handlers.onPartial({ - role: "assistant", - content: eventText, - }); - } - } - } - } - - // Handle leftover partial event in the buffer - if (buffer.trim()) { - const lines = buffer - .split("\n") - .map(line => line.trim()) - .filter(line => !!line && !line.startsWith(":")); - - let leftoverText = ""; - for (const line of lines) { - const parsedText = plugin.parseServerSentEvent(line); - if (parsedText === "[DONE]") { - if (handlers.onDone) { - handlers.onDone({ - role: "assistant", - content: fullResponse, - }); - } - controller.close(); - return; - } - if (parsedText) { - leftoverText += parsedText; - } - } - - if (leftoverText) { - fullResponse += leftoverText; - controller.enqueue(encoder.encode(leftoverText)); - - if (handlers.onPartial) { - handlers.onPartial({ - role: "assistant", - content: leftoverText, - }); - } - } - } - - // Done reading; finalize - if (handlers.onDone) { - handlers.onDone({ - role: "assistant", - content: fullResponse, - }); - } - controller.close(); - } catch (error) { - controller.error(error); - if (handlers.onError) { - handlers.onError(error, { - role: "assistant", - content: fullResponse, - }); - } - } - }, - }); -} \ No newline at end of file diff --git a/packages/streaming-engine/src/streaming-types.ts b/packages/streaming-engine/src/streaming-types.ts deleted file mode 100644 index 6652366..0000000 --- a/packages/streaming-engine/src/streaming-types.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ProviderPlugin } from "./provider-plugin"; - -/** - * Standard message roles: system, user, assistant. - * You can add more roles if needed. - */ -export type SSEMessage = { - role: "system" | "user" | "assistant"; - content: string; - // You can attach more data/metadata in here if desired -}; - -/** - * Handlers/callbacks to handle different phases of streaming. - * All are optional—implement only what you need. - */ -export interface SSEEngineHandlers { - /** - * Called once before streaming begins if you pass in a system message - */ - onSystemMessage?: (message: SSEMessage) => void; - - /** - * If you want to log the user message that triggered this, do it here - */ - onUserMessage?: (message: SSEMessage) => void; - - /** - * Called every time we parse a non-empty chunk of SSE text. Typically partial assistant text. - */ - onPartial?: (partial: SSEMessage) => void; - - /** - * Called when the SSE stream signals completion (i.e. [DONE]) or we exhaust the stream. - * `fullContent` is the entire assistant response aggregated so far. - */ - onDone?: (fullContent: SSEMessage) => void; - - /** - * Called if an error happens during SSE read. `partialSoFar` is - * the text we accumulated until the error occurred (if any). - */ - onError?: (error: unknown, partialSoFar: SSEMessage) => void; -} - -/** - * The minimal set of input parameters the streaming engine needs. - * Notice there's no ChatService or database references here. - */ -export interface SSEEngineParams { - /** The user's text prompt */ - userMessage: string; - /** An optional system message or instructions */ - systemMessage?: string; - - /** Provider plugin that knows how to prepare & parse the SSE stream */ - plugin: ProviderPlugin; - - /** Any other settings your plugin might need (model, temperature, etc.) */ - options?: Record; - - /** - * Handlers to drive updates back to the caller. - * These can do e.g. database updates, broadcast events, etc. - */ - handlers: SSEEngineHandlers; -} \ No newline at end of file diff --git a/packages/streaming-engine/tsconfig.json b/packages/streaming-engine/tsconfig.json deleted file mode 100644 index a11683f..0000000 --- a/packages/streaming-engine/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": ["ES2020"], - "declaration": false, - "declarationMap": false, - "sourceMap": true, - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "moduleResolution": "bundler", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "types": ["bun-types"], - "isolatedModules": true, - "verbatimModuleSyntax": true, - "emitDeclarationOnly": false, - "noEmit": true - }, - "include": [ - "*.ts", - "src/**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/packages/websocket-manager-react/.gitignore b/packages/websocket-manager-react/.gitignore deleted file mode 100644 index d5891dc..0000000 --- a/packages/websocket-manager-react/.gitignore +++ /dev/null @@ -1 +0,0 @@ -app.db \ No newline at end of file diff --git a/packages/websocket-manager-react/README.md b/packages/websocket-manager-react/README.md deleted file mode 100644 index ce027bb..0000000 --- a/packages/websocket-manager-react/README.md +++ /dev/null @@ -1,403 +0,0 @@ -# @bnk/websocket-manager-react - -A lightweight yet powerful **React** wrapper for managing real-time WebSocket connections in **TypeScript**—optimized for **Bun** and designed to minimize dependencies. This library provides a **strongly-typed**, **modular**, and **well-tested** approach to establishing client-side WebSocket connections, handling incoming/outgoing messages, and seamlessly integrating with React via **hooks** and **context providers**. - -## Table of Contents - -1. [Introduction](#introduction) -2. [Key Features](#key-features) -3. [Installation](#installation) -4. [Quick Start](#quick-start) -5. [Usage Examples](#usage-examples) - - [Simple Example](#simple-example) - - [Advanced React + Bun Example](#advanced-react--bun-example) -6. [API Documentation](#api-documentation) - - [WebSocketClientProvider](#websocketclientprovider) - - [useWebSocketClient](#usewebsocketclient) - - [ClientWebSocketManager](#clientwebsocketmanager) -7. [Performance Notes](#performance-notes) -8. [Configuration & Customization](#configuration--customization) -9. [Testing](#testing) -10. [Contributing](#contributing) -11. [License](#license) - ---- - -## Introduction - -**@bnk/websocket-manager-react** is built on top of a generic WebSocket manager library for Bun. It provides a **React**-friendly API for managing client-side WebSocket connections with a focus on: - -- **Type Safety**: Define your incoming and outgoing message types, and gain compile-time checks. -- **Performance**: Leverages [Bun’s](https://bun.sh/) efficient WebSocket implementation for both server and client side (where applicable). -- **Plug-and-Play Modularity**: Use simple context and hooks to integrate WebSockets seamlessly into React apps. -- **Minimal External Dependencies**: Aside from React, no heavy dependencies are required. - ---- - -## Key Features - -- **Fully Typed**: Pass in your own `BaseServerMessage` and `BaseClientMessage` generics for robust type checking. -- **React Context**: Easily share the WebSocket instance and connection state (`isOpen`) throughout your component tree. -- **Configurable Message Handlers**: Map `message.type` to your own handler functions in a well-typed manner. -- **Lightweight & Performant**: Uses Bun’s built-in capabilities where possible, reducing overhead. -- **Simple Testing**: Leverages Bun’s native test runner. You can mock WebSocket events and verify message flows. - ---- - -## Installation - -Using **Bun**: - -```bash -bun add @bnk/websocket-manager-react -``` - ---- - -## Quick Start - -1. **Create** or **import** your shared message types. -2. **Configure** the `WebSocketClientProvider` with your server’s WebSocket URL and optional handlers. -3. **Use** the `useWebSocketClient()` hook in any component to send messages or check the connection status. - ---- - -## Usage Examples - -### Simple Example - -Below is a minimal setup demonstrating how to connect to a WebSocket server and handle messages in React. - -```ts -// shared/types.ts -export interface MyIncomingMessage { - type: "greeting" | "some_other_type"; - data?: unknown; -} - -export interface MyOutgoingMessage { - type: "send_greeting"; - payload?: unknown; -} -``` - -```tsx -// App.tsx -import React, { useState } from "react"; -import { - WebSocketClientProvider, - useWebSocketClient, - type ClientWebSocketManagerConfig -} from "@bnk/websocket-manager-react"; - -// 1. Define message handlers -const messageHandlers: ClientWebSocketManagerConfig["messageHandlers"] = { - greeting: (msg) => { - console.log("Received greeting:", msg.data); - }, - some_other_type: (msg) => { - console.log("Handling other type:", msg.data); - }, -}; - -// 2. Provide config for the WebSocket connection -const wsConfig: ClientWebSocketManagerConfig = { - url: "ws://localhost:3000", - debug: true, - messageHandlers, -}; - -// 3. Wrap your app in the WebSocketClientProvider -function App() { - return ( - - - - ); -} - -// 4. Use the hook inside your pages/components -function HomePage() { - const { isOpen, sendMessage } = useWebSocketClient(); - const [text, setText] = useState(""); - - const handleSend = () => { - sendMessage({ type: "send_greeting", payload: { text } }); - setText(""); - }; - - return ( -

-

WebSocket is {isOpen ? "Open" : "Closed"}

- setText(e.target.value)} /> - -
- ); -} - -export default App; -``` - -### Advanced React + Bun Example - -This package pairs perfectly with its sibling **@bnk/websocket-manager** (for the server side). Here’s a summarized flow: - -1. **Server (Bun)**: - - Set up a `WebSocketManager` using `@bnk/websocket-manager`. - - Handle connections and broadcast state updates. - -2. **Shared Types** (in a separate `shared` module): - - Define your `IncomingServerMessage` and `OutgoingClientMessage`. - -3. **React Client**: - - Use `@bnk/websocket-manager-react` to establish the connection, manage state with React context, and send messages. - -#### Example Snippet - -```ts -// shared/index.ts -export interface ChatAppState { - messageLog: string[]; -} - -export interface ChatClientMessage { - type: "chat"; - payload: { - text: string; - sender: string; - }; -} - -export interface InitialStateServerMessage { - type: "initial_state"; - data: ChatAppState; -} -export interface StateUpdateServerMessage { - type: "state_update"; - data: ChatAppState; -} - -export type IncomingServerMessage = InitialStateServerMessage | StateUpdateServerMessage; -export type OutgoingClientMessage = ChatClientMessage; -``` - -```ts -// server/index.ts (Bun) -import { serve } from "bun"; -import { WebSocketManager } from "@bnk/websocket-manager"; -import { - ChatAppState, - IncomingServerMessage, - OutgoingClientMessage, -} from "shared"; - -let currentState: ChatAppState = { messageLog: [] }; - -async function getState(): Promise { - return structuredClone(currentState); -} -async function setState(newState: ChatAppState): Promise { - currentState = structuredClone(newState); -} - -const manager = new WebSocketManager({ - getState, - setState, - messageHandlers: [ - { - type: "chat", - async handle(ws, message, getState, setState) { - const state = await getState(); - const entry = `${message.payload.sender}: ${message.payload.text}`; - state.messageLog.push(entry); - await setState(state); - }, - }, - ], - debug: true, -}); - -serve({ - port: 3000, - fetch() { - return new Response("Hello from Bun!", { status: 200 }); - }, - websocket: { - open(ws) { - manager.handleOpen(ws); - }, - close(ws) { - manager.handleClose(ws); - }, - async message(ws, msg) { - await manager.handleMessage(ws, msg.toString()); - await manager.broadcastState(); - }, - }, -}); -``` - -```tsx -// client/chat-websocket-provider.tsx -import React, { useState } from "react"; -import { - WebSocketClientProvider, - type ClientWebSocketManagerConfig, -} from "@bnk/websocket-manager-react"; -import { - IncomingServerMessage, - OutgoingClientMessage, -} from "shared"; - -export function ChatWebSocketProvider({ children }: { children: React.ReactNode }) { - const [messageLog, setMessageLog] = useState([]); - - const messageHandlers: ClientWebSocketManagerConfig["messageHandlers"] = { - initial_state: (msg) => setMessageLog(msg.data.messageLog), - state_update: (msg) => setMessageLog(msg.data.messageLog), - }; - - const wsConfig: ClientWebSocketManagerConfig = { - url: "ws://localhost:3000", - debug: true, - messageHandlers, - }; - - return ( - - - {children} - - - ); -} - -interface IMessageLogContext { - messageLog: string[]; - setMessageLog: React.Dispatch>; -} -export const MessageLogContext = React.createContext({ - messageLog: [], - setMessageLog: () => {}, -}); -``` - ---- - -## API Documentation - -### WebSocketClientProvider - -A context provider that sets up a **client-side WebSocket** instance when mounted and tears it down when unmounted. - -**Generic Types** -- `` extends `BaseServerMessage` -- `` extends `BaseClientMessage` - -**Props**: -- `url: string` – The WebSocket server URL. -- `debug?: boolean` – Enable/disable console logs. -- `messageHandlers?: { [K in TIncoming["type"]]?: (msg: Extract) => void }` – - A map of handlers keyed by each incoming message type. -- `onOpen?(): void` – Called when the socket opens. -- `onClose?(event: CloseEvent): void` – Called when the socket closes. -- `onError?(event: Event): void` – Called on socket error. - -```ts -function WebSocketClientProvider( - props: ClientWebSocketManagerConfig & { - children: React.ReactNode; - } -): JSX.Element; -``` - -### useWebSocketClient - -A hook that gives access to the underlying client manager and some convenience state. - -```ts -interface WebSocketClientContextValue { - manager: ClientWebSocketManager; - isOpen: boolean; - sendMessage(msg: TOutgoing): void; - disconnect(): void; -} - -/** - * Must be used inside a context. - */ -function useWebSocketClient(): WebSocketClientContextValue; -``` - -### ClientWebSocketManager - -A class that manages the low-level `WebSocket` connection. Typically you don’t instantiate this manually in your React app; it’s created by `WebSocketClientProvider`. - -```ts -interface ClientWebSocketManagerConfig { - url: string; - debug?: boolean; - onOpen?: () => void; - onClose?: (event: CloseEvent) => void; - onError?: (event: Event) => void; - messageHandlers?: { - [K in TIncoming["type"]]?: (message: Extract) => void; - }; -} - -class ClientWebSocketManager { - constructor(config: ClientWebSocketManagerConfig); - public disconnect(): void; - public sendMessage(msg: TOutgoing): void; -} -``` - ---- - -## Performance Notes - -- Uses **Bun**’s native `WebSocket` when available for faster I/O and reduced overhead. -- Minimal external dependencies ensure smaller bundle size and faster startup. -- The library avoids heavy frameworks or polyfills, relying on modern browser/Bun APIs. - ---- - -## Configuration & Customization - -- **Reconnection Logic**: You can implement custom reconnection inside `onClose` if desired. -- **MessageHandlers**: Extend or alter the `messageHandlers` at any time for more dynamic plug-and-play behavior. -- **Context Composition**: If you have multiple WebSocket endpoints, you can create multiple instances of `WebSocketClientProvider` with different contexts, or unify them behind a single provider. - ---- - -## Testing - -We recommend **Bun**’s test suite for quick and efficient testing: - -```bash -bun test -``` - -**Client Testing**: -- Render your components using a testing library like `@testing-library/react`. -- Mock the `WebSocket` to simulate incoming messages and test how your handlers respond. - -**Server Testing** (optional context if you also use `@bnk/websocket-manager`): -- You can unit-test each message handler by providing mock `getState` and `setState` functions and verifying state updates. - ---- - -## Contributing - -Contributions are welcome! Feel free to open issues, suggest enhancements, or submit PRs for bug fixes. For code style or testing guidelines: - -1. **Fork** the repository and create a feature branch. -2. **Implement** your changes. -3. **Test** thoroughly using `bun test`. -4. **Submit** a PR describing your changes. - ---- - -## License - -This project is available under the [MIT License](./LICENSE). Please see the license file for details. \ No newline at end of file diff --git a/packages/websocket-manager-react/package.json b/packages/websocket-manager-react/package.json deleted file mode 100644 index 8ff2478..0000000 --- a/packages/websocket-manager-react/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@bnk/websocket-manager-react", - "type": "module", - "main": "dist/websocket-manager-react.cjs.js", - "module": "dist/websocket-manager-react.es.js", - "types": "dist/types/index.d.ts", - "files": [ - "dist" - ], - "version": "1.0.5", - "scripts": { - "test": "bun test src/", - "test:watch": "bun test --watch src/", - "prepublishOnly": "bun test && bun run build", - "publish:websocket-manager-react": "bun publish --access public", - "build": "vite build", - "preview": "vite preview" - }, - "devDependencies": { - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "bun-types": "latest", - "typescript": "^5.7.2", - "@vitejs/plugin-react": "^4.3.4", - "vite": "^6.0.5", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "vite-plugin-dts": "^4.5.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } -} diff --git a/packages/websocket-manager-react/src/client-websocket-context.tsx b/packages/websocket-manager-react/src/client-websocket-context.tsx deleted file mode 100644 index 2f336df..0000000 --- a/packages/websocket-manager-react/src/client-websocket-context.tsx +++ /dev/null @@ -1,130 +0,0 @@ -// client-websocket-context.tsx -import React, { - createContext, - useContext, - useEffect, - useMemo, - useState, - useCallback, -} from "react"; -import { - ClientWebSocketManager, - type ClientWebSocketManagerConfig, - type BaseServerMessage, - type BaseClientMessage -} from "./client-websocket-manager"; - -export interface WebSocketClientContextValue< - TIncoming extends BaseServerMessage = BaseServerMessage, - TOutgoing extends BaseClientMessage = BaseClientMessage -> { - /** - * The low-level manager controlling our WebSocket connection. - */ - manager: ClientWebSocketManager; - - /** - * Boolean indicating if the WebSocket is currently open. - * (We can track more granular states if needed: connecting, error, etc.) - */ - isOpen: boolean; - - /** - * Send a typed message to the server. - */ - sendMessage: (msg: TOutgoing) => void; - - /** - * Disconnect the WebSocket. - */ - disconnect: () => void; -} - -const WebSocketClientContext = { - // Using a function to create the context allows us to maintain type safety - create: < - TIncoming extends BaseServerMessage = BaseServerMessage, - TOutgoing extends BaseClientMessage = BaseClientMessage - >() => createContext | null>(null) -}; - -export function useWebSocketClient< - TIncoming extends BaseServerMessage = BaseServerMessage, - TOutgoing extends BaseClientMessage = BaseClientMessage ->(): WebSocketClientContextValue { - const ctx = useContext(WebSocketClientContext.create()); - if (!ctx) { - throw new Error("useWebSocketClient must be used within a ."); - } - return ctx; -} - -export interface WebSocketClientProviderProps< - TIncoming extends BaseServerMessage = BaseServerMessage, - TOutgoing extends BaseClientMessage = BaseClientMessage -> extends ClientWebSocketManagerConfig { - children: React.ReactNode; -} - -/** - * This provider sets up a ClientWebSocketManager and passes it - * (plus some convenience state and methods) to all descendants. - */ -export function WebSocketClientProvider< - TIncoming extends BaseServerMessage = BaseServerMessage, - TOutgoing extends BaseClientMessage = BaseClientMessage ->(props: WebSocketClientProviderProps) { - const { children, ...managerConfig } = props; - const [isOpen, setIsOpen] = useState(false); - const Context = WebSocketClientContext.create(); - - // Create the manager on mount. UseMemo ensures we only create once per config change. - const manager = useMemo(() => { - return new ClientWebSocketManager({ - ...managerConfig, - onOpen: () => { - setIsOpen(true); - managerConfig.onOpen?.(); - }, - onClose: (event) => { - setIsOpen(false); - managerConfig.onClose?.(event); - }, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [managerConfig.url, managerConfig.debug]); - - // Clean up by disconnecting on unmount. - useEffect(() => { - return () => { - manager.disconnect(); - }; - }, [manager]); - - /** - * Helper to send a typed message - */ - const sendMessage = useCallback((msg: TOutgoing) => { - manager.sendMessage(msg); - }, [manager]); - - /** - * Helper to manually disconnect - */ - const disconnect = useCallback(() => { - manager.disconnect(); - }, [manager]); - - const value: WebSocketClientContextValue = { - manager, - isOpen, - sendMessage, - disconnect, - }; - - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/packages/websocket-manager-react/src/client-websocket-manager.ts b/packages/websocket-manager-react/src/client-websocket-manager.ts deleted file mode 100644 index 78f2c6b..0000000 --- a/packages/websocket-manager-react/src/client-websocket-manager.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { BaseServerMessage, BaseClientMessage } from "./client-websocket-types"; - -export type { BaseServerMessage, BaseClientMessage }; - -/** - * Configuration for our client-side manager. - * You can give default types for TIncoming and TOutgoing if you like. - */ -export interface ClientWebSocketManagerConfig< - TIncoming extends BaseServerMessage = BaseServerMessage, - TOutgoing extends BaseClientMessage = BaseClientMessage -> { - /** - * The URL to which we connect. For example: "ws://localhost:3007" - */ - url: string; - - /** - * Optional debug mode for console logs. - */ - debug?: boolean; - - /** - * Called whenever the WebSocket successfully opens. - */ - onOpen?: () => void; - - /** - * Called whenever the WebSocket closes. - */ - onClose?: (event: CloseEvent) => void; - - /** - * Called when an error occurs. - */ - onError?: (event: Event) => void; - - /** - * A map of handlers keyed by `message.type`, so you can handle each incoming - * message type in a well-typed, pluggable way. - */ - messageHandlers?: { - [K in TIncoming["type"]]?: ( - message: Extract - ) => void; - }; -} - -/** - * A generic client-side WebSocket Manager. - */ -export class ClientWebSocketManager< - TIncoming extends BaseServerMessage = BaseServerMessage, - TOutgoing extends BaseClientMessage = BaseClientMessage -> { - private config: ClientWebSocketManagerConfig; - private socket: WebSocket | null = null; - - constructor(config: ClientWebSocketManagerConfig) { - this.config = config; - this.connect(); - } - - /** - * Create and open the WebSocket connection - */ - private connect() { - const { url, debug } = this.config; - - if (debug) { - console.log(`[ClientWebSocketManager] Connecting to ${url} ...`); - } - - this.socket = new WebSocket(url); - - this.socket.addEventListener("open", this.handleOpen); - this.socket.addEventListener("close", this.handleClose); - this.socket.addEventListener("error", this.handleError); - this.socket.addEventListener("message", this.handleMessage); - } - - /** - * Close the WebSocket connection gracefully - */ - public disconnect() { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { - if (this.config.debug) { - console.log("[ClientWebSocketManager] Closing connection"); - } - this.socket.close(); - } - } - - /** - * Send a strongly-typed message to the server - */ - public sendMessage(msg: TOutgoing) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - if (this.config.debug) { - console.warn("[ClientWebSocketManager] Cannot send, socket not open:", msg); - } - return; - } - - try { - const str = JSON.stringify(msg); - this.socket.send(str); - } catch (error) { - console.error("[ClientWebSocketManager] Error sending message:", error); - } - } - - /** - * Called when WebSocket opens - */ - private handleOpen = () => { - if (this.config.debug) { - console.log("[ClientWebSocketManager] Connection opened"); - } - this.config.onOpen?.(); - }; - - /** - * Called when WebSocket closes - */ - private handleClose = (event: CloseEvent) => { - if (this.config.debug) { - console.log("[ClientWebSocketManager] Connection closed:", event.reason); - } - this.config.onClose?.(event); - // Optional: reconnection logic here - }; - - /** - * Called on a WebSocket error - */ - private handleError = (event: Event) => { - if (this.config.debug) { - console.error("[ClientWebSocketManager] Connection error:", event); - } - this.config.onError?.(event); - }; - - /** - * Called when a message arrives from the server - */ - private handleMessage = (event: MessageEvent) => { - let incoming: TIncoming; - try { - incoming = JSON.parse(event.data) as TIncoming; - } catch (err) { - console.error("[ClientWebSocketManager] Failed to parse incoming message", err); - return; - } - - // @ts-ignore - const handler = this.config.messageHandlers?.[incoming.type]; - if (handler) { - handler(incoming); - } else if (this.config.debug) { - console.warn("[ClientWebSocketManager] No handler for message type:", incoming.type); - } - }; -} \ No newline at end of file diff --git a/packages/websocket-manager-react/src/client-websocket-types.ts b/packages/websocket-manager-react/src/client-websocket-types.ts deleted file mode 100644 index 5ea164a..0000000 --- a/packages/websocket-manager-react/src/client-websocket-types.ts +++ /dev/null @@ -1,46 +0,0 @@ -// client-websocket-types.ts -export interface BaseClientMessage { - type: string; - // Optionally, you can define other common fields here -} - -export interface BaseServerMessage { - type: string; - // The server may send some data payload - data?: unknown; -} - -/** - * Example: We might have a specific client->server message - * that increments something on the server. - */ -export interface IncrementClientMessage extends BaseClientMessage { - type: "increment"; - amount: number; -} - -/** - * Example: The server might respond with state updates. - */ -export interface StateUpdateServerMessage extends BaseServerMessage { - type: "state_update"; - data: { - counter: number; - }; -} - -/** - * Combine your client->server messages into a union type. - */ -export type OutgoingClientMessage = - | IncrementClientMessage - // Add others here as needed - ; - -/** - * Combine your server->client messages into a union type. - */ -export type IncomingServerMessage = - | StateUpdateServerMessage - // Add others here as needed - ; \ No newline at end of file diff --git a/packages/websocket-manager-react/src/index.ts b/packages/websocket-manager-react/src/index.ts deleted file mode 100644 index 4db4eb1..0000000 --- a/packages/websocket-manager-react/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - ClientWebSocketManager, - type ClientWebSocketManagerConfig, - type BaseServerMessage, - type BaseClientMessage -} from './client-websocket-manager' - -export { - WebSocketClientProvider, - useWebSocketClient, - type WebSocketClientContextValue, - type WebSocketClientProviderProps -} from './client-websocket-context' \ No newline at end of file diff --git a/packages/websocket-manager-react/tsconfig.app.json b/packages/websocket-manager-react/tsconfig.app.json deleted file mode 100644 index bceb079..0000000 --- a/packages/websocket-manager-react/tsconfig.app.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] -} diff --git a/packages/websocket-manager-react/tsconfig.build.json b/packages/websocket-manager-react/tsconfig.build.json deleted file mode 100644 index 0b2fc3a..0000000 --- a/packages/websocket-manager-react/tsconfig.build.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - // Target modern JS but not too cutting-edge - "target": "ES2020", - // Standard module setting so tooling doesn't get confused - "module": "ESNext", - "moduleResolution": "Node", - - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - - // IMPORTANT: We want to actually emit .js and .d.ts - "noEmit": false, - "declaration": true, - "declarationMap": true, - "outDir": "dist/types", - - // Strict type checking & typical bundler settings - "strict": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "isolatedModules": false, - "skipLibCheck": true - }, - // You only need to include your actual source code - "include": ["src"] - } \ No newline at end of file diff --git a/packages/websocket-manager-react/tsconfig.json b/packages/websocket-manager-react/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/packages/websocket-manager-react/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/packages/websocket-manager-react/tsconfig.node.json b/packages/websocket-manager-react/tsconfig.node.json deleted file mode 100644 index 00d37ab..0000000 --- a/packages/websocket-manager-react/tsconfig.node.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "incremental": true - }, - "include": ["vite.config.ts"] -} diff --git a/packages/websocket-manager-react/vite.config.ts b/packages/websocket-manager-react/vite.config.ts deleted file mode 100644 index 8bbef47..0000000 --- a/packages/websocket-manager-react/vite.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import dts from 'vite-plugin-dts' -import * as path from 'path' - -export default defineConfig({ - plugins: [ - react(), - dts({ - entryRoot: 'src', - outDir: 'dist/types', - insertTypesEntry: true, - // Point to your new build config - tsconfigPath: path.resolve(__dirname, 'tsconfig.build.json'), - }), - ], - build: { - lib: { - entry: path.resolve(__dirname, 'src', 'index.ts'), - name: 'WebSocketManagerReact', - fileName: (format) => `websocket-manager-react.${format}.js`, - formats: ['es', 'cjs'], - }, - rollupOptions: { - external: ['react', 'react-dom'], - output: { - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - }, - }, - }, - }, -}) \ No newline at end of file diff --git a/packages/websocket-manager/.gitignore b/packages/websocket-manager/.gitignore deleted file mode 100644 index d5891dc..0000000 --- a/packages/websocket-manager/.gitignore +++ /dev/null @@ -1 +0,0 @@ -app.db \ No newline at end of file diff --git a/packages/websocket-manager/README.md b/packages/websocket-manager/README.md deleted file mode 100644 index 6fa1017..0000000 --- a/packages/websocket-manager/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# @bnk/websocket-manager - -**@bnk/websocket-manager** is a modular, extensible, and strongly-typed WebSocket manager for Bun-based servers, designed to handle a variety of real-time use cases with minimal overhead. This package leverages Bun’s native `ServerWebSocket`, offering a customizable, pluggable architecture to manage application state and broadcast changes to connected clients. - -## Key Features - -- **Strong Typing**: Uses TypeScript generics and advanced types (`BaseMessage`, `MessageHandler`) to ensure type safety for state and message handling. -- **Pluggable Architecture**: Register multiple message handlers for different message types. Each handler deals with its own piece of state logic. -- **Easy State Management**: Provide `getState` and `setState` functions, allowing you to store and retrieve state from a DB, in-memory object, or any other storage. -- **Broadcast Support**: Broadcast updated state to all connected clients using `broadcastState()`. -- **Debug-Ready**: Pass `debug: true` in the configuration to enable verbose logging. -- **Testable by Design**: Written with testability in mind; easily mock `getState`, `setState`, or individual message handlers within Bun’s test suite. - -## Installation - -```bash -# Using Bun -bun add @bnk/websocket-manager - -# Or, if you are mixing with npm/yarn, you can also do: -npm install @bnk/websocket-manager -# yarn add @bnk/websocket-manager -``` - -## Basic Usage - -Below is a minimal example of how to use **@bnk/websocket-manager**. This example sets up an in-memory state and a single message handler for demonstration. - -### 1. Create Your State and Handlers - -```ts -// my-app-state.ts -export interface MyAppState { - counter: number; -} - -// my-message-types.ts -import { BaseMessage } from "@bnk/websocket-manager"; - -export interface IncrementMessage extends BaseMessage { - type: "increment"; - amount: number; -} - -// Optionally combine multiple messages into a union -export type MyAppMessage = IncrementMessage; -``` - -```ts -// my-message-handlers.ts -import { MessageHandler } from "@bnk/websocket-manager"; -import { MyAppState, MyAppMessage } from "./my-message-types"; - -// A simple handler to increment a counter in the state -export const incrementHandler: MessageHandler = { - type: "increment", - async handle(ws, message, getState, setState) { - const state = await getState(); - state.counter += message.amount; - await setState(state); - }, -}; - -export const myHandlers = [incrementHandler]; -``` - -### 2. Set Up Your WebSocket Manager - -```ts -// websocket-manager-setup.ts -import { WebSocketManager } from "@bnk/websocket-manager"; -import { MyAppState, MyAppMessage } from "./my-message-types"; -import { myHandlers } from "./my-message-handlers"; - -// In-memory example state -let currentState: MyAppState = { counter: 0 }; - -const getState = async (): Promise => { - // Return a clone to simulate immutable reads - return structuredClone(currentState); -}; - -const setState = async (newState: MyAppState): Promise => { - // Simulate saving newState to a DB or in-memory store - currentState = structuredClone(newState); -}; - -// Create the manager -export const myWebSocketManager = new WebSocketManager({ - getState, - setState, - messageHandlers: myHandlers, - debug: true, // optional -}); -``` - -### 3. Integrate with a Bun Server - -```ts -// bun-server.ts -import { serve } from "bun"; -import { myWebSocketManager } from "./websocket-manager-setup"; - -serve({ - port: 3000, - fetch(req: Request) { - return new Response("Hello from Bun server!", { status: 200 }); - }, - websocket: { - open(ws) { - myWebSocketManager.handleOpen(ws); - }, - close(ws) { - myWebSocketManager.handleClose(ws); - }, - async message(ws, msg) { - // Handle the incoming message - await myWebSocketManager.handleMessage(ws, msg.toString()); - - // Optionally broadcast updated state to all clients - await myWebSocketManager.broadcastState(); - }, - } -}); - -console.log("Server running at http://localhost:3000"); -``` - -### 4. Sending Messages from the Client - -From the browser (or another WebSocket client), connect and send an `increment` message: - -```ts -const ws = new WebSocket("ws://localhost:3000"); - -ws.onopen = () => { - ws.send(JSON.stringify({ type: "increment", amount: 5 })); -}; - -ws.onmessage = (event) => { - console.log("Server says:", event.data); -}; -``` - -## Advanced Usage - -### Multiple Message Handlers - -You can define multiple handlers to deal with different message subtypes. Each handler is matched by its `type` field. -In more complex applications, create separate modules for different domains (e.g., user chat, project management, etc.) and then combine all handlers into one array: - -```ts -const allHandlers = [ - ...chatHandlers, - ...projectHandlers, - // etc... -]; - -const manager = new WebSocketManager({ - getState, - setState, - messageHandlers: allHandlers, -}); -``` - -### Broadcasting State - -Whenever your state changes, you can call `manager.broadcastState()` to push the updated state to all connected clients. Internally, this calls `getState()`, serializes it, and sends it to each active WebSocket connection. - -### Debug Logging - -Set `debug: true` in the manager config to see detailed logs of connections, closures, and message parsing. This is helpful for troubleshooting and development. - -### Heartbeats & Connection Stability - -If you’re building a high-availability or production-grade system, you may also implement a heartbeat or ping/pong mechanism. This can help detect stale connections. See the [test suite](./src/generic-websocket-manager.test.ts) for an example of using a heartbeat interval. - -## Testing - -**@bnk/websocket-manager** is built for straightforward testing with Bun. Key points: - -- **Mocking**: You can mock out `getState` and `setState` for unit tests. -- **Handlers**: Each message handler is testable as a standalone function since it just needs `getState`, `setState`, and a mocked WebSocket. -- **Integration**: Combine all handlers, start a test Bun server, and run integration tests to ensure messages flow as expected. - -A sample test file is included in `src/generic-websocket-manager.test.ts`, showcasing how to verify: - -1. Incoming messages are parsed and handled. -2. State is updated correctly. -3. WebSocket connections open and close as expected. - -To run tests: - -```bash -bun test src/ -``` - -## Contributing - -Feel free to open issues or pull requests to improve **@bnk/websocket-manager**. Whether it’s a feature request, bug fix, or documentation enhancement, all contributions are welcome. - -## License - -This project is licensed under the [MIT License](./LICENSE). - ---- - -**@bnk/websocket-manager** aims to give you a solid foundation for real-time WebSocket applications using Bun, with minimal friction and maximum flexibility. If you find this library helpful, consider sharing feedback and improvements! diff --git a/packages/websocket-manager/package.json b/packages/websocket-manager/package.json deleted file mode 100644 index 13fb32e..0000000 --- a/packages/websocket-manager/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@bnk/websocket-manager", - "module": "src/index.ts", - "main": "src/index.ts", - "type": "module", - "version": "1.0.0", - "scripts": { - "test": "bun test src/", - "test:watch": "bun test --watch src/", - "prepublishOnly": "bun test", - "publish:websocket-manager": "bun publish --access public", - "dev:example": "bun run src/example/example-bun-server.ts" - }, - "devDependencies": { - "bun-types": "latest", - "typescript": "^5.7.2" - } -} \ No newline at end of file diff --git a/packages/websocket-manager/src/example/counter-handlers.ts b/packages/websocket-manager/src/example/counter-handlers.ts deleted file mode 100644 index d67b64f..0000000 --- a/packages/websocket-manager/src/example/counter-handlers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { MessageHandler } from "../websocket-types"; - -/** - * This is the union of all messages we handle. - * In this simplified example, we only have one message type. - */ -export type CounterMessage = { - type: "increment_counter"; - amount: number; -}; - -/** - * Our global state includes a single numeric counter. - */ -export interface MyAppState { - counter: number; -} - -/** - * A handler that increments the counter by the specified amount. - */ -export const incrementCounterHandler: MessageHandler = { - type: "increment_counter" as const, - - async handle(ws, message, getState, setState) { - const state = await getState(); - state.counter += message.amount; - await setState(state); - }, -}; - -/** - * Export an array of all handlers for your domain. - * If there were multiple message types, each would get its own handler - * and they'd all be bundled here. - */ -export const counterHandlers = [incrementCounterHandler]; \ No newline at end of file diff --git a/packages/websocket-manager/src/example/database.ts b/packages/websocket-manager/src/example/database.ts deleted file mode 100644 index 3bfbd9d..0000000 --- a/packages/websocket-manager/src/example/database.ts +++ /dev/null @@ -1,44 +0,0 @@ -// database.ts -import { Database } from "bun:sqlite"; -import type { MyAppState } from "./counter-handlers"; - -const db = new Database("app.db"); - -function createTables() { - // Create a table to store the application state as JSON - db.run(` - CREATE TABLE IF NOT EXISTS app_state ( - id INTEGER PRIMARY KEY, - json TEXT NOT NULL - ) - `); -} - -export function initializeDatabase() { - createTables(); -} - -export async function loadInitialStateFromDb(): Promise { - // We'll assume there's only a single row (id=1) for the global state - const row = db.query("SELECT json FROM app_state WHERE id = 1").get() as { json: string } | undefined; - - if (row && row.json) { - return JSON.parse(row.json) as MyAppState; - } - - // If we don't have any saved data in the DB yet, return a default - return { - counter: 0, - }; -} - -export async function saveStateToDb(state: MyAppState): Promise { - const jsonState = JSON.stringify(state); - - // If row with id=1 already exists, update; otherwise insert - db.run(` - INSERT INTO app_state (id, json) - VALUES (1, ?) - ON CONFLICT(id) DO UPDATE SET json=excluded.json - `, [jsonState]); -} \ No newline at end of file diff --git a/packages/websocket-manager/src/example/example-bun-server.ts b/packages/websocket-manager/src/example/example-bun-server.ts deleted file mode 100644 index 5016d3e..0000000 --- a/packages/websocket-manager/src/example/example-bun-server.ts +++ /dev/null @@ -1,111 +0,0 @@ -// server.ts -import { serve } from "bun"; -import { join } from "path"; -import { WebSocketManager } from "../generic-websocket-manager"; -import { counterHandlers, type MyAppState, type CounterMessage } from "./counter-handlers"; -import { initializeDatabase, loadInitialStateFromDb, saveStateToDb } from "./database"; - -/** - * Initialize the database and tables before anything else. - */ -initializeDatabase(); - -/** - * Load the initial state from the database. - */ -let currentState: MyAppState = await loadInitialStateFromDb(); - -/** - * 1) Implement getState / setState for the manager config. - */ -async function getStateFromMemory(): Promise { - return structuredClone(currentState); -} - -async function setStateInMemory(newState: MyAppState): Promise { - currentState = structuredClone(newState); -} - -/** - * 2) Combine all message handlers needed for your domain. - */ -type MyMessages = CounterMessage; -const allHandlers = [...counterHandlers]; - -/** - * 3) Create the WebSocket Manager. - */ -const wsManager = new WebSocketManager({ - getState: getStateFromMemory, - setState: setStateInMemory, - messageHandlers: allHandlers, - debug: true, -}); - -/** - * Optionally, set up an interval to persist state to the DB - * so we're not always writing on every change. - */ -const SAVE_INTERVAL_MS = 10_000; // 10 seconds (adjust as you like) - -const saveInterval = setInterval(async () => { - try { - const stateToSave = await getStateFromMemory(); - await saveStateToDb(stateToSave); - console.log("[Server] State saved to DB on interval."); - } catch (error) { - console.error("[Server] Failed to save state to DB:", error); - } -}, SAVE_INTERVAL_MS); - -/** - * 4) Start your Bun server. - */ -serve({ - port: 3005, - async fetch(req, server) { - const url = new URL(req.url); - - if (url.pathname === "/ws") { - const upgraded = server.upgrade(req, { - data: { someContext: "hello" }, - }); - return upgraded - ? undefined - : new Response("Failed to upgrade", { status: 400 }); - } - - // Serve index.html for any non-websocket route - return new Response(Bun.file(join(import.meta.dir, "index.html"))); - }, - websocket: { - open(ws) { - console.log("[Server] WebSocket opened!"); - wsManager.handleOpen(ws); - }, - close(ws) { - console.log("[Server] WebSocket closed!"); - wsManager.handleClose(ws); - }, - async message(ws, msg) { - - try { - await wsManager.handleMessage(ws, msg.toString()) - .then(() => { - // Optionally broadcast the new state to all connected clients: - }) - - } catch (error) { - console.error("[Server] Error handling message:", error); - } - - - await wsManager.broadcastState(); - }, - }, -}); - -console.log(`Server running at http://localhost:3005`); - -// Optional cleanup if your process stops (e.g. SIGTERM) -// clearInterval(saveInterval); \ No newline at end of file diff --git a/packages/websocket-manager/src/example/index.html b/packages/websocket-manager/src/example/index.html deleted file mode 100644 index 27a812c..0000000 --- a/packages/websocket-manager/src/example/index.html +++ /dev/null @@ -1,54 +0,0 @@ - - - - - WebSocket Counter Example - - -

Bun WebSocket Counter Demo

-

- Current Counter: 0 -

- - - - - \ No newline at end of file diff --git a/packages/websocket-manager/src/example/project-tab-handlers.ts b/packages/websocket-manager/src/example/project-tab-handlers.ts deleted file mode 100644 index 07f6ab7..0000000 --- a/packages/websocket-manager/src/example/project-tab-handlers.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { MessageHandler, BaseMessage } from "../websocket-types"; - -// 1) Define a single union type for all messages -export type ProjectTabMessage = - | { - type: "create_project_tab"; - tabId: string; - title: string; - } - | { - type: "update_project_tab"; - tabId: string; - content: string; - }; - -// Your global or slice-of-state type -export interface MyAppState { - projectTabs: Record< - string, - { - title: string; - content: string; - } - >; -} - -// 2) Each handler is typed with the full union -export const createOrUpdateProjectTabHandler: MessageHandler< - MyAppState, - ProjectTabMessage -> = { - // The manager will match the handler by `type` field: - // We'll do a runtime check to ensure we only handle the relevant subtype. - type: "create_project_tab" as ProjectTabMessage["type"], - - async handle(ws, message, getState, setState) { - const state = await getState(); - - // 3) Narrow by checking `message.type` - if (message.type === "create_project_tab") { - state.projectTabs[message.tabId] = { - title: message.title, - content: "", - }; - } else if (message.type === "update_project_tab") { - const tab = state.projectTabs[message.tabId]; - if (tab) { - tab.content = message.content; - } - } - - await setState(state); - }, -}; - -/** - * Export an array of handlers, all typed to the union. - * Each can do its own internal if-check to see if it - * actually wants to process the message. - */ -export const projectTabHandlers = [createOrUpdateProjectTabHandler]; \ No newline at end of file diff --git a/packages/websocket-manager/src/generic-websocket-manager.test.ts b/packages/websocket-manager/src/generic-websocket-manager.test.ts deleted file mode 100644 index e00d09d..0000000 --- a/packages/websocket-manager/src/generic-websocket-manager.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// packages/websocket-manager/test/generic-websocket-manager.test.ts - -import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import { WebSocketManager, type WebSocketManagerConfig } from "../src"; -import { type BaseMessage, type MessageHandler } from "../src/websocket-types"; - -type TestState = { - value: number; -}; - -interface IncrementTestMessage extends BaseMessage { - type: "increment"; - amount: number; -} - -type TestMessage = IncrementTestMessage; - -describe("WebSocketManager Tests", () => { - let manager: WebSocketManager; - let mockWs: any; - - beforeEach(() => { - mockWs = { - readyState: 1, - send: mock((data: string) => { }), - close: mock(() => { }) - }; - - const incrementHandler: MessageHandler = { - type: "increment", - handle: async (ws, message, getState, setState) => { - const currentState = await getState(); - const updated = { ...currentState, value: currentState.value + message.amount }; - await setState(updated); - }, - }; - - const config: WebSocketManagerConfig = { - getState: async () => ({ value: 0 }), - setState: async (newState) => { }, - messageHandlers: [incrementHandler], - debug: false, - }; - - manager = new WebSocketManager(config); - }); - - afterEach(() => { - manager.stopHeartbeat(); - }); - - it("should add a new connection and send initial state", async () => { - await manager.handleOpen(mockWs); - expect(manager["connections"].size).toBe(1); - - // Access call count via `.mock.calls` - expect(mockWs.send.mock.calls.length).toBe(1); - }); - - it("should remove a connection on close", async () => { - await manager.handleOpen(mockWs); - await manager.handleClose(mockWs); - expect(manager["connections"].size).toBe(0); - }); - - it("should handle an incoming message and update state via handler", async () => { - const getStateSpy = mock(async () => ({ value: 5 })); - const setStateSpy = mock(async (newState) => { }); - - manager["config"].getState = getStateSpy; - manager["config"].setState = setStateSpy; - - await manager.handleOpen(mockWs); - const message = JSON.stringify({ type: "increment", amount: 10 }); - await manager.handleMessage(mockWs, message); - - // Check calls via `.mock` - expect(getStateSpy.mock.calls.length).toBe(3); - expect(setStateSpy.mock.calls.length).toBe(1); // from handler - expect(setStateSpy.mock.calls[0][0]).toEqual({ value: 15 }); - }); - - it("should use middleware to modify messages before handling", async () => { - await manager.use(async (msg) => { - if (msg.type === "increment") { - return { ...msg, amount: msg.amount * 2 }; - } - return msg; - }); - - const setStateSpy = mock(async (newState) => { }); - manager["config"].setState = setStateSpy; - - await manager.handleOpen(mockWs); - await manager.handleMessage(mockWs, JSON.stringify({ type: "increment", amount: 1 })); - - // The final updated state should have used amount=2 - expect(setStateSpy.mock.calls[0][0]).toEqual({ value: 2 }); - }); - - it("should handle heartbeat pings if interval is set", async () => { - manager.stopHeartbeat(); - manager = new WebSocketManager({ - ...manager["config"], - heartbeatIntervalMs: 10, - pingTimeoutMs: 50, - }); - - await manager.handleOpen(mockWs); - await new Promise((resolve) => setTimeout(resolve, 30)); - - // Filter mock calls for ping messages - const pingCalls = mockWs.send.mock.calls.filter(call => { - try { - const parsed = JSON.parse(call[0]); - return parsed.type === "ping"; - } catch { - return false; - } - }); - expect(pingCalls.length).toBeGreaterThan(0); - }); - - it("should handle a pong message", async () => { - manager.stopHeartbeat(); - manager = new WebSocketManager({ - ...manager["config"], - heartbeatIntervalMs: 10, - pingTimeoutMs: 50, - hooks: { - onPong: async (ws) => { - // Just for coverage - } - } - }); - - await manager.handleOpen(mockWs); - await manager.handleMessage(mockWs, "pong"); - - // lastPongTimes should have been updated - const lastPongMap = manager["lastPongTimes"].get(mockWs); - expect(typeof lastPongMap).toBe("number"); - }); -}); \ No newline at end of file diff --git a/packages/websocket-manager/src/generic-websocket-manager.ts b/packages/websocket-manager/src/generic-websocket-manager.ts deleted file mode 100644 index 8a3bbbe..0000000 --- a/packages/websocket-manager/src/generic-websocket-manager.ts +++ /dev/null @@ -1,369 +0,0 @@ -import type { ServerWebSocket } from "bun"; -import type { BaseMessage, MessageHandler } from "./websocket-types"; - -/** - * Hooks that can be provided to the WebSocketManager for executing - * custom logic at various lifecycle events. - */ -export interface WebSocketManagerHooks { - /** - * Called whenever a new client connects. - */ - onConnect?: (ws: ServerWebSocket) => Promise; - - /** - * Called whenever a client disconnects. - */ - onDisconnect?: (ws: ServerWebSocket) => Promise; - - /** - * Called whenever the manager's state is updated. - */ - onStateChange?: (oldState: TState, newState: TState) => Promise; - - /** - * Called when the server sends a "ping" message to a client. - */ - onPing?: (ws: ServerWebSocket) => Promise; - - /** - * Called when the server receives a "pong" message from a client. - */ - onPong?: (ws: ServerWebSocket) => Promise; - - /** - * Called if a client fails to respond to a ping message. - */ - onPingTimeout?: (ws: ServerWebSocket) => Promise; -} - -/** - * Configuration object for the generic WebSocket manager. - * - * @template TState The shape of your application's state - * @template TMessage The union of all message types that may be handled - */ -export interface WebSocketManagerConfig< - TState, - TMessage extends BaseMessage -> { - /** - * A function to retrieve the current state from wherever it is stored - * (database, in-memory, etc.). - */ - getState: () => Promise; - - /** - * A function to persist the updated state in your data store. - */ - setState: (state: TState) => Promise; - - /** - * An array of message handlers. Each handler processes a specific message type. - */ - messageHandlers: Array>; - - /** - * Optional debug flag for logging. - */ - debug?: boolean; - - /** - * Optional hooks for lifecycle events. - */ - hooks?: WebSocketManagerHooks; - - /** - * Milliseconds to wait before sending a ping to each client. - * If not provided, pinging is disabled. - */ - heartbeatIntervalMs?: number; - - /** - * Milliseconds to wait for a pong response before marking a client as timed out. - */ - pingTimeoutMs?: number; - - /** - * Optional validator for incoming messages. - * Example usage with zod: - * validateMessage: (msg) => MyZodSchema.parse(msg) - */ - validateMessage?: (rawMessage: unknown) => TMessage; -} - -/** - * A generic WebSocket manager that can handle a variety of states and messages. - * - * @template TState - The shape of your application's state - * @template TMessage - The union of all message types that may be handled - */ -export class WebSocketManager< - TState, - TMessage extends BaseMessage -> { - private connections: Set>; - private config: WebSocketManagerConfig; - - /** - * Middleware array. Each middleware processes a TMessage - * and returns a (possibly transformed) TMessage. - */ - private middlewares: Array<(message: TMessage) => Promise> = []; - - /** - * Keeps track of timestamps (in ms) of the last pong received from each client. - * Used for heartbeat/ping checks. - */ - private lastPongTimes: Map, number>; - - private heartbeatTimer: NodeJS.Timer | undefined; - - constructor(config: WebSocketManagerConfig) { - this.config = config; - this.connections = new Set(); - this.lastPongTimes = new Map(); - - if (this.config.debug) { - console.log("[WebSocketManager] Initialized with debug = true"); - } - - // If heartbeatIntervalMs is set, start the interval. - if (this.config.heartbeatIntervalMs && this.config.heartbeatIntervalMs > 0) { - this.startHeartbeat(); - } - } - - /** - * Register a new middleware function that processes incoming messages. - */ - public async use(middleware: (message: TMessage) => Promise): Promise { - this.middlewares.push(middleware); - } - - /** - * Starts the heartbeat/ping cycle if not already started. - */ - private startHeartbeat(): void { - // Clear any existing timer. - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - } - - this.heartbeatTimer = setInterval(() => { - for (const ws of this.connections) { - // If a client is still open, attempt to ping it. - if (ws.readyState === WebSocket.OPEN) { - this.sendPing(ws); - } - } - }, this.config.heartbeatIntervalMs); - } - - /** - * Sends a ping message to a specific client. - */ - private async sendPing(ws: ServerWebSocket): Promise { - try { - const pingMessage = JSON.stringify({ type: "ping" }); - ws.send(pingMessage); - if (this.config.hooks?.onPing) { - await this.config.hooks.onPing(ws); - } - - // If we have a pingTimeoutMs configured, we track the time. - if (this.config.pingTimeoutMs && this.config.pingTimeoutMs > 0) { - const timeout = setTimeout(async () => { - const lastPong = this.lastPongTimes.get(ws) || 0; - const now = Date.now(); - // If we haven't received a pong in time, close or mark inactive. - if (now - lastPong > this.config.pingTimeoutMs!) { - if (this.config.debug) { - console.warn("[WebSocketManager] Ping timeout for connection. Closing..."); - } - if (this.config.hooks?.onPingTimeout) { - await this.config.hooks.onPingTimeout(ws); - } - ws.close(); - } - }, this.config.pingTimeoutMs); - - // Clear the timer if we receive a pong, so store it if needed. - // We'll do that in handlePong method. - } - } catch (error) { - if (this.config.debug) { - console.error("[WebSocketManager] Failed to send ping:", error); - } - } - } - - /** - * Stops the heartbeat/ping cycle. - */ - public stopHeartbeat(): void { - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = undefined; - } - } - - /** - * Handle a new connection. - */ - public async handleOpen(ws: ServerWebSocket): Promise { - this.connections.add(ws); - this.lastPongTimes.set(ws, Date.now()); - - if (this.config.debug) { - console.log("[WebSocketManager] New connection opened."); - } - - // Call onConnect hook if provided - if (this.config.hooks?.onConnect) { - await this.config.hooks.onConnect(ws); - } - - // Optional: Send the current state to the new client - try { - const currentState = await this.config.getState(); - const message = { - type: "initial_state", - data: currentState - }; - ws.send(JSON.stringify(message)); - } catch (error) { - console.error("[WebSocketManager] Error fetching initial state:", error); - ws.close(); - } - } - - /** - * Handle a closed connection. - */ - public async handleClose(ws: ServerWebSocket): Promise { - this.connections.delete(ws); - this.lastPongTimes.delete(ws); - - if (this.config.debug) { - console.log("[WebSocketManager] Connection closed."); - } - - // Call onDisconnect hook if provided - if (this.config.hooks?.onDisconnect) { - await this.config.hooks.onDisconnect(ws); - } - } - - /** - * Handle any raw incoming messages from clients. - */ - public async handleMessage(ws: ServerWebSocket, rawMessage: string): Promise { - if (this.config.debug) { - console.log("[WebSocketManager] Received raw message:", rawMessage); - } - - // Special case for "pong": we track that we've received a pong for heartbeat - if (rawMessage === "pong") { - if (this.config.debug) { - console.log("[WebSocketManager] Received pong"); - } - this.lastPongTimes.set(ws, Date.now()); - if (this.config.hooks?.onPong) { - await this.config.hooks.onPong(ws); - } - return; - } - - let parsed: TMessage; - try { - // If a validator is provided, use it. Otherwise fallback to JSON.parse - parsed = this.config.validateMessage - ? this.config.validateMessage(JSON.parse(rawMessage)) - : (JSON.parse(rawMessage) as TMessage); - } catch (error) { - console.error("[WebSocketManager] Failed to parse or validate message:", error); - return; - } - - // Pass the parsed message through all registered middlewares - for (const mw of this.middlewares) { - try { - parsed = await mw(parsed); - } catch (middlewareError) { - console.error("[WebSocketManager] Middleware error:", middlewareError); - return; - } - } - - // Find a handler that matches the parsed type - const handler = this.config.messageHandlers.find((h) => h.type === parsed.type); - if (!handler) { - if (this.config.debug) { - console.warn("[WebSocketManager] No handler found for message type:", parsed.type); - } - return; - } - - // Run the handler - try { - // We read the old state for the onStateChange hook, in case the handler changes state. - const oldState = await this.config.getState(); - - await handler.handle( - ws, - parsed, - this.config.getState, - async (updated: TState) => { - // Before we actually set the state, check if it's changed from oldState. - await this.config.setState(updated); - // If changed, call onStateChange if provided. - if (this.config.hooks?.onStateChange && JSON.stringify(oldState) !== JSON.stringify(updated)) { - await this.config.hooks.onStateChange(oldState, updated); - } - } - ); - } catch (error) { - console.error("[WebSocketManager] Error in handler:", error); - } - } - - /** - * Broadcast helper to send the entire updated state to all clients. - */ - public async broadcastState(): Promise { - try { - const updatedState = await this.config.getState(); - const message = { - type: "state_update", - data: updatedState - }; - const serialized = JSON.stringify(message); - - let successCount = 0; - let failCount = 0; - - for (const conn of this.connections) { - try { - conn.send(serialized); - successCount++; - } catch (error) { - failCount++; - if (this.config.debug) { - console.error("[WebSocketManager] Failed to send state update:", error); - } - } - } - - if (this.config.debug) { - console.log("[WebSocketManager] Broadcast complete:", { - totalConnections: this.connections.size, - successCount, - failCount - }); - } - } catch (error) { - console.error("[WebSocketManager] Broadcast error:", error); - } - } -} \ No newline at end of file diff --git a/packages/websocket-manager/src/index.ts b/packages/websocket-manager/src/index.ts deleted file mode 100644 index 40a4d6a..0000000 --- a/packages/websocket-manager/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { WebSocketManager, type WebSocketManagerConfig, type WebSocketManagerHooks } from './generic-websocket-manager' -export type { BaseMessage, MessageHandler } from './websocket-types' \ No newline at end of file diff --git a/packages/websocket-manager/src/websocket-types.ts b/packages/websocket-manager/src/websocket-types.ts deleted file mode 100644 index a03a740..0000000 --- a/packages/websocket-manager/src/websocket-types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ServerWebSocket } from "bun"; - -/** - * Base interface for all WebSocket messages. - * Each message must have a `type` field. - */ -export interface BaseMessage { - type: string; -} - -/** - * A generic message handler interface that can handle - * messages of a certain `type`. - */ -export interface MessageHandler { - /** - * The message type that this handler is responsible for processing. - */ - type: TMessage["type"]; - - /** - * Handle the incoming message. - * @param ws - The connected WebSocket - * @param message - The received message - * @param getState - A function to retrieve current state - * @param setState - A function to persist updated state - * - * This returns a Promise so you can handle async - * operations (e.g., DB writes, external calls). - */ - handle: ( - ws: ServerWebSocket, - message: TMessage, - getState: () => Promise, - setState: (updated: TState) => Promise - ) => Promise; -} \ No newline at end of file diff --git a/packages/websocket-manager/tsconfig.json b/packages/websocket-manager/tsconfig.json deleted file mode 100644 index a11683f..0000000 --- a/packages/websocket-manager/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": ["ES2020"], - "declaration": false, - "declarationMap": false, - "sourceMap": true, - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "moduleResolution": "bundler", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "types": ["bun-types"], - "isolatedModules": true, - "verbatimModuleSyntax": true, - "emitDeclarationOnly": false, - "noEmit": true - }, - "include": [ - "*.ts", - "src/**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/packages/websocket-react-example/client/.gitignore b/packages/websocket-react-example/client/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/packages/websocket-react-example/client/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/packages/websocket-react-example/client/README.md b/packages/websocket-react-example/client/README.md deleted file mode 100644 index 74872fd..0000000 --- a/packages/websocket-react-example/client/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` - -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from 'eslint-plugin-react' - -export default tseslint.config({ - // Set the react version - settings: { react: { version: '18.3' } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, - }, -}) -``` diff --git a/packages/websocket-react-example/client/bun.lockb b/packages/websocket-react-example/client/bun.lockb deleted file mode 100755 index 34bbb38c975f8825d0ffa4c0b72b21bc8560e6c7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44966 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 - - - - - - Vite + React + TS - - -
- - - diff --git a/packages/websocket-react-example/client/package.json b/packages/websocket-react-example/client/package.json deleted file mode 100644 index 2b1a3eb..0000000 --- a/packages/websocket-react-example/client/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "websocket-react-example-client", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "shared": "workspace:*" - }, - "devDependencies": { - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "globals": "^15.14.0", - "typescript": "~5.6.2", - "vite": "^6.0.5", - "@bnk/websocket-manager-react": "^1.0.5" - } -} \ No newline at end of file diff --git a/packages/websocket-react-example/client/public/vite.svg b/packages/websocket-react-example/client/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/packages/websocket-react-example/client/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/websocket-react-example/client/src/app.css b/packages/websocket-react-example/client/src/app.css deleted file mode 100644 index b9d355d..0000000 --- a/packages/websocket-react-example/client/src/app.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/packages/websocket-react-example/client/src/app.tsx b/packages/websocket-react-example/client/src/app.tsx deleted file mode 100644 index 5dd7579..0000000 --- a/packages/websocket-react-example/client/src/app.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// File: packages/websocket-react-example/client/src/App.tsx -import { useContext, useState } from "react"; -import { useWebSocketClient } from "@bnk/websocket-manager-react"; -import { MessageLogContext } from "./chat-web-socket-provider"; -import { OutgoingClientMessage } from "./websocket-chat-types"; - -function App() { - // Grab our WebSocket context to send messages or manually disconnect if needed - const { sendMessage, isOpen } = useWebSocketClient(); - - // Grab the chat log from the ChatWebSocketProvider - const { messageLog } = useContext(MessageLogContext); - - // Local input form state - const [text, setText] = useState(""); - const [sender, setSender] = useState("Anonymous"); - - /** - * A helper to send a new chat message to the server - */ - const sendChat = () => { - if (!text.trim()) return; - const msg: OutgoingClientMessage = { - type: "chat", - payload: { - text: text.trim(), - sender: sender.trim() || "Anonymous", - } - }; - sendMessage(msg); - setText(""); - }; - - return ( -
-

WebSocket Chat Example

-

- WebSocket Connection Status:{" "} - {isOpen ? "OPEN" : "CLOSED"} -

- -
-
- {messageLog?.map((line, idx) => ( -
- {line} -
- ))} -
-
- setSender(e.target.value)} - /> - setText(e.target.value)} - onKeyPress={(e) => e.key === "Enter" && sendChat()} - /> - -
-
-
- ); -} - -export default App; - -/** Some inline styles for clarity */ -const styles: Record = { - container: { - maxWidth: 600, - margin: "0 auto", - padding: 16, - fontFamily: "sans-serif" - }, - chatSection: { - border: "1px solid #ccc", - padding: 16, - marginTop: 16 - }, - logContainer: { - height: 200, - overflowY: "auto", - border: "1px solid #aaa", - marginBottom: 8, - padding: 8 - }, - logLine: { - margin: "4px 0" - }, - form: { - display: "flex", - gap: 8 - } -}; \ No newline at end of file diff --git a/packages/websocket-react-example/client/src/assets/react.svg b/packages/websocket-react-example/client/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/packages/websocket-react-example/client/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/websocket-react-example/client/src/chat-web-socket-provider.tsx b/packages/websocket-react-example/client/src/chat-web-socket-provider.tsx deleted file mode 100644 index 8ed9f5e..0000000 --- a/packages/websocket-react-example/client/src/chat-web-socket-provider.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { useState } from "react"; -import { - WebSocketClientProvider, - type ClientWebSocketManagerConfig, -} from "@bnk/websocket-manager-react"; -import { - IncomingServerMessage, - OutgoingClientMessage, -} from "shared"; - -/** - * We'll store our chat log in React state. The server will send us - * either an `initial_state` or `state_update` message, - * each containing { messageLog: string[] } data. - */ -export function ChatWebSocketProvider({ children }: { children: React.ReactNode }) { - const [messageLog, setMessageLog] = useState([]); - - /** - * This type ensures we only allow known message types: - * "initial_state" | "state_update" - */ - const messageHandlers: ClientWebSocketManagerConfig["messageHandlers"] = { - - // On initial_state, the server provides the full ChatAppState - initial_state: (msg) => { - // msg is `Extract` - setMessageLog(msg.data.messageLog); - }, - // On state_update, the server has appended new messages - state_update: (msg) => { - // msg is `Extract` - setMessageLog(msg.data.messageLog); - }, - }; - - /** - * For the client->server messages, we can define them below or inside other components. - * Example: sending a new chat message of type = "chat". - */ - - // Our config object, passed to WebSocketClientProvider - const wsConfig: ClientWebSocketManagerConfig = { - url: "ws://localhost:3007/ws", - debug: true, - messageHandlers, - onOpen: () => { - console.log("[Client] WebSocket opened!"); - }, - onClose: () => { - console.log("[Client] WebSocket closed"); - }, - onError: (err) => { - console.error("[Client] WebSocket error:", err); - }, - }; - - return ( - {...wsConfig}> - - {children} - -
- ); -} - -/** - * Expose messageLog via context so children can read/update it easily. - */ -interface IMessageLogContext { - messageLog: string[]; - setMessageLog: React.Dispatch>; -} -export const MessageLogContext = React.createContext({ - messageLog: [], - setMessageLog: () => { } -}); \ No newline at end of file diff --git a/packages/websocket-react-example/client/src/index.css b/packages/websocket-react-example/client/src/index.css deleted file mode 100644 index 6119ad9..0000000 --- a/packages/websocket-react-example/client/src/index.css +++ /dev/null @@ -1,68 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/packages/websocket-react-example/client/src/main.tsx b/packages/websocket-react-example/client/src/main.tsx deleted file mode 100644 index f1c2457..0000000 --- a/packages/websocket-react-example/client/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './app.tsx' -import { ChatWebSocketProvider } from './chat-web-socket-provider' - -createRoot(document.getElementById('root')!).render( - - - - - , -) diff --git a/packages/websocket-react-example/client/src/vite-env.d.ts b/packages/websocket-react-example/client/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/packages/websocket-react-example/client/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/packages/websocket-react-example/client/tsconfig.app.json b/packages/websocket-react-example/client/tsconfig.app.json deleted file mode 100644 index da77f95..0000000 --- a/packages/websocket-react-example/client/tsconfig.app.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - }, - "include": ["src", "../shared/index.ts"] -} diff --git a/packages/websocket-react-example/client/tsconfig.json b/packages/websocket-react-example/client/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/packages/websocket-react-example/client/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/packages/websocket-react-example/client/tsconfig.node.json b/packages/websocket-react-example/client/tsconfig.node.json deleted file mode 100644 index ca1831b..0000000 --- a/packages/websocket-react-example/client/tsconfig.node.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - }, - "include": ["vite.config.ts"] -} diff --git a/packages/websocket-react-example/client/vite.config.ts b/packages/websocket-react-example/client/vite.config.ts deleted file mode 100644 index c00872c..0000000 --- a/packages/websocket-react-example/client/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], - server: { - port: 3006 - } -}) diff --git a/packages/websocket-react-example/server/package.json b/packages/websocket-react-example/server/package.json deleted file mode 100644 index e852180..0000000 --- a/packages/websocket-react-example/server/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "websocket-example-server", - "version": "1.0.0", - "main": "src/index.ts", - "type": "module", - "scripts": { - "dev": "bun run src/index.ts" - }, - "dependencies": { - "@bnk/websocket-manager-react": "^1.0.5", - "shared": "workspace:*" - } -} diff --git a/packages/websocket-react-example/server/src/index.html b/packages/websocket-react-example/server/src/index.html deleted file mode 100644 index 0519ecb..0000000 --- a/packages/websocket-react-example/server/src/index.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/websocket-react-example/server/src/index.ts b/packages/websocket-react-example/server/src/index.ts deleted file mode 100644 index 8f8ceef..0000000 --- a/packages/websocket-react-example/server/src/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -// File: packages/websocket-react-example/server/src/index.ts - -import { serve } from "bun"; -import { - WebSocketManager, - type BaseMessage, - type MessageHandler -} from "@bnk/websocket-manager"; - -import type { - ChatAppState, - OutgoingClientMessage, - IncomingServerMessage -} from "shared"; - - -/** - * Example "chat" message handler. We store incoming chat messages - * in memory, but you could do DB writes, etc. - */ -const chatHandler: MessageHandler = { - type: "chat", - async handle(ws, message, getState, setState) { - const state = await getState(); - const newEntry = `${message.payload.sender}: ${message.payload.text}`; - state.messageLog.push(newEntry); - - await setState(state); - - // Optionally broadcast the updated log to all connected clients: - // manager.broadcastState(); - }, -}; - -/** - * In-memory state for demonstration. In production, you might use a DB. - */ -let currentState: ChatAppState = { - messageLog: [], -}; - -/** - * getState and setState for the manager config - */ -async function getState(): Promise { - // Return a structured clone for immutability - return structuredClone(currentState); -} - -async function setState(newState: ChatAppState): Promise { - currentState = structuredClone(newState); -} - -/** - * Create the manager with a debug flag to see logs in the console. - */ -const manager = new WebSocketManager({ - getState, - setState, - messageHandlers: [chatHandler], - debug: true, -}); - -/** - * Start the Bun server on port 3007. - * Wire up the manager in the `websocket` config. - */ -serve({ - port: 3007, - fetch(req, server) { - const url = new URL(req.url); - - if (url.pathname === "/ws") { - const upgraded = server.upgrade(req, { - data: { someContext: "hello" }, - }); - return upgraded - ? undefined - : new Response("Failed to upgrade", { status: 400 }); - } - - - return new Response("Hello from the Bun WebSocket demo server!", { status: 200 }); - }, - websocket: { - open(ws) { - manager.handleOpen(ws); - }, - close(ws) { - manager.handleClose(ws); - }, - async message(ws, msg) { - // msg is a Bun.Buffer; convert to string - console.log("Before handleMessage:", currentState); - await manager.handleMessage(ws, msg.toString()); - console.log("After handleMessage:", currentState); - // Also broadcast updated state to all clients - await manager.broadcastState(); - }, - }, -}); - -console.log(`Server is running at http://localhost:3007`); \ No newline at end of file diff --git a/packages/websocket-react-example/server/tsconfig.json b/packages/websocket-react-example/server/tsconfig.json deleted file mode 100644 index 1950d6e..0000000 --- a/packages/websocket-react-example/server/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": ["ES2020"], - "declaration": false, - "declarationMap": false, - "sourceMap": true, - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "moduleResolution": "bundler", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "types": ["bun-types"], - "isolatedModules": true, - "verbatimModuleSyntax": true, - "emitDeclarationOnly": false, - "noEmit": true - }, - "include": [ - "*.ts", - "src/**/*.ts", - "shared/**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/prompts/bnk-websocket-manager.md b/prompts/bnk-websocket-manager.md new file mode 100644 index 0000000..490aed3 --- /dev/null +++ b/prompts/bnk-websocket-manager.md @@ -0,0 +1,262 @@ +# BNK WebSocket Monorepo + +A **highly-focused**, **pluggable**, and **well-tested** monorepo for building real-time apps with Bun and TypeScript. This workspace contains two core packages—**@bnk/websocket-manager** (server-side) and **@bnk/websocket-manager-react** (client-side for React)—as well as example projects demonstrating basic and full-stack integrations. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Packages](#packages) + - [@bnk/websocket-manager](#bnkwebsocket-manager) + - [@bnk/websocket-manager-react](#bnkwebsocket-manager-react) +3. [Installation](#installation) +4. [Examples](#examples) + - [Example: Vanilla](#example-vanilla) + - [Example: Full-Stack React](#example-full-stack-react) +5. [Basic Usage Snippet](#basic-usage-snippet) +6. [Contributing](#contributing) +7. [License](#license) + +--- + +## Introduction + +This monorepo offers a **type-safe**, **performant**, and **modular** solution for **WebSocket** applications built on [Bun](https://bun.sh/). It’s split into two main packages: + +- **`@bnk/websocket-manager`**: A server-side manager for maintaining shared application state, broadcasting updates, and handling incoming messages in a strongly-typed manner. +- **`@bnk/websocket-manager-react`**: A lightweight React wrapper for the client, providing hooks and context to handle real-time updates with minimal overhead. + +**Why BNK WebSocket Manager?** + +- **Type Safety**: Define your incoming/outgoing message types and rely on TypeScript generics for robust compile-time checks. +- **Performance**: Built for and tested on Bun’s fast WebSocket implementation. +- **Plug-and-Play**: Use only what you need. Compose and extend handlers on the server side. Integrate seamlessly on the client side with React hooks. +- **Minimal Dependencies**: Keep things lean—no heavy frameworks or libraries. +- **Well-Tested**: Each package includes a comprehensive Bun test suite. + +--- + +## Packages + +### @bnk/websocket-manager + +**Server-side** library for managing application state and broadcasting real-time updates. Features include: + +- **Generic State Management**: Plug in any store (in-memory, DB, etc.). +- **Message Handlers**: Register typed handlers for each message subtype. +- **Broadcast**: Easily push updated state to all connected clients. +- **Debuggable**: Use `debug: true` for verbose logs. + +See [packages/websocket-manager/README.md](./packages/websocket-manager/README.md) for details. + +### @bnk/websocket-manager-react + +**Client-side** React utilities for strongly-typed WebSocket connections: + +- **WebSocketClientProvider**: Wrap your app to manage one or more WebSocket connections. +- **useWebSocketClient**: Use the shared connection state and send messages from any component. +- **Pluggable Handlers**: Match message `type` to a typed handler function. + +See [packages/websocket-manager-react/README.md](./packages/websocket-manager-react/README.md) for details. + +--- + +## Installation + +Install these packages individually in your Bun projects. For server-side logic: + +```bash +bun add @bnk/websocket-manager +``` + +For React client integration: + +```bash +bun add @bnk/websocket-manager-react +``` + +*(You can also use npm or yarn if you prefer, though Bun is recommended.)* + +--- + +## Examples + +This monorepo comes with two example workspaces to demonstrate usage: + +### Example: Vanilla + +Located at [packages/example-vanilla](./packages/example-vanilla). It sets up a **basic WebSocket server** that also serves the client files: + +- **Server**: Uses `@bnk/websocket-manager` to track and broadcast state. +- **Client**: A minimal HTML/JS example, connecting via a WebSocket. + +To run: + +```bash +cd packages/example-vanilla +bun run example-bun-server.ts +``` + +Open `http://localhost:3005` in your browser to see it in action. + +### Example: Full-Stack React + +Located at [packages/example-fullstack-react](./packages/example-fullstack-react). A **complete** React + Bun setup with: + +- **Server**: A Bun server using `@bnk/websocket-manager`. +- **Client**: A React app using `@bnk/websocket-manager-react`. +- **Shared Types**: A `shared-types` package so that both server and client share the same message interfaces. + +To run: + +1. **Server**: + + ```bash + cd packages/example-fullstack-react/server + bun run src/index.ts + ``` + + This starts a Bun server on `http://localhost:3007`. + +2. **Client**: + + ```bash + cd ../client + bun run dev + ``` + + This starts the React dev server on `http://localhost:3006`. + +--- + +## Basic Usage Snippet + +Below is a **simplified** example of how the two libraries work together. + +
+Server (using @bnk/websocket-manager) + +```ts +import { serve } from "bun"; +import { WebSocketManager } from "@bnk/websocket-manager"; + +interface MyAppState { + counter: number; +} + +interface IncrementMessage { + type: "increment"; + amount: number; +} + +let currentState: MyAppState = { counter: 0 }; + +function getState(): Promise { + return Promise.resolve({ ...currentState }); +} +function setState(newState: MyAppState): Promise { + currentState = { ...newState }; + return Promise.resolve(); +} + +const manager = new WebSocketManager({ + getState, + setState, + messageHandlers: [ + { + type: "increment", + async handle(ws, msg, getState, setState) { + const oldState = await getState(); + oldState.counter += msg.amount; + await setState(oldState); + // Optionally broadcast to all clients + await manager.broadcastState(); + }, + }, + ], + debug: true, +}); + +serve({ + port: 3000, + fetch() { + return new Response("Hello from Bun!", { status: 200 }); + }, + websocket: { + open(ws) { + manager.handleOpen(ws); + }, + close(ws) { + manager.handleClose(ws); + }, + async message(ws, msg) { + await manager.handleMessage(ws, msg.toString()); + }, + }, +}); +``` + +
+ +
+Client (React using @bnk/websocket-manager-react) + +```tsx +import React from "react"; +import { + WebSocketClientProvider, + useWebSocketClient, +} from "@bnk/websocket-manager-react"; + +interface IncrementMessage { + type: "increment"; + amount: number; +} +interface StateUpdate { + type: "state_update"; + data: { + counter: number; + }; +} + +export function App() { + return ( + + url="ws://localhost:3000" + debug={true} + messageHandlers={{ + state_update: (msg) => { + console.log("Counter is now:", msg.data.counter); + }, + }} + > + + + ); +} + +function CounterComponent() { + const { sendMessage, isOpen } = useWebSocketClient(); + + const increment = () => { + sendMessage({ type: "increment", amount: 1 }); + }; + + return ( +
+ +
+ ); +} +``` + +
+ +--- + +## License + +This project is [MIT Licensed](./LICENSE). Feel free to use, modify, and distribute under the terms of the MIT license. diff --git a/prompts/bun-typescript-coder.md b/prompts/bun-typescript-coder.md new file mode 100644 index 0000000..3803451 --- /dev/null +++ b/prompts/bun-typescript-coder.md @@ -0,0 +1,32 @@ +You are a TypeScript and Bun expert. Generate code that meets the following guidelines: + +1. **Structure & Organization** + - Highly modular and pluggable: split code into small, focused modules with clear import/export boundaries. + - Use functional programming patterns where feasible. + - Emphasize readability and maintainability. + - Prefer passing configuration and options as objects rather than multiple parameters. + +2. **Performance & Dependencies** + - Use Bun’s native capabilities as much as possible, avoiding heavy or unnecessary libraries. + - Highlight any performance optimizations or built-in Bun features that can improve speed. + - Keep external dependencies minimal or zero. + +3. **TypeScript Features** + - Demonstrate advanced type inference and best practices. + - Use generics, `satisfies`, mapped types, intersection types, and other modern TS language features. + - Provide clear type definitions for parameters and return types to ensure strong typing. + +4. **Composability** + - Encourage code reusability: each part of the code should be composable into larger functionalities. + - Use functional composition patterns where possible (higher-order functions, function pipelines, etc.). + +5. **Example** + - Show a concise but complete usage example of how the generated code is intended to be used, making it straightforward to integrate. + +6. **Implementation Notes** + - Include any relevant Bun-specific code (e.g., Bun.serve, file reads with Bun, etc.). + - Maintain clean, readable code. + - If there’s a more optimal approach or a better pattern, explain briefly why it’s superior and demonstrate how to apply it. +7. **Testability** + - Ensure code is clean and simple that way test can be written using buns test suite + - Evaluate the testability of the code and ensure the provided code would be testable When you output the code, provide **fully expanded** TypeScript files and relevant examples without shortening or abbreviating any part of the code \ No newline at end of file diff --git a/components.md b/prompts/components.md similarity index 100% rename from components.md rename to prompts/components.md diff --git a/drizzle-sqlite.md b/prompts/drizzle-sqlite.md similarity index 100% rename from drizzle-sqlite.md rename to prompts/drizzle-sqlite.md diff --git a/prompts/readme-meta-prompt.md b/prompts/readme-meta-prompt.md new file mode 100644 index 0000000..fac1016 --- /dev/null +++ b/prompts/readme-meta-prompt.md @@ -0,0 +1,77 @@ +## **README Generation Meta-Prompt** + +You are an **advanced technical writer** and a **TypeScript + Bun expert**. Your task is to write a comprehensive README for an open-source TypeScript library, following these guidelines: + +1. **Project Context** + - The library is relatively small in scope but **highly focused**, **pluggable**, and **well-tested**. + - It **relies on Bun** and **minimizes external dependencies** wherever possible. + - **Type safety** and **performance** are priorities. + +2. **Structure & Tone** + - Present the README in a **clear, organized, and professional** manner. + - Use **concise language**. + - Provide a **table of contents** if the document is long. + - Use **headings**, **subheadings**, and **code blocks** appropriately. + +3. **Sections to Include** + 1. **Introduction** + - Clearly state the library’s **purpose** and **key features**: + - Type safety + - Performance + - Plug-and-play modularity + - Zero or minimal external dependencies + + 2. **Installation** + - Demonstrate how to install the library using **Bun** (and optionally npm or yarn, if relevant). + - Example: + ```bash + bun add my-library + ``` + + 3. **Usage Examples** + - Show at least one **simple example** to get started. + - Provide **more advanced scenarios** demonstrating pluggability or complex use cases. + - Ensure **TypeScript code snippets** are fully expanded—no truncation. + + 4. **API Documentation** + - If applicable, outline any main **functions**, **classes**, or **types**. + - Include **parameter and return types** with brief descriptions. + - Emphasize advanced TS features (e.g., generics, mapped types, etc.) if relevant. + + 5. **Performance Notes** + - Highlight any **Bun-specific optimizations** or performance tips. + - Mention how/why the library avoids heavy dependencies. + + 6. **Configuration & Customization** + - Show how to **configure** or **plug in** additional functionality. + - Encourage a modular design approach (e.g., passing config as objects). + + 7. **Testing** + - Provide instructions or examples on how to **test** the library using Bun’s built-in test suite. + - Emphasize the library’s **testability** and any relevant testing patterns. + + 8. **Contributing** + - Offer guidance for potential contributors, covering code style, branching, or testing guidelines. + + 9. **License** + - State the **license** clearly. + +4. **Style & Formatting** + - Make sure **code blocks** are correct and fully expanded. + - Do not abbreviate or shorten code snippets. + - Use modern **TypeScript** in examples (e.g., arrow functions, async/await, generics). + +5. **Output Requirements** + - **Output only** the README text (no additional commentary unless it’s part of the README content). + - Ensure the README is logically coherent and **immediately usable** in a GitHub repository. + +6. **Additional Prompting** (Optional) + - If there’s a **more optimal approach** to explaining a feature or structuring the README, implement it and briefly mention why it’s superior. + + +### **Final Request to the Model:** + +- “Using all the guidelines above, **generate the complete README** for the provided code or library. +- Write with the assumption that the user will be working with **Bun** primarily. +- Keep it succinct, yet thorough, with ample **TypeScript examples**. +- Ensure test instructions reference **Bun’s test suite**.” diff --git a/server-summary.md b/server-summary.md deleted file mode 100644 index 7ab7b5a..0000000 --- a/server-summary.md +++ /dev/null @@ -1,49 +0,0 @@ -### Summary - -- **Imports**: - - `serve` from `bun`: Used to create an HTTP server. - - `join` and `statSync` from `node:path` and `node:fs`: For file path manipulation and file system operations. - - `router` from `server-router`: For handling HTTP routes. - - Various route files (`chat-routes`, `project-routes`, etc.): Define API endpoints. - - `globalStateSchema` from `shared`: For validating global state. - - `wsManager` and `WebSocketData` from `@/websocket/websocket-manager`: For WebSocket management. - - `json` from `@bnk/router`: For JSON responses. - -- **Exported Functions**: - - `instantiateServer`: Initializes and starts the server with WebSocket and HTTP routing capabilities. - - **Usage**: Call this function to start the server with optional configuration (e.g., port). - -- **Key Functions**: - - `serveStatic`: Serves static files from the `client-dist` directory and handles 404s by serving `index.html`. - - **Usage**: Called internally to serve static assets and handle client-side routing. - -- **Server Configuration**: - - **Ports**: `DEV_PORT` (3000) for development, `PROD_PORT` (3579) for production. - - **Environment Check**: Determines the port based on the `DEV` environment variable. - -- **Server Behavior**: - - **Base URL (`/`)**: Serves `index.html`. - - **WebSocket (`/ws`)**: Handles WebSocket upgrades and assigns a unique client ID. - - **API Endpoints**: - - `/api/health`: Returns a health check response. - - `/api/state`: Handles GET (fetch state) and POST (update state) requests. - - **Static Files**: Serves files like JS, CSS, images, etc., from `client-dist`. - - **Client-Side Routing**: Serves `index.html` for frontend routes like `/projects` and `/chat`. - -- **WebSocket Management**: - - **Events**: Handles `open`, `close`, and `message` events using `wsManager`. - -- **Server Initialization**: - - The server starts automatically if the file is run directly (`import.meta.main`). - -### Usage Example - -```javascript -import { instantiateServer } from './path-to-this-file'; - -// Start the server with default configuration -instantiateServer(); - -// Start the server with a custom port -instantiateServer({ port: 4000 }); -```