diff --git a/apps/web/app/(main)/docs/layout.tsx b/apps/web/app/(main)/docs/layout.tsx index 3091a99..bb25a3d 100644 --- a/apps/web/app/(main)/docs/layout.tsx +++ b/apps/web/app/(main)/docs/layout.tsx @@ -1,6 +1,7 @@ import { DocsMobileNav } from "@/components/docs-mobile-nav"; import { DocsSidebar } from "@/components/docs-sidebar"; import { CopyPageButton } from "@/components/copy-page-button"; +import { TableOfContents } from "@/components/table-of-contents"; export default function DocsLayout({ children, @@ -10,7 +11,7 @@ export default function DocsLayout({ return ( <> -
+
{/* Sidebar */}
{children}
+ + {/* On this page */} + ); diff --git a/apps/web/app/(main)/examples/page.tsx b/apps/web/app/(main)/examples/page.tsx new file mode 100644 index 0000000..6df8045 --- /dev/null +++ b/apps/web/app/(main)/examples/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { examples, allTags, getGitHubUrl, type Example } from "@/lib/examples"; +import { cn } from "@/lib/utils"; + +function ExampleCard({ example }: { example: Example }) { + return ( +
+
+

{example.title}

+ +

+ {example.description} +

+ +
+ {example.tags.map((tag) => ( + + {tag} + + ))} +
+ +
+ {example.demoUrl && ( + + + + + + + Live Demo + + )} + + + Source + +
+
+
+ ); +} + +export default function ExamplesPage() { + const [activeTag, setActiveTag] = useState(null); + + const filtered = activeTag + ? examples.filter((e) => e.tags.includes(activeTag)) + : examples; + + return ( +
+
+

+ Examples +

+

+ Explore json-render across frameworks, renderers, and use cases. +

+
+ +
+ + {allTags.map((tag) => ( + + ))} +
+ +
+ {filtered.map((example) => ( + + ))} +
+ + {filtered.length === 0 && ( +

+ No examples match the selected filter. +

+ )} +
+ ); +} diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts new file mode 100644 index 0000000..ae6a3d4 --- /dev/null +++ b/apps/web/app/api/search/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSearchIndex } from "@/lib/search-index"; + +export async function GET(req: NextRequest) { + const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase(); + + if (!q) { + return NextResponse.json({ results: [] }); + } + + const index = await getSearchIndex(); + const terms = q.split(/\s+/).filter(Boolean); + + const results = index + .map((entry) => { + const titleLower = entry.title.toLowerCase(); + const contentLower = entry.content.toLowerCase(); + + const titleMatch = terms.every((t) => titleLower.includes(t)); + const contentMatch = terms.every((t) => contentLower.includes(t)); + + if (!titleMatch && !contentMatch) return null; + + let snippet = ""; + if (contentMatch) { + const firstTermIdx = Math.min( + ...terms.map((t) => { + const idx = contentLower.indexOf(t); + return idx === -1 ? Infinity : idx; + }), + ); + if (firstTermIdx !== Infinity) { + const start = Math.max(0, firstTermIdx - 40); + const end = Math.min(entry.content.length, firstTermIdx + 120); + snippet = + (start > 0 ? "..." : "") + + entry.content.slice(start, end).replace(/\n/g, " ") + + (end < entry.content.length ? "..." : ""); + } + } + + return { + title: entry.title, + href: entry.href, + section: entry.section, + snippet, + score: titleMatch ? 2 : 1, + }; + }) + .filter( + ( + r, + ): r is { + title: string; + href: string; + section: string; + snippet: string; + score: number; + } => r !== null, + ) + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(({ title, href, section, snippet }) => ({ + title, + href, + section, + snippet, + })); + + return NextResponse.json( + { results }, + { headers: { "Cache-Control": "public, max-age=60" } }, + ); +} diff --git a/apps/web/components/docs-chat.tsx b/apps/web/components/docs-chat.tsx index ee33f4b..21b23c9 100644 --- a/apps/web/components/docs-chat.tsx +++ b/apps/web/components/docs-chat.tsx @@ -261,10 +261,10 @@ export function DocsChat({ } }, [messages, isLoading]); - // Cmd+K to open sidebar and focus prompt, Escape to close + // Cmd+I to open sidebar and focus prompt, Escape to close useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + if (e.key === "i" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((prev) => { if (!prev) { @@ -497,12 +497,12 @@ export function DocsChat({ {!open && ( )} diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index ff2a4a1..0834005 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -1,17 +1,59 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { ThemeToggle } from "./theme-toggle"; +import { Search } from "./search"; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetTitle, +} from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; +const navLinks = [ + { href: "/playground", label: "Playground" }, + { href: "/examples", label: "Examples" }, + { href: "/docs", label: "Docs" }, +]; + +function GitHubLink({ className }: { className?: string }) { + return ( + + + 12k + + ); +} + export function Header() { const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = useState(false); const isActive = (href: string) => { if (href === "/playground") { return pathname === "/playground"; } + if (href === "/examples") { + return pathname.startsWith("/examples"); + } if (href === "/docs") { return pathname.startsWith("/docs"); } @@ -62,48 +104,81 @@ export function Header() { - + + {/* Mobile nav */} +
+ + + + + + + + + + + + Menu + + + +
); diff --git a/apps/web/components/search.tsx b/apps/web/components/search.tsx new file mode 100644 index 0000000..e0b8ac3 --- /dev/null +++ b/apps/web/components/search.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +type SearchResult = { + title: string; + href: string; + section: string; + snippet: string; +}; + +export function Search() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const abortRef = useRef(null); + + const navigate = useCallback( + (href: string) => { + setOpen(false); + setQuery(""); + setResults([]); + router.push(href); + }, + [router], + ); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setOpen((prev) => !prev); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 0); + } else { + setQuery(""); + setResults([]); + } + }, [open]); + + useEffect(() => { + const q = query.trim(); + if (!q) { + setResults([]); + setLoading(false); + return; + } + + setLoading(true); + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + const timeout = setTimeout(async () => { + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { + signal: controller.signal, + }); + if (res.ok) { + const data = await res.json(); + setResults(data.results); + } + } catch { + // aborted or network error + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, 150); + + return () => { + clearTimeout(timeout); + controller.abort(); + }; + }, [query]); + + useEffect(() => { + setActiveIndex(0); + }, [results]); + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && results[activeIndex]) { + e.preventDefault(); + navigate(results[activeIndex].href); + } + } + + useEffect(() => { + const active = listRef.current?.querySelector("[data-active='true']"); + active?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + const hasQuery = query.trim().length > 0; + + return ( + <> + + + + + + + Search documentation +
+ + + + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search docs..." + className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground" + /> + {query && ( + + )} +
+ +
+ {loading && hasQuery ? ( +
+
+
+ ) : hasQuery && results.length === 0 ? ( +

+ No results found. +

+ ) : !hasQuery ? ( +

+ Type to search documentation... +

+ ) : ( + results.map((item, i) => ( + + )) + )} +
+ +
+ + ); +} diff --git a/apps/web/components/table-of-contents.tsx b/apps/web/components/table-of-contents.tsx new file mode 100644 index 0000000..ac6f306 --- /dev/null +++ b/apps/web/components/table-of-contents.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +type Heading = { + id: string; + text: string; + level: number; +}; + +function getHeadings(): Heading[] { + const article = document.querySelector("article"); + if (!article) return []; + const elements = article.querySelectorAll("h2[id], h3[id]"); + return Array.from(elements).map((el) => ({ + id: el.id, + text: el.textContent?.replace(/#$/, "").trim() ?? "", + level: el.tagName === "H3" ? 3 : 2, + })); +} + +export function TableOfContents() { + const pathname = usePathname(); + const [headings, setHeadings] = useState([]); + const [activeId, setActiveId] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => setHeadings(getHeadings()), 100); + return () => clearTimeout(timer); + }, [pathname]); + + useEffect(() => { + if (headings.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + } + }, + { rootMargin: "0px 0px -75% 0px", threshold: 0.1 }, + ); + + for (const h of headings) { + const el = document.getElementById(h.id); + if (el) observer.observe(el); + } + + return () => observer.disconnect(); + }, [headings]); + + if (headings.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index 172d579..8eeab65 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -45,63 +45,7 @@ export const docsNavigation: NavSection[] = [ }, { title: "Examples", - items: [ - { - title: "Chat", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat", - external: true, - }, - { - title: "Dashboard", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard", - external: true, - }, - { - title: "React Native", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native", - external: true, - }, - { - title: "React PDF", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf", - external: true, - }, - { - title: "React Email", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-email", - external: true, - }, - { - title: "Remotion", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion", - external: true, - }, - { - title: "Image", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/image", - external: true, - }, - { - title: "Svelte", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/svelte", - external: true, - }, - { - title: "Vue", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue", - external: true, - }, - { - title: "Renders with Vite (Vue / React / Svelte / Solid)", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers", - external: true, - }, - { - title: "MCP App", - href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp", - external: true, - }, - ], + items: [{ title: "Browse All Examples", href: "/examples" }], }, { title: "Guides", diff --git a/apps/web/lib/examples.ts b/apps/web/lib/examples.ts new file mode 100644 index 0000000..c457a62 --- /dev/null +++ b/apps/web/lib/examples.ts @@ -0,0 +1,152 @@ +export type Example = { + slug: string; + title: string; + description: string; + tags: string[]; + githubPath: string; + demoUrl?: string; +}; + +const GITHUB_BASE = + "https://github.com/vercel-labs/json-render/tree/main/examples"; + +export const examples: Example[] = [ + { + slug: "chat", + title: "Chat", + description: + "AI chat app with tool calling, streaming UI, and rich components powered by the AI SDK.", + tags: ["React", "Next.js", "AI"], + githubPath: "examples/chat", + demoUrl: "https://chat-demo.json-render.dev", + }, + { + slug: "dashboard", + title: "Dashboard", + description: + "AI-generated dashboard with drag-and-drop, charts, and real-time data binding.", + tags: ["React", "Next.js", "AI"], + githubPath: "examples/dashboard", + demoUrl: "https://dashboard-demo.json-render.dev", + }, + { + slug: "no-ai", + title: "No AI", + description: + "Static specs rendered without any AI — forms, cards, tables, and more from hardcoded JSON.", + tags: ["React", "Next.js"], + githubPath: "examples/no-ai", + demoUrl: "https://no-ai-demo.json-render.dev", + }, + { + slug: "svelte", + title: "Svelte", + description: + "Svelte renderer demo with counter, todo list, and two-way data binding.", + tags: ["Svelte", "Vite"], + githubPath: "examples/svelte", + demoUrl: "https://svelte-demo.json-render.dev", + }, + { + slug: "svelte-chat", + title: "Svelte Chat", + description: "AI chat app built with SvelteKit and the Svelte renderer.", + tags: ["Svelte", "SvelteKit", "AI"], + githubPath: "examples/svelte-chat", + demoUrl: "https://json-render-svelte-chat-demo.labs.vercel.dev", + }, + { + slug: "vue", + title: "Vue", + description: + "Vue renderer demo with counter, todo list, and two-way data binding.", + tags: ["Vue", "Vite"], + githubPath: "examples/vue", + demoUrl: "https://vue-demo.json-render.dev", + }, + { + slug: "solid", + title: "Solid", + description: + "Solid renderer demo with counter, todo list, and two-way data binding.", + tags: ["Solid", "Vite"], + githubPath: "examples/solid", + demoUrl: "https://solid-demo.json-render.dev", + }, + { + slug: "vite-renderers", + title: "Multi-Framework Renderers", + description: + "Same spec rendered with React, Vue, Svelte, and Solid side by side — hot-swappable at runtime.", + tags: ["React", "Vue", "Svelte", "Solid", "Vite"], + githubPath: "examples/vite-renderers", + }, + { + slug: "react-email", + title: "React Email", + description: + "Generate HTML and plain-text emails from json-render specs using React Email.", + tags: ["React", "Email"], + githubPath: "examples/react-email", + demoUrl: "https://react-email-demo.json-render.dev", + }, + { + slug: "react-pdf", + title: "React PDF", + description: + "Generate PDF documents from json-render specs with @react-pdf/renderer.", + tags: ["React", "PDF"], + githubPath: "examples/react-pdf", + demoUrl: "https://react-pdf-demo.json-render.dev", + }, + { + slug: "react-three-fiber", + title: "React Three Fiber", + description: + "3D scenes generated from json-render specs using Three.js and React Three Fiber.", + tags: ["React", "3D"], + githubPath: "examples/react-three-fiber", + demoUrl: "https://react-three-fiber-demo.json-render.dev", + }, + { + slug: "react-native", + title: "React Native", + description: + "Mobile app rendering json-render specs with Expo and React Native.", + tags: ["React Native", "Expo"], + githubPath: "examples/react-native", + }, + { + slug: "remotion", + title: "Remotion", + description: "Generate videos from json-render specs using Remotion.", + tags: ["React", "Video"], + githubPath: "examples/remotion", + demoUrl: "https://remotion-demo.json-render.dev", + }, + { + slug: "image", + title: "Image", + description: + "Generate OG images and social cards from json-render specs using Satori.", + tags: ["React", "Image"], + githubPath: "examples/image", + demoUrl: "https://image-demo.json-render.dev", + }, + { + slug: "mcp", + title: "MCP App", + description: + "MCP server that serves shadcn UIs to Claude, ChatGPT, Cursor, and VS Code.", + tags: ["React", "MCP", "Vite"], + githubPath: "examples/mcp", + }, +]; + +export const allTags = Array.from( + new Set(examples.flatMap((e) => e.tags)), +).sort(); + +export function getGitHubUrl(example: Example): string { + return `${GITHUB_BASE}/${example.slug}`; +} diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 679408c..88f3602 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -11,6 +11,7 @@ export const PAGE_TITLES: Record = { // Top-level playground: "Playground", + examples: "Examples", // Docs docs: "Introduction", diff --git a/apps/web/lib/search-index.ts b/apps/web/lib/search-index.ts new file mode 100644 index 0000000..81636d1 --- /dev/null +++ b/apps/web/lib/search-index.ts @@ -0,0 +1,76 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; +import { docsNavigation } from "./docs-navigation"; +import { mdxToCleanMarkdown } from "./mdx-to-markdown"; + +export type IndexEntry = { + title: string; + href: string; + section: string; + content: string; +}; + +let cached: IndexEntry[] | null = null; + +function stripMarkdown(md: string): string { + return ( + md + // Remove fenced code blocks entirely + .replace(/```[\s\S]*?```/g, "") + // Remove inline code + .replace(/`[^`]+`/g, "") + // Remove markdown links, keep text + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + // Remove heading markers + .replace(/^#{1,6}\s+/gm, "") + // Remove bold/italic markers + .replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1") + // Remove HTML tags + .replace(/<[^>]+>/g, "") + // Collapse whitespace + .replace(/\n{3,}/g, "\n\n") + .trim() + ); +} + +function mdxFileForSlug(slug: string): string { + const docsRoot = join(process.cwd(), "app", "(main)", "docs"); + if (slug === "/docs") { + return join(docsRoot, "page.mdx"); + } + const rest = slug.replace(/^\/docs\/?/, ""); + return join(docsRoot, ...rest.split("/"), "page.mdx"); +} + +export async function getSearchIndex(): Promise { + if (cached) return cached; + + const entries: IndexEntry[] = []; + + for (const section of docsNavigation) { + for (const item of section.items) { + if (item.external) continue; + try { + const raw = await readFile(mdxFileForSlug(item.href), "utf-8"); + const md = mdxToCleanMarkdown(raw); + const content = stripMarkdown(md); + entries.push({ + title: item.title, + href: item.href, + section: section.title, + content, + }); + } catch { + entries.push({ + title: item.title, + href: item.href, + section: section.title, + content: "", + }); + } + } + } + + cached = entries; + return entries; +} diff --git a/apps/web/mdx-components.tsx b/apps/web/mdx-components.tsx index be5807f..30cd3ba 100644 --- a/apps/web/mdx-components.tsx +++ b/apps/web/mdx-components.tsx @@ -35,16 +35,26 @@ export function useMDXComponents(components: MDXComponents): MDXComponents { h2: ({ children }: { children?: React.ReactNode }) => { const id = slugify(extractText(children)); return ( -

- {children} +

+ + {children} + + # + +

); }, h3: ({ children }: { children?: React.ReactNode }) => { const id = slugify(extractText(children)); return ( -

- {children} +

+ + {children} + + # + +

); }, diff --git a/examples/chat/.env.example b/examples/chat/.env.example new file mode 100644 index 0000000..43923ee --- /dev/null +++ b/examples/chat/.env.example @@ -0,0 +1,15 @@ +# Vercel AI Gateway +# Automatically authenticated when deployed on Vercel +# For local development, get your key from https://vercel.com/ai-gateway +AI_GATEWAY_API_KEY= + +# AI Model Configuration +# Default: anthropic/claude-haiku-4.5 +AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5 + +# Rate Limiting (Upstash Redis) +# Optional - rate limiting is disabled when these are not set +KV_REST_API_URL= +KV_REST_API_TOKEN= +RATE_LIMIT_PER_MINUTE=10 +RATE_LIMIT_PER_DAY=100 diff --git a/examples/chat/app/api/generate/route.ts b/examples/chat/app/api/generate/route.ts index bc9b0c5..62db36f 100644 --- a/examples/chat/app/api/generate/route.ts +++ b/examples/chat/app/api/generate/route.ts @@ -1,4 +1,5 @@ import { agent } from "@/lib/agent"; +import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; import { convertToModelMessages, createUIMessageStream, @@ -6,10 +7,35 @@ import { type UIMessage, } from "ai"; import { pipeJsonRender } from "@json-render/core"; +import { headers } from "next/headers"; export const maxDuration = 60; export async function POST(req: Request) { + const headersList = await headers(); + const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; + + const [minuteResult, dailyResult] = await Promise.all([ + minuteRateLimit.limit(ip), + dailyRateLimit.limit(ip), + ]); + + if (!minuteResult.success || !dailyResult.success) { + const isMinuteLimit = !minuteResult.success; + return new Response( + JSON.stringify({ + error: "Rate limit exceeded", + message: isMinuteLimit + ? "Too many requests. Please wait a moment before trying again." + : "Daily limit reached. Please try again tomorrow.", + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const body = await req.json(); const uiMessages: UIMessage[] = body.messages; diff --git a/examples/chat/app/opengraph-image.png b/examples/chat/app/opengraph-image.png new file mode 100644 index 0000000..16eed60 Binary files /dev/null and b/examples/chat/app/opengraph-image.png differ diff --git a/examples/chat/lib/rate-limit.ts b/examples/chat/lib/rate-limit.ts new file mode 100644 index 0000000..a46c49d --- /dev/null +++ b/examples/chat/lib/rate-limit.ts @@ -0,0 +1,57 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// Lazy initialization to avoid errors when Redis env vars are not configured +let _minuteRateLimit: Ratelimit | null = null; +let _dailyRateLimit: Ratelimit | null = null; + +function getRedis(): Redis | null { + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (!url || !token) { + return null; + } + + return new Redis({ url, token }); +} + +// No-op rate limiter for when Redis is not configured +const noopRateLimiter = { + limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), +}; + +const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10; +const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100; + +// Requests per minute (sliding window) +export const minuteRateLimit = { + limit: async (identifier: string) => { + if (!_minuteRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _minuteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"), + prefix: "ratelimit:chat:minute", + }); + } + return _minuteRateLimit.limit(identifier); + }, +}; + +// Requests per day (fixed window) +export const dailyRateLimit = { + limit: async (identifier: string) => { + if (!_dailyRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _dailyRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"), + prefix: "ratelimit:chat:daily", + }); + } + return _dailyRateLimit.limit(identifier); + }, +}; diff --git a/examples/chat/package.json b/examples/chat/package.json index 19b6d50..afc7547 100644 --- a/examples/chat/package.json +++ b/examples/chat/package.json @@ -34,6 +34,8 @@ "streamdown": "^2.2.0", "tailwind-merge": "^3.4.0", "three": "^0.182.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "zod": "4.3.6" }, "devDependencies": { diff --git a/examples/dashboard/.env.example b/examples/dashboard/.env.example index c3810c0..de15ad4 100644 --- a/examples/dashboard/.env.example +++ b/examples/dashboard/.env.example @@ -4,3 +4,10 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/json_render_dashboard # AI (optional - for UI generation) AI_GATEWAY_API_KEY= AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5 + +# Rate Limiting (Upstash Redis) +# Optional - rate limiting is disabled when these are not set +KV_REST_API_URL= +KV_REST_API_TOKEN= +RATE_LIMIT_PER_MINUTE=10 +RATE_LIMIT_PER_DAY=100 diff --git a/examples/dashboard/app/api/generate/route.ts b/examples/dashboard/app/api/generate/route.ts index 4879851..a4b4c51 100644 --- a/examples/dashboard/app/api/generate/route.ts +++ b/examples/dashboard/app/api/generate/route.ts @@ -1,6 +1,8 @@ import { streamText } from "ai"; import { buildUserPrompt } from "@json-render/core"; import { dashboardCatalog } from "@/lib/render/catalog"; +import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; +import { headers } from "next/headers"; export const maxDuration = 30; @@ -9,6 +11,30 @@ const SYSTEM_PROMPT = dashboardCatalog.prompt(); const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; export async function POST(req: Request) { + const headersList = await headers(); + const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; + + const [minuteResult, dailyResult] = await Promise.all([ + minuteRateLimit.limit(ip), + dailyRateLimit.limit(ip), + ]); + + if (!minuteResult.success || !dailyResult.success) { + const isMinuteLimit = !minuteResult.success; + return new Response( + JSON.stringify({ + error: "Rate limit exceeded", + message: isMinuteLimit + ? "Too many requests. Please wait a moment before trying again." + : "Daily limit reached. Please try again tomorrow.", + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const { prompt, context } = await req.json(); const userPrompt = buildUserPrompt({ diff --git a/examples/dashboard/app/opengraph-image.png b/examples/dashboard/app/opengraph-image.png new file mode 100644 index 0000000..1bd4915 Binary files /dev/null and b/examples/dashboard/app/opengraph-image.png differ diff --git a/examples/dashboard/lib/rate-limit.ts b/examples/dashboard/lib/rate-limit.ts new file mode 100644 index 0000000..fc007c8 --- /dev/null +++ b/examples/dashboard/lib/rate-limit.ts @@ -0,0 +1,57 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// Lazy initialization to avoid errors when Redis env vars are not configured +let _minuteRateLimit: Ratelimit | null = null; +let _dailyRateLimit: Ratelimit | null = null; + +function getRedis(): Redis | null { + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (!url || !token) { + return null; + } + + return new Redis({ url, token }); +} + +// No-op rate limiter for when Redis is not configured +const noopRateLimiter = { + limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), +}; + +const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10; +const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100; + +// Requests per minute (sliding window) +export const minuteRateLimit = { + limit: async (identifier: string) => { + if (!_minuteRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _minuteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"), + prefix: "ratelimit:dashboard:minute", + }); + } + return _minuteRateLimit.limit(identifier); + }, +}; + +// Requests per day (fixed window) +export const dailyRateLimit = { + limit: async (identifier: string) => { + if (!_dailyRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _dailyRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"), + prefix: "ratelimit:dashboard:daily", + }); + } + return _dailyRateLimit.limit(identifier); + }, +}; diff --git a/examples/dashboard/package.json b/examples/dashboard/package.json index acba8da..0581938 100644 --- a/examples/dashboard/package.json +++ b/examples/dashboard/package.json @@ -40,6 +40,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/examples/image/.env.example b/examples/image/.env.example new file mode 100644 index 0000000..43923ee --- /dev/null +++ b/examples/image/.env.example @@ -0,0 +1,15 @@ +# Vercel AI Gateway +# Automatically authenticated when deployed on Vercel +# For local development, get your key from https://vercel.com/ai-gateway +AI_GATEWAY_API_KEY= + +# AI Model Configuration +# Default: anthropic/claude-haiku-4.5 +AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5 + +# Rate Limiting (Upstash Redis) +# Optional - rate limiting is disabled when these are not set +KV_REST_API_URL= +KV_REST_API_TOKEN= +RATE_LIMIT_PER_MINUTE=10 +RATE_LIMIT_PER_DAY=100 diff --git a/examples/image/app/api/generate/route.ts b/examples/image/app/api/generate/route.ts index 80f0b9f..8c18e50 100644 --- a/examples/image/app/api/generate/route.ts +++ b/examples/image/app/api/generate/route.ts @@ -1,6 +1,8 @@ import { streamText } from "ai"; import { buildUserPrompt, type Spec } from "@json-render/core"; import { imageCatalog } from "@/lib/catalog"; +import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; +import { headers } from "next/headers"; export const maxDuration = 60; @@ -9,6 +11,30 @@ const SYSTEM_PROMPT = imageCatalog.prompt(); const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; export async function POST(req: Request) { + const headersList = await headers(); + const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; + + const [minuteResult, dailyResult] = await Promise.all([ + minuteRateLimit.limit(ip), + dailyRateLimit.limit(ip), + ]); + + if (!minuteResult.success || !dailyResult.success) { + const isMinuteLimit = !minuteResult.success; + return new Response( + JSON.stringify({ + error: "Rate limit exceeded", + message: isMinuteLimit + ? "Too many requests. Please wait a moment before trying again." + : "Daily limit reached. Please try again tomorrow.", + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const { prompt, startingSpec } = (await req.json()) as { prompt: string; startingSpec?: Spec | null; diff --git a/examples/image/app/opengraph-image.png b/examples/image/app/opengraph-image.png new file mode 100644 index 0000000..399bb9e Binary files /dev/null and b/examples/image/app/opengraph-image.png differ diff --git a/examples/image/lib/rate-limit.ts b/examples/image/lib/rate-limit.ts new file mode 100644 index 0000000..2c8bf5f --- /dev/null +++ b/examples/image/lib/rate-limit.ts @@ -0,0 +1,57 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// Lazy initialization to avoid errors when Redis env vars are not configured +let _minuteRateLimit: Ratelimit | null = null; +let _dailyRateLimit: Ratelimit | null = null; + +function getRedis(): Redis | null { + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (!url || !token) { + return null; + } + + return new Redis({ url, token }); +} + +// No-op rate limiter for when Redis is not configured +const noopRateLimiter = { + limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), +}; + +const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10; +const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100; + +// Requests per minute (sliding window) +export const minuteRateLimit = { + limit: async (identifier: string) => { + if (!_minuteRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _minuteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"), + prefix: "ratelimit:image:minute", + }); + } + return _minuteRateLimit.limit(identifier); + }, +}; + +// Requests per day (fixed window) +export const dailyRateLimit = { + limit: async (identifier: string) => { + if (!_dailyRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _dailyRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"), + prefix: "ratelimit:image:daily", + }); + } + return _dailyRateLimit.limit(identifier); + }, +}; diff --git a/examples/image/package.json b/examples/image/package.json index 716ef7a..4d056e1 100644 --- a/examples/image/package.json +++ b/examples/image/package.json @@ -28,6 +28,8 @@ "react-resizable-panels": "^4.4.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.18", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "zod": "4.3.6" }, "devDependencies": { diff --git a/examples/no-ai/app/opengraph-image.png b/examples/no-ai/app/opengraph-image.png new file mode 100644 index 0000000..7140499 Binary files /dev/null and b/examples/no-ai/app/opengraph-image.png differ diff --git a/examples/react-email/.env.example b/examples/react-email/.env.example index f867d6d..be58522 100644 --- a/examples/react-email/.env.example +++ b/examples/react-email/.env.example @@ -7,3 +7,10 @@ AI_GATEWAY_API_KEY= # Override the default model used for email generation # Default: anthropic/claude-haiku-4.5 AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5 + +# Rate Limiting (Upstash Redis) +# Optional - rate limiting is disabled when these are not set +KV_REST_API_URL= +KV_REST_API_TOKEN= +RATE_LIMIT_PER_MINUTE=10 +RATE_LIMIT_PER_DAY=100 diff --git a/examples/react-email/app/api/generate/route.ts b/examples/react-email/app/api/generate/route.ts index 30161b6..98fcff8 100644 --- a/examples/react-email/app/api/generate/route.ts +++ b/examples/react-email/app/api/generate/route.ts @@ -1,6 +1,8 @@ import { streamText } from "ai"; import { buildUserPrompt, type Spec } from "@json-render/core"; import { emailCatalog } from "@/lib/catalog"; +import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; +import { headers } from "next/headers"; export const maxDuration = 60; @@ -9,6 +11,30 @@ const SYSTEM_PROMPT = emailCatalog.prompt(); const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; export async function POST(req: Request) { + const headersList = await headers(); + const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; + + const [minuteResult, dailyResult] = await Promise.all([ + minuteRateLimit.limit(ip), + dailyRateLimit.limit(ip), + ]); + + if (!minuteResult.success || !dailyResult.success) { + const isMinuteLimit = !minuteResult.success; + return new Response( + JSON.stringify({ + error: "Rate limit exceeded", + message: isMinuteLimit + ? "Too many requests. Please wait a moment before trying again." + : "Daily limit reached. Please try again tomorrow.", + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const { prompt, startingSpec } = (await req.json()) as { prompt: string; startingSpec?: Spec | null; diff --git a/examples/react-email/app/opengraph-image.png b/examples/react-email/app/opengraph-image.png new file mode 100644 index 0000000..adf9e40 Binary files /dev/null and b/examples/react-email/app/opengraph-image.png differ diff --git a/examples/react-email/lib/rate-limit.ts b/examples/react-email/lib/rate-limit.ts new file mode 100644 index 0000000..1dd2886 --- /dev/null +++ b/examples/react-email/lib/rate-limit.ts @@ -0,0 +1,57 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// Lazy initialization to avoid errors when Redis env vars are not configured +let _minuteRateLimit: Ratelimit | null = null; +let _dailyRateLimit: Ratelimit | null = null; + +function getRedis(): Redis | null { + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (!url || !token) { + return null; + } + + return new Redis({ url, token }); +} + +// No-op rate limiter for when Redis is not configured +const noopRateLimiter = { + limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), +}; + +const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10; +const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100; + +// Requests per minute (sliding window) +export const minuteRateLimit = { + limit: async (identifier: string) => { + if (!_minuteRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _minuteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"), + prefix: "ratelimit:react-email:minute", + }); + } + return _minuteRateLimit.limit(identifier); + }, +}; + +// Requests per day (fixed window) +export const dailyRateLimit = { + limit: async (identifier: string) => { + if (!_dailyRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _dailyRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"), + prefix: "ratelimit:react-email:daily", + }); + } + return _dailyRateLimit.limit(identifier); + }, +}; diff --git a/examples/react-email/package.json b/examples/react-email/package.json index 8f2c147..a92867f 100644 --- a/examples/react-email/package.json +++ b/examples/react-email/package.json @@ -25,6 +25,8 @@ "react-resizable-panels": "^4.4.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.18", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "zod": "4.3.6" }, "devDependencies": { diff --git a/examples/react-pdf/.env.example b/examples/react-pdf/.env.example index a884748..b0636ea 100644 --- a/examples/react-pdf/.env.example +++ b/examples/react-pdf/.env.example @@ -3,3 +3,10 @@ AI_GATEWAY_API_KEY= # Optional: override the default model (default: anthropic/claude-haiku-4.5) # AI_GATEWAY_MODEL=openai/gpt-4o-mini + +# Rate Limiting (Upstash Redis) +# Optional - rate limiting is disabled when these are not set +KV_REST_API_URL= +KV_REST_API_TOKEN= +RATE_LIMIT_PER_MINUTE=10 +RATE_LIMIT_PER_DAY=100 diff --git a/examples/react-pdf/app/api/generate/route.ts b/examples/react-pdf/app/api/generate/route.ts index ca4f835..4694ad4 100644 --- a/examples/react-pdf/app/api/generate/route.ts +++ b/examples/react-pdf/app/api/generate/route.ts @@ -2,6 +2,8 @@ import { streamText } from "ai"; import { gateway } from "@ai-sdk/gateway"; import { buildUserPrompt, type Spec } from "@json-render/core"; import { pdfCatalog } from "@/lib/catalog"; +import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; +import { headers } from "next/headers"; export const maxDuration = 60; @@ -10,6 +12,30 @@ const SYSTEM_PROMPT = pdfCatalog.prompt(); const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; export async function POST(req: Request) { + const headersList = await headers(); + const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; + + const [minuteResult, dailyResult] = await Promise.all([ + minuteRateLimit.limit(ip), + dailyRateLimit.limit(ip), + ]); + + if (!minuteResult.success || !dailyResult.success) { + const isMinuteLimit = !minuteResult.success; + return new Response( + JSON.stringify({ + error: "Rate limit exceeded", + message: isMinuteLimit + ? "Too many requests. Please wait a moment before trying again." + : "Daily limit reached. Please try again tomorrow.", + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const { prompt, startingSpec } = (await req.json()) as { prompt: string; startingSpec?: Spec | null; diff --git a/examples/react-pdf/app/opengraph-image.png b/examples/react-pdf/app/opengraph-image.png new file mode 100644 index 0000000..7468d02 Binary files /dev/null and b/examples/react-pdf/app/opengraph-image.png differ diff --git a/examples/react-pdf/lib/rate-limit.ts b/examples/react-pdf/lib/rate-limit.ts new file mode 100644 index 0000000..bb75645 --- /dev/null +++ b/examples/react-pdf/lib/rate-limit.ts @@ -0,0 +1,57 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// Lazy initialization to avoid errors when Redis env vars are not configured +let _minuteRateLimit: Ratelimit | null = null; +let _dailyRateLimit: Ratelimit | null = null; + +function getRedis(): Redis | null { + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (!url || !token) { + return null; + } + + return new Redis({ url, token }); +} + +// No-op rate limiter for when Redis is not configured +const noopRateLimiter = { + limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), +}; + +const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10; +const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100; + +// Requests per minute (sliding window) +export const minuteRateLimit = { + limit: async (identifier: string) => { + if (!_minuteRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _minuteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"), + prefix: "ratelimit:react-pdf:minute", + }); + } + return _minuteRateLimit.limit(identifier); + }, +}; + +// Requests per day (fixed window) +export const dailyRateLimit = { + limit: async (identifier: string) => { + if (!_dailyRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _dailyRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"), + prefix: "ratelimit:react-pdf:daily", + }); + } + return _dailyRateLimit.limit(identifier); + }, +}; diff --git a/examples/react-pdf/package.json b/examples/react-pdf/package.json index 31091d8..4baed53 100644 --- a/examples/react-pdf/package.json +++ b/examples/react-pdf/package.json @@ -26,6 +26,8 @@ "react-resizable-panels": "^4.4.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.18", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "zod": "4.3.6" }, "devDependencies": { diff --git a/examples/react-three-fiber/app/opengraph-image.png b/examples/react-three-fiber/app/opengraph-image.png new file mode 100644 index 0000000..71457ea Binary files /dev/null and b/examples/react-three-fiber/app/opengraph-image.png differ diff --git a/examples/react-three-fiber/app/page.tsx b/examples/react-three-fiber/app/page.tsx index 5363181..6673a93 100644 --- a/examples/react-three-fiber/app/page.tsx +++ b/examples/react-three-fiber/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useSyncExternalStore, useCallback } from "react"; import { defineCatalog } from "@json-render/core"; import type { ComputedFunction } from "@json-render/core"; import { schema, defineRegistry } from "@json-render/react"; @@ -109,9 +109,27 @@ function highlightJson(json: string): string { ); } +const MOBILE_BREAKPOINT = 768; + +function subscribeToResize(cb: () => void) { + window.addEventListener("resize", cb); + return () => window.removeEventListener("resize", cb); +} + +function getIsMobile() { + return typeof window !== "undefined" + ? window.innerWidth < MOBILE_BREAKPOINT + : false; +} + +function useIsMobile() { + return useSyncExternalStore(subscribeToResize, getIsMobile, () => false); +} + const LIST_WIDTH = 220; const JSON_WIDTH = 380; const HEADER_HEIGHT = 40; +const MOBILE_HEADER_HEIGHT = 48; const headerStyle: React.CSSProperties = { height: HEADER_HEIGHT, @@ -132,7 +150,236 @@ const headerStyle: React.CSSProperties = { boxSizing: "border-box", }; -export default function Page() { +function MobileLayout() { + const [selectedIndex, setSelectedIndex] = useState(0); + const [showJson, setShowJson] = useState(false); + const [showScenes, setShowScenes] = useState(false); + const selected = scenes[selectedIndex]!; + + const closePanels = useCallback(() => { + setShowJson(false); + setShowScenes(false); + }, []); + + return ( +
+
+ + + + {selected.description} + + + +
+ +
+ + + {showScenes && ( + <> +
+
+
Scenes
+
+ {scenes.map((scene, i) => ( + + ))} +
+
+ + )} + + {showJson && ( + <> +
+
+
Spec JSON
+
+            
+ + )} +
+
+ ); +} + +function DesktopLayout() { const [selectedIndex, setSelectedIndex] = useState(0); const selected = scenes[selectedIndex]!; @@ -236,3 +483,8 @@ export default function Page() {
); } + +export default function Page() { + const isMobile = useIsMobile(); + return isMobile ? : ; +} diff --git a/examples/remotion/.env.example b/examples/remotion/.env.example index 4a41362..41979de 100644 --- a/examples/remotion/.env.example +++ b/examples/remotion/.env.example @@ -7,3 +7,10 @@ AI_GATEWAY_API_KEY= # Override the default model used for video generation # Default: anthropic/claude-haiku-4.5 AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5 + +# Rate Limiting (Upstash Redis) +# Optional - rate limiting is disabled when these are not set +KV_REST_API_URL= +KV_REST_API_TOKEN= +RATE_LIMIT_PER_MINUTE=10 +RATE_LIMIT_PER_DAY=100 diff --git a/examples/remotion/app/api/generate/route.ts b/examples/remotion/app/api/generate/route.ts index 7c76f6d..dd19882 100644 --- a/examples/remotion/app/api/generate/route.ts +++ b/examples/remotion/app/api/generate/route.ts @@ -1,5 +1,7 @@ import { streamText } from "ai"; import { getVideoPrompt } from "@/lib/catalog"; +import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; +import { headers } from "next/headers"; export const maxDuration = 30; @@ -10,6 +12,30 @@ const MAX_PROMPT_LENGTH = 500; const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; export async function POST(req: Request) { + const headersList = await headers(); + const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; + + const [minuteResult, dailyResult] = await Promise.all([ + minuteRateLimit.limit(ip), + dailyRateLimit.limit(ip), + ]); + + if (!minuteResult.success || !dailyResult.success) { + const isMinuteLimit = !minuteResult.success; + return new Response( + JSON.stringify({ + error: "Rate limit exceeded", + message: isMinuteLimit + ? "Too many requests. Please wait a moment before trying again." + : "Daily limit reached. Please try again tomorrow.", + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const { prompt } = await req.json(); const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH); diff --git a/examples/remotion/app/opengraph-image.png b/examples/remotion/app/opengraph-image.png new file mode 100644 index 0000000..d97502c Binary files /dev/null and b/examples/remotion/app/opengraph-image.png differ diff --git a/examples/remotion/lib/rate-limit.ts b/examples/remotion/lib/rate-limit.ts new file mode 100644 index 0000000..807c907 --- /dev/null +++ b/examples/remotion/lib/rate-limit.ts @@ -0,0 +1,57 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// Lazy initialization to avoid errors when Redis env vars are not configured +let _minuteRateLimit: Ratelimit | null = null; +let _dailyRateLimit: Ratelimit | null = null; + +function getRedis(): Redis | null { + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (!url || !token) { + return null; + } + + return new Redis({ url, token }); +} + +// No-op rate limiter for when Redis is not configured +const noopRateLimiter = { + limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), +}; + +const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10; +const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100; + +// Requests per minute (sliding window) +export const minuteRateLimit = { + limit: async (identifier: string) => { + if (!_minuteRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _minuteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"), + prefix: "ratelimit:remotion:minute", + }); + } + return _minuteRateLimit.limit(identifier); + }, +}; + +// Requests per day (fixed window) +export const dailyRateLimit = { + limit: async (identifier: string) => { + if (!_dailyRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _dailyRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"), + prefix: "ratelimit:remotion:daily", + }); + } + return _dailyRateLimit.limit(identifier); + }, +}; diff --git a/examples/remotion/package.json b/examples/remotion/package.json index 0241845..ef46f15 100644 --- a/examples/remotion/package.json +++ b/examples/remotion/package.json @@ -27,6 +27,8 @@ "remotion": "4.0.418", "shiki": "^3.21.0", "tailwind-merge": "^3.4.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/examples/solid/index.html b/examples/solid/index.html index 37daa06..3b27bca 100644 --- a/examples/solid/index.html +++ b/examples/solid/index.html @@ -4,6 +4,13 @@ json-render solid example + + + + + + +
diff --git a/examples/solid/public/og-image.png b/examples/solid/public/og-image.png new file mode 100644 index 0000000..91ea910 Binary files /dev/null and b/examples/solid/public/og-image.png differ diff --git a/examples/svelte-chat/.env.example b/examples/svelte-chat/.env.example new file mode 100644 index 0000000..43923ee --- /dev/null +++ b/examples/svelte-chat/.env.example @@ -0,0 +1,15 @@ +# Vercel AI Gateway +# Automatically authenticated when deployed on Vercel +# For local development, get your key from https://vercel.com/ai-gateway +AI_GATEWAY_API_KEY= + +# AI Model Configuration +# Default: anthropic/claude-haiku-4.5 +AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5 + +# Rate Limiting (Upstash Redis) +# Optional - rate limiting is disabled when these are not set +KV_REST_API_URL= +KV_REST_API_TOKEN= +RATE_LIMIT_PER_MINUTE=10 +RATE_LIMIT_PER_DAY=100 diff --git a/examples/svelte-chat/package.json b/examples/svelte-chat/package.json index 47703d5..9181119 100644 --- a/examples/svelte-chat/package.json +++ b/examples/svelte-chat/package.json @@ -34,6 +34,8 @@ "clsx": "^2.1.1", "lucide-svelte": "^0.500.0", "tailwind-merge": "^3.2.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "zod": "4.3.6" } } diff --git a/examples/svelte-chat/src/app.html b/examples/svelte-chat/src/app.html index f273cc5..c002445 100644 --- a/examples/svelte-chat/src/app.html +++ b/examples/svelte-chat/src/app.html @@ -3,6 +3,13 @@ + + + + + + + %sveltekit.head% diff --git a/examples/svelte-chat/src/lib/rate-limit.ts b/examples/svelte-chat/src/lib/rate-limit.ts new file mode 100644 index 0000000..4041eb9 --- /dev/null +++ b/examples/svelte-chat/src/lib/rate-limit.ts @@ -0,0 +1,57 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// Lazy initialization to avoid errors when Redis env vars are not configured +let _minuteRateLimit: Ratelimit | null = null; +let _dailyRateLimit: Ratelimit | null = null; + +function getRedis(): Redis | null { + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (!url || !token) { + return null; + } + + return new Redis({ url, token }); +} + +// No-op rate limiter for when Redis is not configured +const noopRateLimiter = { + limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), +}; + +const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10; +const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100; + +// Requests per minute (sliding window) +export const minuteRateLimit = { + limit: async (identifier: string) => { + if (!_minuteRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _minuteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"), + prefix: "ratelimit:svelte-chat:minute", + }); + } + return _minuteRateLimit.limit(identifier); + }, +}; + +// Requests per day (fixed window) +export const dailyRateLimit = { + limit: async (identifier: string) => { + if (!_dailyRateLimit) { + const redis = getRedis(); + if (!redis) return noopRateLimiter.limit(); + _dailyRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"), + prefix: "ratelimit:svelte-chat:daily", + }); + } + return _dailyRateLimit.limit(identifier); + }, +}; diff --git a/examples/svelte-chat/src/routes/api/generate/+server.ts b/examples/svelte-chat/src/routes/api/generate/+server.ts index bfbf51c..61e80aa 100644 --- a/examples/svelte-chat/src/routes/api/generate/+server.ts +++ b/examples/svelte-chat/src/routes/api/generate/+server.ts @@ -1,4 +1,5 @@ import { agent } from "$lib/agent"; +import { minuteRateLimit, dailyRateLimit } from "$lib/rate-limit"; import { convertToModelMessages, createUIMessageStream, @@ -9,6 +10,30 @@ import { pipeJsonRender } from "@json-render/core"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ request }) => { + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; + + const [minuteResult, dailyResult] = await Promise.all([ + minuteRateLimit.limit(ip), + dailyRateLimit.limit(ip), + ]); + + if (!minuteResult.success || !dailyResult.success) { + const isMinuteLimit = !minuteResult.success; + return new Response( + JSON.stringify({ + error: "Rate limit exceeded", + message: isMinuteLimit + ? "Too many requests. Please wait a moment before trying again." + : "Daily limit reached. Please try again tomorrow.", + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const body = await request.json(); const uiMessages: UIMessage[] = body.messages; diff --git a/examples/svelte-chat/static/og-image.png b/examples/svelte-chat/static/og-image.png new file mode 100644 index 0000000..353754e Binary files /dev/null and b/examples/svelte-chat/static/og-image.png differ diff --git a/examples/svelte/index.html b/examples/svelte/index.html index 1e0ab77..33cfca8 100644 --- a/examples/svelte/index.html +++ b/examples/svelte/index.html @@ -4,6 +4,13 @@ json-render svelte example + + + + + + +
diff --git a/examples/svelte/public/og-image.png b/examples/svelte/public/og-image.png new file mode 100644 index 0000000..f62eb7c Binary files /dev/null and b/examples/svelte/public/og-image.png differ diff --git a/examples/vite-renderers/index.html b/examples/vite-renderers/index.html index 9f396e9..f59b99c 100644 --- a/examples/vite-renderers/index.html +++ b/examples/vite-renderers/index.html @@ -4,6 +4,13 @@ @json-render multi-renderer demo + + + + + + +