Web Build (Dual Target)
zudo-text ships from a single React renderer in two flavors:
| Target | Entry HTML | Bootstrap | Backend adapter | Output |
|---|---|---|---|---|
| Desktop (Tauri) | tauri- | renderer/ | TauriAdapter (IPC) | dist-renderer/ (consumed by cargo tauri build) |
| Browser (Web) | tauri- | renderer/ | RestAdapter (HTTPS) | dist-web/ |
The renderer code under tauri-app/renderer/ is the same source for both targets. Only the bootstrap module differs — it picks which backend adapter the rest of the app talks to via getBackend(). There is no UI duplication.
Why a Vite second config?
vite.config.ts is owned by cargo tauri dev / cargo tauri build and must stay byte-stable for the desktop pipeline. The web target uses a sibling vite.config.web.ts so the two pipelines never collide:
Different entry HTML (
index.web.htmlvsindex.html)Different output dir (
dist-web/vsdist-renderer/)Different dev port (
1423vs37461)Configurable
basepath viaWEB_BASE_PATHfor sub-path deploys
Commands
# Browser dev server (Vite, hot reload, port 1423)
pnpm dev:web
# Browser production build → tauri-app/dist-web/
pnpm build:web
# Override base path for sub-path deploys
WEB_BASE_PATH=/app/ pnpm build:webThe desktop pipeline uses the same renderer, but vite.config.ts fails the build if VITE_BETTER_AUTH_URL, VITE_SYNC_SERVER_URL, or VITE_PUBLISH_SERVER_URL resolves empty in tauri-app/.env — see Build & Deploy for the prerequisite:
pnpm tauri:dev # uses vite.config.ts + index.html
pnpm tauri:build # ships dist-renderer/ inside the .appConfiguration
The web bootstrap reads the following Vite env vars (define them in tauri-, gitignored, or via the build environment):
| Variable | Default | Notes |
|---|---|---|
VITE_SYNC_SERVER_URL | http: | Base URL for the REST/sync backend the web build talks to. Point at the deployed sync-server worker for production builds. |
VITE_BETTER_AUTH_URL | VITE_SYNC_SERVER_URL | Better Auth authority and browser-handoff origin. Override only when auth is served from another origin. |
VITE_PUBLISH_SERVER_URL | (unset) | Base URL of the publish-server worker — a separate Worker from the sync server. The publish client's only configuration source (#4512); unset leaves it unconfigured and every publish action fails closed. |
VITE_HIDE_WEB_BADGE | (unset) | Set to 1 to hide the corner "WEB" marker in production releases. |
See tauri- for the canonical list — it is shared with the desktop build (tauri-app/.env), where all three service origins are required.
Local end-to-end run
# Terminal 1 — start the sync-server worker locally
pnpm sync-server:dev
# Terminal 2 — start the web renderer pointing at it
pnpm dev:webOpen http: in a browser. The bootstrap mounts the same React app you'd see in the Tauri shell, but every backend call routes through RestAdapter to the worker.
Cloud-workspace mode routing (#2322): When the cloud sync client is initialized and encryption keys are derived (the user is signed in and has entered their workspace password), the web build operates in cloud-workspace mode:
messages.*CRUD routes through an in-memory workspace model populated bytriggerSync/syncDrain(cloud-sync-bridge.ts) rather than the/HTTP route (which 404s on the deployed worker — the worker only exposes the sync and asset endpoints, not a REST messages API).api/ messages The workspace is auto-bound on sign-in: there is no manual workspace-selection step. The
@takazudo/backend-bridgeRestAdaptercallsseedWorkspaceModelafter a successfulsyncDrainto populate the in-memory store, and subsequentmessages.*reads/writes go directly to that store.Echo-loop guard: writing a pulled remote delta back into the editor triggers a local-change event.
ChangeTracker(@takazudo/cloud-sync) suppresses the resulting re-upload within a 5-second settle window (configurable viaSETTLE_WINDOW_MS), preventing the pulled content from bouncing back to the server as a new upload.The
/HTTP routes remain live forapi/ messages|pins|settings pnpm dev:rest(local Rust server) — the workspace-mode gate falls through to HTTP when the cloud client is not yet initialized.
Files
tauri-— Tauri bootstrap (default). Initializesapp/ renderer/ bootstrap/ tauri. tsx TauriAdapterand callsinitBackendbefore mounting the React app.tauri-— Web bootstrap entry point. The non-Tauri parallel ofapp/ renderer/ bootstrap/ web. tsx bootstrap/: initializestauri. tsx RestAdapterpointing at the sync-server and callsinitBackendbefore mounting the same React app. This is the only file that differs between the desktop and browser targets.tauri-— Web HTML entry (loadsapp/ index. web. html bootstrap/).web. tsx tauri-— Vite config for the web target.app/ vite. config. web. ts tauri-— Documents the web build env vars.app/ . env. example
Asset upload (R2) — Media Uploader integration
The Media Uploader feature (epic #1210) routes bridge.assets.* through the sync-server / endpoints when running in web mode. Storage is R2; per-workspace D1 rows map deterministic encrypted filename tokens to random per-upload object IDs so the list endpoint can avoid enumerating R2 keys. Encryption and decryption happen in the client bridge.
R2 keyspace: assets-e2ee/<workspaceId>/<random-object-id>. The <workspaceId> comes from the authenticated request's X-Workspace-Id header (or ?workspaceId= query param) and is validated against the user's workspace memberships before any R2 / D1 access. Cross-workspace reads, writes, lists, and deletes are rejected without exposing whether the object exists.
D1 schema (workers/):
CREATE TABLE user_assets (
workspace_id TEXT NOT NULL,
encrypted_filename TEXT NOT NULL,
r2_key TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
uploaded_at INTEGER NOT NULL,
PRIMARY KEY (workspace_id, encrypted_filename)
);
CREATE INDEX idx_user_assets_workspace_uploaded
ON user_assets(workspace_id, uploaded_at DESC);Server-side MAX_ASSET_SIZE = 25 MB cap; oversized uploads are rejected with HTTP 413.
Web mode — Media Uploader smoke checklist
Run this manually after touching workers/ or anything in the asset-upload pipeline. None of these steps are automated — wrangler dev is sequential and slow, and the R2 round-trip is intentionally outside the unit-test boundary.
Boot the local worker —
pnpm sync-server:dev(orcd workers/sync-server && pnpm wrangler dev).Boot the renderer —
pnpm dev:web. Sign in with a mapped Better Auth user that has at least one workspace membership.Drag a PDF into the editor. The renderer passes plaintext base64 through
bridge.assets.saveFile; the workspace core encrypts the name and bytes, POSTs the opaque values to/, and insertsapi/ assets [file.at the cursor.pdf](. . / assets/ file. pdf) Inspect the wrangler log — confirm a 201 response on the POST and no 413 / 401 errors.
List the R2 object —
npx wrangler r2 object list sync-blobs --prefix=assets-e2ee/<workspaceId>/. The uploaded file should be present under a random object ID; neither that key nor its D1 row should contain the plaintext filename or MIME type.Open the assets manager dialog in the renderer. The new entry appears with the document icon. Verify in the wrangler log that NO
GET /api/assets/<encrypted-token>request fires until the entry is clicked (Sub 1216's perf rule: non-image entries are metadata-only at list time).Click the file chip in the preview pane. A download starts with the original filename.
Try a 26 MB upload. Should be rejected with HTTP 413; the editor toast should mention the 25 MB cap.
Out of scope for the Media Uploader epic
Chunked / multipart upload — entire asset is sent as one base64 JSON payload after encryption. A 25 MB file becomes roughly 33 MB on the wire. Replacing base64 with binary streaming is an explicit follow-up.
Presigned R2 URLs — the read endpoint returns base64 as plain text to keep the contract identical to
TauriAdapter. A future PR could swap to a presigned-URL redirect for large files; if so it MUST setX-Content-Type-Options: nosniffon the redirect target.Magic-byte MIME validation — MIME is derived client-side from the decrypted extension, not sniffed or stored by the server.
Per-type size caps — a single 25 MB cap applies to every type.