From 5a6b4ebacd6cee366e03777f263df73fd3a12c60 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 21 Jan 2026 02:03:36 +0100 Subject: [PATCH] fix(frontend): ws replacement --- frontend/bun.lock | 1 + frontend/src/lib/convex-polling.svelte.ts | 109 ++++++++++++++++++++ frontend/src/lib/convex/_generated/api.d.ts | 2 + frontend/src/lib/convex/http.ts | 40 +++++++ frontend/src/routes/+layout.svelte | 11 +- frontend/src/routes/[mnemonic]/+page.svelte | 72 ++++++++++--- frontend/svelte.config.js | 20 ++-- 7 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/convex-polling.svelte.ts create mode 100644 frontend/src/lib/convex/http.ts diff --git a/frontend/bun.lock b/frontend/bun.lock index 8f6e8b2..e772d16 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "frontend", diff --git a/frontend/src/lib/convex-polling.svelte.ts b/frontend/src/lib/convex-polling.svelte.ts new file mode 100644 index 0000000..627e4ae --- /dev/null +++ b/frontend/src/lib/convex-polling.svelte.ts @@ -0,0 +1,109 @@ +import { ConvexHttpClient } from 'convex/browser'; +import { getContext, setContext } from 'svelte'; +import type { FunctionReference, FunctionArgs, FunctionReturnType } from 'convex/server'; + +const POLLING_CONTEXT_KEY = 'convex-polling'; +const POLL_INTERVAL = 1000; + +type PollingContext = { + client: ConvexHttpClient; +}; + +export function hasWebSocketSupport(): boolean { + if (typeof window === 'undefined') return true; + try { + return 'WebSocket' in window && typeof WebSocket !== 'undefined'; + } catch { + return false; + } +} + +export function setupPollingConvex(url: string): void { + const client = new ConvexHttpClient(url); + setContext(POLLING_CONTEXT_KEY, { client }); +} + +export function usePollingClient(): ConvexHttpClient { + const ctx = getContext(POLLING_CONTEXT_KEY); + if (!ctx) { + throw new Error('Convex polling client not set up. Call setupPollingConvex first.'); + } + return ctx.client; +} + +type QueryState = { + data: T | undefined; + error: Error | null; + isLoading: boolean; +}; + +export function usePollingMutation>( + mutation: Mutation +): (args: FunctionArgs) => Promise> { + const client = usePollingClient(); + return (args: FunctionArgs) => client.mutation(mutation, args); +} + +export function usePollingQuery>( + query: Query, + argsGetter: () => FunctionArgs | 'skip' +): { data: FunctionReturnType | undefined; error: Error | null; isLoading: boolean } { + const client = usePollingClient(); + + // eslint-disable-next-line prefer-const + let state = $state>>({ + data: undefined, + error: null, + isLoading: true + }); + + let intervalId: ReturnType | null = null; + let lastArgsJson = ''; + + async function poll() { + const args = argsGetter(); + if (args === 'skip') { + state.isLoading = false; + return; + } + + const argsJson = JSON.stringify(args); + if (argsJson !== lastArgsJson) { + state.isLoading = true; + lastArgsJson = argsJson; + } + + try { + const result = await client.query(query, args); + state.data = result; + state.error = null; + state.isLoading = false; + } catch (err) { + state.error = err instanceof Error ? err : new Error(String(err)); + state.isLoading = false; + } + } + + $effect(() => { + poll(); + intervalId = setInterval(poll, POLL_INTERVAL); + + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }); + + return { + get data() { + return state.data; + }, + get error() { + return state.error; + }, + get isLoading() { + return state.isLoading; + } + }; +} diff --git a/frontend/src/lib/convex/_generated/api.d.ts b/frontend/src/lib/convex/_generated/api.d.ts index 1f51849..f223d27 100644 --- a/frontend/src/lib/convex/_generated/api.d.ts +++ b/frontend/src/lib/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as chats from "../chats.js"; +import type * as http from "../http.js"; import type * as messages from "../messages.js"; import type * as pendingGenerations from "../pendingGenerations.js"; import type * as users from "../users.js"; @@ -21,6 +22,7 @@ import type { declare const fullApi: ApiFromModules<{ chats: typeof chats; + http: typeof http; messages: typeof messages; pendingGenerations: typeof pendingGenerations; users: typeof users; diff --git a/frontend/src/lib/convex/http.ts b/frontend/src/lib/convex/http.ts new file mode 100644 index 0000000..a5f1fdc --- /dev/null +++ b/frontend/src/lib/convex/http.ts @@ -0,0 +1,40 @@ +import { httpRouter } from 'convex/server'; +import { httpAction } from './_generated/server'; +import { internal } from './_generated/api'; +import type { Id } from './_generated/dataModel'; + +const http = httpRouter(); + +http.route({ + path: '/upload-image', + method: 'POST', + handler: httpAction(async (ctx, req) => { + const chatId = req.headers.get('X-Chat-Id'); + const mediaType = req.headers.get('Content-Type') || 'image/jpeg'; + const caption = req.headers.get('X-Caption') || ''; + + if (!chatId) { + return new Response(JSON.stringify({ error: 'Missing X-Chat-Id header' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const blob = await req.blob(); + const storageId = await ctx.storage.store(blob); + + await ctx.runMutation(internal.messages.createWithImage, { + chatId: chatId as Id<'chats'>, + content: caption, + imageStorageId: storageId, + imageMediaType: mediaType + }); + + return new Response(JSON.stringify({ storageId }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }) +}); + +export default http; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 009474f..61e0a0b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,10 +3,19 @@ import favicon from '$lib/assets/favicon.svg'; import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { setupConvex } from 'convex-svelte'; + import { hasWebSocketSupport, setupPollingConvex } from '$lib/convex-polling.svelte'; + import { setContext } from 'svelte'; let { children } = $props(); - setupConvex(PUBLIC_CONVEX_URL); + const usePolling = !hasWebSocketSupport(); + setContext('convex-use-polling', usePolling); + + if (usePolling) { + setupPollingConvex(PUBLIC_CONVEX_URL); + } else { + setupConvex(PUBLIC_CONVEX_URL); + } diff --git a/frontend/src/routes/[mnemonic]/+page.svelte b/frontend/src/routes/[mnemonic]/+page.svelte index fd4d1d9..8da178b 100644 --- a/frontend/src/routes/[mnemonic]/+page.svelte +++ b/frontend/src/routes/[mnemonic]/+page.svelte @@ -1,18 +1,35 @@ diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index b787fcb..1d5479b 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -1,18 +1,18 @@ import adapter from 'svelte-adapter-bun'; -import {vitePreprocess} from '@sveltejs/vite-plugin-svelte'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { - // Consult https://svelte.dev/docs/kit/integrations - // for more information about preprocessors - preprocess: vitePreprocess(), + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), - kit: { - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() - } + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + } }; export default config;