Machine-translated draft — terminology + flow still being reviewed.

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 build runs in the runner-web container, the harness uploads every dist/ 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 old WebDockerBuilder + cloud-build-web image, the ViteLocalBuilder host-local fallback, and the whole webdeployer package 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

ComponentLocationStatus
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 + uploadfabric/harness/internal/preview/build.govite 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 persistenceapp_bundles.manifest jsonb, goose 00013_bundle_manifest.sql✅ applied on staging + prod
per-path servingfabric/server/internal/handler/webpreview.goGET /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 imagedeleted — the old Next.js docker path is gone
MultiBuilder / MultiResult / WebLocalStub / ViteLocalBuilderdeleted — 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.tsx mounts the React root into index.html, src/App.tsx is 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 button mental model (per project-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).

  • Storeartifact.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 (bucket appunvs-artifacts, cn-shenzhen), MinIO in local dev. LocalFS survives for unit tests only. Two OSS quirks are handled in NewS3: OSS demands virtual-host addressing (PathStyle=false; MinIO wants true), and rejects the SDK’s default checksum trailer mode, so checksums are computed WhenRequired.
  • ServingGET /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 to index.html so client-side routes work. Bundle.URI is <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:

  1. Add a Project.PreviewURL field surfaced in pb.ProjectResponse.current for Web/Desktop projects (Mobile keeps bundle.uri semantics). Comes from path.
  2. iOS host shell: WKWebView with configuration.preferences. javaScriptEnabled = true, navigation delegate that blocks off-domain redirects so users don’t accidentally browse out. Reload triggered by ProjectVersionUpdate SSE: simply call webView.reload().
  3. Android host shell: WebView with WebSettings.javaScriptEnabled = true, same domain-bound WebViewClient.shouldOverrideUrlLoading guard, 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.CustomDomain field + migration v10 + CRUD
  • Service.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/domain handler (RBAC editor role, returns 400 on invalid shape, 404 on unknown project)
  • Project.CustomDomain is stored configuration only — it deliberately does NOT become Bundle.URI. Publish keeps the URI on the servable <web-preview base>/<project>/<version>/ link, because nothing serves a custom domain yet (see below); advertising https://<their-domain>/ would hand the user a link that resolves to nothing
  • Project.CustomDomain surfaced in pb.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:

  1. 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.
  2. A unique index on custom_domain — the column has none today, so two projects can claim the same domain with no defined winner.
  3. A reserved-domain blocklist (appunvs.com and friends).
  4. 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)

  1. SSR / RSC vs client SPA? Resolved: client SPA, no SSR. The generated app is a Vite + React SPA that vite builds to a static dist/ 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.

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

  3. Per-project workspace structure? Mobile RN workspace has package.json + index.tsx + app.json. Web has package.json + tsconfig.json + tailwind.config.ts + vite.config.ts + index.html + src/main.tsx + src/App.tsx. These are supplied by the embedded webapp template package (shared by preview + publish), so a project can’t break HMR or the build by omitting infra files; the AI fills in src/ app code via fs_write.

Hosting CDN region strategy

RegionProviderStatus
China mainlandAliyun OSS + Aliyun CDNWeb GA
Overseas / EUDropped 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).