Files
archived-linsa/packages/worker/src/index.ts

767 lines
22 KiB
TypeScript

import { Hono, type Context, type MiddlewareHandler } from "hono"
import { cors } from "hono/cors"
import { eq } from "drizzle-orm"
import {
browser_session_tabs,
browser_sessions,
canvas,
canvas_images,
chat_messages,
chat_threads,
context_items,
thread_context_items,
} from "../../web/src/db/schema"
import { getDb, type Hyperdrive } from "./db"
type Env = {
ADMIN_API_KEY?: string
DATABASE_URL?: string
HYPERDRIVE?: Hyperdrive
}
// Create a new Hono app
type AppEnv = { Bindings: Env }
const app = new Hono<AppEnv>()
// Enable CORS for all routes
app.use(
"/*",
cors({
origin: "*",
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
}),
)
const requireAdmin: MiddlewareHandler<AppEnv> = async (c, next) => {
if (c.req.method === "OPTIONS") {
return next()
}
const apiKey = c.env.ADMIN_API_KEY
if (!apiKey) {
return next()
}
const authHeader = c.req.header("Authorization")
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: "Missing Authorization header" }, 401)
}
const providedKey = authHeader.slice(7)
if (providedKey !== apiKey) {
return c.json({ error: "Invalid API key" }, 401)
}
return next()
}
app.use("/api/v1/admin/*", requireAdmin)
const parseBody = async (c: Context<AppEnv>) => {
return (await c.req.json().catch(() => ({}))) as Record<string, unknown>
}
const parseInteger = (value: unknown) => {
const numberValue =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN
return Number.isInteger(numberValue) ? numberValue : null
}
const parseDate = (value: unknown) => {
if (typeof value !== "string" && typeof value !== "number") {
return null
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
const parsePosition = (value: unknown) => {
if (
value &&
typeof value === "object" &&
"x" in value &&
"y" in value &&
typeof (value as { x: unknown }).x === "number" &&
typeof (value as { y: unknown }).y === "number"
) {
return { x: (value as { x: number }).x, y: (value as { y: number }).y }
}
return null
}
const parseSize = (value: unknown) => {
if (
value &&
typeof value === "object" &&
"width" in value &&
"height" in value &&
typeof (value as { width: unknown }).width === "number" &&
typeof (value as { height: unknown }).height === "number"
) {
return {
width: (value as { width: number }).width,
height: (value as { height: number }).height,
}
}
return null
}
// Health check endpoint
app.get("/health", (c) => {
return c.json({ status: "ok", message: "Worker is running!" })
})
// Root endpoint
app.get("/", (c) => {
return c.json({
message: "Welcome to the Cloudflare Worker API",
endpoints: {
health: "/health",
api: "/api/v1",
admin: "/api/v1/admin",
},
})
})
// Example API endpoint
app.get("/api/v1/hello", (c) => {
const name = c.req.query("name") || "World"
return c.json({ message: `Hello, ${name}!` })
})
// Canvas endpoints
app.post("/api/v1/admin/canvas", async (c) => {
const body = await parseBody(c)
const ownerId = typeof body.ownerId === "string" ? body.ownerId.trim() : ""
if (!ownerId) {
return c.json({ error: "ownerId required" }, 400)
}
const values: typeof canvas.$inferInsert = {
owner_id: ownerId,
}
if (typeof body.name === "string" && body.name.trim()) {
values.name = body.name.trim()
}
if (typeof body.width === "number" && Number.isFinite(body.width)) {
values.width = body.width
}
if (typeof body.height === "number" && Number.isFinite(body.height)) {
values.height = body.height
}
if (typeof body.defaultModel === "string") {
values.default_model = body.defaultModel
}
if (typeof body.defaultStyle === "string") {
values.default_style = body.defaultStyle
}
if (body.backgroundPrompt === null) {
values.background_prompt = null
} else if (typeof body.backgroundPrompt === "string") {
values.background_prompt = body.backgroundPrompt
}
try {
const database = getDb(c.env)
const [record] = await database.insert(canvas).values(values).returning()
return c.json({ canvas: record }, 201)
} catch (error) {
console.error("[worker] create canvas failed", error)
return c.json({ error: "Failed to create canvas" }, 500)
}
})
app.patch("/api/v1/admin/canvas/:canvasId", async (c) => {
const canvasId = c.req.param("canvasId")
if (!canvasId) {
return c.json({ error: "canvasId required" }, 400)
}
const body = await parseBody(c)
const updates: Partial<typeof canvas.$inferInsert> = {
updated_at: new Date(),
}
if (typeof body.name === "string") {
updates.name = body.name
}
if (typeof body.width === "number" && Number.isFinite(body.width)) {
updates.width = body.width
}
if (typeof body.height === "number" && Number.isFinite(body.height)) {
updates.height = body.height
}
if (typeof body.defaultModel === "string") {
updates.default_model = body.defaultModel
}
if (typeof body.defaultStyle === "string") {
updates.default_style = body.defaultStyle
}
if (body.backgroundPrompt === null) {
updates.background_prompt = null
} else if (typeof body.backgroundPrompt === "string") {
updates.background_prompt = body.backgroundPrompt
}
if (Object.keys(updates).length <= 1) {
return c.json({ error: "No updates provided" }, 400)
}
try {
const database = getDb(c.env)
const [record] = await database
.update(canvas)
.set(updates)
.where(eq(canvas.id, canvasId))
.returning()
if (!record) {
return c.json({ error: "Canvas not found" }, 404)
}
return c.json({ canvas: record })
} catch (error) {
console.error("[worker] update canvas failed", error)
return c.json({ error: "Failed to update canvas" }, 500)
}
})
app.post("/api/v1/admin/canvas/:canvasId/images", async (c) => {
const canvasId = c.req.param("canvasId")
if (!canvasId) {
return c.json({ error: "canvasId required" }, 400)
}
const body = await parseBody(c)
const position = parsePosition(body.position)
const size = parseSize(body.size)
const values: typeof canvas_images.$inferInsert = {
canvas_id: canvasId,
}
if (typeof body.name === "string") values.name = body.name
if (typeof body.prompt === "string") values.prompt = body.prompt
if (typeof body.modelId === "string") values.model_id = body.modelId
if (typeof body.modelUsed === "string") values.model_used = body.modelUsed
if (typeof body.styleId === "string") values.style_id = body.styleId
if (typeof body.rotation === "number" && Number.isFinite(body.rotation)) {
values.rotation = body.rotation
}
if (position) values.position = position
if (size) {
values.width = size.width
values.height = size.height
}
if (body.metadata !== undefined) {
values.metadata =
body.metadata && typeof body.metadata === "object" ? body.metadata : null
}
if (body.branchParentId === null) {
values.branch_parent_id = null
} else if (typeof body.branchParentId === "string") {
values.branch_parent_id = body.branchParentId
}
if (body.contentBase64 === null) {
values.content_base64 = null
} else if (typeof body.contentBase64 === "string") {
values.content_base64 = body.contentBase64
} else if (typeof body.content_base64 === "string") {
values.content_base64 = body.content_base64
}
if (body.imageUrl === null) {
values.image_url = null
} else if (typeof body.imageUrl === "string") {
values.image_url = body.imageUrl
}
try {
const database = getDb(c.env)
const [image] = await database.insert(canvas_images).values(values).returning()
return c.json({ image }, 201)
} catch (error) {
console.error("[worker] create canvas image failed", error)
return c.json({ error: "Failed to create canvas image" }, 500)
}
})
app.patch("/api/v1/admin/canvas/images/:imageId", async (c) => {
const imageId = c.req.param("imageId")
if (!imageId) {
return c.json({ error: "imageId required" }, 400)
}
const body = await parseBody(c)
const updates: Partial<typeof canvas_images.$inferInsert> = {
updated_at: new Date(),
}
const position = parsePosition(body.position)
const size = parseSize(body.size)
if (typeof body.name === "string") updates.name = body.name
if (typeof body.prompt === "string") updates.prompt = body.prompt
if (typeof body.modelId === "string") updates.model_id = body.modelId
if (typeof body.modelUsed === "string") updates.model_used = body.modelUsed
if (typeof body.styleId === "string") updates.style_id = body.styleId
if (typeof body.rotation === "number" && Number.isFinite(body.rotation)) {
updates.rotation = body.rotation
}
if (position) updates.position = position
if (size) {
updates.width = size.width
updates.height = size.height
}
if (body.metadata !== undefined) {
updates.metadata =
body.metadata && typeof body.metadata === "object" ? body.metadata : null
}
if (body.branchParentId === null) {
updates.branch_parent_id = null
} else if (typeof body.branchParentId === "string") {
updates.branch_parent_id = body.branchParentId
}
if (body.contentBase64 === null) {
updates.content_base64 = null
} else if (typeof body.contentBase64 === "string") {
updates.content_base64 = body.contentBase64
} else if (typeof body.content_base64 === "string") {
updates.content_base64 = body.content_base64
}
if (body.imageUrl === null) {
updates.image_url = null
} else if (typeof body.imageUrl === "string") {
updates.image_url = body.imageUrl
}
if (Object.keys(updates).length <= 1) {
return c.json({ error: "No updates provided" }, 400)
}
try {
const database = getDb(c.env)
const [image] = await database
.update(canvas_images)
.set(updates)
.where(eq(canvas_images.id, imageId))
.returning()
if (!image) {
return c.json({ error: "Image not found" }, 404)
}
return c.json({ image })
} catch (error) {
console.error("[worker] update canvas image failed", error)
return c.json({ error: "Failed to update canvas image" }, 500)
}
})
app.delete("/api/v1/admin/canvas/images/:imageId", async (c) => {
const imageId = c.req.param("imageId")
if (!imageId) {
return c.json({ error: "imageId required" }, 400)
}
try {
const database = getDb(c.env)
const [deleted] = await database
.delete(canvas_images)
.where(eq(canvas_images.id, imageId))
.returning()
if (!deleted) {
return c.json({ error: "Image not found" }, 404)
}
return c.json({ id: imageId })
} catch (error) {
console.error("[worker] delete canvas image failed", error)
return c.json({ error: "Failed to delete canvas image" }, 500)
}
})
// Chat endpoints
app.post("/api/v1/admin/chat/threads", async (c) => {
const body = await parseBody(c)
const title =
typeof body.title === "string" && body.title.trim() ? body.title.trim() : "New chat"
const userId = typeof body.userId === "string" ? body.userId : null
try {
const database = getDb(c.env)
const [thread] = await database
.insert(chat_threads)
.values({ title, user_id: userId })
.returning()
return c.json({ thread }, 201)
} catch (error) {
console.error("[worker] create thread failed", error)
return c.json({ error: "Failed to create thread" }, 500)
}
})
app.patch("/api/v1/admin/chat/threads/:threadId", async (c) => {
const threadId = parseInteger(c.req.param("threadId"))
if (!threadId) {
return c.json({ error: "threadId required" }, 400)
}
const body = await parseBody(c)
const title = typeof body.title === "string" ? body.title.trim() : ""
if (!title) {
return c.json({ error: "title required" }, 400)
}
try {
const database = getDb(c.env)
const [thread] = await database
.update(chat_threads)
.set({ title })
.where(eq(chat_threads.id, threadId))
.returning()
if (!thread) {
return c.json({ error: "Thread not found" }, 404)
}
return c.json({ thread })
} catch (error) {
console.error("[worker] update thread failed", error)
return c.json({ error: "Failed to update thread" }, 500)
}
})
app.post("/api/v1/admin/chat/messages", async (c) => {
const body = await parseBody(c)
const threadId = parseInteger(body.threadId)
const role = typeof body.role === "string" ? body.role.trim() : ""
const content = typeof body.content === "string" ? body.content.trim() : ""
const createdAt = parseDate(body.createdAt)
if (!threadId || !role || !content) {
return c.json({ error: "threadId, role, and content are required" }, 400)
}
try {
const database = getDb(c.env)
const values: typeof chat_messages.$inferInsert = {
thread_id: threadId,
role,
content,
}
if (createdAt) {
values.created_at = createdAt
}
const [message] = await database
.insert(chat_messages)
.values(values)
.returning()
return c.json({ message }, 201)
} catch (error) {
console.error("[worker] add message failed", error)
return c.json({ error: "Failed to add message" }, 500)
}
})
// Context items endpoints
app.post("/api/v1/admin/context-items", async (c) => {
const body = await parseBody(c)
const userId = typeof body.userId === "string" ? body.userId.trim() : ""
const type =
typeof body.type === "string" ? body.type.trim().toLowerCase() : ""
const url = typeof body.url === "string" ? body.url.trim() : null
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: url
? (() => {
try {
const parsed = new URL(url)
return `${parsed.hostname}${parsed.pathname}`
} catch {
return url
}
})()
: "Untitled context"
const threadId = parseInteger(body.threadId)
const parentId = parseInteger(body.parentId)
if (!userId) {
return c.json({ error: "userId required" }, 400)
}
if (type !== "url" && type !== "file") {
return c.json({ error: "type must be 'url' or 'file'" }, 400)
}
const values: typeof context_items.$inferInsert = {
user_id: userId,
type,
name,
}
if (url) values.url = url
if (body.content === null) {
values.content = null
} else if (typeof body.content === "string") {
values.content = body.content
}
if (typeof body.refreshing === "boolean") {
values.refreshing = body.refreshing
}
if (parentId) {
values.parent_id = parentId
}
try {
const database = getDb(c.env)
const [item] = await database.insert(context_items).values(values).returning()
if (threadId) {
await database.insert(thread_context_items).values({
thread_id: threadId,
context_item_id: item.id,
})
}
return c.json({ item }, 201)
} catch (error) {
console.error("[worker] create context item failed", error)
return c.json({ error: "Failed to create context item" }, 500)
}
})
app.patch("/api/v1/admin/context-items/:itemId", async (c) => {
const itemId = parseInteger(c.req.param("itemId"))
if (!itemId) {
return c.json({ error: "itemId required" }, 400)
}
const body = await parseBody(c)
const updates: Partial<typeof context_items.$inferInsert> = {
updated_at: new Date(),
}
const parentId = parseInteger(body.parentId)
if (typeof body.name === "string") updates.name = body.name
if (typeof body.type === "string") {
const nextType = body.type.trim().toLowerCase()
if (nextType !== "url" && nextType !== "file") {
return c.json({ error: "type must be 'url' or 'file'" }, 400)
}
updates.type = nextType
}
if (body.url === null) {
updates.url = null
} else if (typeof body.url === "string") {
updates.url = body.url
}
if (body.content === null) {
updates.content = null
} else if (typeof body.content === "string") {
updates.content = body.content
}
if (typeof body.refreshing === "boolean") {
updates.refreshing = body.refreshing
}
if (body.parentId === null) {
updates.parent_id = null
} else if (parentId) {
updates.parent_id = parentId
}
if (Object.keys(updates).length <= 1) {
return c.json({ error: "No updates provided" }, 400)
}
try {
const database = getDb(c.env)
const [item] = await database
.update(context_items)
.set(updates)
.where(eq(context_items.id, itemId))
.returning()
if (!item) {
return c.json({ error: "Context item not found" }, 404)
}
return c.json({ item })
} catch (error) {
console.error("[worker] update context item failed", error)
return c.json({ error: "Failed to update context item" }, 500)
}
})
app.post("/api/v1/admin/context-items/:itemId/link", async (c) => {
const itemId = parseInteger(c.req.param("itemId"))
if (!itemId) {
return c.json({ error: "itemId required" }, 400)
}
const body = await parseBody(c)
const threadId = parseInteger(body.threadId)
if (!threadId) {
return c.json({ error: "threadId required" }, 400)
}
try {
const database = getDb(c.env)
await database.insert(thread_context_items).values({
thread_id: threadId,
context_item_id: itemId,
})
return c.json({ success: true })
} catch (error) {
console.error("[worker] link context item failed", error)
return c.json({ error: "Failed to link context item" }, 500)
}
})
app.delete("/api/v1/admin/context-items/:itemId", async (c) => {
const itemId = parseInteger(c.req.param("itemId"))
if (!itemId) {
return c.json({ error: "itemId required" }, 400)
}
try {
const database = getDb(c.env)
const [item] = await database
.delete(context_items)
.where(eq(context_items.id, itemId))
.returning()
if (!item) {
return c.json({ error: "Context item not found" }, 404)
}
return c.json({ id: itemId })
} catch (error) {
console.error("[worker] delete context item failed", error)
return c.json({ error: "Failed to delete context item" }, 500)
}
})
// Browser session endpoints
app.post("/api/v1/admin/browser-sessions", async (c) => {
const body = await parseBody(c)
const userId = typeof body.userId === "string" ? body.userId.trim() : ""
const name = typeof body.name === "string" ? body.name.trim() : ""
const browser = typeof body.browser === "string" ? body.browser.trim() : "safari"
const capturedAt = parseDate(body.capturedAt)
const isFavorite = typeof body.isFavorite === "boolean" ? body.isFavorite : undefined
const tabs = Array.isArray(body.tabs) ? body.tabs : []
if (!userId || !name) {
return c.json({ error: "userId and name are required" }, 400)
}
const tabValues = tabs
.map((tab) => {
if (!tab || typeof tab !== "object") {
return null
}
const title = typeof (tab as { title?: unknown }).title === "string"
? (tab as { title: string }).title
: ""
const url = typeof (tab as { url?: unknown }).url === "string"
? (tab as { url: string }).url
: ""
if (!url) {
return null
}
const faviconUrl =
typeof (tab as { faviconUrl?: unknown }).faviconUrl === "string"
? (tab as { faviconUrl: string }).faviconUrl
: typeof (tab as { favicon_url?: unknown }).favicon_url === "string"
? (tab as { favicon_url: string }).favicon_url
: null
return { title, url, favicon_url: faviconUrl }
})
.filter((tab): tab is { title: string; url: string; favicon_url: string | null } =>
Boolean(tab),
)
try {
const database = getDb(c.env)
const [session] = await database
.insert(browser_sessions)
.values({
user_id: userId,
name,
browser,
tab_count: tabValues.length,
is_favorite: isFavorite ?? false,
captured_at: capturedAt ?? new Date(),
})
.returning()
if (tabValues.length > 0) {
await database.insert(browser_session_tabs).values(
tabValues.map((tab, index) => ({
session_id: session.id,
title: tab.title,
url: tab.url,
position: index,
favicon_url: tab.favicon_url ?? null,
})),
)
}
return c.json({ session }, 201)
} catch (error) {
console.error("[worker] create browser session failed", error)
return c.json({ error: "Failed to create browser session" }, 500)
}
})
app.patch("/api/v1/admin/browser-sessions/:sessionId", async (c) => {
const sessionId = c.req.param("sessionId")
if (!sessionId) {
return c.json({ error: "sessionId required" }, 400)
}
const body = await parseBody(c)
const updates: Partial<typeof browser_sessions.$inferInsert> = {}
if (typeof body.name === "string") updates.name = body.name
if (typeof body.isFavorite === "boolean") updates.is_favorite = body.isFavorite
if (Object.keys(updates).length === 0) {
return c.json({ error: "No updates provided" }, 400)
}
try {
const database = getDb(c.env)
const [session] = await database
.update(browser_sessions)
.set(updates)
.where(eq(browser_sessions.id, sessionId))
.returning()
if (!session) {
return c.json({ error: "Session not found" }, 404)
}
return c.json({ session })
} catch (error) {
console.error("[worker] update browser session failed", error)
return c.json({ error: "Failed to update browser session" }, 500)
}
})
app.delete("/api/v1/admin/browser-sessions/:sessionId", async (c) => {
const sessionId = c.req.param("sessionId")
if (!sessionId) {
return c.json({ error: "sessionId required" }, 400)
}
try {
const database = getDb(c.env)
const [session] = await database
.delete(browser_sessions)
.where(eq(browser_sessions.id, sessionId))
.returning()
if (!session) {
return c.json({ error: "Session not found" }, 404)
}
return c.json({ id: sessionId })
} catch (error) {
console.error("[worker] delete browser session failed", error)
return c.json({ error: "Failed to delete browser session" }, 500)
}
})
// Export the Hono app as default (handles HTTP requests)
export default app
// Export the RPC worker for RPC calls via service bindings
export { WorkerRpc } from "./rpc"