growth(day-23): @byok-relay/svelte — Svelte stores for BYOK AI - #61
growth(day-23): @byok-relay/svelte — Svelte stores for BYOK AI#61alokit-bot wants to merge 1 commit into
Conversation
packages/svelte/src/index.js: four stores: createByokRelayStore (token
registration + key CRUD + logout, localStorage persistence,
SvelteKit SSR-safe), createChatStore (stateful message list,
non-streaming, openai/anthropic/groq/mistral/openrouter, systemPrompt +
extraParams), createStreamingChatStore (SSE streaming with
AbortController, stopStreaming(), streamingContent live string,
partial-commit on abort), createRelayHealthStore (polls /health,
refetch(), destroy() for onDestroy cleanup, deep readiness probe).
Svelte-compatible store contract: each factory returns
{ subscribe, ...methods } — works with Svelte auto-subscription
syntax, Svelte 5 runes, and plain JS .subscribe(). Svelte peer dep
optional (marked optional in peerDependenciesMeta) — stores run in any
JS environment without a build step.
63 smoke tests passing: store shape, initial state, logout, subscribe
fires immediately, storeKey rejects without token, clear, send error
handling, stopStreaming no-op, destroy, Svelte store contract for all
four stores.
packages/svelte/README.md: full store API docs, SvelteKit usage guide,
Svelte 5 runes compatibility note, provider table, self-hosting note,
related packages table.
llms.txt: Svelte Stores section (npm install + usage snippet +
composable list + npm URL) + React Hooks, Vue Composables, MCP Server,
and API Reference sections consolidated.
README.md: Svelte stores section before 'For AI coding agents'.
package.json: workspaces: ['packages/*'] added.
Opens @byok-relay/svelte npm discovery channel targeting Svelte/SvelteKit
ecosystem.
metrics/daily.jsonl: 2026-07-02 snapshot (stars=52 forks=0 views=20
clones=110).
Next for Avi: cd packages/svelte && npm publish --access public
📝 WalkthroughWalkthroughAdds a new ChangesSvelte stores package
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Component
participant StreamingChatStore
participant RelayServer
Component->>StreamingChatStore: send(content, opts)
StreamingChatStore->>RelayServer: POST /relay/{provider}/{path}
RelayServer-->>StreamingChatStore: SSE data: chunks
StreamingChatStore->>StreamingChatStore: extractDelta, update streamingContent
StreamingChatStore-->>Component: subscribe updates
Component->>StreamingChatStore: stopStreaming()
StreamingChatStore->>StreamingChatStore: abort(), commit partial content to messages
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (7)
packages/svelte/test/stores.test.js (4)
35-44: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUnused helper
assertThrows.Defined but never invoked in the file — dead code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/svelte/test/stores.test.js` around lines 35 - 44, The helper assertThrows is dead code because it is defined in stores.test.js but never used. Remove the unused assertThrows function, and if the surrounding test file no longer needs the passed/failed tracking tied to it, clean up any related unused variables or logging in the test harness.
126-133: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMisleading "clear resets state" tests never populate prior state.
The comment at Line 129 says a message is manually injected to simulate a prior conversation, but no injection actually happens —
messagesis already empty beforeclear()runs, so the assertion is trivially true and doesn't verifyclear()'s reset behavior. The same pattern repeats for the streaming-chatclear()test at Lines 171-179.♻️ Proposed fix: actually populate state before clearing (chat store example)
const chat = createChatStore({ appId: 'test-clear-svelte', provider: 'openai' }); - // Manually inject a message by reaching into store update (simulate prior conversation) - chat.clear(); + // Populate some state via a failed send (adds a user message even without a token) + await chat.send('hello'); + chat.clear(); const state = get(chat); assert(state.messages.length === 0, 'clear empties messages');Also applies to: 171-179
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/svelte/test/stores.test.js` around lines 126 - 133, The clear() tests in createChatStore and the streaming-chat store are currently asserting on an already-empty state, so they do not verify reset behavior. Update the tests to first populate the store with non-empty prior state using the store’s public update mechanisms or helper setup, then call clear() and assert that messages are emptied afterward. Make the same fix in both clear-reset test blocks so the assertions exercise real state transitions.
194-216: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winTiming-fragile wait for unreachable-host failure.
Waiting a fixed 200ms for the automatic initial
refetch()call (triggered insidecreateRelayHealthStore) to fail is timing-dependent. Sincerefetch()is exposed on the returned store and returns a promise, awaiting it directly would be deterministic.🐛 Proposed fix using the exposed refetch()
- // Wait for the initial fetch to fail (unreachable host) - await new Promise(r => setTimeout(r, 200)); + // Explicitly await a fetch to the unreachable host + await health.refetch(); const after = get(health); assert(after.ok === false, 'ok=false on unreachable host'); assert(after.status === 'unreachable', 'status=unreachable on network error');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/svelte/test/stores.test.js` around lines 194 - 216, The test in createRelayHealthStore is timing-fragile because it waits a fixed delay for the initial fetch to fail; replace the sleep with an explicit await of the store’s exposed refetch() promise so the unreachable-host assertion in stores.test.js is deterministic. Keep the existing checks for subscribe, destroy, and the resulting ok/status state, but drive the failure through refetch() instead of relying on the automatic background fetch timing.
97-106: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winFlaky timing-based async assertion.
storeKeyreturns a rejected promise directly (perpackages/svelte/src/index.js, itthrows inside an async function when no token exists), so relying on a.catch()flag plus a fixed 10mssetTimeoutto observe the rejection is unnecessary and can be flaky under CI load.🐛 Proposed fix using direct await
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'); + let threw = false; + try { + await relay.storeKey('openai', 'sk-test'); + } catch { + threw = true; + } + assert(threw, 'storeKey throws if not registered');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/svelte/test/stores.test.js` around lines 97 - 106, The `storeKey` rejection test is using a flaky timer-based `.catch()` flag check instead of awaiting the promise directly. Update the `storeKey` assertion in `stores.test.js` to use a direct async rejection check against `createByokRelayStore(...).storeKey(...)`, matching the behavior in `packages/svelte/src/index.js` where `storeKey` throws from an async function when no token exists, and remove the fixed `setTimeout`/manual flag pattern.packages/svelte/src/index.js (3)
147-155: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winNo validation that
appIdis provided.
tokenKeyis built as`byok_relay_token_${appId}`with no guard againstappIdbeingundefined. Forgetting to passappIdsilently produces the keybyok_relay_token_undefined, which any other consumer on the same origin that also forgetsappIdwould collide with. The same pattern is repeated increateChatStore(Line 291) andcreateStreamingChatStore(Line 405).💡 Suggested fix
function createByokRelayStore({ relayUrl = DEFAULT_RELAY_URL, appId } = {}) { + if (!appId) throw new Error('createByokRelayStore: `appId` is required'); const tokenKey = `byok_relay_token_${appId}`;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/svelte/src/index.js` around lines 147 - 155, Add a required validation for appId before building any storage key in createByokRelayStore, createChatStore, and createStreamingChatStore; if appId is missing, fail fast with a clear error instead of creating a key like byok_relay_token_undefined. Update the store factory entry points to enforce the guard consistently and make sure the tokenKey/chat key generation only runs after appId has been verified.
93-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
parseSSE()is defined but never used, and diverges from the inline parser.The streaming loop in
createStreamingChatStore.send()(Lines 479-495) re-implements SSE line parsing inline instead of using this generator, and the two differ subtly:parseSSEcontinues past a[DONE]line (Line 98), while the inline loopbreaks out of theforentirely on[DONE](Line 485), discarding any remaining buffered lines in that chunk. Either wire the streaming loop to useparseSSE/extractDeltatogether, or drop the unused generator to avoid two sources of truth for SSE parsing.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/svelte/src/index.js` around lines 93 - 101, The SSE parsing logic is duplicated between parseSSE and createStreamingChatStore.send(), and they handle [DONE] differently. Update send() to use the existing parseSSE generator (and extractDelta if applicable) so there is a single source of truth, or remove parseSSE entirely if it is no longer needed. Make sure the chosen path preserves buffered chunk handling correctly and keeps the [DONE] behavior consistent across parseSSE and createStreamingChatStore.send().
39-68: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
.get()API is inconsistent with the real Svelte store contract this file tries to delegate to.The fallback store exposes a custom
.get()method (Line 63-65) that the rest of the file relies on heavily (_getToken(),send(),stopStreaming(), etc.). But Svelte's realwritable()has no.get()method — reading a store's value requires the separateget(store)helper fromsvelte/store. So ifglobalThis.__svelteStoreWritableis ever wired up to delegate to real Svelte (Lines 41-43), everystore.get()call elsewhere in this file would throwstore.get is not a function.This is currently unreachable dead code (nothing in the provided context ever sets
globalThis.__svelteStoreWritable), but it's a landmine if that hook is ever activated. Consider either removing the delegation branch entirely (simplify to always use the internal implementation, which already satisfies the Svelte store contract for$storeauto-subscription) or wrapping.get()consistently regardless of branch.💡 Suggested simplification
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;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/svelte/src/index.js` around lines 39 - 68, The writable store shim in writable() exposes a custom .get() that is used by _getToken(), send(), and stopStreaming(), but the delegation path to globalThis.__svelteStoreWritable returns a real Svelte store without that method. Make the store API consistent by either removing the delegation branch and always using the local implementation, or by ensuring the returned store always includes a compatible get() wrapper regardless of whether __svelteStoreWritable is used. Reference writable(), subscribe(), set(), update(), and get() when updating the implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/svelte/package.json`:
- Around line 22-26: The package metadata is advertising the wrong module format
because the `module` field in `packages/svelte/package.json` points to
`src/index.js`, which is CommonJS in `src/index.js` rather than an ESM build.
Update the `module` entry used alongside `main` and `exports` so it either
points to a real ESM bundle or is removed entirely if no ESM build exists,
keeping the package entry points consistent for bundlers.
In `@packages/svelte/README.md`:
- Around line 305-317: The Svelte README example is missing the import for
createRelayHealthStore, so the snippet will fail as written. Update the import
statement in the example to include createRelayHealthStore alongside
createByokRelayStore and createStreamingChatStore, and ensure the snippet
remains consistent with the createRelayHealthStore usage in the health variable
initialization.
In `@packages/svelte/src/index.js`:
- Around line 24-31: The `google` provider is exposed in `PROVIDER_PATHS` but
the chat flow still only handles `anthropic` and the OpenAI-style fallback, so
Gemini requests/responses are silently mis-shaped. Update
`createChatStore.send()`, `createStreamingChatStore.send()`, and
`extractDelta()` to either add a `google`/Gemini-specific branch that sends and
parses the correct payload shape, or remove `PROVIDER_PATHS.google` and the
related provider support until it is implemented. Ensure the provider list and
the JSDoc/type surface stay aligned with the actual supported symbols.
- Around line 420-521: The send() flow can let an aborted prior request
overwrite the state of a newer active stream. Add a per-request generation/token
inside send() and compare it before applying any async state changes in the
catch/finally paths and after the fetch/read loop. In particular, guard the
AbortError branch and the non-abort error branch so stale callbacks do not reset
isStreaming or streamingContent for a newer request. Use the send(),
stopStreaming(), _controller, and store.update/_patch paths to locate the fix.
In `@README.md`:
- Around line 33-36: The Svelte README examples call relay.register() directly
from onMount, but register() is async and can reject, causing an unhandled
rejection. Update the example in README.md and the repeated snippet in
packages/svelte/README.md to handle the returned promise explicitly with a
.catch(...) on relay.register(), using the existing createByokRelayStore and
onMount example so failures are safely handled.
---
Nitpick comments:
In `@packages/svelte/src/index.js`:
- Around line 147-155: Add a required validation for appId before building any
storage key in createByokRelayStore, createChatStore, and
createStreamingChatStore; if appId is missing, fail fast with a clear error
instead of creating a key like byok_relay_token_undefined. Update the store
factory entry points to enforce the guard consistently and make sure the
tokenKey/chat key generation only runs after appId has been verified.
- Around line 93-101: The SSE parsing logic is duplicated between parseSSE and
createStreamingChatStore.send(), and they handle [DONE] differently. Update
send() to use the existing parseSSE generator (and extractDelta if applicable)
so there is a single source of truth, or remove parseSSE entirely if it is no
longer needed. Make sure the chosen path preserves buffered chunk handling
correctly and keeps the [DONE] behavior consistent across parseSSE and
createStreamingChatStore.send().
- Around line 39-68: The writable store shim in writable() exposes a custom
.get() that is used by _getToken(), send(), and stopStreaming(), but the
delegation path to globalThis.__svelteStoreWritable returns a real Svelte store
without that method. Make the store API consistent by either removing the
delegation branch and always using the local implementation, or by ensuring the
returned store always includes a compatible get() wrapper regardless of whether
__svelteStoreWritable is used. Reference writable(), subscribe(), set(),
update(), and get() when updating the implementation.
In `@packages/svelte/test/stores.test.js`:
- Around line 35-44: The helper assertThrows is dead code because it is defined
in stores.test.js but never used. Remove the unused assertThrows function, and
if the surrounding test file no longer needs the passed/failed tracking tied to
it, clean up any related unused variables or logging in the test harness.
- Around line 126-133: The clear() tests in createChatStore and the
streaming-chat store are currently asserting on an already-empty state, so they
do not verify reset behavior. Update the tests to first populate the store with
non-empty prior state using the store’s public update mechanisms or helper
setup, then call clear() and assert that messages are emptied afterward. Make
the same fix in both clear-reset test blocks so the assertions exercise real
state transitions.
- Around line 194-216: The test in createRelayHealthStore is timing-fragile
because it waits a fixed delay for the initial fetch to fail; replace the sleep
with an explicit await of the store’s exposed refetch() promise so the
unreachable-host assertion in stores.test.js is deterministic. Keep the existing
checks for subscribe, destroy, and the resulting ok/status state, but drive the
failure through refetch() instead of relying on the automatic background fetch
timing.
- Around line 97-106: The `storeKey` rejection test is using a flaky timer-based
`.catch()` flag check instead of awaiting the promise directly. Update the
`storeKey` assertion in `stores.test.js` to use a direct async rejection check
against `createByokRelayStore(...).storeKey(...)`, matching the behavior in
`packages/svelte/src/index.js` where `storeKey` throws from an async function
when no token exists, and remove the fixed `setTimeout`/manual flag pattern.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0c872cc1-c526-49bc-b556-8dd661eabd95
📒 Files selected for processing (7)
README.mdllms.txtpackage.jsonpackages/svelte/README.mdpackages/svelte/package.jsonpackages/svelte/src/index.jspackages/svelte/test/stores.test.js
| "main": "src/index.js", | ||
| "module": "src/index.js", | ||
| "exports": { | ||
| ".": "./src/index.js" | ||
| }, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
git ls-files packages/svelte/package.json packages/svelte/src/index.js packages/svelte/src/index.mjs packages/svelte/src/index.ts 2>/dev/null || true
echo
echo "---- package.json ----"
cat -n packages/svelte/package.json
echo
echo "---- entry file ----"
if [ -f packages/svelte/src/index.js ]; then
cat -n packages/svelte/src/index.js
fi
echo
echo "---- sibling entry files ----"
find packages/svelte/src -maxdepth 1 -type f \( -name 'index.*' -o -name '*.mjs' -o -name '*.cjs' \) -print | sortRepository: avikalpg/byok-relay
Length of output: 25020
module points to the CommonJS entry
packages/svelte/src/index.js is CommonJS ('use strict' + module.exports), so "module": "src/index.js" advertises the wrong format to bundlers. Drop it, or point it at a real ESM build if tree-shaking/static analysis matters.
💡 Suggested fix
"main": "src/index.js",
- "module": "src/index.js",
"exports": {
".": "./src/index.js"
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "main": "src/index.js", | |
| "module": "src/index.js", | |
| "exports": { | |
| ".": "./src/index.js" | |
| }, | |
| "main": "src/index.js", | |
| "exports": { | |
| ".": "./src/index.js" | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/svelte/package.json` around lines 22 - 26, The package metadata is
advertising the wrong module format because the `module` field in
`packages/svelte/package.json` points to `src/index.js`, which is CommonJS in
`src/index.js` rather than an ESM build. Update the `module` entry used
alongside `main` and `exports` so it either points to a real ESM bundle or is
removed entirely if no ESM build exists, keeping the package entry points
consistent for bundlers.
| ```svelte | ||
| <script> | ||
| import { createByokRelayStore, createStreamingChatStore } from '@byok-relay/svelte'; | ||
| import { onMount, onDestroy } from 'svelte'; | ||
|
|
||
| const relay = createByokRelayStore({ appId: 'myapp' }); | ||
| const chat = createStreamingChatStore({ appId: 'myapp', provider: 'openai' }); | ||
| const health = createRelayHealthStore({ pollIntervalMs: 60_000 }); | ||
|
|
||
| onMount(() => relay.register()); | ||
| onDestroy(health.destroy); | ||
| </script> | ||
| ``` |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Import createRelayHealthStore in this snippet.
Line 312 calls createRelayHealthStore(), but the import list only includes createByokRelayStore and createStreamingChatStore, so the example won’t run as written.
♻️ Proposed fix
- import { createByokRelayStore, createStreamingChatStore } from '`@byok-relay/svelte`';
+ import { createByokRelayStore, createStreamingChatStore, createRelayHealthStore } from '`@byok-relay/svelte`';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ```svelte | |
| <script> | |
| import { createByokRelayStore, createStreamingChatStore } from '@byok-relay/svelte'; | |
| import { onMount, onDestroy } from 'svelte'; | |
| const relay = createByokRelayStore({ appId: 'myapp' }); | |
| const chat = createStreamingChatStore({ appId: 'myapp', provider: 'openai' }); | |
| const health = createRelayHealthStore({ pollIntervalMs: 60_000 }); | |
| onMount(() => relay.register()); | |
| onDestroy(health.destroy); | |
| </script> | |
| ``` |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/svelte/README.md` around lines 305 - 317, The Svelte README example
is missing the import for createRelayHealthStore, so the snippet will fail as
written. Update the import statement in the example to include
createRelayHealthStore alongside createByokRelayStore and
createStreamingChatStore, and ensure the snippet remains consistent with the
createRelayHealthStore usage in the health variable initialization.
| const PROVIDER_PATHS = { | ||
| openai: 'chat/completions', | ||
| anthropic: 'messages', | ||
| google: 'models/{model}:generateContent', | ||
| groq: 'chat/completions', | ||
| mistral: 'chat/completions', | ||
| openrouter: 'chat/completions', | ||
| }; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
google provider is registered but never actually implemented.
PROVIDER_PATHS.google and its {model} templating exist, but neither createChatStore.send() (Lines 314-330) nor createStreamingChatStore.send() (Lines 438-455) nor extractDelta() (Lines 103-111) has a Gemini-specific branch — they only special-case anthropic vs. a generic OpenAI-style fallback. A caller passing provider: 'google' would silently send an OpenAI-shaped body (messages array) to a Gemini endpoint and try to read data.choices[0].message.content from a response shaped as candidates[...].content.parts[...], producing empty/broken replies instead of an error. This also contradicts the JSDoc ('openai' | 'anthropic' | 'groq' | 'mistral' | 'openrouter', Line 259) and the PR's stated provider list (openai, anthropic, groq, mistral, openrouter).
Either implement a Gemini-specific request/response branch or remove the google entry until it's supported, to avoid silently broken behavior.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/svelte/src/index.js` around lines 24 - 31, The `google` provider is
exposed in `PROVIDER_PATHS` but the chat flow still only handles `anthropic` and
the OpenAI-style fallback, so Gemini requests/responses are silently mis-shaped.
Update `createChatStore.send()`, `createStreamingChatStore.send()`, and
`extractDelta()` to either add a `google`/Gemini-specific branch that sends and
parses the correct payload shape, or remove `PROVIDER_PATHS.google` and the
related provider support until it is implemented. Ensure the provider list and
the JSDoc/type surface stay aligned with the actual supported symbols.
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Race between an aborted send and a newly-started send can clobber isStreaming/streamingContent.
When send() is called while already streaming, it calls stopStreaming() (Line 427) then immediately resets state and starts a new controller/fetch (Lines 429-462). The aborted request's catch block (Lines 503-517) still runs asynchronously afterward and unconditionally does store.update(s => ({ ...s, isStreaming: false, streamingContent: '', ... })). If that stale handler resolves after the new send() has already set isStreaming: true and started receiving new deltas, it will flip isStreaming back to false mid-stream (hiding the "streaming" UI indicator) even though the new request is still actively writing to streamingContent.
Guard against stale completions with a generation/request token compared before applying state from an async callback.
💡 Suggested fix
let _controller = null;
+ let _generation = 0;
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();
+ const myGen = ++_generation;
_patch({ error: null, isStreaming: true, streamingContent: '' });
...
while (true) {
const { done, value } = await reader.read();
+ if (myGen !== _generation) return; // superseded by a newer send()
if (done) break;
...
}
+ if (myGen !== _generation) return;
store.update(s => ({
...s,
isStreaming: false,
streamingContent: '',
messages: [...s.messages, { role: 'assistant', content: full }],
}));
} catch (err) {
+ if (myGen !== _generation) return;
if (err.name === 'AbortError') {
...📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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; | |
| } | |
| } | |
| let _generation = 0; | |
| 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(); | |
| const myGen = ++_generation; | |
| _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 (myGen !== _generation) return; // superseded by a newer send() | |
| 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 */ } | |
| } | |
| } | |
| if (myGen !== _generation) return; | |
| store.update(s => ({ | |
| ...s, | |
| isStreaming: false, | |
| streamingContent: '', | |
| messages: [...s.messages, { role: 'assistant', content: full }], | |
| })); | |
| } catch (err) { | |
| if (myGen !== _generation) return; | |
| 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; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/svelte/src/index.js` around lines 420 - 521, The send() flow can let
an aborted prior request overwrite the state of a newer active stream. Add a
per-request generation/token inside send() and compare it before applying any
async state changes in the catch/finally paths and after the fetch/read loop. In
particular, guard the AbortError branch and the non-abort error branch so stale
callbacks do not reset isStreaming or streamingContent for a newer request. Use
the send(), stopStreaming(), _controller, and store.update/_patch paths to
locate the fix.
| const relay = createByokRelayStore({ appId: 'myapp' }); | ||
| const chat = createStreamingChatStore({ appId: 'myapp', provider: 'openai' }); | ||
|
|
||
| onMount(() => relay.register()); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== File list ==\n'
git ls-files README.md packages/svelte/README.md packages/svelte src 2>/dev/null || true
printf '\n== Search for register() definitions/usages ==\n'
rg -n "register\(\)" README.md packages/svelte/README.md packages/svelte src . --glob '!**/node_modules/**' || true
printf '\n== Search for createByokRelayStore ==\n'
rg -n "createByokRelayStore|createStreamingChatStore|onMount" README.md packages/svelte/README.md packages/svelte src . --glob '!**/node_modules/**' || trueRepository: avikalpg/byok-relay
Length of output: 17793
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the relevant README snippets with line numbers.
for f in README.md packages/svelte/README.md; do
if [ -f "$f" ]; then
printf '\n== %s ==\n' "$f"
nl -ba "$f" | sed -n '1,120p'
fi
doneRepository: avikalpg/byok-relay
Length of output: 214
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the implementation of register() and any error handling around it.
files=$(git ls-files | tr '\n' '\0' | xargs -0 rg -n --no-heading "register\\s*\\(" --glob '!**/node_modules/**' 2>/dev/null | cut -d: -f1 | sort -u)
printf 'Candidate files:\n%s\n' "$files"
# If we can find a likely implementation file, print the relevant slice.
for f in $(printf '%s\n' "$files" | head -n 20); do
[ -f "$f" ] || continue
printf '\n== %s ==\n' "$f"
nl -ba "$f" | sed -n '1,220p'
doneRepository: avikalpg/byok-relay
Length of output: 342
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== README snippets ==\n'
for f in README.md packages/svelte/README.md; do
if [ -f "$f" ]; then
printf '\n-- %s --\n' "$f"
nl -ba "$f" | sed -n '25,55p'
fi
done
printf '\n== register() implementation candidates ==\n'
rg -n --glob '!**/node_modules/**' "register\\s*[:=]\\s*(async\\s*)?\\(|async\\s+register\\s*\\(|class .*register\\s*\\(" .Repository: avikalpg/byok-relay
Length of output: 237
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== packages/svelte/src/index.js around createByokRelayStore/register ==\n'
sed -n '147,220p' packages/svelte/src/index.js
printf '\n== README.md snippet around onMount ==\n'
sed -n '25,40p' README.md
printf '\n== packages/svelte/README.md snippets around onMount ==\n'
sed -n '30,45p;132,148p;198,214p;306,318p' packages/svelte/README.mdRepository: avikalpg/byok-relay
Length of output: 4798
Handle the register() rejection in the Svelte examples.
relay.register() is async and rethrows on failure, so calling it directly from onMount can surface an unhandled rejection. Use an explicit .catch(...) in README.md and the repeated snippets in packages/svelte/README.md.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@README.md` around lines 33 - 36, The Svelte README examples call
relay.register() directly from onMount, but register() is async and can reject,
causing an unhandled rejection. Update the example in README.md and the repeated
snippet in packages/svelte/README.md to handle the returned promise explicitly
with a .catch(...) on relay.register(), using the existing createByokRelayStore
and onMount example so failures are safely handled.
@byok-relay/svelte — Svelte stores for BYOK AI (Growth Day 23)
Four stores covering the full BYOK lifecycle in Svelte/SvelteKit apps.
What’s in this PR
packages/svelte/src/index.jscreateByokRelayStore— relay token registration + key CRUD + logout; localStorage persisted; SvelteKit SSR-safe (typeof windowguard)createChatStore— stateful message list, non-streaming; supports openai / anthropic / groq / mistral / openrouter;systemPrompt+extraParamscreateStreamingChatStore— SSE streaming withAbortController;stopStreaming()commits partial reply;streamingContentlive stringcreateRelayHealthStore— polls/health;refetch()on demand;destroy()foronDestroycleanup;deep=truereadiness probeAll stores expose the Svelte store contract (
{ subscribe, ...methods }): works with Svelte’s$storeauto-subscription syntax, Svelte 5 runes, and plain JS.subscribe(). Svelte peer dep is optional — stores run without a build step in any JS environment.packages/svelte/test/stores.test.js63 smoke tests passing (plain Node, no Svelte build step required):
logoutclears tokensubscribefires immediately and returns unsubscribe functionstoreKeyrejects without tokensendwithout token sets error + loading=falsestopStreamingis a no-op when idleclearresets all statedestroydoes not throwpackages/svelte/README.mdFull API docs: store options tables, state field tables, method tables, full
<script>+ template examples for all four stores. SvelteKitonMount/onDestroypattern. Svelte 5 rune compatibility note. Provider table. Self-hosting section. Related packages table.README.mdSvelte stores section (install + streaming example) added before “For AI coding agents”.
llms.txtSvelte Stores section + React Hooks + Vue Composables + MCP Server + API Reference sections — complete picture for AI agents discovering the ecosystem.
package.jsonworkspaces: ["packages/*"]added.Metrics (2026-07-02)
stars=52 ⬆️ (was 51) forks=0 watchers=1 clones=110 views=20
Next for Avi
Summary by CodeRabbit
New Features
Documentation
Tests