Web — architecture spec
Status (2026-07-26): the generated-app stack is Vite + React + TypeScript + Tailwind + shadcn/ui (a client-side SPA). Publish lives entirely in the harness:
vite buildruns in the runner-web container, the harness uploads everydist/file content-addressed to the artifact object store (Aliyun OSS via its S3-compatibility endpoint; MinIO in dev) and returns only a manifest (path → sha256). fabric-server records that manifest and serves each request path out of the store — no file bytes pass through fabric-server, and nothing published lands on a service’s disk. (The oldWebDockerBuilder+cloud-build-webimage, theViteLocalBuilderhost-local fallback, and the wholewebdeployerpackage were deleted.) This doc tracks what Web needs end-to-end.
Web is one of appunvs’s 4 project-type pipelines (see
project-types). Output target: a static
client-side SPA (Vite + React) served from appunvs-managed CDN; the
backend is Mortar via its browser client SDK — there is no SSR /
server components / API routes in the generated app. Reference
competitors (v0, Bolt, and similar chat-on-left preview-on-right AI
builders) historically converged on Next.js App Router; Fabric’s
generated stack is now Vite + React + Tailwind + shadcn/ui — what the
AI emits today is a plain Vite SPA, not a Next.js App Router project.
Pipeline overview
Chat turn
(user → fabric-server AI)
│
▼ fs_write tool calls into project workspace
┌──────────────────────────┐
│ fabric-server workspace │ = git repo at fabric/server/workspaces/<proj>/
│ (TypeScript / TSX + │ same as Mobile, but AI prompt is web-flavoured
│ tailwind.config + ...) │
└──────────────────────────┘
│
▼ publish_project tool fires the build
┌──────────────────────────┐
│ cloudbuild.HarnessBuilder│ fabric-server client: POST the harness
│ (project_type: "web") │ /v1/projects/{id}/build
└──────────────────────────┘
│
▼ (in the harness, runner-web container)
┌──────────────────────────┐
│ `vite build` → dist/ │
│ then upload EVERY file │──────► artifact store (OSS / MinIO)
│ content-addressed │ key: _artifacts/<sha256>
└──────────────────────────┘
│ Manifest{entry:"index.html",
│ files:[{path,hash,size}…]} ← only the index, no bytes
▼
┌──────────────────────────┐
│ fabric-server │ records the manifest on the bundle row
│ app_bundles.manifest │ (jsonb; migration 00013)
└──────────────────────────┘
│
▼ GET /web-preview/<proj>/<ver>/<path>
┌──────────────────────────┐
│ path → manifest → hash │ handler/webpreview.go streams from the store;
│ → artifact store │ unknown extension-less path → index.html
└──────────────────────────┘
What’s scaffolded
| Component | Location | Status |
|---|---|---|
RUNTIME_KIND_WEB in proto (value 2; renamed from WEB_NEXTJS) | shared/proto/project.proto | ✅ |
RuntimeKindWeb generated enum (value 2) | generated from shared/proto/project.proto | ✅ |
cloudbuild.Builder interface (returns an artifact.Manifest) | fabric/server/internal/cloudbuild/cloudbuild.go | ✅ one interface for web / mobile / desktop |
cloudbuild.HarnessBuilder (active, all three types) | fabric/server/internal/cloudbuild/harness.go | ✅ POSTs /v1/projects/{id}/build with project_type; relays the manifest (no bytes) |
| harness-side build + upload | fabric/harness/internal/preview/build.go | ✅ vite build in runner-web → uploads each file content-addressed → returns artifact.Manifest |
| artifact store (S3-compatible) | fabric/{harness,server}/internal/artifact | ✅ same bucket both sides, key _artifacts/<sha256>; OSS (prod) / MinIO (dev); LocalFS is unit-test only |
| manifest persistence | app_bundles.manifest jsonb, goose 00013_bundle_manifest.sql | ✅ applied on staging + prod |
| per-path serving | fabric/server/internal/handler/webpreview.go | ✅ GET /web-preview/<proj>/<ver>/*path → manifest → hash → store, SPA fallback |
webapp template package (canonical Vite + React project files) | fabric/server/templates/webapp/ | ✅ embedded scaffold (vite.config.ts / package.json / index.html / tailwind + shadcn components) shared by preview + publish |
WebDockerBuilder + cloud-build-web image | — | ❌ deleted — the old Next.js docker path is gone |
MultiBuilder / MultiResult / WebLocalStub / ViteLocalBuilder | — | ❌ deleted — superseded by the manifest model; publish now REQUIRES a harness (no host-local fallback) |
artifact/webdeployer/ (LocalFS + Aliyun OSS deployers) | — | ❌ deleted — the harness uploads content-addressed instead of a per-version tree |
What’s still missing for Web GA
B1. Web builder — cloudbuild.HarnessBuilder (harness required) ✅ done
fabric/server/internal/cloudbuild/harness.go. Web publish asks the harness to
run a production vite build inside the project’s runner-web container
(fabric-server’s own container has no Node toolchain, and the source lives in
the harness worktree). The harness then uploads each dist/ file
content-addressed to the artifact store and returns an artifact.Manifest; the
builder here just relays it. The same type serves mobile and desktop — only the
project_type field differs.
The runner lays down the same webapp template the HMR preview/dev server
(vite) uses, so publish and preview build the identical project shape. Vite is
static by default — no SSR, no server step.
(Deleted: cloudbuild.WebDockerBuilder + the cloud-build-web image — it
synthesized a Next.js skeleton, inconsistent with the Vite SPA the AI emits — and
the host-local ViteLocalBuilder fallback. Publish now requires a harness; a
fabric-server without one has no way to build.)
B2. AI prompt for Vite / React / Tailwind / shadcn
Add fabric/server/internal/ai/prompts/web with system prompt
that:
- Knows the Vite SPA file conventions (
src/main.tsxmounts the React root intoindex.html,src/App.tsxis the app shell, client-side routing via react-router; no server components, no API routes — the backend is Mortar via its browser client SDK) - Knows Tailwind 4 utility classes verbatim — don’t invent CSS class names that need a separate stylesheet
- Knows shadcn/ui’s pattern of copying source rather than installing
a library; prefer
npx shadcn-ui@latest add buttonmental model (perproject-types“anti-pattern” list)
Driven by cfg.AI.Backend already in fabric-server; AI engine picks
the system prompt by Project.Runtime (RN bundle vs Web).
B3. Publish storage + serving — content-addressed store + manifest ✅ done
There is no “web deployer” any more. The harness uploads every built file to a
content-addressed artifact store (_artifacts/<sha256>) and hands
fabric-server a manifest (path → hash), persisted on the bundle row
(app_bundles.manifest, migration 00013).
- Store —
artifact.S3(AWS SDK v2) in BOTH modules against the SAME bucket, so nothing published touches a service’s local disk: Aliyun OSS via its S3-compatibility endpoint in staging/prod (bucketappunvs-artifacts, cn-shenzhen), MinIO in local dev.LocalFSsurvives for unit tests only. Two OSS quirks are handled inNewS3: OSS demands virtual-host addressing (PathStyle=false; MinIO wantstrue), and rejects the SDK’s default checksum trailer mode, so checksums are computedWhenRequired. - Serving —
GET /web-preview/<project>/<version>/*path(handler/webpreview.go) resolves the path through the manifest to a hash and streams that object, setting content-type from the path extension; an extension-less unknown path falls back toindex.htmlso client-side routes work.Bundle.URIis<web_deploy.base_url>/<project>/<version>/, overridden by the project’s custom domain when set.
Content addressing makes writes idempotent and de-duplicates identical files across versions and projects, and there is no per-version directory to garbage collect on project delete.
(Deleted: fabric/server/internal/artifact/webdeployer/ — both the
LocalFSDeployer per-version tree and the AliyunOSSDeployer hand-rolled
HMAC-SHA1 uploader, plus their 11 tests. CDN-refresh remains TODO — see
§ Domain + SSL automation below.)
B4. Preview tab UI for Web projects — webview embed (decided 2026-05-12)
Decision: host shells embed a WKWebView (iOS) / WebView (Android) on the Preview tab pointed at the Web deploy URL. Reasons:
- Real-time preview is the Web user value: chat turn → publish → Preview tab refreshes within seconds. External browser breaks that loop (user has to alt-tab + refresh manually).
- Same UX shape as Mobile RN bundle preview, which is the trust mark the Fabric has built — “you change code, you see it immediately on your phone”.
- Webview is sandboxed; no special permission asks the way a custom URL scheme would need.
Implementation:
- Add a
Project.PreviewURLfield surfaced inpb.ProjectResponse.currentfor Web/Desktop projects (Mobile keepsbundle.urisemantics). Comes from path. - iOS host shell:
WKWebViewwithconfiguration.preferences. javaScriptEnabled = true, navigation delegate that blocks off-domain redirects so users don’t accidentally browse out. Reload triggered byProjectVersionUpdateSSE: simply callwebView.reload(). - Android host shell:
WebViewwithWebSettings.javaScriptEnabled = true, same domain-boundWebViewClient.shouldOverrideUrlLoadingguard, same reload-on-SSE handler.
Estimated effort: ~2-3 days per platform, mostly UI wiring + the SSE-to-reload trigger.
For Desktop: the user downloads the .dmg/.exe; “preview” inside
the Fabric host shell doesn’t apply (you don’t run a
desktop app on a phone). Instead, Desktop’s Preview tab shows the Web
preview as an in-editor proxy + a “Download for macOS / Windows
/ Linux” panel pulling installer URLs from
updater-manifest.json.
B5. Domain + SSL automation — NOT served yet (config stored, routing absent)
Editor-server side ✅ landed:
Project.CustomDomainfield + migration v10 + CRUDService.SetCustomDomain(ctx, ns, id, domain)with shape validation (no scheme, no path, lowercase ASCII, contains dot, no leading/trailing dots or hyphens)PUT /project/:id/domainhandler (RBAC editor role, returns 400 on invalid shape, 404 on unknown project)Project.CustomDomainis stored configuration only — it deliberately does NOT becomeBundle.URI. Publish keeps the URI on the servable<web-preview base>/<project>/<version>/link, because nothing serves a custom domain yet (see below); advertisinghttps://<their-domain>/would hand the user a link that resolves to nothingProject.CustomDomainsurfaced inpb.ProjectResponse.project.custom_domain
Operator side ❌ remaining (needs cloud infra):
- Wildcard DNS
*.preview.appunvs.com→ CDN origin - Wildcard SSL cert (Let’s Encrypt DNS challenge or Aliyun ACM)
- Per-custom-domain CDN routing:
myapp.com→ OSS bucket prefix at<projectid>/<current_version>/ - Per-user SNI cert provisioning (Let’s Encrypt HTTP challenge)
fabric-server side ❌ remaining — host-based routing does not exist:
handler/webpreview.go only matches /web-preview/<project>/<version>/*path;
nothing maps an inbound Host: header to a project. Making custom domains real
needs, in this order:
- TXT-record ownership verification (decided 2026-07-27). A domain only becomes active after the user proves control via a TXT record — without it any user could claim someone else’s domain (or a platform domain) and be served at that host.
- A unique index on
custom_domain— the column has none today, so two projects can claim the same domain with no defined winner. - A reserved-domain blocklist (appunvs.com and friends).
- Host → project → current version lookup in the serving handler.
Plus the operator/infra prerequisites, which no amount of code removes: DNS, on-demand TLS at the edge (Caddy), and — for mainland China — the customer’s own ICP 备案 for that domain.
7 tests cover the shape validation (valid → persists, lowercase + trim, empty →
clears, 11 invalid shapes rejected, a configured domain NOT leaking into
Bundle.URI, the plain web-preview URI, unknown project → 404).
Decision points (resolved)
-
SSR / RSC vs client SPA? Resolved: client SPA, no SSR. The generated app is a Vite + React SPA that
vite builds to a staticdist/tree (pure HTML/JS/assets, no server) and runs entirely in the browser; all backend capability comes from Mortar via its browser client SDK. There is no edge RSC / server-render story to waver on — the static-first position is now the only position. -
shadcn copy-source vs install? shadcn is technically “copy the file into your repo”. Decision: AI agent writes
shadcn add <component>as a tool call → fabric-server runs it in the workspace → commits resulting files. Components live in the user’s repo, fully editable, no library dependency on shadcn itself. Standard pattern; no novel work. -
Per-project workspace structure? Mobile RN workspace has
package.json+index.tsx+app.json. Web haspackage.json+tsconfig.json+tailwind.config.ts+vite.config.ts+index.html+src/main.tsx+src/App.tsx. These are supplied by the embeddedwebapptemplate package (shared by preview + publish), so a project can’t break HMR or the build by omitting infra files; the AI fills insrc/app code via fs_write.
Hosting CDN region strategy
| Region | Provider | Status |
|---|---|---|
| China mainland | Aliyun OSS + Aliyun CDN | Web GA |
| Overseas / EU | Dropped from Web backlog (2026-05-12) | only revisit if an Enterprise customer specifically requires data-residency or non-China latency. Individual creators making small apps don’t need overseas CDN — App Store / app store distribution + AppUser acquisition are the real bottlenecks. |
Aliyun-only at launch keeps the cost surface small; overseas adds ~$50/month per 1TB egress at Cloudflare prices but unlocks non-China customers.
Connection to Mortar BaaS
Web projects use Mortar for runtime auth / data / files / realtime
just like Mobile. The SDK is @mortar-ai/client (web build, browser
bundle) — already shipped under mortar/sdk/typescript/.
Cross-project consistency: a user has one Mortar Workspace per project regardless of project type. Switching Mobile→Web means the same underlying database, files, and auth users carry over (the AI just generates a new frontend for the same backend).