diff --git a/README.md b/README.md index 3f2d256..d575d16 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,31 @@ https://relay.byokrelay.com Free to use. Open CORS (any origin). [Health check →](https://relay.byokrelay.com/health) +## Svelte stores + +```bash +npm install @byok-relay/svelte +``` + +```svelte + + +{#if $chat.isStreaming} +

{$chat.streamingContent}

+ +{/if} +``` + +Four stores: `createByokRelayStore` · `createChatStore` · `createStreamingChatStore` · `createRelayHealthStore`. SvelteKit SSR-safe. See [`packages/svelte`](./packages/svelte). + ## For AI coding agents If you're using a coding agent (Cursor, Claude Code, Copilot, Codex, etc.), install the skill and let it handle the integration: diff --git a/llms.txt b/llms.txt index fb7ae21..7421267 100644 --- a/llms.txt +++ b/llms.txt @@ -73,6 +73,62 @@ const TOKEN = await fetch('https://your-relay.com/users', { Apache 2.0 — free to self-host or build on top of. +## Svelte Stores (`@byok-relay/svelte`) + +For Svelte and SvelteKit apps: + +```bash +npm install @byok-relay/svelte +``` + +```svelte + + +``` + +Stores: `createByokRelayStore` · `createChatStore` · `createStreamingChatStore` · `createRelayHealthStore`. +SSR-safe for SvelteKit. npm: https://www.npmjs.com/package/@byok-relay/svelte + +## React Hooks (`@byok-relay/react`) + +```bash +npm install @byok-relay/react +``` + +Hooks: `useByokRelay` · `useChat` · `useStreamingChat` · `useRelayHealth`. +npm: https://www.npmjs.com/package/@byok-relay/react + +## Vue Composables (`@byok-relay/vue`) + +```bash +npm install @byok-relay/vue +``` + +Composables: `useByokRelay` · `useChat` · `useStreamingChat` · `useRelayHealth`. +npm: https://www.npmjs.com/package/@byok-relay/vue + +## MCP Server (`@byok-relay/mcp`) + +For Claude Desktop, Claude Code, Cursor, Windsurf: + +```bash +npx -y @byok-relay/mcp +``` + +Tools: `byok_relay_health` · `byok_relay_register` · `byok_relay_store_key` · `byok_relay_request` · `byok_relay_chat` · `byok_relay_stats`. +npm: https://www.npmjs.com/package/@byok-relay/mcp + +## API Reference + +OpenAPI 3.0 spec (machine-readable): https://relay.byokrelay.com/openapi.json +YAML: https://relay.byokrelay.com/openapi.yaml + ## Links - GitHub: https://github.com/avikalpg/byok-relay diff --git a/package.json b/package.json index 747063c..76b8595 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "byok-relay", "version": "1.0.1", - "description": "BYOK relay server — stores user API keys encrypted and proxies requests to AI providers", + "description": "BYOK relay server \u2014 stores user API keys encrypted and proxies requests to AI providers", "main": "src/index.js", "scripts": { "start": "node src/index.js", @@ -19,5 +19,8 @@ "engines": { "node": ">=18" }, - "license": "Apache-2.0" + "license": "Apache-2.0", + "workspaces": [ + "packages/*" + ] } diff --git a/packages/svelte/README.md b/packages/svelte/README.md new file mode 100644 index 0000000..b6f0c1b --- /dev/null +++ b/packages/svelte/README.md @@ -0,0 +1,376 @@ +# @byok-relay/svelte + +> Svelte stores for [byok-relay](https://byokrelay.com) — drop-in BYOK AI in any Svelte or SvelteKit app. + +[![npm](https://img.shields.io/npm/v/@byok-relay/svelte)](https://www.npmjs.com/package/@byok-relay/svelte) +[![license](https://img.shields.io/github/license/avikalpg/byok-relay)](../../LICENSE) + +**Let your users bring their own OpenAI/Anthropic/Groq API keys. Zero backend code. Full streaming.** + +--- + +## Install + +```bash +npm install @byok-relay/svelte +``` + +Or use the managed relay without installing anything — just import from a CDN: + +```html + +``` + +--- + +## Quick start + +```svelte + + + +{#if $relay.isRegistered} + + + {#if saved}

Key saved ✓

{/if} +{:else} + +{/if} +{#if $relay.error}

{$relay.error}

{/if} +``` + +--- + +## Stores + +### `createByokRelayStore(opts)` + +Core store — manages relay token registration and encrypted API key storage. + +**Options** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `relayUrl` | `string` | `https://relay.byokrelay.com` | Relay base URL (use your own for self-hosted) | +| `appId` | `string` | required | App identifier — used to namespace tokens in localStorage | + +**State** (`$relay`) + +| Field | Type | Description | +|-------|------|-------------| +| `token` | `string \| null` | Relay token (persisted in localStorage) | +| `isRegistered` | `boolean` | `true` when token is present | +| `error` | `string \| null` | Last error message, or `null` | + +**Methods** + +| Method | Description | +|--------|-------------| +| `relay.register()` | Register a new relay token (or reload from localStorage) | +| `relay.storeKey(provider, apiKey)` | Store an encrypted API key for a provider | +| `relay.deleteKey(provider)` | Delete the stored key for a provider | +| `relay.listProviders()` | Returns `string[]` of providers with stored keys | +| `relay.logout()` | Clear token and state (does not delete stored keys) | + +--- + +### `createChatStore(opts)` + +Non-streaming chat — stateful message list for any supported provider. + +**Options** + +| Option | Default | Description | +|--------|---------|-------------| +| `relayUrl` | `https://relay.byokrelay.com` | Relay base URL | +| `appId` | required | App identifier | +| `provider` | `'openai'` | AI provider | +| `model` | provider default | Model override | +| `systemPrompt` | `undefined` | System prompt | +| `extraParams` | `{}` | Extra body params forwarded on every request | + +**State** (`$chat`) + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `Array<{role, content}>` | Full conversation history | +| `loading` | `boolean` | `true` while request is in-flight | +| `error` | `string \| null` | Last error message | + +**Methods** + +| Method | Description | +|--------|-------------| +| `chat.send(content, opts?)` | Send a user message; appends assistant reply when done | +| `chat.clear()` | Reset messages, loading, error | + +**Full example** + +```svelte + + +
+ {#each $chat.messages as msg} +
{msg.content}
+ {/each} + {#if $chat.loading}
Thinking…
{/if} + {#if $chat.error}
{$chat.error}
{/if} +
+ +
+ + +
+``` + +--- + +### `createStreamingChatStore(opts)` + +SSE streaming chat — live token streaming with AbortController cancel support. + +Same options as `createChatStore`. + +**State** (`$chat`) + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `Array<{role, content}>` | Completed messages | +| `streamingContent` | `string` | Live content being streamed (empty when not streaming) | +| `isStreaming` | `boolean` | `true` while SSE stream is open | +| `error` | `string \| null` | Last error message | + +**Methods** + +| Method | Description | +|--------|-------------| +| `chat.send(content, opts?)` | Send a user message; streams reply in real time | +| `chat.stopStreaming()` | Abort the active stream (partial reply committed to `messages`) | +| `chat.clear()` | Stop streaming + reset all state | + +**Full example** + +```svelte + + +
+ {#each $chat.messages as msg} +
{msg.content}
+ {/each} + + {#if $chat.isStreaming} +
+ {$chat.streamingContent} +
+ + {/if} + + {#if $chat.error}
{$chat.error}
{/if} +
+ +
{ chat.send(input); input = ''; }}> + + +
+``` + +--- + +### `createRelayHealthStore(opts)` + +Polls `/health` on the relay — tracks liveness and readiness. + +**Options** + +| Option | Default | Description | +|--------|---------|-------------| +| `relayUrl` | `https://relay.byokrelay.com` | Relay base URL | +| `pollIntervalMs` | `30000` | Polling interval ms (`0` = no polling) | +| `deep` | `false` | If `true`, also pings upstream provider | +| `provider` | `undefined` | Provider to deep-check (e.g. `'openai'`) | + +**State** (`$health`) + +| Field | Type | Description | +|-------|------|-------------| +| `status` | `string` | `'ok'` \| `'error'` \| `'unreachable'` \| `'unknown'` | +| `ok` | `boolean` | `true` when relay is healthy | +| `uptime` | `number \| null` | Relay process uptime in seconds | +| `warnings` | `string[]` | Non-fatal warnings from the relay | +| `error` | `string \| null` | Network error message | + +**Methods** + +| Method | Description | +|--------|-------------| +| `health.refetch()` | Manually trigger a health check | +| `health.destroy()` | Stop the polling timer (call in `onDestroy`) | + +**Example** + +```svelte + + + + {$health.ok ? '● Relay live' : '● Relay down'} + +{#if $health.warnings.length} + +{/if} +``` + +--- + +## Supported providers + +| Provider | `provider` value | Notes | +|----------|-----------------|-------| +| OpenAI | `'openai'` | GPT-4o, GPT-4o-mini, o1, o3, … | +| Anthropic | `'anthropic'` | Claude 3, Claude 3.5, Claude 4, … | +| Groq | `'groq'` | Llama 3, Mixtral, … | +| Mistral | `'mistral'` | Mistral 7B, Mixtral, … | +| OpenRouter | `'openrouter'` | Any model via OpenRouter | +| Google | `'google'` | Gemini (pass model as `models/gemini-pro`) | + +--- + +## SvelteKit usage + +All stores are SSR-safe. localStorage access is guarded by `typeof window !== 'undefined'`. + +**+page.svelte with onMount** + +```svelte + +``` + +**Self-hosted relay** (set `relayUrl` to your own instance): + +```svelte + +``` + +--- + +## Svelte 5 / Runes + +These stores work unchanged in Svelte 5 via the `$` auto-subscription syntax. +For Svelte 5 rune-based reactivity, wrap with `$derived`: + +```svelte + +``` + +--- + +## Self-hosting + +To run your own relay: + +```bash +docker run -p 3000:3000 \ + -e ENCRYPTION_SECRET=your-secret-min-32-chars \ + -e ALLOWED_ORIGINS=https://yourapp.com \ + ghcr.io/avikalpg/byok-relay +``` + +Then pass `relayUrl: 'http://localhost:3000'` to each store factory. + +See [byok-relay](https://github.com/avikalpg/byok-relay) for full self-hosting docs. + +--- + +## Related packages + +| Package | Description | +|---------|-------------| +| [`@byok-relay/client`](https://www.npmjs.com/package/@byok-relay/client) | Vanilla JS / Node.js client | +| [`@byok-relay/react`](https://www.npmjs.com/package/@byok-relay/react) | React hooks | +| [`@byok-relay/vue`](https://www.npmjs.com/package/@byok-relay/vue) | Vue 3 composables | +| [`@byok-relay/mcp`](https://www.npmjs.com/package/@byok-relay/mcp) | MCP server for Claude Desktop | + +--- + +If this saved you time, consider [⭐ starring the repo](https://github.com/avikalpg/byok-relay). diff --git a/packages/svelte/package.json b/packages/svelte/package.json new file mode 100644 index 0000000..a15c135 --- /dev/null +++ b/packages/svelte/package.json @@ -0,0 +1,54 @@ +{ + "name": "@byok-relay/svelte", + "version": "1.0.0", + "description": "Svelte stores for byok-relay — drop-in BYOK AI in any Svelte or SvelteKit app", + "keywords": [ + "byok", + "ai", + "svelte", + "sveltekit", + "stores", + "openai", + "anthropic", + "llm", + "relay", + "frontend", + "lovable", + "bolt", + "no-backend", + "api-key", + "self-hosted" + ], + "main": "src/index.js", + "module": "src/index.js", + "exports": { + ".": "./src/index.js" + }, + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "node test/stores.test.js" + }, + "peerDependencies": { + "svelte": ">=3.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + }, + "devDependencies": {}, + "repository": { + "type": "git", + "url": "https://github.com/avikalpg/byok-relay.git", + "directory": "packages/svelte" + }, + "homepage": "https://byokrelay.com", + "bugs": { + "url": "https://github.com/avikalpg/byok-relay/issues" + }, + "license": "MIT", + "author": "avikalpg" +} diff --git a/packages/svelte/src/index.js b/packages/svelte/src/index.js new file mode 100644 index 0000000..17f8757 --- /dev/null +++ b/packages/svelte/src/index.js @@ -0,0 +1,610 @@ +/** + * @byok-relay/svelte + * + * Svelte stores for byok-relay — drop-in BYOK AI in any Svelte or SvelteKit app. + * + * Usage: + * import { + * createByokRelayStore, + * createChatStore, + * createStreamingChatStore, + * createRelayHealthStore + * } from '@byok-relay/svelte'; + * + * No build step required. Svelte peer dep optional — stores work in plain JS too. + * For SvelteKit SSR: stores are browser-safe (localStorage guarded by `typeof window`). + */ + +'use strict'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const DEFAULT_RELAY_URL = 'https://relay.byokrelay.com'; + +const PROVIDER_PATHS = { + openai: 'chat/completions', + anthropic: 'messages', + google: 'models/{model}:generateContent', + groq: 'chat/completions', + mistral: 'chat/completions', + openrouter: 'chat/completions', +}; + +// ─── Store factory helpers ──────────────────────────────────────────────────── + +/** + * Create a minimal Svelte-compatible writable store. + * Works with Svelte's `$store` auto-subscription syntax and plain JS `.subscribe()`. + */ +function writable(initial) { + // Try to use Svelte's writable if available (tree-shaken out when not bundled with Svelte) + if (typeof globalThis !== 'undefined' && globalThis.__svelteStoreWritable) { + return globalThis.__svelteStoreWritable(initial); + } + + let value = initial; + const subscribers = new Set(); + + function subscribe(run, invalidate = () => {}) { + subscribers.add(run); + run(value); + return () => subscribers.delete(run); + } + + function set(newValue) { + value = newValue; + subscribers.forEach(run => run(value)); + } + + function update(fn) { + set(fn(value)); + } + + function get() { + return value; + } + + return { subscribe, set, update, get }; +} + +// ─── Storage helpers ────────────────────────────────────────────────────────── + +function isBrowser() { + return typeof window !== 'undefined' && typeof localStorage !== 'undefined'; +} + +function storageGet(key) { + try { return isBrowser() ? localStorage.getItem(key) : null; } + catch { return null; } +} + +function storageSet(key, value) { + try { if (isBrowser()) localStorage.setItem(key, value); } + catch { /* ignore */ } +} + +function storageRemove(key) { + try { if (isBrowser()) localStorage.removeItem(key); } + catch { /* ignore */ } +} + +// ─── SSE parsing ───────────────────────────────────────────────────────────── + +function* parseSSE(chunk) { + const lines = chunk.split('\n'); + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (data === '[DONE]') { yield null; continue; } + try { yield JSON.parse(data); } catch { /* skip malformed */ } + } +} + +function extractDelta(event, provider) { + if (!event) return ''; + if (provider === 'anthropic') { + if (event.type === 'content_block_delta') return event.delta?.text || ''; + return ''; + } + // OpenAI-style + return event.choices?.[0]?.delta?.content || ''; +} + +// ─── createByokRelayStore ───────────────────────────────────────────────────── + +/** + * Core store — manages relay token registration and API key CRUD. + * + * @param {object} opts + * @param {string} [opts.relayUrl] Relay base URL (default: https://relay.byokrelay.com) + * @param {string} opts.appId Your app identifier (used for token namespacing in localStorage) + * + * @returns {{ + * subscribe: Function, // Svelte store subscribe — state: { token, isRegistered, error } + * register: () => Promise, + * storeKey: (provider: string, apiKey: string) => Promise, + * deleteKey: (provider: string) => Promise, + * listProviders: () => Promise, + * logout: () => void + * }} + * + * @example + * // +page.svelte + * + * {#if $relay.isRegistered} + *

Connected ✓

+ * {:else} + * + * {/if} + * {#if $relay.error}

{$relay.error}

{/if} + */ +function createByokRelayStore({ relayUrl = DEFAULT_RELAY_URL, appId } = {}) { + const tokenKey = `byok_relay_token_${appId}`; + const storedToken = storageGet(tokenKey); + + const store = writable({ + token: storedToken, + isRegistered: Boolean(storedToken), + error: null, + }); + + function _patch(patch) { + store.update(s => ({ ...s, ...patch })); + } + + function _getToken() { + return store.get().token; + } + + async function register() { + _patch({ error: null }); + try { + const res = await fetch(`${relayUrl}/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: appId }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `Registration failed (${res.status})`); + const token = data.token; + storageSet(tokenKey, token); + _patch({ token, isRegistered: true, error: null }); + } catch (err) { + _patch({ error: err.message }); + throw err; + } + } + + async function storeKey(provider, apiKey) { + const token = _getToken(); + if (!token) throw new Error('Not registered — call register() first'); + _patch({ error: null }); + try { + const res = await fetch(`${relayUrl}/keys/${provider}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ api_key: apiKey }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `Failed to store key (${res.status})`); + } catch (err) { + _patch({ error: err.message }); + throw err; + } + } + + async function deleteKey(provider) { + const token = _getToken(); + if (!token) throw new Error('Not registered'); + _patch({ error: null }); + try { + const res = await fetch(`${relayUrl}/keys/${provider}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Failed to delete key (${res.status})`); + } + } catch (err) { + _patch({ error: err.message }); + throw err; + } + } + + async function listProviders() { + const token = _getToken(); + if (!token) return []; + try { + const res = await fetch(`${relayUrl}/keys`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!res.ok) return []; + const data = await res.json(); + return data.providers || []; + } catch { + return []; + } + } + + function logout() { + storageRemove(tokenKey); + _patch({ token: null, isRegistered: false, error: null }); + } + + return { + subscribe: store.subscribe, + register, + storeKey, + deleteKey, + listProviders, + logout, + }; +} + +// ─── createChatStore ────────────────────────────────────────────────────────── + +/** + * Non-streaming chat store — stateful message list for any provider. + * + * @param {object} opts + * @param {string} [opts.relayUrl] Relay base URL + * @param {string} opts.appId App identifier + * @param {string} [opts.provider] Default provider ('openai' | 'anthropic' | 'groq' | 'mistral' | 'openrouter') + * @param {string} [opts.model] Default model override + * @param {string} [opts.systemPrompt] System prompt + * @param {object} [opts.extraParams] Extra body params forwarded on every request + * + * @returns {{ + * subscribe: Function, // state: { messages, loading, error } + * send: (content: string, opts?: { provider?, model?, extraParams? }) => Promise, + * clear: () => void, + * }} + * + * @example + * + * {#each $chat.messages as msg} + *
{msg.content}
+ * {/each} + * e.key === 'Enter' && submit()} /> + */ +function createChatStore({ + relayUrl = DEFAULT_RELAY_URL, + appId, + provider: defaultProvider = 'openai', + model: defaultModel, + systemPrompt, + extraParams = {}, +} = {}) { + const tokenKey = `byok_relay_token_${appId}`; + + const store = writable({ messages: [], loading: false, error: null }); + + function _patch(patch) { + store.update(s => ({ ...s, ...patch })); + } + + async function send(content, opts = {}) { + const provider = opts.provider || defaultProvider; + const model = opts.model || defaultModel; + const extra = { ...extraParams, ...(opts.extraParams || {}) }; + const token = storageGet(tokenKey); + + if (!token) { _patch({ error: 'Not registered — call relay.register() first' }); return; } + + _patch({ error: null, loading: true }); + store.update(s => ({ + ...s, + messages: [...s.messages, { role: 'user', content }], + })); + + try { + const path = (PROVIDER_PATHS[provider] || 'chat/completions').replace('{model}', model || ''); + let body; + + if (provider === 'anthropic') { + body = { + model: model || 'claude-3-haiku-20240307', + max_tokens: 1024, + messages: store.get().messages.map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })), + ...(systemPrompt ? { system: systemPrompt } : {}), + ...extra, + }; + } else { + const msgs = []; + if (systemPrompt) msgs.push({ role: 'system', content: systemPrompt }); + msgs.push(...store.get().messages.map(m => ({ role: m.role, content: m.content }))); + body = { model: model || 'gpt-4o-mini', messages: msgs, ...extra }; + } + + const res = await fetch(`${relayUrl}/relay/${provider}/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`); + + const reply = + provider === 'anthropic' + ? data.content?.[0]?.text || '' + : data.choices?.[0]?.message?.content || ''; + + store.update(s => ({ + ...s, + loading: false, + messages: [...s.messages, { role: 'assistant', content: reply }], + })); + } catch (err) { + _patch({ loading: false, error: err.message }); + } + } + + function clear() { + store.set({ messages: [], loading: false, error: null }); + } + + return { subscribe: store.subscribe, send, clear }; +} + +// ─── createStreamingChatStore ───────────────────────────────────────────────── + +/** + * SSE streaming chat store — live token streaming with AbortController cancel. + * + * @param {object} opts + * @param {string} [opts.relayUrl] + * @param {string} opts.appId + * @param {string} [opts.provider] + * @param {string} [opts.model] + * @param {string} [opts.systemPrompt] + * @param {object} [opts.extraParams] + * + * @returns {{ + * subscribe: Function, // state: { messages, streamingContent, isStreaming, error } + * send: (content: string, opts?) => Promise, + * stopStreaming: () => void, + * clear: () => void, + * }} + * + * @example + * + * {#each $chat.messages as msg} + *
{msg.content}
+ * {/each} + * {#if $chat.isStreaming} + *
{$chat.streamingContent}
+ * + * {/if} + * e.key === 'Enter' && chat.send(input)} /> + */ +function createStreamingChatStore({ + relayUrl = DEFAULT_RELAY_URL, + appId, + provider: defaultProvider = 'openai', + model: defaultModel, + systemPrompt, + extraParams = {}, +} = {}) { + const tokenKey = `byok_relay_token_${appId}`; + + const store = writable({ + messages: [], + streamingContent: '', + isStreaming: false, + error: null, + }); + + let _controller = null; + + function _patch(patch) { + store.update(s => ({ ...s, ...patch })); + } + + async function send(content, opts = {}) { + const provider = opts.provider || defaultProvider; + const model = opts.model || defaultModel; + const extra = { ...extraParams, ...(opts.extraParams || {}) }; + const token = storageGet(tokenKey); + + if (!token) { _patch({ error: 'Not registered — call relay.register() first' }); return; } + if (store.get().isStreaming) stopStreaming(); + + _patch({ error: null, isStreaming: true, streamingContent: '' }); + store.update(s => ({ + ...s, + messages: [...s.messages, { role: 'user', content }], + })); + + _controller = new AbortController(); + + try { + const path = (PROVIDER_PATHS[provider] || 'chat/completions').replace('{model}', model || ''); + let body; + + if (provider === 'anthropic') { + body = { + model: model || 'claude-3-haiku-20240307', + max_tokens: 1024, + stream: true, + messages: store.get().messages.map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })), + ...(systemPrompt ? { system: systemPrompt } : {}), + ...extra, + }; + } else { + const msgs = []; + if (systemPrompt) msgs.push({ role: 'system', content: systemPrompt }); + msgs.push(...store.get().messages.map(m => ({ role: m.role, content: m.content }))); + body = { model: model || 'gpt-4o-mini', messages: msgs, stream: true, ...extra }; + } + + const res = await fetch(`${relayUrl}/relay/${provider}/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify(body), + signal: _controller.signal, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Request failed (${res.status})`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let full = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop(); // keep incomplete line + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const raw = line.slice(5).trim(); + if (raw === '[DONE]') break; + try { + const event = JSON.parse(raw); + const delta = extractDelta(event, provider); + if (delta) { + full += delta; + _patch({ streamingContent: full }); + } + } catch { /* skip malformed */ } + } + } + + store.update(s => ({ + ...s, + isStreaming: false, + streamingContent: '', + messages: [...s.messages, { role: 'assistant', content: full }], + })); + } catch (err) { + if (err.name === 'AbortError') { + // User stopped — commit whatever was streamed + const partial = store.get().streamingContent; + store.update(s => ({ + ...s, + isStreaming: false, + streamingContent: '', + messages: partial + ? [...s.messages, { role: 'assistant', content: partial }] + : s.messages, + })); + } else { + _patch({ isStreaming: false, streamingContent: '', error: err.message }); + } + } finally { + _controller = null; + } + } + + function stopStreaming() { + if (_controller) { _controller.abort(); _controller = null; } + } + + function clear() { + stopStreaming(); + store.set({ messages: [], streamingContent: '', isStreaming: false, error: null }); + } + + return { subscribe: store.subscribe, send, stopStreaming, clear }; +} + +// ─── createRelayHealthStore ─────────────────────────────────────────────────── + +/** + * Health polling store — tracks relay liveness and readiness. + * + * @param {object} opts + * @param {string} [opts.relayUrl] Relay base URL + * @param {number} [opts.pollIntervalMs] Polling interval ms (default: 30 000; 0 = no polling) + * @param {boolean}[opts.deep] If true, also pings upstream provider (/health?deep=1) + * @param {string} [opts.provider] Provider to deep-check (e.g. 'openai') + * + * @returns {{ + * subscribe: Function, // state: { status, ok, uptime, warnings, error } + * refetch: () => Promise, + * destroy: () => void, // call in onDestroy to stop polling + * }} + * + * @example + * + * + * {$health.ok ? '● Live' : '● Down'} + * + */ +function createRelayHealthStore({ + relayUrl = DEFAULT_RELAY_URL, + pollIntervalMs = 30_000, + deep = false, + provider, +} = {}) { + const store = writable({ status: 'unknown', ok: false, uptime: null, warnings: [], error: null }); + let _timer = null; + + async function refetch() { + try { + let url = `${relayUrl}/health`; + if (deep) { url += '?deep=1'; if (provider) url += `&provider=${provider}`; } + const res = await fetch(url); + const data = await res.json(); + store.set({ + status: data.status || (res.ok ? 'ok' : 'error'), + ok: res.ok && data.status === 'ok', + uptime: data.uptime || null, + warnings: data.warnings || [], + error: null, + }); + } catch (err) { + store.update(s => ({ ...s, status: 'unreachable', ok: false, error: err.message })); + } + } + + refetch(); + + if (pollIntervalMs > 0) { + _timer = setInterval(refetch, pollIntervalMs); + } + + function destroy() { + if (_timer) { clearInterval(_timer); _timer = null; } + } + + return { subscribe: store.subscribe, refetch, destroy }; +} + +// ─── Exports ────────────────────────────────────────────────────────────────── + +module.exports = { + createByokRelayStore, + createChatStore, + createStreamingChatStore, + createRelayHealthStore, +}; diff --git a/packages/svelte/test/stores.test.js b/packages/svelte/test/stores.test.js new file mode 100644 index 0000000..cb1a92f --- /dev/null +++ b/packages/svelte/test/stores.test.js @@ -0,0 +1,261 @@ +/** + * @byok-relay/svelte — smoke tests + * + * Run with: node test/stores.test.js + * + * Tests run in plain Node (no Svelte build step) because all stores export + * pure JS objects with a Svelte-compatible { subscribe, set, update } interface. + */ + +'use strict'; + +// Top-level await requires wrapping in an async runner +async function runTests() { + +const { + createByokRelayStore, + createChatStore, + createStreamingChatStore, + createRelayHealthStore, +} = require('../src/index.js'); + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + console.log(` ✅ ${label}`); + passed++; + } else { + console.error(` ❌ FAIL: ${label}`); + failed++; + } +} + +function assertThrows(fn, label) { + try { + fn(); + console.error(` ❌ FAIL (no throw): ${label}`); + failed++; + } catch { + console.log(` ✅ ${label}`); + passed++; + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Read current store value synchronously. */ +function get(store) { + let v; + const unsub = store.subscribe(val => { v = val; }); + unsub(); + return v; +} + +// ─── createByokRelayStore ────────────────────────────────────────────────────── + +console.log('\n── createByokRelayStore ─────────────────────────────────────────'); + +{ + const relay = createByokRelayStore({ appId: 'test-svelte' }); + + assert(typeof relay.subscribe === 'function', 'has subscribe'); + assert(typeof relay.register === 'function', 'has register()'); + assert(typeof relay.storeKey === 'function', 'has storeKey()'); + assert(typeof relay.deleteKey === 'function', 'has deleteKey()'); + assert(typeof relay.listProviders === 'function', 'has listProviders()'); + assert(typeof relay.logout === 'function', 'has logout()'); + + const state = get(relay); + assert(typeof state === 'object', 'initial state is object'); + assert('token' in state, 'state has token'); + assert('isRegistered' in state, 'state has isRegistered'); + assert('error' in state, 'state has error'); + assert(state.error === null, 'initial error is null'); + assert(typeof state.isRegistered === 'boolean', 'isRegistered is boolean'); +} + +{ + // logout clears state + const relay = createByokRelayStore({ appId: 'test-logout-svelte' }); + relay.logout(); + const state = get(relay); + assert(state.token === null, 'logout clears token'); + assert(state.isRegistered === false,'logout clears isRegistered'); +} + +{ + // subscribe fires immediately with current value + const relay = createByokRelayStore({ appId: 'test-sub-svelte' }); + let fired = 0; + const unsub = relay.subscribe(() => fired++); + assert(fired === 1, 'subscribe fires immediately'); + unsub(); +} + +{ + // storeKey rejects if not registered + const relay = createByokRelayStore({ appId: 'test-store-key-svelte' }); + relay.logout(); // ensure no token + let threw = false; + relay.storeKey('openai', 'sk-test').catch(() => { threw = true; }); + // give microtask a tick + await new Promise(r => setTimeout(r, 10)); + assert(threw, 'storeKey throws if not registered'); +} + +// ─── createChatStore ─────────────────────────────────────────────────────────── + +console.log('\n── createChatStore ──────────────────────────────────────────────'); + +{ + const chat = createChatStore({ appId: 'test-chat-svelte', provider: 'openai' }); + + assert(typeof chat.subscribe === 'function', 'has subscribe'); + assert(typeof chat.send === 'function', 'has send()'); + assert(typeof chat.clear === 'function', 'has clear()'); + + const state = get(chat); + assert(Array.isArray(state.messages), 'messages is array'); + assert(state.messages.length === 0, 'messages starts empty'); + assert(state.loading === false, 'loading starts false'); + assert(state.error === null, 'error starts null'); +} + +{ + // clear resets state + const chat = createChatStore({ appId: 'test-clear-svelte', provider: 'openai' }); + // Manually inject a message by reaching into store update (simulate prior conversation) + chat.clear(); + const state = get(chat); + assert(state.messages.length === 0, 'clear empties messages'); +} + +{ + // send without token sets error (no token in localStorage for test-notoken-svelte) + const chat = createChatStore({ appId: 'test-notoken-svelte', provider: 'openai' }); + await chat.send('hello'); + const state = get(chat); + assert(typeof state.error === 'string', 'send without token sets error string'); + assert(state.loading === false, 'loading false after error'); +} + +// ─── createStreamingChatStore ───────────────────────────────────────────────── + +console.log('\n── createStreamingChatStore ─────────────────────────────────────'); + +{ + const chat = createStreamingChatStore({ appId: 'test-stream-svelte', provider: 'openai' }); + + assert(typeof chat.subscribe === 'function', 'has subscribe'); + assert(typeof chat.send === 'function', 'has send()'); + assert(typeof chat.stopStreaming === 'function', 'has stopStreaming()'); + assert(typeof chat.clear === 'function', 'has clear()'); + + const state = get(chat); + assert(Array.isArray(state.messages), 'messages is array'); + assert(state.messages.length === 0, 'messages starts empty'); + assert(state.isStreaming === false, 'isStreaming starts false'); + assert(state.streamingContent === '', 'streamingContent starts empty string'); + assert(state.error === null, 'error starts null'); +} + +{ + // stopStreaming is a no-op when not streaming + const chat = createStreamingChatStore({ appId: 'test-stop-svelte' }); + chat.stopStreaming(); // should not throw + assert(true, 'stopStreaming no-op when idle'); +} + +{ + // clear resets all state + const chat = createStreamingChatStore({ appId: 'test-stream-clear-svelte' }); + chat.clear(); + const s = get(chat); + assert(s.messages.length === 0, 'clear empties messages'); + assert(s.isStreaming === false, 'clear sets isStreaming false'); + assert(s.streamingContent === '', 'clear clears streamingContent'); +} + +{ + // send without token sets error + const chat = createStreamingChatStore({ appId: 'test-stream-notoken-svelte', provider: 'anthropic' }); + await chat.send('hi'); + const s = get(chat); + assert(typeof s.error === 'string', 'send without token sets error'); + assert(s.isStreaming === false, 'isStreaming false after error'); +} + +// ─── createRelayHealthStore ─────────────────────────────────────────────────── + +console.log('\n── createRelayHealthStore ───────────────────────────────────────'); + +{ + // No polling + const health = createRelayHealthStore({ relayUrl: 'http://localhost:19999', pollIntervalMs: 0 }); + + assert(typeof health.subscribe === 'function', 'has subscribe'); + assert(typeof health.refetch === 'function', 'has refetch()'); + assert(typeof health.destroy === 'function', 'has destroy()'); + + // Initial state + const init = get(health); + assert('status' in init, 'state has status'); + assert('ok' in init, 'state has ok'); + assert('warnings' in init, 'state has warnings'); + assert('error' in init, 'state has error'); + + // Wait for the initial fetch to fail (unreachable host) + await new Promise(r => setTimeout(r, 200)); + const after = get(health); + assert(after.ok === false, 'ok=false on unreachable host'); + assert(after.status === 'unreachable', 'status=unreachable on network error'); + + health.destroy(); // stop polling (no-op since pollIntervalMs=0) +} + +{ + // destroy stops polling timer + const health = createRelayHealthStore({ relayUrl: 'http://localhost:19999', pollIntervalMs: 5_000 }); + health.destroy(); + assert(true, 'destroy() does not throw'); +} + +// ─── Svelte store contract ──────────────────────────────────────────────────── + +console.log('\n── Svelte store contract ────────────────────────────────────────'); + +{ + // All stores expose { subscribe } = valid Svelte store + const relay = createByokRelayStore({ appId: 'contract-svelte' }); + const chat = createChatStore({ appId: 'contract-svelte' }); + const stream = createStreamingChatStore({ appId: 'contract-svelte' }); + const health = createRelayHealthStore({ pollIntervalMs: 0 }); + + for (const [name, store] of [ + ['createByokRelayStore', relay], + ['createChatStore', chat], + ['createStreamingChatStore', stream], + ['createRelayHealthStore', health], + ]) { + assert(typeof store.subscribe === 'function', `${name}: subscribe is function`); + // Svelte contract: subscribe must call the callback immediately and return unsubscribe fn + let called = 0; + const unsub = store.subscribe(() => called++); + assert(called === 1, `${name}: subscribe fires immediately`); + assert(typeof unsub === 'function', `${name}: subscribe returns unsubscribe function`); + unsub(); + health.destroy(); + } +} + +// ─── Summary ───────────────────────────────────────────────────────────────── + +console.log(`\n${'─'.repeat(56)}`); +console.log(`@byok-relay/svelte smoke tests: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); + +} // end runTests + +runTests().catch(err => { console.error(err); process.exit(1); });