Compare commits

..

1 Commits

Author SHA1 Message Date
7631caa254 feat: add conversation entries API 2026-06-18 17:18:02 +01:00
17 changed files with 1944 additions and 526 deletions

View File

@@ -21,8 +21,8 @@
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",

View File

@@ -0,0 +1,11 @@
export class ConversationNotFoundError extends Error {
readonly conversationId: string
readonly userId: string
constructor(conversationId: string, userId: string) {
super(`Conversation "${conversationId}" not found for user "${userId}"`)
this.name = "ConversationNotFoundError"
this.conversationId = conversationId
this.userId = userId
}
}

View File

@@ -2,20 +2,54 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"
import { Hono } from "hono"
import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts"
import type {
ConversationEntryRow,
ConversationRow,
ListConversationEntriesParams,
} from "./storage.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { registerConversationsHttpHandlers } from "./http.ts"
import { ConversationEntryKind, ConversationEntryVisibility } from "./types.ts"
const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
const ConversationId = "11111111-1111-4111-8111-111111111111"
const MissingConversationId = "22222222-2222-4222-8222-222222222222"
const conversationRowsByUser = new Map<string, ConversationRow[]>()
const conversationEntryRowsByUserAndConversation = new Map<string, ConversationEntryRow[]>()
const listEntriesCalls: Array<{
userId: string
conversationId: string
params: ListConversationEntriesParams
}> = []
mock.module("./storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async listConversations(): Promise<ConversationRow[]> {
return conversationRowsByUser.get(userId) ?? []
},
async listEntries(
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
listEntriesCalls.push({ userId, conversationId, params })
const rows = conversationEntryRowsByUserAndConversation.get(
conversationEntriesKey(userId, conversationId),
)
if (!rows) {
throw new ConversationNotFoundError(conversationId, userId)
}
if (params.visibility) {
return rows.filter((row) => row.visibility === params.visibility)
}
return rows
},
}),
}))
@@ -44,9 +78,39 @@ function createConversationRow(
}
}
function createConversationEntryRow(
id: string,
conversationId: string,
sequence: number,
kind: ConversationEntryRow["kind"],
visibility: ConversationEntryRow["visibility"],
payload: ConversationEntryRow["payload"],
createdAt: string,
metadata: ConversationEntryRow["metadata"] = {},
fileId: string | null = null,
): ConversationEntryRow {
return {
id,
conversationId,
sequence,
kind,
visibility,
fileId,
payload,
metadata,
createdAt: new Date(createdAt),
}
}
function conversationEntriesKey(userId: string, conversationId: string): string {
return `${userId}:${conversationId}`
}
describe("GET /api/conversations", () => {
beforeEach(() => {
conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
})
test("returns 401 without auth", async () => {
@@ -108,3 +172,162 @@ describe("GET /api/conversations", () => {
})
})
})
describe("GET /api/conversations/:id/entries", () => {
beforeEach(() => {
conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
})
test("returns 401 without auth", async () => {
const app = buildTestApp()
const res = await app.request("/api/conversations/conversation-1/entries")
expect(res.status).toBe(401)
})
test("returns user-visible entries for the authenticated user", async () => {
conversationEntryRowsByUserAndConversation.set(
conversationEntriesKey(MockUserId, ConversationId),
[
createConversationEntryRow(
"entry-user",
ConversationId,
1,
ConversationEntryKind.UserMessage,
ConversationEntryVisibility.UserVisible,
{
role: "user",
parts: [{ type: "text", text: "What is on today?" }],
},
"2026-06-17T09:30:00.000Z",
),
createConversationEntryRow(
"entry-tool",
ConversationId,
2,
ConversationEntryKind.ToolCall,
ConversationEntryVisibility.Internal,
{
toolName: "freya_list_context",
input: {},
},
"2026-06-17T09:30:01.000Z",
),
createConversationEntryRow(
"entry-assistant",
ConversationId,
3,
ConversationEntryKind.AssistantMessage,
ConversationEntryVisibility.UserVisible,
{
role: "assistant",
parts: [{ type: "text", text: "You have two calendar events." }],
},
"2026-06-17T09:30:02.000Z",
{ runId: "run-1" },
),
],
)
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${ConversationId}/entries`)
expect(res.status).toBe(200)
expect(listEntriesCalls).toEqual([
{
userId: MockUserId,
conversationId: ConversationId,
params: { visibility: ConversationEntryVisibility.UserVisible },
},
])
const body = (await res.json()) as { entries: unknown[] }
expect(body).toEqual({
entries: [
{
id: "entry-user",
conversationId: ConversationId,
sequence: 1,
kind: ConversationEntryKind.UserMessage,
visibility: ConversationEntryVisibility.UserVisible,
fileId: null,
payload: {
role: "user",
parts: [{ type: "text", text: "What is on today?" }],
},
metadata: {},
createdAt: "2026-06-17T09:30:00.000Z",
},
{
id: "entry-assistant",
conversationId: ConversationId,
sequence: 3,
kind: ConversationEntryKind.AssistantMessage,
visibility: ConversationEntryVisibility.UserVisible,
fileId: null,
payload: {
role: "assistant",
parts: [{ type: "text", text: "You have two calendar events." }],
},
metadata: { runId: "run-1" },
createdAt: "2026-06-17T09:30:02.000Z",
},
],
})
})
test("returns an empty list when the conversation has no user-visible entries", async () => {
conversationEntryRowsByUserAndConversation.set(
conversationEntriesKey(MockUserId, ConversationId),
[
createConversationEntryRow(
"entry-tool",
ConversationId,
1,
ConversationEntryKind.ToolResult,
ConversationEntryVisibility.Internal,
{ toolCallId: "call-1", output: { ok: true } },
"2026-06-17T09:30:00.000Z",
),
],
)
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${ConversationId}/entries`)
expect(res.status).toBe(200)
const body = (await res.json()) as { entries: unknown[] }
expect(body).toEqual({ entries: [] })
})
test("returns 404 for malformed conversation ids without querying storage", async () => {
const app = buildTestApp("user-1")
const res = await app.request("/api/conversations/missing-conversation/entries")
expect(res.status).toBe(404)
expect(listEntriesCalls).toEqual([])
const body = (await res.json()) as { error: string }
expect(body).toEqual({ error: "Conversation not found" })
})
test("returns 404 when the conversation does not exist for the user", async () => {
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${MissingConversationId}/entries`)
expect(res.status).toBe(404)
expect(listEntriesCalls).toEqual([
{
userId: MockUserId,
conversationId: MissingConversationId,
params: { visibility: ConversationEntryVisibility.UserVisible },
},
])
const body = (await res.json()) as { error: string }
expect(body).toEqual({ error: "Conversation not found" })
})
})

View File

@@ -1,11 +1,15 @@
import type { Context, Hono } from "hono"
import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { conversations } from "./storage.ts"
import { ConversationEntryVisibility } from "./types.ts"
type Env = {
Variables: {
@@ -13,11 +17,19 @@ type Env = {
}
}
interface ConversationSummaryResponse {
id: string
createdAt: string
updatedAt: string
}
interface ConversationsHttpHandlersDeps {
db: Database
authSessionMiddleware: AuthSessionMiddleware
}
const ConversationIdParam = type("string.uuid")
export function registerConversationsHttpHandlers(
app: Hono,
{ db, authSessionMiddleware }: ConversationsHttpHandlersDeps,
@@ -28,6 +40,7 @@ export function registerConversationsHttpHandlers(
})
app.get("/api/conversations", inject, authSessionMiddleware, handleListConversations)
app.get("/api/conversations/:id/entries", inject, authSessionMiddleware, handleListEntries)
}
async function handleListConversations(c: Context<Env>) {
@@ -35,10 +48,54 @@ async function handleListConversations(c: Context<Env>) {
const db = c.get("db")
return c.json({
conversations: (await conversations(db, user.id).listConversations()).map((row) => ({
id: row.id,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
conversations: (await conversations(db, user.id).listConversations()).map(
serializeConversation,
),
})
}
async function handleListEntries(c: Context<Env>) {
const user = c.get("user")!
const db = c.get("db")
const conversationId = c.req.param("id")
if (!conversationId) {
return c.json({ error: "Conversation not found" }, 404)
}
const parsedConversationId = ConversationIdParam(conversationId)
if (parsedConversationId instanceof type.errors) {
return c.json({ error: "Conversation not found" }, 404)
}
try {
const entries = await conversations(db, user.id).listEntries(parsedConversationId, {
visibility: ConversationEntryVisibility.UserVisible,
})
return c.json({
entries: entries.map((row) => ({
id: row.id,
conversationId: row.conversationId,
sequence: row.sequence,
kind: row.kind,
visibility: row.visibility,
fileId: row.fileId,
payload: row.payload,
metadata: row.metadata,
createdAt: row.createdAt.toISOString(),
})),
})
} catch (err) {
if (err instanceof ConversationNotFoundError) {
return c.json({ error: "Conversation not found" }, 404)
}
throw err
}
}
function serializeConversation(row: ConversationRow): ConversationSummaryResponse {
return {
id: row.id,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}

View File

@@ -19,6 +19,7 @@ import {
files,
user,
} from "../db/schema.ts"
import { ConversationNotFoundError } from "./errors.ts"
import {
ConversationEntryMetadata as ConversationEntryMetadataSchema,
AssistantMessagePayload as AssistantMessagePayloadSchema,
@@ -96,7 +97,7 @@ export interface ListConversationEntriesParams {
}
export function conversations(db: Database, userId: string) {
return {
const storage = {
async createConversation(): Promise<ConversationRow> {
return insertConversation(db, userId)
},
@@ -109,6 +110,18 @@ export function conversations(db: Database, userId: string) {
.orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt))
},
async getConversation(conversationId: string): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(
and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)),
)
.limit(1)
return rows[0] ?? null
},
async getOrCreateConversation(): Promise<ConversationRow> {
return db.transaction(async (tx) => {
await requireUserForUpdate(tx, userId)
@@ -141,7 +154,9 @@ export function conversations(db: Database, userId: string) {
}
const rows = await db.transaction(async (tx) => {
await requireConversationForUpdate(tx, userId, conversationId)
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
const sequence = await nextSequence(tx, conversationId)
const rows = await tx
@@ -175,7 +190,9 @@ export function conversations(db: Database, userId: string) {
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {})
return db.transaction(async (tx) => {
await requireConversationForUpdate(tx, userId, conversationId)
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
const file = await insertFile(tx, userId, input.file)
const sequence = await nextSequence(tx, conversationId)
@@ -204,7 +221,9 @@ export function conversations(db: Database, userId: string) {
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
await requireConversation(db, userId, conversationId)
if (!(await storage.getConversation(conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
if (params.visibility) {
return db
@@ -226,6 +245,8 @@ export function conversations(db: Database, userId: string) {
.orderBy(asc(conversationEntries.sequence))
},
}
return storage
}
function payloadForKind(
@@ -259,25 +280,11 @@ async function requireUserForUpdate(db: Database, userId: string): Promise<void>
requireRow(rows, `User not found: ${userId}`)
}
async function requireConversation(
async function findConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
return requireRow(rows, `Conversation not found: ${conversationId}`)
}
async function requireConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
@@ -285,7 +292,7 @@ async function requireConversationForUpdate(
.limit(1)
.for("update")
return requireRow(rows, `Conversation not found: ${conversationId}`)
return rows[0] ?? null
}
async function latestConversation(db: Database, userId: string): Promise<ConversationRow | null> {

View File

@@ -11,6 +11,7 @@ import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts"
import { createRequireSession } from "./auth/session-middleware.ts"
import { CalDavSourceProvider } from "./caldav/provider.ts"
import { registerConversationsHttpHandlers } from "./conversations/http.ts"
import { createDatabase } from "./db/index.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
@@ -129,6 +130,7 @@ function main() {
sessionManager,
authSessionMiddleware,
})
registerConversationsHttpHandlers(app, { db, authSessionMiddleware })
if (isDebugMode) {
registerDebugAgentHttpHandlers(app, {
authSessionMiddleware,

View File

@@ -1,12 +1,13 @@
{
"expo": {
"name": "Freya",
"slug": "freya",
"slug": "freya-client",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "freya",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
@@ -23,6 +24,7 @@
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "sh.nym.freya"
},
@@ -52,82 +54,55 @@
{
"fontFamily": "Inter",
"fontDefinitions": [
{
"path": "./assets/fonts/Inter_100Thin.ttf",
"weight": 100
},
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
{
"path": "./assets/fonts/Inter_100Thin_Italic.ttf",
"weight": 100,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_200ExtraLight.ttf",
"weight": 200
},
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
{
"path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_300Light.ttf",
"weight": 300
},
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
{
"path": "./assets/fonts/Inter_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_400Regular.ttf",
"weight": 400
},
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
{
"path": "./assets/fonts/Inter_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_500Medium.ttf",
"weight": 500
},
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
{
"path": "./assets/fonts/Inter_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_600SemiBold.ttf",
"weight": 600
},
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
{
"path": "./assets/fonts/Inter_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_700Bold.ttf",
"weight": 700
},
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
{
"path": "./assets/fonts/Inter_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_800ExtraBold.ttf",
"weight": 800
},
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
{
"path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_900Black.ttf",
"weight": 900
},
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
{
"path": "./assets/fonts/Inter_900Black_Italic.ttf",
"weight": 900,
@@ -138,73 +113,49 @@
{
"fontFamily": "Source Serif 4",
"fontDefinitions": [
{
"path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf",
"weight": 200
},
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
{
"path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_300Light.ttf",
"weight": 300
},
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
{
"path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_400Regular.ttf",
"weight": 400
},
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
{
"path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_500Medium.ttf",
"weight": 500
},
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
{
"path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_600SemiBold.ttf",
"weight": 600
},
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
{
"path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_700Bold.ttf",
"weight": 700
},
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
{
"path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf",
"weight": 800
},
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
{
"path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_900Black.ttf",
"weight": 900
},
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
{
"path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf",
"weight": 900,
@@ -253,9 +204,7 @@
]
}
}
],
"expo-web-browser",
"expo-image"
]
],
"experiments": {
"typedRoutes": true,
@@ -264,7 +213,7 @@
"extra": {
"router": {},
"eas": {
"projectId": "c54ea4e5-27da-4066-b081-db8005ecf70a"
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
}
}
}

View File

@@ -10,8 +10,8 @@
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint",
"build:ios": "bunx eas-cli build --profile development --platform ios --non-interactive",
"build:ios-simulator": "bunx eas-cli build --profile development-simulator --platform ios --non-interactive",
"build:ios": "eas build --profile development --platform ios --non-interactive",
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
"debugger": "bun run scripts/open-debugger.ts"
},
"dependencies": {
@@ -19,38 +19,42 @@
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "^56.0.0",
"expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20",
"expo-font": "~56.0.7",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.11",
"expo-linking": "~56.0.14",
"expo-location": "~56.0.18",
"expo-router": "~56.2.11",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-system-ui": "~56.0.5",
"expo-web-browser": "~56.0.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-location": "~19.0.8",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"react-native-worklets": "0.5.1",
"twrnc": "^4.16.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "~19.2.10",
"@types/react": "~19.1.0",
"eas-cli": "^18.0.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~56.0.4",
"typescript": "~6.0.3"
"eslint-config-expo": "~10.0.0",
"typescript": "^6"
}
}

View File

@@ -8,16 +8,14 @@ import type { ServerWebSocket } from "bun"
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
const PROXY_HOST = process.env.PROXY_HOST || "0.0.0.0"
const METRO_HOST = process.env.METRO_HOST || "localhost"
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
const METRO_BASE = `http://${METRO_HOST}:${METRO_PORT}`
const METRO_WS_BASE = `ws://${METRO_HOST}:${METRO_PORT}`
const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
function forwardHeaders(headers: Headers): Headers {
const result = new Headers(headers)
result.delete("origin")
result.delete("referer")
result.set("host", `${METRO_HOST}:${METRO_PORT}`)
result.set("host", `127.0.0.1:${METRO_PORT}`)
return result
}
@@ -42,7 +40,7 @@ Bun.serve<WsData>({
// WebSocket upgrade — bridge to Metro's ws endpoint
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const wsUrl = `${METRO_WS_BASE}${url.pathname}${url.search}`
const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
const upstream = new WebSocket(wsUrl)
// Wait for upstream to connect before upgrading the client
@@ -67,12 +65,12 @@ Bun.serve<WsData>({
// HTTP proxy
const upstream = `${METRO_BASE}${url.pathname}${url.search}`
const body = req.body ? await req.arrayBuffer() : undefined
const res = await fetchUpstream(upstream, req.method, forwardHeaders(req.headers), body)
if (res == null) {
return new Response(`Metro is not reachable on ${METRO_HOST}. Restart the Expo dev server.`, {
status: 502,
})
}
const res = await fetch(upstream, {
method: req.method,
headers: forwardHeaders(req.headers),
body,
redirect: "manual",
})
return new Response(res.body, {
status: res.status,
@@ -123,7 +121,9 @@ async function printDebuggerUrl() {
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) return
const wsPath = getProxyWebSocketPath(target.webSocketDebuggerUrl)
const wsPath = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
console.log(
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
@@ -131,28 +131,9 @@ async function printDebuggerUrl() {
}
console.log(
`[proxy] listening on ${PROXY_HOST}:${PROXY_PORT}, forwarding to ${METRO_HOST}:${METRO_PORT}`,
`[proxy] listening on ${PROXY_HOST}:${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`,
)
async function fetchUpstream(
upstream: string,
method: string,
headers: Headers,
body: ArrayBuffer | undefined,
) {
try {
return await fetch(upstream, {
method,
headers,
body,
redirect: "manual",
})
} catch {
console.error(`[proxy] ${method} ${upstream} failed; Metro is not reachable`)
return null
}
}
function isDebugTarget(value: unknown): value is DebugTarget {
if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false
@@ -168,11 +149,6 @@ function isDebugTarget(value: unknown): value is DebugTarget {
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
}
function getProxyWebSocketPath(webSocketDebuggerUrl: string) {
const url = new URL(webSocketDebuggerUrl)
return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View File

@@ -4,6 +4,7 @@
import { $ } from "bun"
const PROXY_PORT = process.env.PROXY_PORT || "8080"
const METRO_PORT = process.env.METRO_PORT || "8081"
const tsIp = (await $`tailscale ip -4`.text()).trim()
const base = `http://${tsIp}:${PROXY_PORT}`
@@ -36,7 +37,9 @@ if (!target) {
process.exit(1)
}
const wsUrl = getProxyWebSocketPath(target.webSocketDebuggerUrl)
const wsUrl = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
@@ -68,11 +71,6 @@ function isDebugTarget(value: unknown): value is DebugTarget {
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
}
function getProxyWebSocketPath(webSocketDebuggerUrl: string) {
const url = new URL(webSocketDebuggerUrl)
return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View File

@@ -1,47 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
PROXY_PORT=${PROXY_PORT:-8080}
METRO_HOST=${METRO_HOST:-localhost}
METRO_PORT=${METRO_PORT:-8081}
PROXY_PORT=8080
METRO_PORT=8081
TS_IP=$(tailscale ip -4)
port_is_open() {
(: >"/dev/tcp/$1/$2") >/dev/null 2>&1
}
ensure_port_available() {
local port=$1
local name=$2
if port_is_open localhost "$port"; then
echo "$name port $port is already in use." >&2
echo "Stop the existing process or set ${name}_PORT to another value." >&2
exit 1
fi
}
wait_for_metro() {
for _ in {1..120}; do
if port_is_open "$METRO_HOST" "$METRO_PORT"; then
return 0
fi
sleep 0.5
done
echo "Metro did not start on ${METRO_HOST}:${METRO_PORT}." >&2
return 1
}
ensure_port_available "$PROXY_PORT" PROXY
ensure_port_available "$METRO_PORT" METRO
# Start the proxy only after Metro is listening. Otherwise an iOS client can hit
# the proxy during Expo startup and get a misleading upstream connection error.
(
wait_for_metro
exec env PROXY_PORT=$PROXY_PORT METRO_HOST=$METRO_HOST METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts
) &
# Start a reverse proxy so Metro sees all requests as loopback.
# This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
# accessible through the Tailscale IP.
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
PROXY_PID=$!
trap "kill $PROXY_PID 2>/dev/null" EXIT

View File

@@ -1,5 +1,5 @@
import Feather from "@expo/vector-icons/Feather"
import { type PressableProps, Pressable, type StyleProp, View, type ViewStyle } from "react-native"
import { type PressableProps, Pressable, View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
@@ -14,10 +14,9 @@ function ButtonIcon({ name }: ButtonIconProps) {
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
}
type ButtonProps = Omit<PressableProps, "children" | "style"> & {
type ButtonProps = Omit<PressableProps, "children"> & {
label: string
leadingIcon?: React.ReactNode
style?: StyleProp<ViewStyle>
trailingIcon?: React.ReactNode
}

View File

@@ -16,9 +16,9 @@
"lottie-react": "^2.4.1",
"lucide-react": "^0.577.0",
"motion": "^12.35.0",
"react": "19.2.3",
"react": "^19.2.4",
"react-aria-components": "^1.16.0",
"react-dom": "19.2.3",
"react-dom": "^19.2.4",
"react-router": "7.12.0",
"resend": "^6.9.3",
"streamdown": "^2.4.0"

1834
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
[install]
linker = "hoisted"

View File

@@ -45,7 +45,7 @@
# node_modules is content-addressed. If bun.lock or package manifests
# change, Nix will report the new hash to put here.
nodeModulesHashes = {
x86_64-linux = "sha256-8uhlaQAFfCgGdUlrz8sqhtIkC/WfdasbTCi3p/NkU/w=";
x86_64-linux = "sha256-apVZaFGf9OKpil1WdcQ1CJODsIdjLWlBBZErHg5mjZA=";
};
checkSystems = lib.attrNames nodeModulesHashes;
@@ -53,9 +53,7 @@
# so source-only edits do not force Bun to reinstall.
dependencySource = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.fileFilter (
file: file.name == "bun.lock" || file.name == "package.json" || file.name == "bunfig.toml"
) ./.;
fileset = lib.fileset.fileFilter (file: file.name == "bun.lock" || file.name == "package.json") ./.;
};
# Checks run against a clean source tree, even when using `path:.`.

View File

@@ -10,7 +10,6 @@
"expo": "cd apps/freya-client && bun run start",
"drizzle-studio": "TS_IP=$(tailscale ip -4); echo \"Drizzle Studio: https://local.drizzle.studio/?host=${TS_IP}&port=4983\"; cd apps/freya-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983",
"freya-backend": "TS_IP=$(tailscale ip -4); echo \"Freya Backend: http://${TS_IP}:3000\"; echo \"\"; echo \"------------------ Bun Debugger ------------------\"; echo \"https://debug.bun.sh/#${TS_IP}:6499\"; echo \"------------------ Bun Debugger ------------------\"; echo \"\"; cd apps/freya-backend && bun run dev",
"client": "bun run --elide-lines=0 --filter freya-client start",
"admin-dashboard": "TS_IP=$(tailscale ip -4); echo \"Admin Dashboard: http://${TS_IP}:5174\"; cd apps/admin-dashboard && bun run dev --host 0.0.0.0",
"agent-test-cli": "cd apps/agent-test-cli && bun run start",
"test": "bun run --filter '*' test",