diff --git a/apps/server/.env.sample b/apps/server/.env.sample index 39fa017..f4c1287 100644 --- a/apps/server/.env.sample +++ b/apps/server/.env.sample @@ -10,3 +10,5 @@ SUPABASE_SERVICE_ROLE_KEY= SUPABASE_DB_PASSWORD= POSTGRES_URL="postgresql://postgres:postgres@localhost:54322/postgres" +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/apps/server/package.json b/apps/server/package.json index a7ffd5c..64a4b54 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -12,14 +12,17 @@ "author": "", "license": "ISC", "dependencies": { - "@fastify/cors": "^9.0.1", - "@fastify/request-context": "^5.1.0", + "@fastify/cookie": "^10.0.1", + "@fastify/cors": "^10.0.1", + "@fastify/request-context": "^6.0.1", "@repo/db": "workspace:*", "@supabase/supabase-js": "^2.45.1", "@trpc/server": "11.0.0-rc.477", + "@upstash/redis": "^1.34.0", "dotenv": "^16.4.5", - "fastify": "^4.28.1", - "fastify-plugin": "^4.5.1", + "fastify": "^5.0.0", + "fastify-plugin": "^5.0.1", + "ioredis": "^5.4.1", "luxon": "^3.5.0", "openai": "^4.55.3", "slonik": "^46.0.1", diff --git a/apps/server/src/ChatService/ChatService.ts b/apps/server/src/ChatService/ChatService.ts index ceb2fb0..89e511e 100644 --- a/apps/server/src/ChatService/ChatService.ts +++ b/apps/server/src/ChatService/ChatService.ts @@ -43,6 +43,7 @@ export default class ChatService implements IChatService { chatID: input.chatID, messageType: 'assistant', messageContent: chunk.choices[0]?.delta.content || '', + status: 'streaming', createdAt: DateTime.now().toJSDate(), updatedAt: DateTime.now().toJSDate(), } satisfies DBChatMessage; diff --git a/apps/server/src/fastifyPlugins/AIServiceSingletonPlugin.ts b/apps/server/src/fastify/plugins/AIServiceSingletonPlugin.ts similarity index 100% rename from apps/server/src/fastifyPlugins/AIServiceSingletonPlugin.ts rename to apps/server/src/fastify/plugins/AIServiceSingletonPlugin.ts diff --git a/apps/server/src/fastifyPlugins/ChatServicePlugin.ts b/apps/server/src/fastify/plugins/ChatServicePlugin.ts similarity index 100% rename from apps/server/src/fastifyPlugins/ChatServicePlugin.ts rename to apps/server/src/fastify/plugins/ChatServicePlugin.ts diff --git a/apps/server/src/fastifyPlugins/SlonikDBSingletonPlugin.ts b/apps/server/src/fastify/plugins/SlonikDBSingletonPlugin.ts similarity index 100% rename from apps/server/src/fastifyPlugins/SlonikDBSingletonPlugin.ts rename to apps/server/src/fastify/plugins/SlonikDBSingletonPlugin.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d04f5bc..617776a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; +import fastifyCookie from '@fastify/cookie'; import fastifyCors from '@fastify/cors'; import { fastifyRequestContext } from '@fastify/request-context'; import type { User } from '@supabase/supabase-js'; @@ -8,9 +9,9 @@ import { fastifyTRPCPlugin, } from '@trpc/server/adapters/fastify'; import fastify from 'fastify'; -import AIServiceSingletonPlugin from './fastifyPlugins/AIServiceSingletonPlugin'; -import ChatServicePlugin from './fastifyPlugins/ChatServicePlugin'; -import SlonikDBSingletonPlugin from './fastifyPlugins/SlonikDBSingletonPlugin'; +import AIServiceSingletonPlugin from './fastify/plugins/AIServiceSingletonPlugin'; +import ChatServicePlugin from './fastify/plugins/ChatServicePlugin'; +import SlonikDBSingletonPlugin from './fastify/plugins/SlonikDBSingletonPlugin'; import { createContext } from './trpc/context'; import { type AppRouter, appRouter } from './trpc/router'; import { supabase } from './utils/supabase'; @@ -28,28 +29,54 @@ declare module '@fastify/request-context' { } } server.register(fastifyRequestContext); + +// Cors config +server.register(fastifyCors, { + origin: 'http://localhost:5173', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'authorization'], + credentials: true, +}); + +// Cookies +server.register(fastifyCookie, { + secret: 'abc123', + hook: 'onRequest', + parseOptions: {}, +}); + +// Add AI Service as a singleton across fastify +server.register(AIServiceSingletonPlugin); + +// Add a chat service +server.register(ChatServicePlugin); + +// Add a db connection pool as a singleton across fastify +server.register(SlonikDBSingletonPlugin); + +// Auth server.addHook('onRequest', async (req, reply) => { - let authToken = req.headers.authorization; - // It should always be in the form of `Bearer ${token}` - if (typeof authToken !== 'string' || !authToken.startsWith('Bearer ')) { - // Could be CORS requests or something - req.requestContext.set('user', null); + // const accessToken = req.cookies['sb-access-token']; + let accessToken = req.headers.authorization; + accessToken = accessToken?.substring(7); + + if (!accessToken) { + console.log('NO ACCESS TOKEN'); return; } - // Start of the actual token after `Bearer` - authToken = authToken.substring(7); - let user: User; try { - const { data, error } = await supabase.auth.getUser(authToken); + const { data, error } = await supabase.auth.getUser(accessToken); if (error) { + console.error('[AUTH ERROR]:', error); reply.code(401).send({ error: 'Invalid token.' }); return reply; } if (!data.user) { + console.error('[ERROR]: NO USER FOUND'); reply.code(401).send({ error: 'User not found.' }); return reply; } @@ -60,26 +87,10 @@ server.addHook('onRequest', async (req, reply) => { reply.code(500).send({ error: 'Internal server error.' }); return reply; } - req.requestContext.set('user', user); -}); -// Cors config -server.register(fastifyCors, { - origin: ['http://localhost:5173'], - methods: ['GET', 'POST', 'OPTIONS'], - allowedHeaders: ['*'], - credentials: true, + req.requestContext.set('user', user); }); -// Add AI Service as a singleton across fastify -server.register(AIServiceSingletonPlugin); - -// Add a chat service -server.register(ChatServicePlugin); - -// Add a db connection pool as a singleton across fastify -server.register(SlonikDBSingletonPlugin); - // TRPC server.register(fastifyTRPCPlugin, { prefix: '/trpc', @@ -102,6 +113,7 @@ server.register(fastifyTRPCPlugin, { console.info(`[INFO]: Listening on port ${SERVER_PORT}`); } catch (err) { server.log.error(err); + console.error(err); process.exit(1); } })(); diff --git a/apps/server/src/trpc/context.ts b/apps/server/src/trpc/context.ts index aa2540c..07fb6f2 100644 --- a/apps/server/src/trpc/context.ts +++ b/apps/server/src/trpc/context.ts @@ -1,20 +1,12 @@ import { requestContext } from '@fastify/request-context'; +import type { User } from '@supabase/supabase-js'; import { TRPCError } from '@trpc/server'; import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify'; export async function createContext({ req, res }: CreateFastifyContextOptions) { - const user = requestContext.get('user'); - console.log('NO USER'); - if (!user) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Unauthorized', - }); - } return { req, res, - user, aiService: req.server.aiService, chatService: req.server.chatService, dbPool: req.server.dbPool, @@ -22,3 +14,4 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) { } export type Context = Awaited>; +export type AuthedContext = Context & { user: User }; diff --git a/apps/server/src/trpc/router.ts b/apps/server/src/trpc/router.ts index 145a0be..f9a6577 100644 --- a/apps/server/src/trpc/router.ts +++ b/apps/server/src/trpc/router.ts @@ -1,10 +1,8 @@ import { chatRouter } from './routers/chat'; -import { chatMessagesRouter } from './routers/chatMessages'; import { router } from './trpc'; export const appRouter = router({ chat: chatRouter, - chatMessages: chatMessagesRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/server/src/trpc/routers/chat/create.ts b/apps/server/src/trpc/routers/chat/create.ts index a538ca7..d7904a6 100644 --- a/apps/server/src/trpc/routers/chat/create.ts +++ b/apps/server/src/trpc/routers/chat/create.ts @@ -1,37 +1,24 @@ // Creates a DB chat and streams down the response. Chats can only be created from the home page. import type { IAIService } from '@/AIService/AIService.interface'; +import { upsertDBChatMessage } from '@/utils/sql'; import { type DBChat, type DBChatMessage, DBChatSchema } from '@repo/db'; import { type DatabasePool, sql } from 'slonik'; import { ulid } from 'ulid'; import { z } from 'zod'; -import { publicProcedure } from '../../trpc'; -import { - type SendMessageOutput, - updateDBChatMessage, - upsertDBChatMessage, -} from '../chatMessages/send'; +import { authedProcedure } from '../../trpc'; +import { generateAssistantMessage } from './sendMessage'; export const CreateChatSchema = z.object({ initialMessage: z.string(), }); -type CreateChatOutput = - | { - type: 'chat'; - chat: DBChat; - } - | SendMessageOutput; - -export const create = publicProcedure +export const create = authedProcedure .input(CreateChatSchema) - .mutation(async function* ({ - input, - ctx, - }): AsyncGenerator { + .mutation(async ({ input, ctx }) => { const chatID = ulid(); - // Try to overlap this request as best as possible with the db insertions + // TODO: Change to pub sub sse with redis const previewMessagePromise = maybeSetChatPreview( { chatID, @@ -64,60 +51,23 @@ export const create = publicProcedure chatID, messageContent: input.initialMessage, messageType: 'user', - responseStatus: 'streaming', + status: 'done', }, ctx.dbPool, ); messages.push(initialMessage); - yield { - type: 'chat', - chat: newChat, - }; - - const chatIterator = ctx.chatService.generateResponse({ - userID: ctx.user.id, - chatID, - message: input.initialMessage, - previousMessages: [], - }); - - let fullMessage = ''; - let messageID = ''; - for await (const chunk of chatIterator) { - yield { - type: 'messageChunk', - messageChunk: chunk, - }; - messageID = chunk.id; - fullMessage += chunk.messageContent; - } - - const completedAssistantMessage = await upsertDBChatMessage( - { - id: messageID, - userID: ctx.user.id, - chatID: chatID, - messageType: 'assistant', - messageContent: fullMessage, - }, - ctx.dbPool, + generateAssistantMessage( + { message: input.initialMessage, chatID }, + ctx, + ).catch((e) => + console.error( + '[ERROR] Failed to generate assistant message in send:', + e, + ), ); - await previewMessagePromise; - - yield { - type: 'completeMessage', - message: completedAssistantMessage, - }; - - await updateDBChatMessage( - { - messageID: messageID, - responseStatus: 'done', - }, - ctx.dbPool, - ); + return newChat; }); type MaybeSetChatPreviewParams = { diff --git a/apps/server/src/trpc/routers/chat/get.ts b/apps/server/src/trpc/routers/chat/get.ts index 14f304d..f024c2a 100644 --- a/apps/server/src/trpc/routers/chat/get.ts +++ b/apps/server/src/trpc/routers/chat/get.ts @@ -1,9 +1,9 @@ import { type DBChat, DBChatSchema } from '@repo/db'; import { sql } from 'slonik'; import { z } from 'zod'; -import { publicProcedure } from '../../trpc'; +import { authedProcedure } from '../../trpc'; -export const get = publicProcedure +export const get = authedProcedure .input(z.object({ id: z.string().ulid() })) .query(async ({ input, ctx }) => { return (await ctx.dbPool.one(sql.type(DBChatSchema)` diff --git a/apps/server/src/trpc/routers/chat/index.ts b/apps/server/src/trpc/routers/chat/index.ts index 1532bd5..26743ee 100644 --- a/apps/server/src/trpc/routers/chat/index.ts +++ b/apps/server/src/trpc/routers/chat/index.ts @@ -2,9 +2,15 @@ import { router } from '../../trpc'; import { create } from './create'; import { get } from './get'; import { infiniteList } from './infiniteList'; +import { infiniteListMessages } from './infiniteListMessages'; +import { listenNewMessages } from './listenNewMessages'; +import { sendMessage } from './sendMessage'; export const chatRouter = router({ get, create, infiniteList, + infiniteListMessages, + listenNewMessages, + sendMessage, }); diff --git a/apps/server/src/trpc/routers/chat/infiniteList.ts b/apps/server/src/trpc/routers/chat/infiniteList.ts index 970e8b3..1dd9b60 100644 --- a/apps/server/src/trpc/routers/chat/infiniteList.ts +++ b/apps/server/src/trpc/routers/chat/infiniteList.ts @@ -2,9 +2,9 @@ import { type DBChat, DBChatSchema } from '@repo/db'; import { TRPCError } from '@trpc/server'; import { sql } from 'slonik'; import { z } from 'zod'; -import { publicProcedure } from '../../trpc'; +import { authedProcedure } from '../../trpc'; -export const infiniteList = publicProcedure +export const infiniteList = authedProcedure .input( z.object({ limit: z.number().min(1).max(100).default(50), diff --git a/apps/server/src/trpc/routers/chatMessages/infiniteList.ts b/apps/server/src/trpc/routers/chat/infiniteListMessages.ts similarity index 94% rename from apps/server/src/trpc/routers/chatMessages/infiniteList.ts rename to apps/server/src/trpc/routers/chat/infiniteListMessages.ts index e0ec874..9a7f023 100644 --- a/apps/server/src/trpc/routers/chatMessages/infiniteList.ts +++ b/apps/server/src/trpc/routers/chat/infiniteListMessages.ts @@ -4,9 +4,9 @@ import { type DBChatMessage, DBChatMessageSchema } from '@repo/db'; import { TRPCError } from '@trpc/server'; import { sql } from 'slonik'; import { z } from 'zod'; -import { publicProcedure } from '../../trpc'; +import { authedProcedure } from '../../trpc'; -export const infiniteList = publicProcedure +export const infiniteListMessages = authedProcedure .input( z.object({ chatID: z.string().ulid(), diff --git a/apps/server/src/trpc/routers/chat/listenNewMessages.ts b/apps/server/src/trpc/routers/chat/listenNewMessages.ts new file mode 100644 index 0000000..4fb6aa2 --- /dev/null +++ b/apps/server/src/trpc/routers/chat/listenNewMessages.ts @@ -0,0 +1,79 @@ +import { + getRedisSubscriber, + subscriptionChannelTypes, + subscriptionChannels, +} from '@/utils/redis'; +import { type DBChatMessage, DBChatMessageSchema } from '@repo/db'; +import { tracked } from '@trpc/server'; +import { type DatabasePool, sql } from 'slonik'; +import { z } from 'zod'; +import { authedProcedure } from '../../trpc'; + +export const ListenNewMessagesSchema = z.object({ + chatID: z.string(), + latestSeenMessageID: z.string().optional(), +}); + +export const listenNewMessages = authedProcedure + .input(ListenNewMessagesSchema) + .subscription(async function* ({ input, ctx }) { + const channelName = subscriptionChannels.chatMessages(input.chatID); + + if (input.latestSeenMessageID) { + const messages = await getMessagesSinceLatestSeen( + input.chatID, + input.latestSeenMessageID, + ctx.dbPool, + ); + for (const message of messages) { + yield message; + } + } + + const redisSubscriber = getRedisSubscriber(); + await redisSubscriber.subscribe(channelName); + + try { + for await (const message of createRedisMessageGenerator( + redisSubscriber, + )) { + yield message; + // yield tracked(message.id, message); + } + } finally { + await redisSubscriber.unsubscribe(channelName); + redisSubscriber.quit(); + } + }); + +async function* createRedisMessageGenerator( + subClient: ReturnType, +) { + while (true) { + yield new Promise((resolve, reject) => { + subClient.once('message', (_, message) => { + // We should only be subscribed to one channel + resolve( + subscriptionChannelTypes.chatMessages.parse( + JSON.parse(message), + ).message, + ); + }); + subClient.once('error', reject); + }); + } +} + +async function getMessagesSinceLatestSeen( + chatID: string, + latestMessageSeenID: string, + pool: DatabasePool, +): Promise { + return pool.any(sql.type(DBChatMessageSchema)` + SELECT * + FROM "ChatMessage" + WHERE id > ${latestMessageSeenID} + AND "chatID" = ${chatID} + ORDER BY id ASC + `); +} diff --git a/apps/server/src/trpc/routers/chat/sendMessage.ts b/apps/server/src/trpc/routers/chat/sendMessage.ts new file mode 100644 index 0000000..d775007 --- /dev/null +++ b/apps/server/src/trpc/routers/chat/sendMessage.ts @@ -0,0 +1,104 @@ +import type { AuthedContext } from '@/trpc/context'; +import redis, { subscriptionChannels } from '@/utils/redis'; +import { getPreviousChatMessages, upsertDBChatMessage } from '@/utils/sql'; +import type { DBChatMessage } from '@repo/db'; +import { ulid } from 'ulid'; +import { z } from 'zod'; +import { authedProcedure } from '../../trpc'; + +export const SendMessageSchema = z.object({ + message: z.string(), + customSystemPrompt: z.string().optional(), + chatID: z.string(), +}); + +export const sendMessage = authedProcedure + .input(SendMessageSchema) + .mutation(async ({ input, ctx }) => { + const newUserMessage: DBChatMessage = await upsertDBChatMessage( + { + id: ulid(), + userID: ctx.user.id, + chatID: input.chatID, + messageType: 'user', + messageContent: input.message, + status: 'done', + }, + ctx.dbPool, + ); + + generateAssistantMessage(input, ctx).catch((e) => + console.error( + '[ERROR] Failed to generate assistant message in send:', + e, + ), + ); + + return newUserMessage; + }); + +export async function generateAssistantMessage( + input: z.infer, + ctx: AuthedContext, +) { + let messageID = ulid(); + + const previousMessages = await getPreviousChatMessages( + { chatID: input.chatID }, + ctx.dbPool, + ); + + const chatIterator = ctx.chatService.generateResponse({ + userID: ctx.user.id, + chatID: input.chatID, + message: input.message, + messageID, + previousMessages: previousMessages.slice(1), + customSystemPrompt: input.customSystemPrompt, + }); + + let fullMessage = ''; + for await (const chunk of chatIterator) { + messageID = chunk.id; + fullMessage += chunk.messageContent; + console.log('GOT CHUNK', chunk); + + await upsertDBChatMessage( + { + ...chunk, + messageContent: fullMessage, + }, + ctx.dbPool, + ); + console.log('UPSERTED'); + + await redis.publish( + subscriptionChannels.chatMessages(input.chatID), + JSON.stringify({ + type: 'chunk', + message: chunk, + }), + ); + console.log('PUBLISHED'); + } + + const completedAssistantMessage = await upsertDBChatMessage( + { + id: messageID, + userID: ctx.user.id, + chatID: input.chatID, + messageType: 'assistant', + messageContent: fullMessage, + status: 'done', + }, + ctx.dbPool, + ); + + await redis.publish( + subscriptionChannels.chatMessages(input.chatID), + JSON.stringify({ + type: 'completed', + message: completedAssistantMessage, + }), + ); +} diff --git a/apps/server/src/trpc/routers/chatMessages/index.ts b/apps/server/src/trpc/routers/chatMessages/index.ts deleted file mode 100644 index 3391dde..0000000 --- a/apps/server/src/trpc/routers/chatMessages/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { router } from '../../trpc'; -import { infiniteList } from './infiniteList'; -import { send } from './send'; - -export const chatMessagesRouter = router({ - send, - infiniteList, -}); diff --git a/apps/server/src/trpc/routers/chatMessages/send.ts b/apps/server/src/trpc/routers/chatMessages/send.ts deleted file mode 100644 index 2055f98..0000000 --- a/apps/server/src/trpc/routers/chatMessages/send.ts +++ /dev/null @@ -1,221 +0,0 @@ -// This procedure creates a user message and optionally streams down an assistant -// message. - -import { type DBChatMessage, DBChatMessageSchema } from '@repo/db'; -import { type DatabasePool, sql } from 'slonik'; -import { ulid } from 'ulid'; -import { z } from 'zod'; -import { publicProcedure } from '../../trpc'; - -export const SendMessageSchema = z.object({ - message: z.string(), - customSystemPrompt: z.string().optional(), - chatID: z.string(), -}); -export type SendMessageOutput = - | { - type: 'userMessage'; - message: DBChatMessage; - } - | { - type: 'messageChunk'; - messageChunk: DBChatMessage; - } - | { - type: 'completeMessage'; - message: DBChatMessage; - }; - -export const send = publicProcedure - .input(SendMessageSchema) - .mutation(async function* ({ - input, - ctx, - }): AsyncGenerator { - // First create the user's message in the DB and send it back down. - const newUserMessage = await upsertDBChatMessage( - { - id: ulid(), - userID: ctx.user.id, - chatID: input.chatID, - messageType: 'user', - messageContent: input.message, - responseStatus: 'not_started', - }, - ctx.dbPool, - ); - yield { - type: 'userMessage', - message: newUserMessage, - }; - - let messageID = ulid(); - await updateDBChatMessage( - { - messageID: newUserMessage.id, - responseStatus: 'streaming', - responseMessageID: messageID, - }, - ctx.dbPool, - ); - - const previousMessages = await getPreviousChatMessages( - { chatID: input.chatID }, - ctx.dbPool, - ); - - const chatIterator = ctx.chatService.generateResponse({ - userID: ctx.user.id, - chatID: input.chatID, - message: input.message, - messageID, - previousMessages: previousMessages.slice(1), - customSystemPrompt: input.customSystemPrompt, - }); - - let fullMessage = ''; - for await (const chunk of chatIterator) { - // While the message is in the process of generating, we do not do database updates to it. - // No point slowing it down. If the user cancels the request in the middle it'll still be - // there locally so they can edit it. If they refresh the page its fine for a half-complete - // generation to just disappear as if it never happened. - yield { - type: 'messageChunk', - messageChunk: chunk, - }; - messageID = chunk.id; - fullMessage += chunk.messageContent; - } - - const completedAssistantMessage = await upsertDBChatMessage( - { - id: messageID, - userID: ctx.user.id, - chatID: input.chatID, - messageType: 'assistant', - messageContent: fullMessage, - }, - ctx.dbPool, - ); - yield { - type: 'completeMessage', - message: completedAssistantMessage, - }; - - await updateDBChatMessage( - { - messageID: newUserMessage.id, - responseStatus: 'done', - }, - ctx.dbPool, - ); - }); - -type StrippedDBChatMessage = Omit; -export async function upsertDBChatMessage( - message: StrippedDBChatMessage, - pool: DatabasePool, -): Promise { - try { - return await pool.one(sql.type(DBChatMessageSchema)` - INSERT INTO "ChatMessage" ( - id, - "userID", - "chatID", - "messageType", - "messageContent", - "responseStatus", - "responseMessageID", - "createdAt", - "updatedAt" - ) VALUES ( - ${message.id}, - ${message.userID}, - ${message.chatID}, - ${message.messageType}, - ${message.messageContent}, - ${message.responseStatus ?? sql.fragment`NULL`}, - ${message.responseMessageID ?? sql.fragment`NULL`}, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP - ) - ON CONFLICT (id) DO UPDATE SET - "userID" = EXCLUDED."userID", - "chatID" = EXCLUDED."chatID", - "messageType" = EXCLUDED."messageType", - "messageContent" = EXCLUDED."messageContent", - "responseStatus" = EXCLUDED."responseStatus", - "responseMessageID" = EXCLUDED."responseMessageID", - "updatedAt" = CURRENT_TIMESTAMP - RETURNING *; - `); - } catch (e) { - console.error(e); - throw e; - } -} - -type UpdateDBChatMessageParams = { - messageID: string; - responseStatus?: DBChatMessage['responseStatus']; - responseMessageID?: string; -}; -export async function updateDBChatMessage( - params: UpdateDBChatMessageParams, - pool: DatabasePool, -): Promise { - const { messageID, responseStatus, responseMessageID } = params; - - try { - const updateFields = []; - - if (responseStatus !== undefined) { - updateFields.push( - sql.fragment`"responseStatus" = ${responseStatus === null ? sql.fragment`NULL` : responseStatus}`, - ); - } - - if (responseMessageID !== undefined) { - updateFields.push( - sql.fragment`"responseMessageID" = ${responseMessageID}`, - ); - } - - if (updateFields.length === 0) { - throw new Error('No fields to update'); - } - - updateFields.push(sql.fragment`"updatedAt" = CURRENT_TIMESTAMP`); - - const updateFieldsSQL = sql.join(updateFields, sql.fragment`, `); - - return await pool.one(sql.type(DBChatMessageSchema)` - UPDATE "ChatMessage" - SET ${updateFieldsSQL} - WHERE id = ${messageID} - RETURNING *; - `); - } catch (e) { - console.error(e); - throw e; - } -} - -type GetPreviousChatMessagesParams = { - chatID: string; -}; -export async function getPreviousChatMessages( - params: GetPreviousChatMessagesParams, - pool: DatabasePool, -): Promise> { - const { chatID } = params; - - const messages = await pool.any(sql.type(DBChatMessageSchema)` - SELECT * - FROM "ChatMessage" - WHERE "chatID" = ${chatID} - ORDER BY id DESC - `); - - return messages; -} diff --git a/apps/server/src/trpc/trpc.ts b/apps/server/src/trpc/trpc.ts index 44499b1..41ae713 100644 --- a/apps/server/src/trpc/trpc.ts +++ b/apps/server/src/trpc/trpc.ts @@ -1,4 +1,4 @@ -import { initTRPC } from '@trpc/server'; +import { TRPCError, initTRPC } from '@trpc/server'; import type { Context } from './context'; /** @@ -13,3 +13,16 @@ const t = initTRPC.context().create(); */ export const router = t.router; export const publicProcedure = t.procedure; +export const authedProcedure = t.procedure.use(async ({ ctx, next }) => { + const user = ctx.req.requestContext.get('user'); + if (!user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + return next({ + ctx: { + ...ctx, + user, + }, + }); +}); diff --git a/apps/server/src/utils/redis.ts b/apps/server/src/utils/redis.ts new file mode 100644 index 0000000..b80946a --- /dev/null +++ b/apps/server/src/utils/redis.ts @@ -0,0 +1,25 @@ +import { DBChatMessageSchema } from '@repo/db'; +import { Redis } from '@upstash/redis'; +import { Redis as ioRedis } from 'ioRedis'; +import z from 'zod'; + +const redis = Redis.fromEnv(); +const ioredis = new ioRedis(process.env.UPSTASH_REDIS_URL as string); + +export const subscriptionChannels = { + chatMessages: (chatID: string) => `chat_messages:${chatID}`, +}; +export const subscriptionChannelTypes = { + chatMessages: z.object({ + type: z + .literal('chunk') + .or(z.literal('completed')) + .or(z.literal('canceled')), + message: DBChatMessageSchema, + }), +}; +export const getRedisSubscriber = () => { + return ioredis.duplicate(); +}; + +export default redis; diff --git a/apps/server/src/utils/sql.ts b/apps/server/src/utils/sql.ts new file mode 100644 index 0000000..35ebabb --- /dev/null +++ b/apps/server/src/utils/sql.ts @@ -0,0 +1,99 @@ +import { type DBChatMessage, DBChatMessageSchema } from '@repo/db'; +import { type DatabasePool, sql } from 'slonik'; + +type StrippedDBChatMessage = Omit; +export async function upsertDBChatMessage( + message: StrippedDBChatMessage, + pool: DatabasePool, +): Promise { + try { + return await pool.one(sql.type(DBChatMessageSchema)` + INSERT INTO "ChatMessage" ( + id, + "userID", + "chatID", + "messageType", + "messageContent", + "status", + "createdAt", + "updatedAt" + ) VALUES ( + ${message.id}, + ${message.userID}, + ${message.chatID}, + ${message.messageType}, + ${message.messageContent}, + ${message.status}, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO UPDATE SET + "userID" = EXCLUDED."userID", + "chatID" = EXCLUDED."chatID", + "messageType" = EXCLUDED."messageType", + "messageContent" = EXCLUDED."messageContent", + "status" = EXCLUDED."status", + "updatedAt" = CURRENT_TIMESTAMP + RETURNING *; + `); + } catch (e) { + console.error(e); + throw e; + } +} + +type UpdateDBChatMessageParams = { + messageID: string; + status: DBChatMessage['status']; +}; +export async function updateDBChatMessage( + params: UpdateDBChatMessageParams, + pool: DatabasePool, +): Promise { + const { messageID, status } = params; + + try { + const updateFields = []; + + if (status !== undefined) { + updateFields.push(sql.fragment`"status" = ${status}`); + } + + if (updateFields.length === 0) { + throw new Error('No fields to update'); + } + + updateFields.push(sql.fragment`"updatedAt" = CURRENT_TIMESTAMP`); + + const updateFieldsSQL = sql.join(updateFields, sql.fragment`, `); + + return await pool.one(sql.type(DBChatMessageSchema)` + UPDATE "ChatMessage" + SET ${updateFieldsSQL} + WHERE id = ${messageID} + RETURNING *; + `); + } catch (e) { + console.error(e); + throw e; + } +} + +type GetPreviousChatMessagesParams = { + chatID: string; +}; +export async function getPreviousChatMessages( + params: GetPreviousChatMessagesParams, + pool: DatabasePool, +): Promise> { + const { chatID } = params; + + const messages = await pool.any(sql.type(DBChatMessageSchema)` + SELECT * + FROM "ChatMessage" + WHERE "chatID" = ${chatID} + ORDER BY id DESC + `); + + return messages; +} diff --git a/apps/ui/package.json b/apps/ui/package.json index 4461836..62c541f 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -27,19 +27,22 @@ "@trpc/server": "11.0.0-rc.477", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "event-source-polyfill": "^1.0.31", "lucide-react": "^0.426.0", "luxon": "^3.5.0", "react": "19.0.0-rc-d48603a5-20240813", "react-dom": "19.0.0-rc-d48603a5-20240813", "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", - "ulid": "^2.3.0" + "ulid": "^2.3.0", + "zod": "^3.23.8" }, "devDependencies": { "@repo/db": "workspace:*", "@repo/server": "workspace:*", "@tanstack/router-devtools": "^1.47.1", "@tanstack/router-plugin": "^1.47.0", + "@types/event-source-polyfill": "^1.0.5", "@types/luxon": "^3.4.2", "@types/node": "^22.1.0", "@types/react": "npm:types-react@rc", diff --git a/apps/ui/src/components/Codemirror/Codemirror.tsx b/apps/ui/src/components/Codemirror/Codemirror.tsx index c740e8e..5857f5c 100644 --- a/apps/ui/src/components/Codemirror/Codemirror.tsx +++ b/apps/ui/src/components/Codemirror/Codemirror.tsx @@ -7,6 +7,7 @@ import { EditorView, keymap, placeholder } from '@codemirror/view'; export interface CodemirrorEditorRef { focus: () => void; + clear: () => void; } type Props = { @@ -26,6 +27,17 @@ const CodeMirrorEditor = forwardRef( return viewRef.current.focus(); } }, + clear: () => { + if (viewRef.current) { + viewRef.current.dispatch({ + changes: { + from: 0, + to: viewRef.current.state.doc.length, + insert: '', + }, + }); + } + }, })); useEffect(() => { diff --git a/apps/ui/src/components/InputBox.tsx b/apps/ui/src/components/InputBox.tsx index 6a3d4e0..b780a2e 100644 --- a/apps/ui/src/components/InputBox.tsx +++ b/apps/ui/src/components/InputBox.tsx @@ -38,6 +38,7 @@ const InputBox: React.FC = ({ ) { e.preventDefault(); handleSubmit(inputContent); + editorRef.current?.clear(); } }; diff --git a/apps/ui/src/components/ui/alert.tsx b/apps/ui/src/components/ui/alert.tsx new file mode 100644 index 0000000..994a49c --- /dev/null +++ b/apps/ui/src/components/ui/alert.tsx @@ -0,0 +1,62 @@ +import { type VariantProps, cva } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-none bg-destructive text-white [&>svg]:text-white', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/ui/src/lib/trpc.ts b/apps/ui/src/lib/trpc.ts index 4b5e9cb..18a4f52 100644 --- a/apps/ui/src/lib/trpc.ts +++ b/apps/ui/src/lib/trpc.ts @@ -1,9 +1,17 @@ import type { AppRouter } from '@repo/server/src/trpc/router'; -import { createTRPCClient, unstable_httpBatchStreamLink } from '@trpc/client'; +import { + httpBatchLink, + splitLink, + unstable_httpSubscriptionLink, +} from '@trpc/client'; import { createTRPCQueryUtils, createTRPCReact } from '@trpc/react-query'; import type { inferRouterOutputs } from '@trpc/server'; +import { EventSourcePolyfill } from 'event-source-polyfill'; import { queryClient } from './reactQuery'; +// @ts-expect-error It's fine +globalThis.EventSource = EventSourcePolyfill; + let token: string | undefined; export function setAuthToken(newToken: string | undefined) { @@ -19,13 +27,26 @@ export const trpc = createTRPCReact(); export const trpcClient = trpc.createClient({ links: [ - unstable_httpBatchStreamLink({ - url: `${API_BASE_URL}/trpc`, - headers() { - return { - authorization: `Bearer ${token}`, - }; - }, + splitLink({ + condition: (op) => op.type === 'subscription', + true: unstable_httpSubscriptionLink({ + url: `${API_BASE_URL}/trpc`, + eventSourceOptions() { + return { + headers: { + authorization: `Bearer ${token}`, + }, + } as EventSourceInit; + }, + }), + false: httpBatchLink({ + url: `${API_BASE_URL}/trpc`, + headers() { + return { + authorization: `Bearer ${token}`, + }; + }, + }), }), ], }); @@ -34,16 +55,3 @@ export const trpcQueryUtils = createTRPCQueryUtils({ queryClient, client: trpcClient, }); - -export const vanillaTrpcClient = createTRPCClient({ - links: [ - unstable_httpBatchStreamLink({ - url: `${API_BASE_URL}/trpc`, - headers() { - return { - authorization: `Bearer ${token}`, - }; - }, - }), - ], -}); diff --git a/apps/ui/src/main.tsx b/apps/ui/src/main.tsx index aa2d417..ba3e646 100644 --- a/apps/ui/src/main.tsx +++ b/apps/ui/src/main.tsx @@ -13,7 +13,6 @@ const router = createRouter({ routeTree, defaultPreloadStaleTime: 0, context: { - initialChatStream: null, session: null, }, }); diff --git a/apps/ui/src/routeTree.gen.ts b/apps/ui/src/routeTree.gen.ts index e830aa6..1f9c12f 100644 --- a/apps/ui/src/routeTree.gen.ts +++ b/apps/ui/src/routeTree.gen.ts @@ -8,23 +8,18 @@ // This file is auto-generated by TanStack Router -import { createFileRoute } from "@tanstack/react-router" - // Import Routes import { Route as rootRoute } from "./routes/~__root" +import { Route as IndexImport } from "./routes/~index" import { Route as CChatIDImport } from "./routes/~c/~$chatID" -// Create Virtual Routes - -const IndexLazyImport = createFileRoute("/")() - // Create/Update Routes -const IndexLazyRoute = IndexLazyImport.update({ +const IndexRoute = IndexImport.update({ path: "/", getParentRoute: () => rootRoute, -} as any).lazy(() => import("./routes/~index.lazy").then((d) => d.Route)) +} as any) const CChatIDRoute = CChatIDImport.update({ path: "/c/$chatID", @@ -39,7 +34,7 @@ declare module "@tanstack/react-router" { id: "/" path: "/" fullPath: "/" - preLoaderRoute: typeof IndexLazyImport + preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } "/c/$chatID": { @@ -54,7 +49,7 @@ declare module "@tanstack/react-router" { // Create and export the route tree -export const routeTree = rootRoute.addChildren({ IndexLazyRoute, CChatIDRoute }) +export const routeTree = rootRoute.addChildren({ IndexRoute, CChatIDRoute }) /* prettier-ignore-end */ @@ -69,7 +64,7 @@ export const routeTree = rootRoute.addChildren({ IndexLazyRoute, CChatIDRoute }) ] }, "/": { - "filePath": "~index.lazy.tsx" + "filePath": "~index.tsx" }, "/c/$chatID": { "filePath": "~c/~$chatID.tsx" diff --git a/apps/ui/src/routes/~__root.tsx b/apps/ui/src/routes/~__root.tsx index fb660d9..0a2e9e3 100644 --- a/apps/ui/src/routes/~__root.tsx +++ b/apps/ui/src/routes/~__root.tsx @@ -9,13 +9,8 @@ import { import UserIcon from '@/components/ui/userIcon'; import * as KeyboardListener from '@/lib/keyboardListener'; import { supabase } from '@/lib/supabase'; -import { - type TRPCOutputs, - setAuthToken, - trpc, - trpcQueryUtils, -} from '@/lib/trpc'; -import { type AsyncGeneratorYieldType, truncateString } from '@/lib/utils'; +import { setAuthToken, trpc, trpcQueryUtils } from '@/lib/trpc'; +import { truncateString } from '@/lib/utils'; import type { Session } from '@supabase/supabase-js'; import { Link, @@ -30,9 +25,6 @@ import { DateTime } from 'luxon'; import { useEffect, useState } from 'react'; type RouterContext = { - initialChatStream: ReadableStream< - AsyncGeneratorYieldType - > | null; session: Session | null; }; @@ -49,6 +41,7 @@ export const Route = createRootRouteWithContext()({ // Also have to handle potential sign in errors along with that. if (!session) { const { data } = await supabase.auth.signInAnonymously(); + console.log('ANON SIGNIN', data); session = data.session; isAnonymous = true; } diff --git a/apps/ui/src/routes/~c/components/chatContainer.tsx b/apps/ui/src/routes/~c/components/chatContainer.tsx index 6b9cee6..7f415dd 100644 --- a/apps/ui/src/routes/~c/components/chatContainer.tsx +++ b/apps/ui/src/routes/~c/components/chatContainer.tsx @@ -7,14 +7,10 @@ import Message, { AssistantMessage } from './message'; type Props = { chatID: string; - currentlyStreamingMessage: string | null; }; -const ChatContainer: React.FC = ({ - chatID, - currentlyStreamingMessage, -}) => { +const ChatContainer: React.FC = ({ chatID }) => { const messagesInfiniteQuery = - trpc.chatMessages.infiniteList.useInfiniteQuery( + trpc.chat.infiniteListMessages.useInfiniteQuery( { chatID, limit: 10, @@ -39,7 +35,7 @@ const ChatContainer: React.FC = ({ const chatMessagesRef = useRef(null); const [isFull, setIsFull] = useState(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: It's fine + // biome-ignore lint/correctness/useExhaustiveDependencies: Needs to trigger on every message update useLayoutEffect(() => { // If the entire screen is filled out, then we want to start rendering messages // from the bottom, like the chat is getting pushed upwards each new message. @@ -66,7 +62,7 @@ const ChatContainer: React.FC = ({ setIsFull(false); } } - }, [messages, currentlyStreamingMessage]); + }, [messages]); return (
= ({ className="w-full max-w-4xl flex-grow flex items-center overflow-y-scroll mb-20" >
- {/* If the chat fills the screen then we're rendering in reverse order, so - for the currently streaming message to show on the bottom it needs to - be on the top in the code */} - {currentlyStreamingMessage && isFull && ( - - )} {/* If the chat fills the screen then we need to reverse the order of the messages. However, the messages are *already* in reverse order since the API returns them in latest message first. So we don't need to do @@ -91,9 +81,6 @@ const ChatContainer: React.FC = ({ {(isFull ? messages : messages.slice().reverse()).map((m) => ( ))} - {currentlyStreamingMessage && !isFull && ( - - )}
); diff --git a/apps/ui/src/routes/~c/~$chatID.tsx b/apps/ui/src/routes/~c/~$chatID.tsx index b9d72e2..b7da256 100644 --- a/apps/ui/src/routes/~c/~$chatID.tsx +++ b/apps/ui/src/routes/~c/~$chatID.tsx @@ -14,7 +14,7 @@ import ChatContainer from './components/chatContainer'; import Message, { AssistantMessage } from './components/message'; type InfiniteQueryData = { - pages: TRPCOutputs['chatMessages']['infiniteList'][]; + pages: TRPCOutputs['chat']['infiniteListMessages'][]; }; export const Route = createFileRoute('/c/$chatID')({ @@ -25,22 +25,12 @@ export const Route = createFileRoute('/c/$chatID')({ to: '/', }); } - // Some page redirecting to the chat could give us an initial chat message that - // should immediately start preocessing - const initialChatStream = context.initialChatStream; - context.initialChatStream = null; // Put some data in the cache - await trpcQueryUtils.chatMessages.infiniteList.prefetchInfinite({ + await trpcQueryUtils.chat.infiniteListMessages.prefetchInfinite({ chatID: params.chatID, limit: 10, }); - - const ret = { - initialChatStream, - }; - - return ret; }, component: Chat, }); @@ -48,12 +38,13 @@ export const Route = createFileRoute('/c/$chatID')({ function Chat() { const { session } = Route.useRouteContext(); const { chatID } = Route.useParams(); - const { initialChatStream } = useLoaderData({ - from: '/c/$chatID', - }); + + const [latestSeenMessageID, setLatestSeenMessageID] = useState< + string | undefined + >(undefined); const infiniteMessagesQueryKey = getQueryKey( - trpc.chatMessages.infiniteList, + trpc.chat.infiniteListMessages, { chatID, limit: 10, @@ -62,7 +53,7 @@ function Chat() { ); const getNewPageData = ( prevData: InfiniteQueryData, - message: TRPCOutputs['chatMessages']['infiniteList']['items'][0], + message: TRPCOutputs['chat']['infiniteListMessages']['items'][0], ) => { const updatedPage = { ...prevData.pages[0] }; const existingMessageIndex = updatedPage.items.findIndex( @@ -87,46 +78,7 @@ function Chat() { }; }; - const processMessageChunk = async ( - chunk: - | AsyncGeneratorYieldType - | AsyncGeneratorYieldType, - ): Promise => { - if (chunk.type === 'chat') { - // WTF - return; - } else if (chunk.type === 'userMessage') { - queryClient.setQueryData( - infiniteMessagesQueryKey, - (prevData: InfiniteQueryData) => { - const newData = getNewPageData(prevData, chunk.message); - // Remove optimistic message - newData.pages[0].items = newData.pages[0].items.filter( - (item) => item.id !== 'OPTIMISTIC_USER_MESSAGE', - ); - return newData; - }, - ); - } else if (chunk.type === 'messageChunk') { - setCurrentlyStreamingMessage((m) => { - const chunkContent = chunk.messageChunk.messageContent; - if (m === null) { - return chunkContent; - } else { - return m + chunk.messageChunk.messageContent; - } - }); - } else { - queryClient.setQueryData( - infiniteMessagesQueryKey, - (prevData: InfiniteQueryData) => - getNewPageData(prevData, chunk.message), - ); - setCurrentlyStreamingMessage(null); - } - }; - - const sendMessageMutation = trpc.chatMessages.send.useMutation({ + const sendMessageMutation = trpc.chat.sendMessage.useMutation({ onMutate: (variables) => { // Optimistically set the user message queryClient.setQueryData( @@ -138,76 +90,68 @@ function Chat() { userID: session?.user.id ?? '', messageType: 'user', messageContent: variables.message, - responseStatus: 'not_started', + status: 'done', createdAt: DateTime.now().toISO(), updatedAt: DateTime.now().toISO(), }), ); }, onSuccess: async (data, variables, context) => { - for await (const chunk of data) { - processMessageChunk(chunk); - } - queryClient.invalidateQueries({ - queryKey: infiniteMessagesQueryKey, - }); - const chatListQueryKey = getQueryKey( - trpc.chat.infiniteList, - { - limit: 10, + setLatestSeenMessageID(data.id); + queryClient.setQueryData( + infiniteMessagesQueryKey, + (prevData: InfiniteQueryData) => { + const newData = getNewPageData(prevData, data); + // Remove optimistic message + newData.pages[0].items = newData.pages[0].items.filter( + (item) => item.id !== 'OPTIMISTIC_USER_MESSAGE', + ); + return newData; }, - 'any', ); - queryClient.invalidateQueries({ - queryKey: chatListQueryKey, - }); }, }); - const [currentlyStreamingMessage, setCurrentlyStreamingMessage] = useState< - string | null - >(null); - - // If it was passed an initialMessage then it means we have a message to immediately - // start rendering - // biome-ignore lint/correctness/useExhaustiveDependencies: It's fine - useEffect(() => { - if (!initialChatStream) return; + // TEMP: To trigger rerenders until I figure out how to do this better + const [, setCurrentlyStreamingMessage] = useState(''); - let isMounted = true; - // This doesn't totally work since the reader is async, so when you release the - // lock there could be a pending read which would then error. However, it still - // works so I'm just going to leave it like this because I don't know how to - // fix it - const reader = initialChatStream.getReader(); - - const readStream = async () => { - try { - while (isMounted) { - const { done, value: chunk } = await reader.read(); - if (done) { - queryClient.invalidateQueries({ - queryKey: infiniteMessagesQueryKey, - }); - break; - } - - processMessageChunk(chunk); + trpc.chat.listenNewMessages.useSubscription( + { + chatID, + latestSeenMessageID, + }, + { + onData: (data) => { + // TODO: Shouldn't rerender the entire list on every update + queryClient.setQueryData( + infiniteMessagesQueryKey, + (prevData: InfiniteQueryData) => { + let messageToUpdate = prevData.pages[0].items.find( + (m) => m.id === data.id, + ); + if (!messageToUpdate) { + messageToUpdate = data; + } else { + if (data.status === 'streaming') { + messageToUpdate.messageContent += + data.messageContent; + setCurrentlyStreamingMessage( + messageToUpdate.messageContent, + ); + } else { + messageToUpdate = data; + setCurrentlyStreamingMessage(''); + } + } + return getNewPageData(prevData, messageToUpdate); + }, + ); + if (data.status === 'done') { + setLatestSeenMessageID(data.id); } - } catch (err) { - console.error('[ERROR] failed reading stream:', err); - } finally { - reader.releaseLock(); - } - }; - - readStream(); - - return () => { - isMounted = false; - reader.releaseLock(); - }; - }, [initialChatStream]); + }, + }, + ); const handleSubmit = (message: string) => { if (!sendMessageMutation.isPending) { @@ -220,10 +164,7 @@ function Chat() { return (
- +
{ - const hour = DateTime.local().hour; - if (hour < 12) { - return 'morning'; - } else if (hour < 18) { - return 'afternoon'; - } else { - return 'evening'; - } - }; - - const handleSubmit = (text: string): void => - startSubmitChatMessageTransition(async () => { - // Create the chat and send the first message - const createChatGenerator = await createChatMutation.mutateAsync({ - initialMessage: text, - }); - await queryClient.invalidateQueries({ - queryKey: getQueryKey(trpc.chat.infiniteList, undefined, 'any'), - }); - - // Convert the async generator into a readable stream - const stream = new ReadableStream< - AsyncGeneratorYieldType - >({ - async start(controller) { - for await (const chunk of createChatGenerator) { - controller.enqueue(chunk); - } - controller.close(); - }, - }); - - // Get the first value from the stream which should be a chat - const reader = stream.getReader(); - const { done, value: chunk } = await reader.read(); - reader.releaseLock(); - if (chunk?.type !== 'chat') { - // TODO: handle error - console.error('FIRST VALUE NOT CHAT??'); - return; - } - - // Now the stream gets passed into the router context so you can just keep - // reading from it without worrying about resetting it - - context.initialChatStream = stream; - router.navigate({ - from: '/', - to: '/c/$chatID', - params: { - chatID: chunk.chat.id, - }, - }); - }); - - const [pendingLogin, startLoginTransition] = useTransition(); - - const handleLogin = (): void => - startLoginTransition(async () => { - const { data, error } = await supabase.auth.linkIdentity({ - provider: 'google', - }); - if (error) { - console.error(error); - return; - } - console.log('SUCCESS', data); - }); - - return ( -
-

- Good {getGreeting()} - {name != null ? `, ${name}` : ''} :) -

-
- -
- {(!context.session || context.session.user.is_anonymous) && ( - - )} -
- ); -} diff --git a/apps/ui/src/routes/~index.tsx b/apps/ui/src/routes/~index.tsx new file mode 100644 index 0000000..5fe4052 --- /dev/null +++ b/apps/ui/src/routes/~index.tsx @@ -0,0 +1,199 @@ +import InputBox from '@/components/InputBox'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { queryClient } from '@/lib/reactQuery'; +import { supabase } from '@/lib/supabase'; +import { type TRPCOutputs, trpc } from '@/lib/trpc'; +import type { AsyncGeneratorYieldType } from '@/lib/utils'; +import type { Session } from '@supabase/supabase-js'; +import { + createFileRoute, + useRouteContext, + useRouter, + useSearch, +} from '@tanstack/react-router'; +import { getQueryKey } from '@trpc/react-query'; +import { XCircle } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { useEffect, useState, useTransition } from 'react'; +import { z } from 'zod'; + +const searchSchema = z.object({ + error: z.string().optional(), + error_code: z.number().optional(), + error_description: z.string().optional(), +}); + +export const Route = createFileRoute('/')({ + validateSearch: searchSchema, + beforeLoad(ctx) { + if ( + ctx.search.error && + ctx.search.error_code === 422 && + ctx.search.error_description === + 'Identity+is+already+linked+to+another+user' + ) { + return { + needsRegularLogin: true, + }; + } + return { + needsRegularLogin: false, + }; + }, + component: Index, +}); + +function maybeGetName(session: Session | null): null | string { + if (!session) return null; + const name = + session.user.identities?.[0]?.identity_data?.full_name || + session.user.identities?.[0]?.identity_data?.full_name; + if (!name) return null; + const nameParts = name.split(' '); + if (nameParts.length === 1) { + return nameParts[0]; + } + return nameParts.slice(0, -1).join(' '); +} + +function Index() { + const router = useRouter(); + const context = useRouteContext({ from: '/' }); + const name = maybeGetName(context.session); + + const [pendingSubmitChatMessage, startSubmitChatMessageTransition] = + useTransition(); + const createChatMutation = trpc.chat.create.useMutation(); + + const getGreeting = () => { + const hour = DateTime.local().hour; + if (hour < 12) { + return 'morning'; + } else if (hour < 18) { + return 'afternoon'; + } else { + return 'evening'; + } + }; + + const handleSubmit = (text: string): void => + startSubmitChatMessageTransition(async () => { + // Create the chat and send the first message + const newChat = await createChatMutation.mutateAsync({ + initialMessage: text, + }); + await queryClient.invalidateQueries({ + queryKey: getQueryKey(trpc.chat.infiniteList, undefined, 'any'), + }); + + router.navigate({ + from: '/', + to: '/c/$chatID', + params: { + chatID: newChat.id, + }, + }); + }); + + const [pendingLogin, startLoginTransition] = useTransition(); + + const handleLogin = (): void => + startLoginTransition(async () => { + // Try to link the identity + const linkResult = await supabase.auth.linkIdentity({ + provider: 'google', + }); + if (linkResult.error) { + console.error('FAILED LINK', linkResult.error); + } + console.log('LINK SUCCESS', linkResult.data); + }); + + useEffect(() => { + if (context.needsRegularLogin) { + // If we failed to link the identity because the user already has an account + // then automatically try logging in. It's a bit weird but for now this is just + // how supabase works. I also wish there was a way to do it without the page + // redirects in between. + // TODO: I think this will infinite loop during the login process if either the link + // fails + startLoginTransition(async () => { + const loginResult = await supabase.auth.signInWithOAuth({ + provider: 'google', + }); + if (loginResult.error) { + console.error('FAILED LOGIN', loginResult); + return; + } + console.log('LOGIN SUCCESS', loginResult.data); + }); + } + }, [context.needsRegularLogin]); + + return ( +
+ +

+ Good {getGreeting()} + {name != null ? `, ${name}` : ''} :) +

+
+ +
+ {(!context.session || context.session.user.is_anonymous) && ( + + )} +
+ ); +} + +function ErrorAlert() { + const search = useSearch({ + from: '/', + }); + const [showAlert, setShowAlert] = useState(false); + + useEffect(() => { + if (search.error) { + setShowAlert(true); + // Auto-hide the alert after 5 seconds + const timer = setTimeout(() => setShowAlert(false), 5000); + return () => clearTimeout(timer); + } + }, [search.error]); + + if (!search.error) return null; + + let { error, error_code, error_description } = search; + error_description = error_description?.replace(/\+/g, ' '); + + let title = `Error ${error_code}: ${error}`; + let description = error_description; + + // We should be reshaping known errors to better error messages + if ( + error_code === 422 && + error_description === 'Identity is already linked to another user' + ) { + title = 'Error!'; + description = 'User already existings. Logging in...'; + } + + return ( +
+ + + {title} + {description} + +
+ ); +} diff --git a/infra/redis/docker-compose.yml b/infra/redis/docker-compose.yml new file mode 100644 index 0000000..b9449f7 --- /dev/null +++ b/infra/redis/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3" +services: + redis: + image: redis + ports: + - "6379:6379" + serverless-redis-http: + ports: + - "8079:80" + image: hiett/serverless-redis-http:latest + environment: + SRH_MODE: env + SRH_TOKEN: srh_token + SRH_CONNECTION_STRING: "redis://redis:6379" # Using `redis` hostname since they're in the same Docker network. diff --git a/infra/redis/package.json b/infra/redis/package.json new file mode 100644 index 0000000..4116269 --- /dev/null +++ b/infra/redis/package.json @@ -0,0 +1,9 @@ +{ + "name": "redis", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "dev": "docker-compose up" + } +} diff --git a/infra/redis/start-srh.sh b/infra/redis/start-srh.sh new file mode 100644 index 0000000..6252526 --- /dev/null +++ b/infra/redis/start-srh.sh @@ -0,0 +1,8 @@ +#!/usr/env/bin bash + +docker run \ + -it -d -p 8080:80 --name srh \ + -e SRH_MODE=env \ + -e SRH_TOKEN=your_token_here \ + -e SRH_CONNECTION_STRING="redis://your_server_here:6379" \ + hiett/serverless-redis-http:latest diff --git a/infra/supabase/migrations/20240825065312_init.sql b/infra/supabase/migrations/20240825065312_init.sql index 19d61d2..ed48877 100644 --- a/infra/supabase/migrations/20240825065312_init.sql +++ b/infra/supabase/migrations/20240825065312_init.sql @@ -20,8 +20,7 @@ create table "messageContent" text not null, "createdAt" timestamp with time zone not null default now(), "updatedAt" timestamp with time zone not null default now(), - "responseStatus" text null, - "responseMessageID" text null, + "status" text null, constraint ChatMessage_pkey primary key (id), constraint ChatMessage_chatID_fkey foreign key ("chatID") references "Chat" (id) on update cascade on delete cascade, constraint ChatMessage_userID_fkey foreign key ("userID") references auth.users (id) diff --git a/packages/db/index.ts b/packages/db/index.ts index 0fcb0b7..5ad52ac 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,11 +1,23 @@ import { z } from 'zod'; +const preprocessedDate = z.preprocess((arg) => { + if ( + // If it's a ISO date string + typeof arg === 'string' || + // Or an epoch time + typeof arg === 'number' || + arg instanceof Date + ) + return new Date(arg); + return arg; +}, z.date()); + export const DBChatSchema = z.object({ id: z.string().ulid(), userID: z.string().uuid(), previewName: z.string().nullable(), - createdAt: z.date(), - updatedAt: z.date(), + createdAt: preprocessedDate, + updatedAt: preprocessedDate, }); export type DBChat = z.infer; @@ -14,18 +26,15 @@ const BaseDBChatMessageSchema = z.object({ userID: z.string().uuid(), chatID: z.string().ulid(), messageContent: z.string(), - createdAt: z.date(), - updatedAt: z.date(), + status: z.enum(['streaming', 'done', 'canceled']), + createdAt: preprocessedDate, + updatedAt: preprocessedDate, }); const UserMessageSchema = BaseDBChatMessageSchema.extend({ messageType: z.literal('user'), - responseStatus: z.enum(['not_started', 'streaming', 'done', 'canceled']), - responseMessageID: z.string().ulid().optional(), }); const AssistantMessageSchema = BaseDBChatMessageSchema.extend({ messageType: z.literal('assistant'), - responseStatus: z.undefined(), - responseMessageID: z.undefined(), }); export const DBChatMessageSchema = z.discriminatedUnion('messageType', [ UserMessageSchema, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e243cb..b89013d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,15 @@ importers: apps/server: dependencies: + '@fastify/cookie': + specifier: ^10.0.1 + version: 10.0.1 '@fastify/cors': - specifier: ^9.0.1 - version: 9.0.1 + specifier: ^10.0.1 + version: 10.0.1 '@fastify/request-context': - specifier: ^5.1.0 - version: 5.1.0 + specifier: ^6.0.1 + version: 6.0.1 '@repo/db': specifier: workspace:* version: link:../../packages/db @@ -47,15 +50,21 @@ importers: '@trpc/server': specifier: 11.0.0-rc.477 version: 11.0.0-rc.477 + '@upstash/redis': + specifier: ^1.34.0 + version: 1.34.0 dotenv: specifier: ^16.4.5 version: 16.4.5 fastify: - specifier: ^4.28.1 - version: 4.28.1 + specifier: ^5.0.0 + version: 5.0.0 fastify-plugin: - specifier: ^4.5.1 - version: 4.5.1 + specifier: ^5.0.1 + version: 5.0.1 + ioredis: + specifier: ^5.4.1 + version: 5.4.1 luxon: specifier: ^3.5.0 version: 3.5.0 @@ -153,6 +162,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + event-source-polyfill: + specifier: ^1.0.31 + version: 1.0.31 lucide-react: specifier: ^0.426.0 version: 0.426.0(react@19.0.0-rc-d48603a5-20240813) @@ -174,6 +186,9 @@ importers: ulid: specifier: ^2.3.0 version: 2.3.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@repo/db': specifier: workspace:* @@ -184,6 +199,9 @@ importers: '@tanstack/router-plugin': specifier: ^1.47.0 version: 1.47.0(vite@5.4.0) + '@types/event-source-polyfill': + specifier: ^1.0.5 + version: 1.0.5 '@types/luxon': specifier: ^3.4.2 version: 3.4.2 @@ -227,6 +245,8 @@ importers: specifier: ^5.4.0 version: 5.4.0(@types/node@22.1.0) + infra/redis: {} + infra/supabase: {} packages/db: @@ -280,7 +300,7 @@ packages: '@babel/traverse': 7.25.3 '@babel/types': 7.25.2 convert-source-map: 2.0.0 - debug: 4.3.6 + debug: 4.3.6(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -448,7 +468,7 @@ packages: '@babel/parser': 7.25.3 '@babel/template': 7.25.0 '@babel/types': 7.25.2 - debug: 4.3.6 + debug: 4.3.6(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -903,29 +923,36 @@ packages: dev: true optional: true - /@fastify/ajv-compiler@3.6.0: - resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + /@fastify/ajv-compiler@4.0.1: + resolution: {integrity: sha512-DxrBdgsjNLP0YM6W5Hd6/Fmj43S8zMKiFJYgi+Ri3htTGAowPVG/tG1wpnWLMjufEnehRivUCKZ1pLDIoZdTuw==} dependencies: ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - fast-uri: 2.4.0 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.0.1 dev: false - /@fastify/cors@9.0.1: - resolution: {integrity: sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==} + /@fastify/cookie@10.0.1: + resolution: {integrity: sha512-NV/wbCUv4ETJ5KM1KMu0fLx0nSCm9idIxwg66NZnNbfPQH3rdbx6k0qRs5uy0y+MhBgvDudYRA30KlK659chyw==} dependencies: - fastify-plugin: 4.5.1 - mnemonist: 0.39.6 + cookie-signature: 1.2.1 + fastify-plugin: 5.0.1 dev: false - /@fastify/error@3.4.1: - resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + /@fastify/cors@10.0.1: + resolution: {integrity: sha512-O8JIf6448uQbOgzSkCqhClw6gFTAqrdfeA6R3fc/3gwTJGUp7gl8/3tbNB+6INuu4RmgVOq99BmvdGbtu5pgOA==} + dependencies: + fastify-plugin: 5.0.1 + mnemonist: 0.39.8 dev: false - /@fastify/fast-json-stringify-compiler@4.3.0: - resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + /@fastify/error@4.0.0: + resolution: {integrity: sha512-OO/SA8As24JtT1usTUTKgGH7uLvhfwZPwlptRi2Dp5P4KKmJI3gvsZ8MIHnNwDs4sLf/aai5LzTyl66xr7qMxA==} + dev: false + + /@fastify/fast-json-stringify-compiler@5.0.1: + resolution: {integrity: sha512-f2d3JExJgFE3UbdFcpPwqNUEoHWmt8pAKf8f+9YuLESdefA0WgqxeT6DrGL4Yrf/9ihXNSKOqpjEmurV405meA==} dependencies: - fast-json-stringify: 5.16.1 + fast-json-stringify: 6.0.0 dev: false /@fastify/merge-json-schemas@0.1.1: @@ -934,10 +961,10 @@ packages: fast-deep-equal: 3.1.3 dev: false - /@fastify/request-context@5.1.0: - resolution: {integrity: sha512-PM7wrLJOEylVDpxabOFLaYsdAiaa0lpDUcP2HMFJ1JzgiWuC6k4r3duf6Pm9YLnzlGmT+Yp4tkQjqsu7V/pSOA==} + /@fastify/request-context@6.0.1: + resolution: {integrity: sha512-eivIMvr5IXeCsvOTdS0QQfFLPOinxAKnjmqnz4whOUbqnuzAi2ScpGmd4JPRpRLD2Y/SiX7QW5pfaqGmMZF1ng==} dependencies: - fastify-plugin: 4.5.1 + fastify-plugin: 5.0.1 dev: false /@floating-ui/core@1.6.7: @@ -968,6 +995,10 @@ packages: resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} dev: false + /@ioredis/commands@1.2.0: + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: false + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2220,6 +2251,10 @@ packages: /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + /@types/event-source-polyfill@1.0.5: + resolution: {integrity: sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==} + dev: true + /@types/hast@3.0.4: resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} dependencies: @@ -2272,13 +2307,13 @@ packages: resolution: {integrity: sha512-xegpDuR+z0UqG9fwHqNoy3rI7JDlvaPh2TY47Fl80oq6g+hXT+c/LEuE43X48clZ6lOfANl5WrPur9fYO1RJ/w==} dev: false - /@types/prop-types@15.7.12: - resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + /@types/prop-types@15.7.13: + resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} - /@types/react@18.3.4: - resolution: {integrity: sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==} + /@types/react@18.3.8: + resolution: {integrity: sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==} dependencies: - '@types/prop-types': 15.7.12 + '@types/prop-types': 15.7.13 csstype: 3.1.3 /@types/responselike@1.0.3: @@ -2305,6 +2340,12 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: false + /@upstash/redis@1.34.0: + resolution: {integrity: sha512-TrXNoJLkysIl8SBc4u9bNnyoFYoILpCcFJcLyWCccb/QSUmaVKdvY0m5diZqc3btExsapcMbaw/s/wh9Sf1pJw==} + dependencies: + crypto-js: 4.2.0 + dev: false + /@vitejs/plugin-react@4.3.1(vite@5.4.0): resolution: {integrity: sha512-m/V2syj5CuVnaxcUJOQRel/Wr31FFXRFlnOoq1TVtkCxsY5veGMTEmpWHndrhB2U8ScHtCQB1e+4hWYExQc6Lg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2361,17 +2402,6 @@ packages: humanize-ms: 1.2.1 dev: false - /ajv-formats@2.1.1(ajv@8.17.1): - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - dependencies: - ajv: 8.17.1 - dev: false - /ajv-formats@3.0.1(ajv@8.17.1): resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -2477,10 +2507,10 @@ packages: postcss-value-parser: 4.2.0 dev: true - /avvio@8.3.2: - resolution: {integrity: sha512-st8e519GWHa/azv8S87mcJvZs4WsgTBjOw/Ih1CP6u+8SZvcOeAYNG6JbsIrAUUJJ7JfmrnOkR8ipDS+u9SIRQ==} + /avvio@9.0.0: + resolution: {integrity: sha512-UbYrOXgE/I+knFG+3kJr9AgC7uNo8DG+FGGODpH9Bj1O1kL/QDjBXnTem9leD3VdQKtaHjV3O85DQ7hHh4IIHw==} dependencies: - '@fastify/error': 3.4.1 + '@fastify/error': 4.0.0 fastq: 1.17.1 dev: false @@ -2710,6 +2740,11 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: false + /cmd-shim@6.0.3: resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -2781,6 +2816,11 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie-signature@1.2.1: + resolution: {integrity: sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==} + engines: {node: '>=6.6.0'} + dev: false + /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -2811,6 +2851,10 @@ packages: which: 2.0.2 dev: true + /crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + dev: false + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2825,17 +2869,6 @@ packages: engines: {node: '>= 12'} dev: true - /debug@4.3.6: - resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - /debug@4.3.6(supports-color@5.5.0): resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} engines: {node: '>=6.0'} @@ -2847,7 +2880,6 @@ packages: dependencies: ms: 2.1.2 supports-color: 5.5.0 - dev: true /decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} @@ -2872,6 +2904,11 @@ packages: engines: {node: '>=0.4.0'} dev: false + /denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dev: false + /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2980,6 +3017,10 @@ packages: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} dev: false + /event-source-polyfill@1.0.31: + resolution: {integrity: sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==} + dev: false + /event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -3063,10 +3104,6 @@ packages: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} dev: false - /fast-content-type-parse@1.1.0: - resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} - dev: false - /fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} dev: false @@ -3086,8 +3123,8 @@ packages: micromatch: 4.0.7 dev: true - /fast-json-stringify@5.16.1: - resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + /fast-json-stringify@6.0.0: + resolution: {integrity: sha512-FGMKZwniMTgZh7zQp9b6XnBVxUmKVahQLQeRQHqwYmPDqDhcEKZ3BaQsxelFFI5PY7nN71OEeiL47/zUWcYe1A==} dependencies: '@fastify/merge-json-schemas': 0.1.1 ajv: 8.17.1 @@ -3124,24 +3161,23 @@ packages: resolution: {integrity: sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==} dev: false - /fastify-plugin@4.5.1: - resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + /fastify-plugin@5.0.1: + resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==} dev: false - /fastify@4.28.1: - resolution: {integrity: sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==} + /fastify@5.0.0: + resolution: {integrity: sha512-Qe4dU+zGOzg7vXjw4EvcuyIbNnMwTmcuOhlOrOJsgwzvjEZmsM/IeHulgJk+r46STjdJS/ZJbxO8N70ODXDMEQ==} dependencies: - '@fastify/ajv-compiler': 3.6.0 - '@fastify/error': 3.4.1 - '@fastify/fast-json-stringify-compiler': 4.3.0 + '@fastify/ajv-compiler': 4.0.1 + '@fastify/error': 4.0.0 + '@fastify/fast-json-stringify-compiler': 5.0.1 abstract-logging: 2.0.1 - avvio: 8.3.2 - fast-content-type-parse: 1.1.0 - fast-json-stringify: 5.16.1 - find-my-way: 8.2.0 - light-my-request: 5.13.0 - pino: 9.3.2 - process-warning: 3.0.0 + avvio: 9.0.0 + fast-json-stringify: 6.0.0 + find-my-way: 9.0.1 + light-my-request: 6.0.0 + pino: 9.4.0 + process-warning: 4.0.0 proxy-addr: 2.0.7 rfdc: 1.4.1 secure-json-parse: 2.7.0 @@ -3192,13 +3228,13 @@ packages: to-regex-range: 5.0.1 dev: true - /find-my-way@8.2.0: - resolution: {integrity: sha512-HdWXgFYc6b1BJcOBDBwjqWuHJj1WYiqrxSh25qtU4DabpMFdj/gSunNBQb83t+8Zt67D7CXEzJWTkxaShMTMOA==} + /find-my-way@9.0.1: + resolution: {integrity: sha512-/5NN/R0pFWuff16TMajeKt2JyiW+/OE8nOO8vo1DwZTxLaIURb7lcBYPIgRPh61yCNh9l8voeKwcrkUzmB00vw==} engines: {node: '>=14'} dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 - safe-regex2: 3.1.0 + safe-regex2: 4.0.0 dev: false /find-versions@5.1.0: @@ -3368,7 +3404,6 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - dev: true /hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} @@ -3473,6 +3508,23 @@ packages: resolution: {integrity: sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==} dev: false + /ioredis@5.4.1: + resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.6(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: false + /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3627,11 +3679,11 @@ packages: json-buffer: 3.0.1 dev: true - /light-my-request@5.13.0: - resolution: {integrity: sha512-9IjUN9ZyCS9pTG+KqTDEQo68Sui2lHsYBrfMyVUTTZ3XhH8PMZq7xO94Kr+eP9dhi/kcKsx4N41p2IXEBil1pQ==} + /light-my-request@6.0.0: + resolution: {integrity: sha512-kFkFXrmKCL0EEeOmJybMH5amWFd+AFvlvMlvFTRxCUwbhfapZqDmeLMPoWihntnYY6JpoQDE9k+vOzObF1fDqg==} dependencies: cookie: 0.6.0 - process-warning: 3.0.0 + process-warning: 4.0.0 set-cookie-parser: 2.7.0 dev: false @@ -3680,6 +3732,14 @@ packages: wrap-ansi: 9.0.0 dev: true + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: false + + /lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: false + /log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -4157,7 +4217,7 @@ packages: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} dependencies: '@types/debug': 4.1.12 - debug: 4.3.6 + debug: 4.3.6(supports-color@5.5.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.1 @@ -4259,8 +4319,8 @@ packages: hasBin: true dev: true - /mnemonist@0.39.6: - resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==} + /mnemonist@0.39.8: + resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==} dependencies: obliterator: 2.0.4 dev: false @@ -4671,8 +4731,8 @@ packages: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} dev: false - /pino@9.3.2: - resolution: {integrity: sha512-WtARBjgZ7LNEkrGWxMBN/jvlFiE17LTbBoH0konmBU684Kd0uIiDwBXlcTCW7iJnA6HfIKwUssS/2AC6cDEanw==} + /pino@9.4.0: + resolution: {integrity: sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w==} hasBin: true dependencies: atomic-sleep: 1.0.0 @@ -4683,8 +4743,8 @@ packages: process-warning: 4.0.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 - safe-stable-stringify: 2.4.3 - sonic-boom: 4.0.1 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.1.0 thread-stream: 3.1.0 dev: false @@ -4828,10 +4888,6 @@ packages: hasBin: true dev: true - /process-warning@3.0.0: - resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} - dev: false - /process-warning@4.0.0: resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} dev: false @@ -4970,6 +5026,18 @@ packages: engines: {node: '>= 12.13.0'} dev: false + /redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + dev: false + + /redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: false + /remark-gfm@4.0.0: resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} dependencies: @@ -5044,8 +5112,8 @@ packages: signal-exit: 4.1.0 dev: true - /ret@0.4.3: - resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + /ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} dev: false @@ -5107,10 +5175,10 @@ packages: /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - /safe-regex2@3.1.0: - resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} + /safe-regex2@4.0.0: + resolution: {integrity: sha512-Hvjfv25jPDVr3U+4LDzBuZPPOymELG3PYcSk5hcevooo1yxxamQL/bHs/GrEPGmMoMEwRrHVGiCA1pXi97B8Ew==} dependencies: - ret: 0.4.3 + ret: 0.5.0 dev: false /safe-stable-stringify@2.4.3: @@ -5118,6 +5186,11 @@ packages: engines: {node: '>=10'} dev: false + /safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + dev: false + /scheduler@0.25.0-rc-d48603a5-20240813: resolution: {integrity: sha512-PTvBmlpXlrUb61CeGZqI4zSfdsUDewxnRIQP+UC1XOy3Ol3SvbcOJYMJ+fDY7vF/QfebMB15OgtD7rAc04srQQ==} @@ -5244,8 +5317,8 @@ packages: - pg-native dev: false - /sonic-boom@4.0.1: - resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==} + /sonic-boom@4.1.0: + resolution: {integrity: sha512-NGipjjRicyJJ03rPiZCJYjwlsuP2d1/5QUviozRXC7S3WdVWNK5e3Ojieb9CCyfhq2UC+3+SRd9nG3I2lPRvUw==} dependencies: atomic-sleep: 1.0.0 dev: false @@ -5302,6 +5375,10 @@ packages: type-fest: 0.7.1 dev: false + /standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false + /strict-event-emitter-types@2.0.0: resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} dev: false @@ -5435,7 +5512,6 @@ packages: engines: {node: '>=4'} dependencies: has-flag: 3.0.0 - dev: true /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} @@ -5684,7 +5760,7 @@ packages: /types-react-dom@19.0.0-rc.1: resolution: {integrity: sha512-VSLZJl8VXCD0fAWp7DUTFUDCcZ8DVXOQmjhJMD03odgeFmu14ZQJHCXeETm3BEAhJqfgJaFkLnGkQv88sRx0fQ==} dependencies: - '@types/react': 18.3.4 + '@types/react': 18.3.8 /types-react@19.0.0-rc.1: resolution: {integrity: sha512-RshndUfqTW6K3STLPis8BtAYCGOkMbtvYsi90gmVNDZBXUyUc5juf2PE9LfS/JmOlUIRO8cWTS/1MTnmhjDqyQ==}