This commit is contained in:
Nikita
2025-12-21 13:37:19 -08:00
commit 8cd4b943a5
173 changed files with 44266 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
export const DEFAULT_GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image-preview"
type GeminiEnv = {
GEMINI_API_KEY?: string
GOOGLE_API_KEY?: string
}
const getEnv = (): GeminiEnv => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: GeminiEnv } } | null
}
const ctx = getServerContext()
if (ctx?.cloudflare?.env) {
const env = ctx.cloudflare.env
if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) {
return {
GEMINI_API_KEY: env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY,
GOOGLE_API_KEY: env.GOOGLE_API_KEY,
}
}
}
} catch {
// ignore, running outside Cloudflare
}
const key = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
return { GEMINI_API_KEY: key, GOOGLE_API_KEY: process.env.GOOGLE_API_KEY }
}
export type GeminiImageRequest = {
prompt: string
model?: string
temperature?: number
}
export type GeminiImageResponse = {
base64Image: string
mimeType: string
rawResponse: unknown
}
export async function generateGeminiImage(
params: GeminiImageRequest,
): Promise<GeminiImageResponse> {
const { GEMINI_API_KEY } = getEnv()
if (!GEMINI_API_KEY) {
throw new Error(
"Set GEMINI_API_KEY or GOOGLE_API_KEY to enable Gemini image generation.",
)
}
const model = params.model ?? DEFAULT_GEMINI_IMAGE_MODEL
const body = {
contents: [
{
role: "user",
parts: [{ text: params.prompt }],
},
],
generationConfig: {
temperature: params.temperature ?? 0.9,
},
}
const response = await fetch(`${GEMINI_API_BASE}/models/${model}:generateContent`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-goog-api-key": GEMINI_API_KEY,
},
body: JSON.stringify(body),
})
const json = await response.json().catch(() => ({}))
if (!response.ok) {
const message =
typeof json?.error?.message === "string"
? json.error.message
: "Gemini image generation failed"
throw new Error(message)
}
const candidates = Array.isArray(json?.candidates) ? json.candidates : []
for (const candidate of candidates) {
const parts = candidate?.content?.parts ?? []
for (const part of parts) {
if (part?.inlineData?.data) {
return {
base64Image: part.inlineData.data,
mimeType: part.inlineData.mimeType ?? "image/png",
rawResponse: json,
}
}
}
}
throw new Error("Gemini did not return inline image data.")
}
+77
View File
@@ -0,0 +1,77 @@
const OPENAI_API_URL = "https://api.openai.com/v1/images/generations"
const DEFAULT_OPENAI_MODEL = "gpt-image-1"
type OpenAIEnv = {
OPENAI_API_KEY?: string
}
const getEnv = (): OpenAIEnv => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: OpenAIEnv } } | null
}
const ctx = getServerContext()
if (ctx?.cloudflare?.env?.OPENAI_API_KEY) {
return { OPENAI_API_KEY: ctx.cloudflare.env.OPENAI_API_KEY }
}
} catch {
// ignore — not running in server context
}
return { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
}
export type OpenAIImageRequest = {
prompt: string
model?: string
size?: "1024x1024" | "1024x1792" | "1792x1024"
}
export type OpenAIImageResponse = {
base64Image: string
mimeType: string
revisedPrompt?: string
}
export async function generateOpenAIImage(
params: OpenAIImageRequest,
): Promise<OpenAIImageResponse> {
const { OPENAI_API_KEY } = getEnv()
if (!OPENAI_API_KEY) {
throw new Error("Set OPENAI_API_KEY to enable DALL·E image generation.")
}
const response = await fetch(OPENAI_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: params.model ?? DEFAULT_OPENAI_MODEL,
prompt: params.prompt,
size: params.size ?? "1024x1024",
response_format: "b64_json",
}),
})
const json: any = await response.json().catch(() => ({}))
if (!response.ok) {
const message =
typeof json?.error?.message === "string"
? json.error.message
: "OpenAI image generation failed"
throw new Error(message)
}
const payload = Array.isArray(json?.data) ? json.data[0] : undefined
const base64 = typeof payload?.b64_json === "string" ? payload.b64_json : null
if (!base64) {
throw new Error("OpenAI returned no image data")
}
return {
base64Image: base64,
mimeType: "image/png",
revisedPrompt: typeof payload?.revised_prompt === "string" ? payload.revised_prompt : undefined,
}
}
+39
View File
@@ -0,0 +1,39 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
// Get API key from Cloudflare env or process.env
const getApiKey = (): string | undefined => {
// Try Cloudflare Workers context first
try {
const { getServerContext } = require("@tanstack/react-start/server")
const ctx = getServerContext()
if (ctx?.cloudflare?.env?.OPENROUTER_API_KEY) {
return ctx.cloudflare.env.OPENROUTER_API_KEY as string
}
} catch {
// Not in Cloudflare context
}
return process.env.OPENROUTER_API_KEY
}
const getModel = (): string => {
try {
const { getServerContext } = require("@tanstack/react-start/server")
const ctx = getServerContext()
if (ctx?.cloudflare?.env?.OPENROUTER_MODEL) {
return ctx.cloudflare.env.OPENROUTER_MODEL as string
}
} catch {
// Not in Cloudflare context
}
return process.env.OPENROUTER_MODEL ?? "google/gemini-2.0-flash-001"
}
export const getOpenRouter = () => {
const apiKey = getApiKey()
if (!apiKey) {
return null
}
return createOpenRouter({ apiKey })
}
export const getDefaultModel = () => getModel()
+7
View File
@@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react"
import { emailOTPClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : undefined,
plugins: [emailOTPClient()],
})
+145
View File
@@ -0,0 +1,145 @@
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { tanstackStartCookies } from "better-auth/tanstack-start"
import { emailOTP } from "better-auth/plugins"
import { Resend } from "resend"
import { authDb } from "@/db/connection"
import * as schema from "@/db/schema"
type AuthEnv = {
BETTER_AUTH_SECRET: string
APP_BASE_URL?: string
RESEND_API_KEY?: string
RESEND_FROM_EMAIL?: string
}
// Helper to get Cloudflare env from server context
const getCloudflareEnv = (): Partial<AuthEnv> | undefined => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Partial<AuthEnv> } } | null
}
return getServerContext()?.cloudflare?.env
} catch {
return undefined
}
}
// Get env from Cloudflare context or process.env
const getEnv = (): AuthEnv => {
let BETTER_AUTH_SECRET: string | undefined
let APP_BASE_URL: string | undefined
let RESEND_API_KEY: string | undefined
let RESEND_FROM_EMAIL: string | undefined
// Try Cloudflare Workers context first (production)
const cfEnv = getCloudflareEnv()
if (cfEnv) {
BETTER_AUTH_SECRET = cfEnv.BETTER_AUTH_SECRET
APP_BASE_URL = cfEnv.APP_BASE_URL
RESEND_API_KEY = cfEnv.RESEND_API_KEY
RESEND_FROM_EMAIL = cfEnv.RESEND_FROM_EMAIL
}
// Fall back to process.env (local dev)
BETTER_AUTH_SECRET = BETTER_AUTH_SECRET ?? process.env.BETTER_AUTH_SECRET
APP_BASE_URL = APP_BASE_URL ?? process.env.APP_BASE_URL
RESEND_API_KEY = RESEND_API_KEY ?? process.env.RESEND_API_KEY
RESEND_FROM_EMAIL = RESEND_FROM_EMAIL ?? process.env.RESEND_FROM_EMAIL
if (!BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is not configured")
}
return {
BETTER_AUTH_SECRET,
APP_BASE_URL,
RESEND_API_KEY,
RESEND_FROM_EMAIL,
}
}
export const getAuth = () => {
// Note: We create a fresh auth instance per request because Cloudflare Workers
// doesn't allow sharing I/O objects (like DB connections) across requests
const env = getEnv()
const database = authDb()
// Detect production: if APP_BASE_URL is set and not localhost, we're in production
const isProduction =
env.APP_BASE_URL && !env.APP_BASE_URL.includes("localhost")
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
const fromEmail = env.RESEND_FROM_EMAIL ?? "noreply@example.com"
console.log("[auth] Config:", {
isProduction,
hasResendKey: !!env.RESEND_API_KEY,
fromEmail,
appBaseUrl: env.APP_BASE_URL,
})
return betterAuth({
database: drizzleAdapter(database, {
provider: "pg",
usePlural: true,
schema,
}),
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"],
plugins: [
tanstackStartCookies(),
emailOTP({
async sendVerificationOTP({ email, otp }) {
console.log("[auth] sendVerificationOTP called:", {
email,
isProduction,
hasResend: !!resend,
})
if (!isProduction || !resend) {
// In dev mode or if Resend not configured, log OTP to terminal
console.log("\n" + "=".repeat(50))
console.log(`🔐 OTP CODE for ${email}`)
console.log(` Code: ${otp}`)
console.log("=".repeat(50) + "\n")
return
}
// Send email via Resend in production
console.log("[auth] Sending email via Resend to:", email)
const { error, data } = await resend.emails.send({
from: fromEmail,
to: email,
subject: "Your Linsa verification code",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 400px; margin: 0 auto; padding: 20px; background-color: #050505; color: #ffffff;">
<h2 style="color: #ffffff; margin-bottom: 16px; font-weight: 600;">Your verification code</h2>
<p style="color: #a1a1aa; margin-bottom: 24px;">Enter this code to sign in to Linsa:</p>
<div style="background-color: #18181b; border: 1px solid #27272a; border-radius: 12px; padding: 24px; text-align: center;">
<span style="font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #ffffff; font-family: monospace;">${otp}</span>
</div>
<p style="color: #71717a; font-size: 14px; margin-top: 24px;">This code expires in 5 minutes.</p>
<p style="color: #52525b; font-size: 12px; margin-top: 16px;">If you didn't request this code, you can safely ignore this email.</p>
</div>
`,
})
if (error) {
console.error("[auth] Failed to send OTP email:", error)
throw new Error("Failed to send verification email")
}
console.log("[auth] Email sent successfully:", data)
},
otpLength: 6,
expiresIn: 300, // 5 minutes
}),
],
})
}
// Lazy proxy that calls getAuth() on each access
export const auth = new Proxy({} as ReturnType<typeof betterAuth>, {
get(_target, prop) {
return getAuth()[prop as keyof ReturnType<typeof betterAuth>]
},
})
+109
View File
@@ -0,0 +1,109 @@
import type { BillingWithChecks, Price, UsageMeter, Product } from "@flowglad/server"
/**
* Computes the total usage credits for a given usage meter slug from the current subscription's feature items.
*/
export function computeUsageTotal(
usageMeterSlug: string,
currentSubscription:
| NonNullable<NonNullable<BillingWithChecks["currentSubscriptions"]>[number]>
| undefined,
pricingModel: BillingWithChecks["pricingModel"] | undefined,
): number {
try {
if (!currentSubscription || !pricingModel?.usageMeters) return 0
const experimental = currentSubscription.experimental as
| { featureItems?: Array<{ type: string; usageMeterId: string; amount: number }> }
| undefined
const featureItems = experimental?.featureItems ?? []
if (featureItems.length === 0) return 0
// Build lookup map: usageMeterId -> slug
const usageMeterById: Record<string, string> = {}
for (const meter of pricingModel.usageMeters) {
usageMeterById[String(meter.id)] = String(meter.slug)
}
// Sum up usage credits for matching meter
let total = 0
for (const item of featureItems) {
if (item.type !== "usage_credit_grant") continue
const meterSlug = usageMeterById[item.usageMeterId]
if (meterSlug === usageMeterSlug) {
total += item.amount
}
}
return total
} catch {
return 0
}
}
/**
* Finds a usage meter by its slug from the pricing model.
*/
export function findUsageMeterBySlug(
usageMeterSlug: string,
pricingModel: BillingWithChecks["pricingModel"] | undefined,
): { id: string; slug: string } | null {
if (!pricingModel?.usageMeters) return null
const usageMeter = pricingModel.usageMeters.find(
(meter: UsageMeter) => meter.slug === usageMeterSlug,
)
if (!usageMeter) return null
return {
id: String(usageMeter.id),
slug: String(usageMeter.slug),
}
}
/**
* Finds a usage price by its associated usage meter slug from the pricing model.
*/
export function findUsagePriceByMeterSlug(
usageMeterSlug: string,
pricingModel: BillingWithChecks["pricingModel"] | undefined,
): Price | null {
if (!pricingModel?.products || !pricingModel?.usageMeters) return null
// Build lookup map: slug -> id
const meterIdBySlug = new Map(
pricingModel.usageMeters.map((meter: UsageMeter) => [meter.slug, meter.id]),
)
const usageMeterId = meterIdBySlug.get(usageMeterSlug)
if (!usageMeterId) return null
// Find price by meter ID
const usagePrice = pricingModel.products
.flatMap((product: Product) => product.prices ?? [])
.find(
(price: Price) => price.type === "usage" && price.usageMeterId === usageMeterId,
)
return usagePrice ?? null
}
/**
* Checks if a plan is a default (free) plan by looking up the price by slug.
*/
export function isDefaultPlanBySlug(
pricingModel: BillingWithChecks["pricingModel"] | null | undefined,
priceSlug: string | undefined,
): boolean {
if (!pricingModel?.products || !priceSlug) return false
for (const product of pricingModel.products) {
const price = product.prices?.find((p: Price) => p.slug === priceSlug)
if (price) {
return product.default === true
}
}
return false
}
+236
View File
@@ -0,0 +1,236 @@
import { getFlowgladServer } from "./flowglad"
import { getAuth } from "./auth"
// Usage limits
const GUEST_FREE_REQUESTS = 5
const AUTH_FREE_REQUESTS_DAILY = 20
const PAID_PLAN_REQUESTS = 1000
// Usage meter slug (configure in Flowglad dashboard)
export const AI_REQUESTS_METER = "ai_requests"
// Price slug for the pro plan (configure in Flowglad dashboard)
export const PRO_PLAN_PRICE_SLUG = "pro_monthly"
type UsageCheckResult = {
allowed: boolean
remaining: number
limit: number
reason?: "guest_limit" | "daily_limit" | "subscription_limit" | "no_subscription"
isGuest: boolean
isPaid: boolean
}
/**
* Check if user can make an AI request based on their billing status.
*
* Tiers:
* - Guest (no auth): 5 free requests total (stored in cookie/localStorage)
* - Authenticated free: 20 free requests per day
* - Pro plan ($7.99/mo): 1000 requests per billing period
*/
export async function checkUsageAllowed(request: Request): Promise<UsageCheckResult> {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
// Guest user - check local/cookie based limit
if (!session?.user) {
// For guests, we'll track on client side via localStorage
// Server just knows they're a guest with limited access
return {
allowed: true, // Client will enforce limit
remaining: GUEST_FREE_REQUESTS,
limit: GUEST_FREE_REQUESTS,
isGuest: true,
isPaid: false,
}
}
// Authenticated user - check Flowglad billing
const flowglad = getFlowgladServer(request)
if (!flowglad) {
// Flowglad not configured, fall back to daily free limit
return {
allowed: true,
remaining: AUTH_FREE_REQUESTS_DAILY,
limit: AUTH_FREE_REQUESTS_DAILY,
isGuest: false,
isPaid: false,
}
}
try {
const billing = await flowglad.getBilling()
// Check if user has an active subscription
const hasActiveSubscription = billing.currentSubscriptions &&
billing.currentSubscriptions.length > 0
if (hasActiveSubscription) {
// Check usage balance for paid plan
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
if (usage) {
const remaining = usage.availableBalance
return {
allowed: remaining > 0,
remaining,
limit: PAID_PLAN_REQUESTS,
reason: remaining <= 0 ? "subscription_limit" : undefined,
isGuest: false,
isPaid: true,
}
}
// Has subscription but no usage meter configured yet
return {
allowed: true,
remaining: PAID_PLAN_REQUESTS,
limit: PAID_PLAN_REQUESTS,
isGuest: false,
isPaid: true,
}
}
// No subscription - use daily free limit
// For now we allow without tracking (TODO: implement daily limit tracking)
return {
allowed: true,
remaining: AUTH_FREE_REQUESTS_DAILY,
limit: AUTH_FREE_REQUESTS_DAILY,
isGuest: false,
isPaid: false,
}
} catch (error) {
console.error("[billing] Error checking usage:", error)
// On error, allow with daily limit
return {
allowed: true,
remaining: AUTH_FREE_REQUESTS_DAILY,
limit: AUTH_FREE_REQUESTS_DAILY,
isGuest: false,
isPaid: false,
}
}
}
/**
* Record a usage event after AI request completes.
* Only records for paid users with active subscriptions.
*/
export async function recordUsage(
request: Request,
amount: number = 1,
transactionId?: string
): Promise<void> {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
// Guest users don't record to Flowglad
return
}
const flowglad = getFlowgladServer(request)
if (!flowglad) {
return
}
try {
const billing = await flowglad.getBilling()
const hasActiveSubscription = billing.currentSubscriptions &&
billing.currentSubscriptions.length > 0
if (!hasActiveSubscription) {
// Only record usage for paid subscriptions
return
}
const subscription = billing.currentSubscriptions![0]
// Find the usage price for the AI requests meter
const usagePrice = billing.pricingModel?.products
?.flatMap(p => p.prices || [])
?.find((p: { type?: string; usageMeterSlug?: string }) =>
p.type === "usage" && p.usageMeterSlug === AI_REQUESTS_METER
) as { id: string } | undefined
if (!usagePrice) {
console.warn("[billing] No usage price found for meter:", AI_REQUESTS_METER)
return
}
await flowglad.createUsageEvent({
subscriptionId: subscription.id,
priceId: usagePrice.id,
amount,
transactionId: transactionId ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
})
} catch (error) {
console.error("[billing] Error recording usage:", error)
// Don't throw - usage recording should not block the request
}
}
/**
* Get billing summary for display in UI.
*/
export async function getBillingSummary(request: Request) {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
return {
isGuest: true,
isPaid: false,
freeLimit: GUEST_FREE_REQUESTS,
planName: "Guest",
}
}
const flowglad = getFlowgladServer(request)
if (!flowglad) {
return {
isGuest: false,
isPaid: false,
freeLimit: AUTH_FREE_REQUESTS_DAILY,
planName: "Free",
}
}
try {
const billing = await flowglad.getBilling()
const hasActiveSubscription = billing.currentSubscriptions &&
billing.currentSubscriptions.length > 0
if (hasActiveSubscription) {
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
return {
isGuest: false,
isPaid: true,
remaining: usage?.availableBalance ?? PAID_PLAN_REQUESTS,
limit: PAID_PLAN_REQUESTS,
planName: "Pro",
billingPortalUrl: billing.billingPortalUrl ?? undefined,
}
}
return {
isGuest: false,
isPaid: false,
freeLimit: AUTH_FREE_REQUESTS_DAILY,
planName: "Free",
}
} catch (error) {
console.error("[billing] Error getting summary:", error)
return {
isGuest: false,
isPaid: false,
freeLimit: AUTH_FREE_REQUESTS_DAILY,
planName: "Free",
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import type {
SerializedCanvas,
SerializedCanvasImage,
SerializedCanvasSummary,
} from "./types"
const jsonHeaders = { "content-type": "application/json" }
const handleJson = async (response: Response) => {
if (!response.ok) {
const message = await response.text()
throw new Error(message || "Canvas request failed")
}
return (await response.json()) as any
}
export const fetchCanvasSnapshot = async (
canvasId: string,
): Promise<SerializedCanvas> => {
const res = await fetch(`/api/canvas/${canvasId}`, {
credentials: "include",
})
const data = await handleJson(res)
return data as SerializedCanvas
}
export const fetchCanvasList = async (): Promise<SerializedCanvasSummary[]> => {
const res = await fetch("/api/canvas", { credentials: "include" })
const data = await handleJson(res)
return data.canvases as SerializedCanvasSummary[]
}
export const createCanvasProject = async (params: {
name?: string
} = {}): Promise<SerializedCanvas> => {
const res = await fetch("/api/canvas", {
method: "POST",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify({ name: params.name }),
})
const data = await handleJson(res)
return data as SerializedCanvas
}
export const createCanvasBox = async (params: {
canvasId: string
name?: string
prompt?: string
position?: { x: number; y: number }
size?: { width: number; height: number }
modelId?: string
styleId?: string
branchParentId?: string | null
}): Promise<SerializedCanvasImage> => {
const res = await fetch("/api/canvas/images", {
method: "POST",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify(params),
})
const data = await handleJson(res)
return data.image as SerializedCanvasImage
}
export const updateCanvasBox = async (
imageId: string,
data: Partial<{
name: string
prompt: string
modelId: string
styleId: string
position: { x: number; y: number }
size: { width: number; height: number }
rotation: number
}>,
): Promise<SerializedCanvasImage> => {
const res = await fetch(`/api/canvas/images/${imageId}`, {
method: "PATCH",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify(data),
})
const json = await handleJson(res)
return json.image as SerializedCanvasImage
}
export const deleteCanvasBox = async (imageId: string) => {
const res = await fetch(`/api/canvas/images/${imageId}`, {
method: "DELETE",
headers: jsonHeaders,
credentials: "include",
})
await handleJson(res)
}
export const generateCanvasBoxImage = async (params: {
imageId: string
prompt?: string
modelId?: string
temperature?: number
}): Promise<SerializedCanvasImage> => {
const res = await fetch(`/api/canvas/images/${params.imageId}/generate`, {
method: "POST",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify({
prompt: params.prompt,
modelId: params.modelId,
temperature: params.temperature,
}),
})
const json = await handleJson(res)
return json.image as SerializedCanvasImage
}
+364
View File
@@ -0,0 +1,364 @@
import { asc, desc, eq, inArray } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { canvas, canvas_images } from "@/db/schema"
import type {
CanvasPoint,
CanvasSize,
SerializedCanvas,
SerializedCanvasImage,
SerializedCanvasRecord,
SerializedCanvasSummary,
} from "./types"
const DEFAULT_POSITION: CanvasPoint = { x: 0, y: 0 }
const DEFAULT_IMAGE_SIZE: CanvasSize = { width: 512, height: 512 }
const DEFAULT_IMAGE_NAME = "Box 1"
const DEFAULT_MODEL = "gemini-2.5-flash-image-preview"
const resolveDatabaseUrl = () => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
const ctx = getServerContext()
const url = ctx?.cloudflare?.env?.DATABASE_URL
if (url) {
return url
}
} catch {
// probably not running inside server context
}
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL
}
throw new Error("DATABASE_URL is not configured")
}
const db = () => getDb(resolveDatabaseUrl())
type DatabaseClient = ReturnType<typeof db>
const parsePoint = (value: unknown): CanvasPoint => {
if (
value &&
typeof value === "object" &&
"x" in value &&
"y" in value &&
typeof (value as any).x === "number" &&
typeof (value as any).y === "number"
) {
return { x: (value as any).x, y: (value as any).y }
}
return DEFAULT_POSITION
}
const serializeCanvasRecord = (record: typeof canvas.$inferSelect): SerializedCanvasRecord => ({
id: record.id,
name: record.name,
ownerId: record.owner_id,
defaultModel: record.default_model,
defaultStyle: record.default_style,
backgroundPrompt: record.background_prompt,
width: record.width,
height: record.height,
createdAt: record.created_at.toISOString(),
updatedAt: record.updated_at.toISOString(),
})
const serializeImage = (image: typeof canvas_images.$inferSelect): SerializedCanvasImage => ({
id: image.id,
canvasId: image.canvas_id,
name: image.name,
prompt: image.prompt,
modelId: image.model_id,
modelUsed: image.model_used,
styleId: image.style_id,
width: image.width,
height: image.height,
rotation: image.rotation,
position: parsePoint(image.position),
branchParentId: image.branch_parent_id,
metadata: (image.metadata as Record<string, unknown> | null) ?? null,
imageUrl: image.image_url,
imageData: image.content_base64 ?? null,
createdAt: image.created_at.toISOString(),
updatedAt: image.updated_at.toISOString(),
})
const createCanvasWithDefaults = async (
params: {
ownerId: string
name?: string
database?: DatabaseClient
},
): Promise<SerializedCanvas> => {
const database = params.database ?? db()
const [createdCanvas] = await database
.insert(canvas)
.values({
owner_id: params.ownerId,
name: params.name ?? "Untitled Canvas",
})
.returning()
const [createdImage] = await database
.insert(canvas_images)
.values({
canvas_id: createdCanvas.id,
name: DEFAULT_IMAGE_NAME,
prompt: "",
position: DEFAULT_POSITION,
width: DEFAULT_IMAGE_SIZE.width,
height: DEFAULT_IMAGE_SIZE.height,
model_id: DEFAULT_MODEL,
style_id: "default",
})
.returning()
return {
canvas: serializeCanvasRecord(createdCanvas),
images: [serializeImage(createdImage)],
}
}
export async function getOrCreateCanvasForUser(userId: string): Promise<SerializedCanvas> {
const database = db()
const existing = await database
.select()
.from(canvas)
.where(eq(canvas.owner_id, userId))
.limit(1)
if (existing.length > 0) {
const images = await database
.select()
.from(canvas_images)
.where(eq(canvas_images.canvas_id, existing[0].id))
.orderBy(asc(canvas_images.created_at))
return {
canvas: serializeCanvasRecord(existing[0]),
images: images.map(serializeImage),
}
}
return createCanvasWithDefaults({ ownerId: userId, database })
}
export async function getCanvasSnapshotById(canvasId: string): Promise<SerializedCanvas | null> {
const database = db()
const records = await database.select().from(canvas).where(eq(canvas.id, canvasId)).limit(1)
if (records.length === 0) {
return null
}
const images = await database
.select()
.from(canvas_images)
.where(eq(canvas_images.canvas_id, canvasId))
.orderBy(asc(canvas_images.created_at))
return {
canvas: serializeCanvasRecord(records[0]),
images: images.map(serializeImage),
}
}
export async function createCanvasForUser(params: {
userId: string
name?: string
}): Promise<SerializedCanvas> {
return createCanvasWithDefaults({ ownerId: params.userId, name: params.name })
}
export async function listCanvasesForUser(userId: string): Promise<SerializedCanvasSummary[]> {
const database = db()
const records = await database
.select()
.from(canvas)
.where(eq(canvas.owner_id, userId))
.orderBy(desc(canvas.updated_at))
if (records.length === 0) {
const created = await createCanvasWithDefaults({ ownerId: userId, database })
return [
{
canvas: created.canvas,
previewImage: created.images[0] ?? null,
imageCount: created.images.length,
},
]
}
const canvasIds = records.map((record) => record.id)
const previewMap = new Map<string, SerializedCanvasImage>()
const countMap = new Map<string, number>()
if (canvasIds.length > 0) {
const images = await database
.select()
.from(canvas_images)
.where(inArray(canvas_images.canvas_id, canvasIds))
.orderBy(desc(canvas_images.updated_at))
for (const image of images) {
const serialized = serializeImage(image)
const parentCanvasId = serialized.canvasId
countMap.set(parentCanvasId, (countMap.get(parentCanvasId) ?? 0) + 1)
if (!previewMap.has(parentCanvasId)) {
previewMap.set(parentCanvasId, serialized)
}
}
}
return records.map((record) => ({
canvas: serializeCanvasRecord(record),
previewImage: previewMap.get(record.id) ?? null,
imageCount: countMap.get(record.id) ?? 0,
}))
}
export async function createCanvasImage(params: {
canvasId: string
name?: string
prompt?: string
position?: CanvasPoint
size?: CanvasSize
modelId?: string
styleId?: string
branchParentId?: string | null
}): Promise<SerializedCanvasImage> {
const database = db()
const [image] = await database
.insert(canvas_images)
.values({
canvas_id: params.canvasId,
name: params.name ?? DEFAULT_IMAGE_NAME,
prompt: params.prompt ?? "",
position: params.position ?? DEFAULT_POSITION,
width: params.size?.width ?? DEFAULT_IMAGE_SIZE.width,
height: params.size?.height ?? DEFAULT_IMAGE_SIZE.height,
model_id: params.modelId ?? DEFAULT_MODEL,
style_id: params.styleId ?? "default",
branch_parent_id: params.branchParentId ?? null,
})
.returning()
return serializeImage(image)
}
export async function updateCanvasImage(params: {
imageId: string
data: {
name?: string
prompt?: string
modelId?: string
modelUsed?: string | null
styleId?: string
position?: CanvasPoint
size?: CanvasSize
rotation?: number
metadata?: Record<string, unknown> | null
branchParentId?: string | null
imageDataBase64?: string | null
imageUrl?: string | null
}
}): Promise<SerializedCanvasImage> {
const database = db()
const values: Partial<typeof canvas_images.$inferInsert> = {
updated_at: new Date(),
}
if (params.data.name !== undefined) values.name = params.data.name
if (params.data.prompt !== undefined) values.prompt = params.data.prompt
if (params.data.modelId !== undefined) values.model_id = params.data.modelId
if (params.data.modelUsed !== undefined) values.model_used = params.data.modelUsed
if (params.data.styleId !== undefined) values.style_id = params.data.styleId
if (params.data.position) values.position = params.data.position
if (params.data.size) {
values.width = params.data.size.width
values.height = params.data.size.height
}
if (typeof params.data.rotation === "number") {
values.rotation = params.data.rotation
}
if (params.data.metadata !== undefined) {
values.metadata = params.data.metadata ?? null
}
if (params.data.branchParentId !== undefined) {
values.branch_parent_id = params.data.branchParentId
}
if (params.data.imageDataBase64 !== undefined) {
values.content_base64 = params.data.imageDataBase64 ?? null
}
if (params.data.imageUrl !== undefined) {
values.image_url = params.data.imageUrl
}
const [updated] = await database
.update(canvas_images)
.set(values)
.where(eq(canvas_images.id, params.imageId))
.returning()
return serializeImage(updated)
}
export async function deleteCanvasImage(imageId: string) {
const database = db()
await database.delete(canvas_images).where(eq(canvas_images.id, imageId))
}
export async function updateCanvasRecord(params: {
canvasId: string
data: {
name?: string
width?: number
height?: number
defaultModel?: string
defaultStyle?: string
backgroundPrompt?: string | null
}
}): Promise<SerializedCanvasRecord> {
const database = db()
const values: Partial<typeof canvas.$inferInsert> = {
updated_at: new Date(),
}
if (params.data.name !== undefined) values.name = params.data.name
if (params.data.width !== undefined) values.width = params.data.width
if (params.data.height !== undefined) values.height = params.data.height
if (params.data.defaultModel !== undefined) values.default_model = params.data.defaultModel
if (params.data.defaultStyle !== undefined) values.default_style = params.data.defaultStyle
if (params.data.backgroundPrompt !== undefined)
values.background_prompt = params.data.backgroundPrompt
const [record] = await database
.update(canvas)
.set(values)
.where(eq(canvas.id, params.canvasId))
.returning()
return serializeCanvasRecord(record)
}
export async function getCanvasOwner(canvasId: string) {
const database = db()
const [record] = await database
.select({ ownerId: canvas.owner_id })
.from(canvas)
.where(eq(canvas.id, canvasId))
.limit(1)
return record ?? null
}
export async function getCanvasImageRecord(imageId: string) {
const database = db()
const [record] = await database
.select()
.from(canvas_images)
.where(eq(canvas_images.id, imageId))
.limit(1)
return record ?? null
}
+53
View File
@@ -0,0 +1,53 @@
export type CanvasPoint = {
x: number
y: number
}
export type CanvasSize = {
width: number
height: number
}
export type SerializedCanvasRecord = {
id: string
name: string
ownerId: string
defaultModel: string
defaultStyle: string
backgroundPrompt: string | null
width: number
height: number
createdAt: string
updatedAt: string
}
export type SerializedCanvasImage = {
id: string
canvasId: string
name: string
prompt: string
modelId: string
modelUsed: string | null
styleId: string
width: number
height: number
rotation: number
position: CanvasPoint
branchParentId: string | null
metadata: Record<string, unknown> | null
imageUrl: string | null
imageData: string | null
createdAt: string
updatedAt: string
}
export type SerializedCanvas = {
canvas: SerializedCanvasRecord
images: SerializedCanvasImage[]
}
export type SerializedCanvasSummary = {
canvas: SerializedCanvasRecord
previewImage: SerializedCanvasImage | null
imageCount: number
}
@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm"
import { getAuth } from "@/lib/auth"
import { getAuthDb } from "@/db/connection"
import { users } from "@/db/schema"
const COOKIE_NAME = "canvas_guest_id"
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
const parseCookies = (header: string | null) => {
if (!header) return {}
return header.split(";").reduce<Record<string, string>>((acc, part) => {
const [key, ...rest] = part.trim().split("=")
if (!key) return acc
acc[key] = rest.join("=")
return acc
}, {})
}
const buildCookie = (id: string) =>
`${COOKIE_NAME}=${id}; Path=/; Max-Age=${COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax`
const resolveDatabaseUrl = () => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
const ctx = getServerContext()
const url = ctx?.cloudflare?.env?.DATABASE_URL
if (url) {
return url
}
} catch {
// ignore
}
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL
}
throw new Error("DATABASE_URL is not configured")
}
const getAuthDatabase = () => {
const url = resolveDatabaseUrl()
return getAuthDb(url)
}
async function ensureGuestUser(existingId?: string) {
const db = getAuthDatabase()
if (existingId) {
const existing = await db.query.users.findFirst({
where(fields, { eq }) {
return eq(fields.id, existingId)
},
})
if (existing) {
return { userId: existingId, setCookie: undefined }
}
}
const newId = crypto.randomUUID()
const email = `canvas-guest-${newId}@example.local`
await db.insert(users).values({
id: newId,
name: "Canvas Guest",
email,
})
return { userId: newId, setCookie: buildCookie(newId) }
}
export async function resolveCanvasUser(request: Request) {
const session = await getAuth().api.getSession({ headers: request.headers })
if (session?.user?.id) {
return { userId: session.user.id, setCookie: undefined }
}
const cookies = parseCookies(request.headers.get("cookie"))
const guestId = cookies[COOKIE_NAME]
return ensureGuestUser(guestId)
}
+107
View File
@@ -0,0 +1,107 @@
import { createCollection } from "@tanstack/react-db"
import { electricCollectionOptions } from "@tanstack/electric-db-collection"
import {
selectUsersSchema,
selectChatThreadSchema,
selectChatMessageSchema,
} from "@/db/schema"
export const usersCollection = createCollection(
electricCollectionOptions({
id: "users",
shapeOptions: {
url: new URL(
"/api/users",
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000",
).toString(),
parser: {
timestamptz: (date: string) => new Date(date),
},
},
schema: selectUsersSchema,
getKey: (item) => item.id,
}),
)
const baseUrl =
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000"
// Create collections lazily to avoid fetching before authentication
// Using a factory pattern so each call gets the same collection instance
const createChatThreadsCollection = () =>
createCollection(
electricCollectionOptions({
id: "chat_threads",
shapeOptions: {
url: new URL("/api/chat-threads", baseUrl).toString(),
parser: {
timestamptz: (date: string) => new Date(date),
},
fetchClient: (input, init) =>
fetch(input, { ...init, credentials: "include" }),
onError: () => {
// Silently ignore auth errors for guest users
},
},
schema: selectChatThreadSchema,
getKey: (item) => item.id,
}),
)
const createChatMessagesCollection = () =>
createCollection(
electricCollectionOptions({
id: "chat_messages",
shapeOptions: {
url: new URL("/api/chat-messages", baseUrl).toString(),
parser: {
timestamptz: (date: string) => new Date(date),
},
fetchClient: (input, init) =>
fetch(input, { ...init, credentials: "include" }),
onError: () => {
// Silently ignore auth errors for guest users
},
},
schema: selectChatMessageSchema,
getKey: (item) => item.id,
}),
)
type ChatThreadsCollection = ReturnType<typeof createChatThreadsCollection>
type ChatMessagesCollection = ReturnType<typeof createChatMessagesCollection>
let _chatThreadsCollection: ChatThreadsCollection | null = null
let _chatMessagesCollection: ChatMessagesCollection | null = null
export function getChatThreadsCollection(): ChatThreadsCollection {
if (!_chatThreadsCollection) {
_chatThreadsCollection = createChatThreadsCollection()
}
return _chatThreadsCollection
}
export function getChatMessagesCollection(): ChatMessagesCollection {
if (!_chatMessagesCollection) {
_chatMessagesCollection = createChatMessagesCollection()
}
return _chatMessagesCollection
}
// Keep exports for backward compatibility but as getters
export const chatThreadsCollection = {
get collection() {
return getChatThreadsCollection()
},
}
export const chatMessagesCollection = {
get collection() {
return getChatMessagesCollection()
},
}
+103
View File
@@ -0,0 +1,103 @@
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"
type ElectricEnv = {
ELECTRIC_URL?: string
ELECTRIC_SOURCE_ID?: string
ELECTRIC_SOURCE_SECRET?: string
}
const DEFAULT_ALLOW_HEADERS =
"content-type,authorization,x-requested-with,x-electric-client-id"
// Get env from Cloudflare context or process.env
const getElectricEnv = (): ElectricEnv => {
let ELECTRIC_URL: string | undefined
let ELECTRIC_SOURCE_ID: string | undefined
let ELECTRIC_SOURCE_SECRET: string | undefined
// Try Cloudflare Workers context first (production)
try {
const { getServerContext } = require("@tanstack/react-start/server")
const ctx = getServerContext()
if (ctx?.cloudflare?.env) {
const cfEnv = ctx.cloudflare.env as Partial<ElectricEnv>
ELECTRIC_URL = cfEnv.ELECTRIC_URL
ELECTRIC_SOURCE_ID = cfEnv.ELECTRIC_SOURCE_ID
ELECTRIC_SOURCE_SECRET = cfEnv.ELECTRIC_SOURCE_SECRET
}
} catch {
// Not in Cloudflare context
}
// Fall back to process.env (local dev)
return {
ELECTRIC_URL: ELECTRIC_URL ?? process.env.ELECTRIC_URL,
ELECTRIC_SOURCE_ID: ELECTRIC_SOURCE_ID ?? process.env.ELECTRIC_SOURCE_ID,
ELECTRIC_SOURCE_SECRET:
ELECTRIC_SOURCE_SECRET ?? process.env.ELECTRIC_SOURCE_SECRET,
}
}
export function prepareElectricUrl(requestUrl: string): URL {
const url = new URL(requestUrl)
const env = getElectricEnv()
const electricUrl = env.ELECTRIC_URL ?? "http://localhost:3100"
const originUrl = new URL(`${electricUrl}/v1/shape`)
url.searchParams.forEach((value, key) => {
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
originUrl.searchParams.set(key, value)
}
})
if (env.ELECTRIC_SOURCE_ID && env.ELECTRIC_SOURCE_SECRET) {
originUrl.searchParams.set("source_id", env.ELECTRIC_SOURCE_ID)
originUrl.searchParams.set("secret", env.ELECTRIC_SOURCE_SECRET)
}
return originUrl
}
const buildCorsHeaders = (request?: Request) => {
const headers = new Headers()
const origin = request?.headers.get("origin")
if (origin) {
headers.set("access-control-allow-origin", origin)
headers.set("access-control-allow-credentials", "true")
} else {
headers.set("access-control-allow-origin", "*")
}
const requestedHeaders =
request?.headers.get("access-control-request-headers") ?? DEFAULT_ALLOW_HEADERS
headers.set("access-control-allow-headers", requestedHeaders)
headers.set("access-control-allow-methods", "GET,OPTIONS")
return headers
}
export const optionsResponse = (request?: Request) =>
new Response(null, {
status: 204,
headers: buildCorsHeaders(request),
})
export async function proxyElectricRequest(
originUrl: URL,
request?: Request,
): Promise<Response> {
const response = await fetch(originUrl)
const headers = new Headers(response.headers)
const corsHeaders = buildCorsHeaders(request)
headers.delete("content-encoding")
headers.delete("content-length")
headers.set("vary", "cookie")
corsHeaders.forEach((value, key) => headers.set(key, value))
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
+73
View File
@@ -0,0 +1,73 @@
import { FlowgladServer } from "@flowglad/server"
import { getAuth } from "./auth"
type FlowgladEnv = {
FLOWGLAD_SECRET_KEY?: string
}
const getEnv = (): FlowgladEnv => {
let FLOWGLAD_SECRET_KEY: string | undefined
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: FlowgladEnv } } | null
}
const ctx = getServerContext()
FLOWGLAD_SECRET_KEY = ctx?.cloudflare?.env?.FLOWGLAD_SECRET_KEY
} catch {
// Not in server context
}
FLOWGLAD_SECRET_KEY = FLOWGLAD_SECRET_KEY ?? process.env.FLOWGLAD_SECRET_KEY
return { FLOWGLAD_SECRET_KEY }
}
export const getFlowgladServer = (request?: Request) => {
const env = getEnv()
if (!env.FLOWGLAD_SECRET_KEY) {
return null
}
return new FlowgladServer({
apiKey: env.FLOWGLAD_SECRET_KEY,
getRequestingCustomer: async () => {
if (!request) {
throw new Error("Request required to get customer")
}
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
throw new Error("Unauthenticated")
}
return {
externalId: session.user.id,
email: session.user.email,
name: session.user.name ?? undefined,
}
},
})
}
/**
* Create a FlowgladServer instance for a specific user ID.
* Use this when you already have the user ID and don't need request-based auth.
*/
export const flowglad = (userId: string) => {
const env = getEnv()
if (!env.FLOWGLAD_SECRET_KEY) {
return null
}
return new FlowgladServer({
apiKey: env.FLOWGLAD_SECRET_KEY,
getRequestingCustomer: async () => ({
externalId: userId,
}),
})
}
+36
View File
@@ -0,0 +1,36 @@
export type StreamPageData = {
user: {
id: string
name: string
username: string | null
image: string | null
}
stream: {
id: string
title: string
description: string | null
is_live: boolean
viewer_count: number
hls_url: string | null
thumbnail_url: string | null
started_at: string | null
} | null
}
export async function getStreamByUsername(
username: string,
): Promise<StreamPageData | null> {
const res = await fetch(`/api/streams/${username}`, {
credentials: "include",
})
if (res.status === 404) {
return null
}
if (!res.ok) {
throw new Error("Failed to fetch stream data")
}
return res.json()
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Example utilities for calling the Worker RPC from the web package
*
* Usage in server functions or loaders:
*
* import { getServerContext } from '@tanstack/react-start/server';
* import { callWorkerRpc } from '@/lib/worker-rpc';
*
* export const loader = async () => {
* const { WORKER_RPC } = getServerContext().cloudflare.env;
* const result = await callWorkerRpc(WORKER_RPC);
* return result;
* };
*/
import type { WorkerRpc } from "../../../worker/src/rpc"
/**
* Example: Call the sayHello RPC method
*/
export async function sayHelloRpc(workerRpc: WorkerRpc, name: string) {
return await workerRpc.sayHello(name)
}
/**
* Example: Call the calculate RPC method
*/
export async function calculateRpc(
workerRpc: WorkerRpc,
operation: "add" | "subtract" | "multiply" | "divide",
a: number,
b: number,
) {
return await workerRpc.calculate(operation, a, b)
}
/**
* Example: Call the processBatch RPC method
*/
export async function processBatchRpc(workerRpc: WorkerRpc, items: string[]) {
return await workerRpc.processBatch(items)
}
/**
* Example: Call the getData RPC method
*/
export async function getDataRpc(workerRpc: WorkerRpc, key: string) {
return await workerRpc.getData(key)
}