diff --git a/examples/supabase-chat/.gitignore b/examples/supabase-chat/.gitignore new file mode 100644 index 000000000..e9020b8e8 --- /dev/null +++ b/examples/supabase-chat/.gitignore @@ -0,0 +1,39 @@ +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/supabase-chat/README.md b/examples/supabase-chat/README.md new file mode 100644 index 000000000..ac417625f --- /dev/null +++ b/examples/supabase-chat/README.md @@ -0,0 +1,190 @@ +# OpenUI × Supabase Chat + +A production-ready example of [OpenUI](https://openui.com) chat with full thread persistence using [Supabase](https://supabase.com). + +Demonstrates: + +- **Per-user thread ownership** via Supabase anonymous auth and Row Level Security +- **Full CRUD persistence** — thread list, message history, rename, delete +- **Real-time sidebar updates** using Supabase Realtime (postgres_changes) +- **OpenAI-compatible streaming** with message history saved to Postgres after each turn +- **`threadApiUrl` wiring** — the canonical way to connect OpenUI to a backend + +## Prerequisites + +- Node.js 18+ and [pnpm](https://pnpm.io) +- A [Supabase](https://supabase.com) project (free tier is fine) +- An [OpenRouter](https://openrouter.ai) API key, or any OpenAI-compatible LLM provider + +## Setup + +### 1. Create a Supabase project + +Sign up at [supabase.com](https://supabase.com) and create a new project. Make a note of your **Project URL** and **anon/public key** (Settings → API). + +### 2. Enable anonymous sign-in + +In the Supabase dashboard go to **Authentication → Providers** and enable the **Anonymous** provider. + +### 3. Run the migration + +#### Option A — SQL Editor (quickest) + +Open the Supabase dashboard, navigate to **SQL Editor**, and paste the contents of: + +``` +supabase/migrations/20240101000000_create_chat_tables.sql +``` + +Then click **Run**. + +#### Option B — Supabase CLI + +```bash +npx supabase login +npx supabase link --project-ref +npx supabase db push +``` + +The migration creates: + +| Object | Purpose | +|---|---| +| `threads` table | One row per chat conversation | +| `messages` table | One row per message, linked to a thread | +| RLS policies | Users can only read/write their own rows | +| `update_updated_at` trigger | Keeps `threads.updated_at` fresh | +| Realtime publication | Enables the `postgres_changes` subscription in the UI | + +### 4. Configure environment variables + +```bash +cp .env.local.example .env.local +``` + +| Variable | Where to find it | +|---|---| +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase dashboard → Settings → API → Project URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase dashboard → Settings → API → anon/public key | +| `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) | +| `OPENROUTER_MODEL` _(optional)_ | Defaults to `openai/gpt-4o-mini` | + +### 5. Install and run + +From the repository root: + +```bash +pnpm install +pnpm --filter supabase-chat dev +``` + +Or from this directory: + +```bash +pnpm install +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +--- + +## How it works + +### Authentication + +On first visit `supabase.auth.signInAnonymously()` creates a stable anonymous user ID stored in a browser cookie. Every thread is scoped to this ID via Row Level Security — even anonymous users' data is fully isolated. + +When you want to add traditional sign-in, call `supabase.auth.updateUser({ email, password })` to upgrade the anonymous session to a permanent account. All existing threads transfer automatically. + +### Thread persistence + +`threadApiUrl="/api/threads"` tells OpenUI to use the default endpoint contract: + +| Hook | Method | Route | Purpose | +|---|---|---|---| +| `fetchThreadList` | `GET` | `/api/threads/get` | Sidebar thread list | +| `createThread` | `POST` | `/api/threads/create` | New thread on first message | +| `loadThread` | `GET` | `/api/threads/get/:id` | Restore message history | +| `updateThread` | `PATCH` | `/api/threads/update/:id` | Rename thread | +| `deleteThread` | `DELETE` | `/api/threads/delete/:id` | Remove thread | + +### Message format alignment + +`messageFormat={openAIMessageFormat}` keeps two paths consistent: + +1. **Live chat** — `processMessage` converts to OpenAI format before sending to `/api/chat` +2. **Thread loading** — `loadThread` receives OpenAI-format messages from `/api/threads/get/:id` and `messageFormat.fromApi()` converts them back + +Messages are stored in OpenAI format in the `messages` table so both paths use the same representation. + +### Message persistence flow + +``` +User types message + → ChatProvider calls createThread (first message only) + → POST /api/threads/create → INSERT into threads + → ChatProvider calls processMessage + → POST /api/chat with { messages, threadId } + → OpenRouter streams assistant reply + → After stream: DELETE + re-INSERT all messages for thread_id + → User reopens thread + → ChatProvider calls loadThread + → GET /api/threads/get/:id → SELECT messages +``` + +### Real-time updates + +A Supabase Realtime channel subscribes to `postgres_changes` on the `threads` table. When the thread list changes in another tab or device, the subscription fires and remounts `ChatProvider` (via a React `key` change) so the sidebar refreshes automatically. + +> **Note:** remounting resets any in-progress conversation in the current tab. +> For a smoother experience, replace the `key` trick with a fine-grained state merge. + +--- + +## Project structure + +``` +examples/supabase-chat/ +├── .env.local.example +├── supabase/ +│ └── migrations/ +│ └── 20240101000000_create_chat_tables.sql +└── src/ + ├── middleware.ts # Refreshes Supabase session on every request + ├── lib/ + │ └── supabase/ + │ ├── browser.ts # Browser client (Client Components) + │ └── server.ts # Server client (Route Handlers) + └── app/ + ├── layout.tsx + ├── page.tsx # Chat UI + anon auth + Realtime subscription + └── api/ + ├── chat/ + │ └── route.ts # LLM streaming + message persistence + └── threads/ + ├── get/ + │ ├── route.ts # List threads + │ └── [id]/route.ts # Load thread messages + ├── create/ + │ └── route.ts # Create thread + ├── update/ + │ └── [id]/route.ts # Rename / update thread + └── delete/ + └── [id]/route.ts # Delete thread +``` + +--- + +## Going further + +- **AI-generated titles** — replace the first-message excerpt in `POST /api/threads/create` with a short LLM call that names the conversation based on its content. +- **Upgrade anonymous users** — add an email/password sign-up form and call `supabase.auth.updateUser()` to convert anonymous sessions to permanent accounts. +- **Append-only message writes** — instead of deleting and re-inserting all messages on every turn, track a `position` column and only insert new rows. +- **Shared threads** — extend the RLS policies to include a `thread_members` join table so threads can be read by invited users. +- **Cursor-based pagination** — the `fetchThreadList` response supports a `nextCursor` field; add `LIMIT`/`OFFSET` (or keyset pagination via `updated_at`) to `/api/threads/get` when thread counts grow large. + +## Related docs + +- [Connect Thread History](https://openui.com/docs/chat/persistence) — the persistence API reference this example implements +- [OpenUI Chat Quick Start](https://openui.com/docs/chat/quick-start) diff --git a/examples/supabase-chat/eslint.config.mjs b/examples/supabase-chat/eslint.config.mjs new file mode 100644 index 000000000..c03c2a958 --- /dev/null +++ b/examples/supabase-chat/eslint.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]), +]); + +export default eslintConfig; diff --git a/examples/supabase-chat/next.config.ts b/examples/supabase-chat/next.config.ts new file mode 100644 index 000000000..1fef4f8c2 --- /dev/null +++ b/examples/supabase-chat/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + turbopack: {}, + transpilePackages: ["@openuidev/react-ui", "@openuidev/react-headless"], +}; + +export default nextConfig; diff --git a/examples/supabase-chat/package.json b/examples/supabase-chat/package.json new file mode 100644 index 000000000..5b03d1b35 --- /dev/null +++ b/examples/supabase-chat/package.json @@ -0,0 +1,31 @@ +{ + "name": "supabase-chat", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "@supabase/ssr": "^0.5.0", + "@supabase/supabase-js": "^2.49.4", + "next": "16.1.6", + "openai": "^6.22.0", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/examples/supabase-chat/postcss.config.mjs b/examples/supabase-chat/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/supabase-chat/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/supabase-chat/src/app/api/chat/route.ts b/examples/supabase-chat/src/app/api/chat/route.ts new file mode 100644 index 000000000..0873bfc59 --- /dev/null +++ b/examples/supabase-chat/src/app/api/chat/route.ts @@ -0,0 +1,96 @@ +import { createSupabaseServer } from "@/lib/supabase/server"; +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; + +const MODEL = process.env.OPENROUTER_MODEL ?? "openai/gpt-4o-mini"; + +/** + * POST /api/chat + * + * Accepts an OpenAI-format message array and an optional threadId. + * Streams the assistant reply as Server-Sent Events (openai-completions format). + * After the stream finishes, persists the full conversation to Supabase so + * that loadThread can restore it when the user reopens the thread. + */ +export async function POST(req: NextRequest) { + const { messages, threadId } = (await req.json()) as { + messages: ChatCompletionMessageParam[]; + threadId?: string | null; + }; + + // Create the Supabase client before streaming so the request context + // (cookies) is still available when we persist messages afterwards. + const supabase = await createSupabaseServer(); + + const client = new OpenAI({ + apiKey: process.env.OPENROUTER_API_KEY, + baseURL: "https://openrouter.ai/api/v1", + }); + + const stream = await client.chat.completions.create({ + model: MODEL, + messages, + stream: true, + }); + + let assistantContent = ""; + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + async start(controller) { + const enqueue = (data: Uint8Array) => { + try { + controller.enqueue(data); + } catch { + // Controller already closed (client disconnected) + } + }; + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta; + if (delta?.content) { + assistantContent += delta.content; + } + enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + + enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + + // ── Persist the full conversation ────────────────────────────────────── + // We replace all messages for this thread on every turn so that the + // stored history always reflects the current state. This is simple and + // correct; for large threads you may prefer an append-only strategy. + if (threadId) { + try { + const allMessages: ChatCompletionMessageParam[] = [ + ...messages, + { role: "assistant", content: assistantContent }, + ]; + + await supabase.from("messages").delete().eq("thread_id", threadId); + + await supabase.from("messages").insert( + allMessages.map((m) => ({ + thread_id: threadId, + role: m.role, + content: + typeof m.content === "string" ? m.content : JSON.stringify(m.content), + })), + ); + } catch (err) { + console.error("[chat] Failed to persist messages:", err); + } + } + }, + }); + + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/examples/supabase-chat/src/app/api/threads/create/route.ts b/examples/supabase-chat/src/app/api/threads/create/route.ts new file mode 100644 index 000000000..3ed167188 --- /dev/null +++ b/examples/supabase-chat/src/app/api/threads/create/route.ts @@ -0,0 +1,53 @@ +import { createSupabaseServer } from "@/lib/supabase/server"; +import { NextRequest } from "next/server"; + +/** + * POST /api/threads/create + * + * Called by OpenUI when the user sends their first message. + * Body: { messages: OpenAIMessage[] } (already in OpenAI format via messageFormat.toApi()) + * Response: Thread — { id, title, createdAt } + * + * We derive the thread title from the first user message so the sidebar + * shows a useful label immediately. The initial messages are NOT stored + * here; the /api/chat route writes them after the first assistant reply. + */ +export async function POST(req: NextRequest) { + const { messages } = (await req.json()) as { + messages: Array<{ role: string; content: unknown }>; + }; + + const supabase = await createSupabaseServer(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Use the first user message as a title preview (≤ 60 chars). + const firstUserMsg = messages?.find((m) => m.role === "user"); + const rawContent = firstUserMsg?.content; + const title = + typeof rawContent === "string" && rawContent.trim().length > 0 + ? rawContent.trim().slice(0, 60) + : "New Chat"; + + const { data: thread, error } = await supabase + .from("threads") + .insert({ user_id: user.id, title }) + .select() + .single(); + + if (error) { + console.error("[threads/create]", error.message); + return Response.json({ error: error.message }, { status: 500 }); + } + + return Response.json({ + id: thread.id, + title: thread.title, + createdAt: thread.created_at, + }); +} diff --git a/examples/supabase-chat/src/app/api/threads/delete/[id]/route.ts b/examples/supabase-chat/src/app/api/threads/delete/[id]/route.ts new file mode 100644 index 000000000..c713a175e --- /dev/null +++ b/examples/supabase-chat/src/app/api/threads/delete/[id]/route.ts @@ -0,0 +1,36 @@ +import { createSupabaseServer } from "@/lib/supabase/server"; + +/** + * DELETE /api/threads/delete/:id + * + * Deletes a thread (and its messages, via ON DELETE CASCADE). + * Returns 204 No Content on success. + */ +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const supabase = await createSupabaseServer(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { error } = await supabase + .from("threads") + .delete() + .eq("id", id) + .eq("user_id", user.id); + + if (error) { + console.error("[threads/delete/:id]", error.message); + return Response.json({ error: error.message }, { status: 500 }); + } + + return new Response(null, { status: 204 }); +} diff --git a/examples/supabase-chat/src/app/api/threads/get/[id]/route.ts b/examples/supabase-chat/src/app/api/threads/get/[id]/route.ts new file mode 100644 index 000000000..04f483893 --- /dev/null +++ b/examples/supabase-chat/src/app/api/threads/get/[id]/route.ts @@ -0,0 +1,58 @@ +import { createSupabaseServer } from "@/lib/supabase/server"; + +/** + * GET /api/threads/get/:id + * + * Loads the message history for a single thread. + * Returns an array of OpenAI-format messages so that + * openAIMessageFormat.fromApi() can deserialise them correctly. + */ +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const supabase = await createSupabaseServer(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Confirm the thread belongs to the requesting user before returning messages. + const { data: thread } = await supabase + .from("threads") + .select("id") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (!thread) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + const { data: messages, error } = await supabase + .from("messages") + .select("role, content, tool_calls, tool_call_id, name") + .eq("thread_id", id) + .order("created_at", { ascending: true }); + + if (error) { + console.error("[threads/get/:id]", error.message); + return Response.json({ error: error.message }, { status: 500 }); + } + + // Return in OpenAI chat format; omit null fields so the shape stays clean. + return Response.json( + (messages ?? []).map((m) => ({ + role: m.role, + content: m.content, + ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}), + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + ...(m.name ? { name: m.name } : {}), + })), + ); +} diff --git a/examples/supabase-chat/src/app/api/threads/get/route.ts b/examples/supabase-chat/src/app/api/threads/get/route.ts new file mode 100644 index 000000000..e4e49902a --- /dev/null +++ b/examples/supabase-chat/src/app/api/threads/get/route.ts @@ -0,0 +1,38 @@ +import { createSupabaseServer } from "@/lib/supabase/server"; + +/** + * GET /api/threads/get + * + * Returns the authenticated user's thread list, newest first. + * Response shape: { threads: Thread[], nextCursor?: any } + */ +export async function GET() { + const supabase = await createSupabaseServer(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + // Not yet authenticated — return an empty list so the sidebar renders cleanly. + return Response.json({ threads: [] }); + } + + const { data: threads, error } = await supabase + .from("threads") + .select("id, title, created_at") + .eq("user_id", user.id) + .order("updated_at", { ascending: false }); + + if (error) { + console.error("[threads/get]", error.message); + return Response.json({ error: error.message }, { status: 500 }); + } + + return Response.json({ + threads: (threads ?? []).map((t) => ({ + id: t.id, + title: t.title, + createdAt: t.created_at, + })), + }); +} diff --git a/examples/supabase-chat/src/app/api/threads/update/[id]/route.ts b/examples/supabase-chat/src/app/api/threads/update/[id]/route.ts new file mode 100644 index 000000000..e75cc269f --- /dev/null +++ b/examples/supabase-chat/src/app/api/threads/update/[id]/route.ts @@ -0,0 +1,45 @@ +import { createSupabaseServer } from "@/lib/supabase/server"; +import { NextRequest } from "next/server"; + +/** + * PATCH /api/threads/update/:id + * + * Updates thread metadata (currently just the title). + * Body: Thread — { id, title, createdAt } + * Response: Thread — the updated row + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const body = (await req.json()) as { title?: string }; + + const supabase = await createSupabaseServer(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: updated, error } = await supabase + .from("threads") + .update({ title: body.title }) + .eq("id", id) + .eq("user_id", user.id) + .select() + .single(); + + if (error) { + console.error("[threads/update/:id]", error.message); + return Response.json({ error: error.message }, { status: 500 }); + } + + return Response.json({ + id: updated.id, + title: updated.title, + createdAt: updated.created_at, + }); +} diff --git a/examples/supabase-chat/src/app/globals.css b/examples/supabase-chat/src/app/globals.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/examples/supabase-chat/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/examples/supabase-chat/src/app/layout.tsx b/examples/supabase-chat/src/app/layout.tsx new file mode 100644 index 000000000..85f2ebbe4 --- /dev/null +++ b/examples/supabase-chat/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Supabase Chat", + description: "OpenUI chat with Supabase persistence", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/supabase-chat/src/app/page.tsx b/examples/supabase-chat/src/app/page.tsx new file mode 100644 index 000000000..7f503c18c --- /dev/null +++ b/examples/supabase-chat/src/app/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import "@openuidev/react-ui/components.css"; + +import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; +import { FullScreen } from "@openuidev/react-ui"; +import type { RealtimeChannel } from "@supabase/supabase-js"; +import { useEffect, useState } from "react"; +import { createSupabaseBrowser } from "@/lib/supabase/browser"; + +export default function Page() { + // Incrementing this key remounts ChatProvider, which re-runs fetchThreadList. + // We bump it when a Realtime event signals that the thread list changed in + // another tab so the sidebar stays in sync without a full page reload. + const [threadListKey, setThreadListKey] = useState(0); + + useEffect(() => { + const supabase = createSupabaseBrowser(); + let channel: RealtimeChannel | undefined; + + const init = async () => { + // Ensure an anonymous session exists. + // Anonymous users get a stable UUID that persists across page refreshes + // and is used to scope threads via Row Level Security. + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + await supabase.auth.signInAnonymously(); + } + + // Subscribe to Realtime changes on the threads table. + // This fires whenever any thread is created, updated, or deleted — + // including from another tab or device logged in with the same account. + channel = supabase + .channel("threads-realtime") + .on( + "postgres_changes", + { event: "*", schema: "public", table: "threads" }, + () => { + // Remount ChatProvider so the thread sidebar refreshes. + // Note: remounting clears the current in-progress conversation. + // For production, consider a more granular update strategy. + setThreadListKey((k) => k + 1); + }, + ) + .subscribe(); + }; + + init(); + + return () => { + channel?.unsubscribe(); + }; + }, []); + + return ( +
+ + fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + // Convert from OpenUI's internal format to OpenAI chat format + messages: openAIMessageFormat.toApi(messages), + threadId, + }), + signal: abortController.signal, + }) + } + streamProtocol={openAIAdapter()} + // Tell OpenUI that the thread API stores / returns messages in + // OpenAI chat format so loadThread deserialization stays aligned. + messageFormat={openAIMessageFormat} + threadApiUrl="/api/threads" + agentName="Supabase Chat" + /> +
+ ); +} diff --git a/examples/supabase-chat/src/lib/supabase/browser.ts b/examples/supabase-chat/src/lib/supabase/browser.ts new file mode 100644 index 000000000..c39cba2ac --- /dev/null +++ b/examples/supabase-chat/src/lib/supabase/browser.ts @@ -0,0 +1,12 @@ +import { createBrowserClient } from "@supabase/ssr"; + +/** + * Returns a Supabase client suitable for use in Client Components. + * Reads the session from browser cookies / localStorage automatically. + */ +export function createSupabaseBrowser() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); +} diff --git a/examples/supabase-chat/src/lib/supabase/server.ts b/examples/supabase-chat/src/lib/supabase/server.ts new file mode 100644 index 000000000..14e4fa734 --- /dev/null +++ b/examples/supabase-chat/src/lib/supabase/server.ts @@ -0,0 +1,33 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +/** + * Returns a Supabase client suitable for use in Server Components, + * Server Actions, and Route Handlers. + * The session is read from (and written to) the Next.js cookie store. + */ +export async function createSupabaseServer() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch { + // Called inside a Server Component — cookies cannot be mutated here. + // The middleware handles session refresh so this is safe to ignore. + } + }, + }, + }, + ); +} diff --git a/examples/supabase-chat/src/middleware.ts b/examples/supabase-chat/src/middleware.ts new file mode 100644 index 000000000..41ab358ea --- /dev/null +++ b/examples/supabase-chat/src/middleware.ts @@ -0,0 +1,42 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse, type NextRequest } from "next/server"; + +/** + * Refreshes the Supabase auth session on every request so that + * server-side cookies stay valid across the entire session lifetime. + * See: https://supabase.com/docs/guides/auth/server-side/nextjs + */ +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)); + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options), + ); + }, + }, + }, + ); + + // Must be called to refresh the session — do not remove. + await supabase.auth.getUser(); + + return supabaseResponse; +} + +export const config = { + matcher: [ + // Run on all routes except Next.js internals and static assets + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/examples/supabase-chat/supabase/migrations/20240101000000_create_chat_tables.sql b/examples/supabase-chat/supabase/migrations/20240101000000_create_chat_tables.sql new file mode 100644 index 000000000..def107e07 --- /dev/null +++ b/examples/supabase-chat/supabase/migrations/20240101000000_create_chat_tables.sql @@ -0,0 +1,110 @@ +-- ============================================================ +-- OpenUI × Supabase Chat — initial schema +-- ============================================================ + +-- ── Tables ────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS threads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT 'New Chat', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Messages are stored in OpenAI chat format so they can be +-- returned directly from loadThread and consumed by openAIMessageFormat. +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id UUID NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'tool')), + content TEXT, + -- Populated for assistant messages that invoke tools + tool_calls JSONB, + -- Populated for tool-result messages + tool_call_id TEXT, + name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Indexes ────────────────────────────────────────────────── + +CREATE INDEX IF NOT EXISTS threads_user_id_updated_at + ON threads (user_id, updated_at DESC); + +CREATE INDEX IF NOT EXISTS messages_thread_id_created_at + ON messages (thread_id, created_at ASC); + +-- ── updated_at trigger ─────────────────────────────────────── + +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER threads_updated_at + BEFORE UPDATE ON threads + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- ── Row Level Security ─────────────────────────────────────── + +ALTER TABLE threads ENABLE ROW LEVEL SECURITY; +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; + +-- Threads: each user can only see and modify their own rows +CREATE POLICY "threads: select own" + ON threads FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "threads: insert own" + ON threads FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "threads: update own" + ON threads FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "threads: delete own" + ON threads FOR DELETE + USING (auth.uid() = user_id); + +-- Messages: accessible only through threads the user owns +CREATE POLICY "messages: select via own thread" + ON messages FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM threads + WHERE threads.id = messages.thread_id + AND threads.user_id = auth.uid() + ) + ); + +CREATE POLICY "messages: insert via own thread" + ON messages FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM threads + WHERE threads.id = messages.thread_id + AND threads.user_id = auth.uid() + ) + ); + +CREATE POLICY "messages: delete via own thread" + ON messages FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM threads + WHERE threads.id = messages.thread_id + AND threads.user_id = auth.uid() + ) + ); + +-- ── Realtime ───────────────────────────────────────────────── +-- Allows the client-side Supabase Realtime subscription in page.tsx +-- to receive postgres_changes events for the threads table. + +ALTER PUBLICATION supabase_realtime ADD TABLE threads; diff --git a/examples/supabase-chat/tsconfig.json b/examples/supabase-chat/tsconfig.json new file mode 100644 index 000000000..93e220ad6 --- /dev/null +++ b/examples/supabase-chat/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + } +}