UNPKG

oneie

Version:

Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.

1,015 lines (826 loc) 27.8 kB
--- title: Architecture Frontend dimension: knowledge category: architecture-frontend.md tags: architecture, backend, frontend, ontology, system-design, ui related_dimensions: events, things scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the knowledge dimension in the architecture-frontend.md category. Location: one/knowledge/architecture-frontend.md Purpose: Documents one frontend architecture Related dimensions: events, things For AI agents: Read this to understand architecture frontend. --- j # ONE Frontend Architecture **Building the Rendering Layer - Backend-Agnostic, Type-Safe, Composable** **Version:** 3.0.0 (Aligned with 6-Dimension Ontology) --- ## Executive Summary The ONE frontend is **purely a rendering and interaction layer** with zero business logic. It: 1. **Renders UI** from data provided by backends 2. **Calls backend APIs** via abstract DataProvider interface 3. **Manages UI state** (loading, errors, forms) 4. **Displays real-time updates** via subscriptions ``` ┌────────────────────────────────────────────────────────┐ FRONTEND (Astro + React) Renders HTML/React components Calls DataProvider interface Manages UI state only Backend-agnostic by design NO database access NO business logic NO data validation └──────────────────────┬─────────────────────────────────┘ DataProvider Interface (Universal 6-Dimension API) ┌────────────────────────────────────────────────────────┐ BACKEND PROVIDERS (Pluggable) ConvexProvider Convex real-time backend WordPressProvider WordPress CMS NotionProvider Notion databases SupabaseProvider Supabase PostgreSQL CustomProvider Your own API Change backend with ONE line in config └────────────────────────────────────────────────────────┘ ``` **Tech Stack:** - **Astro 5.14+** - SSR/SSG (pages, layouts) - **React 19** - Interactive components (islands) with `react-dom/server.edge` for Cloudflare - **Effect.ts** - Type-safe, composable operations - **DataProvider Pattern** - Backend-agnostic interface - **Tailwind CSS v4** - CSS-based configuration (no JS config) - **shadcn/ui** - 50+ pre-installed accessible components - **Better Auth** - Multi-method authentication - **TypeScript 5.9+** - End-to-end type safety (strict mode) **Design System:** - **Design:** See `one/knowledge/develop/design.md` for complete design tokens, guidelines, and implementation playbook - **Design Patterns:** See `one/knowledge/patterns/design/` for reusable wireframe and component patterns - **Agent Coordination:** See `.claude/agents/agent-designer.md` for design specification and QA workflows --- ## Table of Contents 1. [What Frontend IS and IS NOT](#what-frontend-is-and-is-not) 2. [Backend-Agnostic Architecture](#backend-agnostic-architecture) 3. [DataProvider Interface](#dataprovider-interface) 4. [Provider Implementations](#provider-implementations) 5. [Configuration](#configuration) 6. [Effect.ts Service Layer](#effectts-service-layer) 7. [React Integration](#react-integration) 8. [Component Patterns](#component-patterns) 9. [Page Patterns (SSR)](#page-patterns-ssr) 10. [Multi-Tenant Routing](#multi-tenant-routing) 11. [File Structure](#file-structure) 12. [Deployment](#deployment) --- ## What Frontend IS and IS NOT ### ✅ Frontend IS Responsible For: **1. Rendering UI** ```tsx <h1>{course.name}</h1> <p>{course.properties.description}</p> ``` **2. Calling Backend APIs** ```tsx const program = Effect.gen(function* () { const provider = yield* DataProvider return yield* provider.things.list({ type: 'course' }) }) ``` **3. Managing UI State** ```tsx const { run, loading, error } = useEffectRunner() ``` **4. User Interactions** ```tsx <button onClick={handleClick}>Create Course</button> ``` ### ❌ Frontend IS NOT Responsible For: **1. Database Operations** ```tsx // NEVER do this in frontend const courses = await db.query('things').filter(...).collect() ``` **2. Business Logic** ```tsx // NEVER do this in frontend if (user.tokens < course.price) { // Backend handles pricing, quotas, calculations } ``` **3. Data Validation** ```tsx // Frontend validation is UX-only // Backend MUST validate for security ``` **4. Authorization** ```tsx // Frontend can hide UI elements // Backend MUST authorize all operations ``` --- ## Backend-Agnostic Architecture **Inspired by Astro's Content Layer Pattern** Frontend components never know which backend they're talking to. Change backends by editing ONE line: ```typescript // astro.config.ts import { defineConfig } from 'astro/config' import { one } from '@one/astro-integration' // Import providers import { convexProvider } from './src/providers/convex' import { wordpressProvider } from './src/providers/wordpress' export default defineConfig({ integrations: [ react(), one({ // Change this ONE line to swap backends provider: convexProvider({ url: import.meta.env.PUBLIC_CONVEX_URL }) // Or use WordPress: // provider: wordpressProvider({ // url: 'https://yoursite.com', // apiKey: import.meta.env.WORDPRESS_API_KEY // }) }) ] }) ``` **Key Principle:** Frontend code stays the same. Only data source changes. --- ## DataProvider Interface **The universal 6-dimension ontology API.** Every backend provider must implement this interface: ```typescript // frontend/src/providers/DataProvider.ts import { Effect, Context } from 'effect' // Error types (universal across all providers) export class ThingNotFoundError { readonly _tag = 'ThingNotFoundError' constructor(readonly thingId: string) {} } export class ConnectionCreateError { readonly _tag = 'ConnectionCreateError' constructor(readonly reason: string) {} } // DataProvider interface - all backends implement this export interface DataProvider { // Dimension 1: Groups (Multi-tenant containers with hierarchical nesting) groups: { get: (id: string) => Effect.Effect<Group, GroupNotFoundError> list: (params?: { type?: 'friend_circle' | 'business' | 'community' | 'dao' | 'government' | 'organization' parentGroupId?: string status?: string }) => Effect.Effect<Group[], Error> create: (input: { name: string type: 'friend_circle' | 'business' | 'community' | 'dao' | 'government' | 'organization' parentGroupId?: string properties?: Record<string, any> }) => Effect.Effect<string, Error> update: (id: string, updates: Partial<Group>) => Effect.Effect<void, Error> } // Dimension 2: People (Authorization & governance) people: { get: (id: string) => Effect.Effect<Person, PersonNotFoundError> list: (params: { groupId?: string role?: 'platform_owner' | 'org_owner' | 'org_user' | 'customer' }) => Effect.Effect<Person[], Error> create: (input: { email: string displayName: string role: 'platform_owner' | 'org_owner' | 'org_user' | 'customer' groupId: string // Primary group groups?: string[] // Multi-group membership }) => Effect.Effect<string, Error> } // Dimension 3: Things (All entities - 66+ types) things: { get: (id: string) => Effect.Effect<Thing, ThingNotFoundError> list: (params: { type: ThingType groupId?: string // Scoped to group filters?: Record<string, any> }) => Effect.Effect<Thing[], Error> create: (input: { type: ThingType name: string groupId: string // Every thing belongs to a group properties: Record<string, any> }) => Effect.Effect<string, Error> update: (id: string, updates: Partial<Thing>) => Effect.Effect<void, Error> delete: (id: string) => Effect.Effect<void, Error> } // Dimension 4: Connections (All relationships - 25+ types) connections: { create: (input: { fromThingId?: string toThingId?: string fromPersonId?: string // Can connect people things toPersonId?: string // Or people people relationshipType: ConnectionType groupId: string // Scoped to group metadata?: Record<string, any> }) => Effect.Effect<string, ConnectionCreateError> getRelated: (params: { thingId: string relationshipType: ConnectionType direction: 'from' | 'to' | 'both' }) => Effect.Effect<Thing[], Error> getCount: (thingId: string, relationshipType: ConnectionType) => Effect.Effect<number, Error> } // Dimension 5: Events (Complete audit trail - 67+ types) events: { log: (event: { type: EventType actorId: string // REQUIRED: Who did it (person) targetId?: string groupId: string // Scoped to group metadata?: Record<string, any> }) => Effect.Effect<void, Error> list: (params: { groupId?: string actorId?: string type?: EventType limit?: number }) => Effect.Effect<Event[], Error> } // Dimension 6: Knowledge (Embeddings, vectors, RAG) knowledge: { search: (params: { query: string groupId?: string // Scoped to group limit?: number }) => Effect.Effect<KnowledgeMatch[], Error> create: (input: { type: 'embedding' | 'label' | 'category' | 'tag' text?: string embedding?: number[] sourceThingId?: string groupId: string }) => Effect.Effect<string, Error> } } export const DataProvider = Context.GenericTag<DataProvider>('DataProvider') ``` **Key Insight:** This interface IS the 6-dimension ONE ontology. Any backend that implements it can power ONE. **Critical:** All dimensions (things, connections, events, knowledge) are scoped to `groupId`. Groups provide hierarchical multi-tenant isolation (friend circles businesses DAOs governments organizations). --- ## Provider Implementations ### Convex Provider ```typescript // frontend/src/providers/convex/ConvexProvider.ts import { Effect, Layer } from 'effect' import { ConvexHttpClient } from 'convex/browser' import { DataProvider } from '../DataProvider' export class ConvexProvider implements DataProvider { constructor(private client: ConvexHttpClient) {} things = { get: (id: string) => Effect.tryPromise({ try: () => this.client.query(api.queries.things.get, { id }), catch: (error) => new ThingNotFoundError(id) }), list: (params) => Effect.tryPromise({ try: () => this.client.query(api.queries.things.list, params), catch: (error) => new Error(String(error)) }), create: (input) => Effect.tryPromise({ try: () => this.client.mutation(api.mutations.things.create, input), catch: (error) => new ConnectionCreateError(String(error)) }) } // ... connections, events, knowledge } export function convexProvider(config: { url: string }) { return Layer.succeed( DataProvider, new ConvexProvider(new ConvexHttpClient(config.url)) ) } ``` ### WordPress Provider ```typescript // frontend/src/providers/wordpress/WordPressProvider.ts import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class WordPressProvider implements DataProvider { constructor(private baseUrl: string, private apiKey: string) {} things = { get: (id: string) => Effect.gen(function* () { const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/wp-json/wp/v2/posts/${id}`, { headers: { Authorization: `Bearer ${this.apiKey}` } }), catch: (error) => new Error(String(error)) }) const post = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) // Transform WordPress post ONE thing return { _id: post.id.toString(), type: 'post' as ThingType, name: post.title.rendered, properties: { content: post.content.rendered, excerpt: post.excerpt.rendered }, status: post.status, createdAt: new Date(post.date).getTime() } }) } // ... other dimensions } export function wordpressProvider(config: { url: string; apiKey: string }) { return Layer.succeed( DataProvider, new WordPressProvider(config.url, config.apiKey) ) } ``` --- ## Configuration **Swap backends by changing ONE line:** ```typescript // astro.config.ts export default defineConfig({ integrations: [ react(), one({ // Option 1: Convex (real-time, serverless) provider: convexProvider({ url: import.meta.env.PUBLIC_CONVEX_URL }) // Option 2: WordPress (existing CMS) // provider: wordpressProvider({ // url: 'https://yoursite.com', // apiKey: import.meta.env.WORDPRESS_API_KEY // }) // Option 3: Supabase (PostgreSQL + real-time) // provider: supabaseProvider({ // url: import.meta.env.PUBLIC_SUPABASE_URL, // apiKey: import.meta.env.PUBLIC_SUPABASE_ANON_KEY // }) // Option 4: Custom backend // provider: customProvider({ ... }) }) ] }) ``` --- ## Effect.ts Service Layer **Services wrap DataProvider in type-safe, composable operations.** ### Generic Thing Service ```typescript // frontend/src/services/ThingService.ts import { Effect } from 'effect' import { DataProvider } from '@/providers/DataProvider' export class ThingService extends Effect.Service<ThingService>()( 'ThingService', { effect: Effect.gen(function* () { const provider = yield* DataProvider return { get: (id: string) => provider.things.get(id), list: (type: ThingType, orgId?: string) => provider.things.list({ type, organizationId: orgId }), create: (input: { type: ThingType name: string properties: any }) => provider.things.create(input), update: (id: string, updates: any) => provider.things.update(id, updates), delete: (id: string) => provider.things.delete(id) } }), dependencies: [DataProvider] } ) {} ``` ### Generic Connection Service ```typescript // frontend/src/services/ConnectionService.ts import { Effect } from 'effect' import { DataProvider } from '@/providers/DataProvider' export class ConnectionService extends Effect.Service<ConnectionService>()( 'ConnectionService', { effect: Effect.gen(function* () { const provider = yield* DataProvider return { create: (input: { fromThingId: string toThingId: string relationshipType: ConnectionType }) => provider.connections.create(input), getRelated: (thingId: string, relationshipType: ConnectionType) => provider.connections.getRelated({ thingId, relationshipType, direction: 'both' }), getCount: (thingId: string, relationshipType: ConnectionType) => provider.connections.getCount(thingId, relationshipType) } }), dependencies: [DataProvider] } ) {} ``` **Key Point:** These 2 generic services handle ALL 66 thing types. No specialized services needed upfront. --- ## React Integration ### useEffectRunner Hook ```typescript // frontend/src/hooks/useEffectRunner.ts import { useCallback, useState } from 'react' import { Effect } from 'effect' import { ClientLayer } from '@/services/ClientLayer' export function useEffectRunner() { const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null) const run = useCallback(async <A, E>( effect: Effect.Effect<A, E>, options?: { onSuccess?: (result: A) => void onError?: (error: E) => void } ) => { setLoading(true) setError(null) try { const result = await Effect.runPromise( effect.pipe(Effect.provide(ClientLayer)) ) options?.onSuccess?.(result) return result } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) setError(errorMessage) options?.onError?.(err as E) throw err } finally { setLoading(false) } }, []) return { run, loading, error } } ``` ### Using in Components ```tsx // frontend/src/components/CourseEnrollButton.tsx import { useEffectRunner } from '@/hooks/useEffectRunner' import { ConnectionService } from '@/services/ConnectionService' import { Effect } from 'effect' export function CourseEnrollButton({ courseId, userId }) { const { run, loading, error } = useEffectRunner() const handleEnroll = () => { const program = Effect.gen(function* () { const connections = yield* ConnectionService // Enroll = create connection return yield* connections.create({ fromThingId: userId, toThingId: courseId, relationshipType: 'enrolled_in' }) }) run(program, { onSuccess: (id) => { console.log('Enrolled successfully') window.location.href = `/courses/${courseId}/learn` } }) } return ( <div> <button onClick={handleEnroll} disabled={loading}> {loading ? 'Enrolling...' : 'Enroll Now'} </button> {error && <Alert variant="destructive">{error}</Alert>} </div> ) } ``` --- ## Component Patterns ### Generic Card Component ```tsx // frontend/src/components/cards/Card.tsx import { Card } from '@/components/ui/card' import { getThingConfig } from '@/ontology/config' import * as Icons from 'lucide-react' interface CardProps { thing: { _id: string type: string name: string properties: Record<string, any> } } export function Card({ thing }: CardProps) { const config = getThingConfig(thing.type) const Icon = Icons[config.icon] const title = thing.properties[config.primaryField] || thing.name const subtitle = thing.properties[config.secondaryField] const image = thing.properties[config.imageField] return ( <Card> {image && <img src={image} alt={title} />} <div className="p-4"> <div className="flex items-center gap-2"> <Icon className="w-5 h-5" /> <span>{config.displayName}</span> </div> <h3 className="text-xl font-bold">{title}</h3> {subtitle && <p className="text-gray-600">{subtitle}</p>} <a href={`/${thing.type}s/${thing._id}`}> <button>View Details</button> </a> </div> </Card> ) } ``` ### Composable Operations ```tsx // frontend/src/components/CourseCreator.tsx import { useEffectRunner } from '@/hooks/useEffectRunner' import { ThingService } from '@/services/ThingService' import { ConnectionService } from '@/services/ConnectionService' import { Effect } from 'effect' export function CourseCreator() { const { run, loading, error } = useEffectRunner() const [formData, setFormData] = useState({ ... }) const handleSubmit = async (e) => { e.preventDefault() const program = Effect.gen(function* () { const things = yield* ThingService const connections = yield* ConnectionService // 1. Create course const courseId = yield* things.create({ type: 'course', name: formData.title, properties: formData }) // 2. Create default lesson const lessonId = yield* things.create({ type: 'lesson', name: 'Introduction', properties: { content: 'Welcome!' } }) // 3. Connect lesson to course yield* connections.create({ fromThingId: lessonId, toThingId: courseId, relationshipType: 'part_of' }) return courseId }) await run(program, { onSuccess: (id) => window.location.href = `/courses/${id}` }) } return <form onSubmit={handleSubmit}>...</form> } ``` --- ## Page Patterns (SSR) ### Fetch Data, Render HTML ```astro --- // frontend/src/pages/courses/[id].astro import { getConvexClient } from '@/lib/convex' import { api } from '@/lib/api' import Layout from '@/layouts/Layout.astro' // SSR: Fetch from backend const convex = getConvexClient() const course = await convex.query(api.queries.things.get, { id: Astro.params.id }) if (!course) { return Astro.redirect('/404') } const lessons = await convex.query(api.queries.connections.getRelated, { thingId: course._id, relationshipType: 'part_of', direction: 'to' }) --- <Layout title={course.name}> <header> <h1>{course.name}</h1> <p>{course.properties.description}</p> <span>${course.properties.price}</span> </header> <section> <h2>Lessons</h2> {lessons.map(lesson => ( <div> <h3>{lesson.name}</h3> <p>{lesson.properties.content}</p> </div> ))} </section> <!-- Interactive component (React Island) --> <EnrollButton client:load courseId={course._id} /> </Layout> ``` --- ## Multi-Tenant Routing ### Middleware: Extract Group from Subdomain ```typescript // frontend/src/middleware.ts import { defineMiddleware } from 'astro:middleware' import { getConvexClient } from './lib/convex' import { api } from './lib/api' export const onRequest = defineMiddleware(async (context, next) => { const url = new URL(context.request.url) // Extract group slug from subdomain // fitnesspro.one.ie "fitnesspro" const hostname = url.hostname const groupSlug = hostname.split('.')[0] if (['api', 'www', 'localhost'].includes(groupSlug)) { return next() } // Fetch group from backend (groups table) const convex = getConvexClient() const group = await convex.query(api.queries.groups.getBySlug, { slug: groupSlug }) if (group) { context.locals.group = group context.locals.groupId = group._id } return next() }) ``` ### Use Group Context in Pages ```astro --- // frontend/src/pages/courses/index.astro const group = Astro.locals.group if (!group) { return Astro.redirect('/404') } const convex = getConvexClient() const courses = await convex.query(api.queries.things.list, { type: 'course', groupId: group._id // All things scoped to groupId }) --- <Layout title={`${group.name} - Courses`}> <h1>{group.name} Courses</h1> {courses.map(course => ( <Card thing={course} /> ))} </Layout> ``` --- ## File Structure ``` frontend/ ├── src/ ├── pages/ # Astro pages (SSR/SSG) ├── index.astro ├── courses/ ├── index.astro └── [id].astro └── [thingType]/ └── [id].astro ├── components/ # React components ├── cards/ └── Card.tsx ├── lists/ └── ThingList.tsx └── forms/ └── ThingForm.tsx ├── providers/ # Backend providers ├── DataProvider.ts # Interface ├── convex/ └── ConvexProvider.ts └── wordpress/ └── WordPressProvider.ts ├── services/ # Effect.ts services ├── ThingService.ts ├── ConnectionService.ts └── ClientLayer.ts ├── hooks/ └── useEffectRunner.ts ├── ontology/ ├── types.ts # Type definitions └── config.ts # UI config └── middleware.ts ├── astro.config.ts # Backend provider config └── package.json ``` --- ## Deployment ### Environment Setup ```bash # Frontend .env.local PUBLIC_CONVEX_URL=https://shocking-falcon-870.convex.cloud CONVEX_DEPLOYMENT=prod:shocking-falcon-870 # Better Auth (Multi-method authentication) BETTER_AUTH_SECRET=your-secret-key BETTER_AUTH_URL=http://localhost:4321 # OAuth credentials (optional) # GOOGLE_CLIENT_ID=... # GITHUB_CLIENT_ID=... # Or WordPress WORDPRESS_URL=https://yoursite.com WORDPRESS_API_KEY=your-key # Or Supabase PUBLIC_SUPABASE_URL=https://project.supabase.co PUBLIC_SUPABASE_ANON_KEY=your-key ``` ### Deploy Frontend ```bash cd web bun run build # Deploy to Cloudflare Pages (recommended for React 19) wrangler pages deploy dist --project-name=web # Or deploy to: # - Vercel (configure for React 19 edge runtime) # - Netlify (configure for React 19 edge runtime) ``` **Important for React 19:** Ensure `react-dom/server.edge` is configured in `astro.config.mjs`: ```typescript import { defineConfig } from 'astro/config' import react from '@astrojs/react' export default defineConfig({ integrations: [react()], vite: { resolve: { alias: { 'react-dom/server': 'react-dom/server.edge' // Required for Cloudflare } } } }) ``` ### Deploy Backend (Separate) ```bash cd backend npx convex deploy # Deployed to: https://shocking-falcon-870.convex.cloud ``` **Frontend and backend are completely separate deployments.** --- ## Summary ### Frontend Responsibilities | What | How | |------|-----| | **Render UI** | Astro pages + React components | | **Call Backend** | Effect.ts services via DataProvider | | **Manage UI State** | useState, useEffectRunner | | **Route Users** | Astro routing, middleware | ### Backend Responsibilities | What | Where | |------|-------| | **Database Operations** | Backend only | | **Business Logic** | Backend only | | **Data Validation** | Backend only | | **Authorization** | Backend only | ### Key Principles 1. **Backend-Agnostic** - Change backends with ONE line 2. **Type-Safe** - Effect.ts enforces error handling 3. **Composable** - Services combine into complex flows 4. **Multi-Tenant** - Subdomain-based org isolation 5. **Stateless** - Frontend has no state, backend is source of truth ### Three Backend Options **Option 1: Self-Hosted (Convex, Supabase, WordPress, etc.)** - Full control over infrastructure - Use existing systems - Implement DataProvider for your backend **Option 2: ONE Backend (BaaS)** - Zero backend work - Managed service at api.one.ie - Free tier: 10K API calls/month **Option 3: Hybrid** - Mix multiple backends - Auth in Convex, blog in WordPress, products in Shopify - CompositeProvider routes by thing type --- **Frontend is pure rendering. Backend is everything else.** Effect.ts for type-safety. DataProvider for flexibility. Separate deployments.