zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Web Build (Dual Target)

zudo-text ships from a single React renderer in two flavors:

TargetEntry HTMLBootstrapBackend adapterOutput
Desktop (Tauri)tauri-app/index.htmlrenderer/bootstrap/tauri.tsxTauriAdapter (IPC)dist-renderer/ (consumed by cargo tauri build)
Browser (Web)tauri-app/index.web.htmlrenderer/bootstrap/web.tsxRestAdapter (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.html vs index.html)

  • Different output dir (dist-web/ vs dist-renderer/)

  • Different dev port (1423 vs 37461)

  • Configurable base path via WEB_BASE_PATH for 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:web

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

Configuration

The web bootstrap reads the following Vite env vars (define them in tauri-app/.env.local, gitignored, or via the build environment):

VariableDefaultNotes
VITE_SYNC_SERVER_URLhttp://localhost:3001Base URL for the REST/sync backend the web build talks to. Point at the deployed sync-server worker for production builds.
VITE_BETTER_AUTH_URLVITE_SYNC_SERVER_URLBetter 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-app/.env.example 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:web

Open http://localhost:1423/ 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 by triggerSync / syncDrain (cloud-sync-bridge.ts) rather than the /api/messages HTTP route (which 404s on the deployed worker — the worker only exposes the sync and asset endpoints, not a REST messages API).

The workspace is auto-bound on sign-in: there is no manual workspace-selection step. The @takazudo/backend-bridge RestAdapter calls seedWorkspaceModel after a successful syncDrain to populate the in-memory store, and subsequent messages.* 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 via SETTLE_WINDOW_MS), preventing the pulled content from bouncing back to the server as a new upload.

The /api/messages|pins|settings HTTP routes remain live for pnpm dev:rest (local Rust server) — the workspace-mode gate falls through to HTTP when the cloud client is not yet initialized.

Files

  • tauri-app/renderer/bootstrap/tauri.tsx — Tauri bootstrap (default). Initializes TauriAdapter and calls initBackend before mounting the React app.

  • tauri-app/renderer/bootstrap/web.tsx — Web bootstrap entry point. The non-Tauri parallel of bootstrap/tauri.tsx: initializes RestAdapter pointing at the sync-server and calls initBackend before mounting the same React app. This is the only file that differs between the desktop and browser targets.

  • tauri-app/index.web.html — Web HTML entry (loads bootstrap/web.tsx).

  • tauri-app/vite.config.web.ts — Vite config for the web target.

  • tauri-app/.env.example — Documents the web build env vars.

Asset upload (R2) — Media Uploader integration

The Media Uploader feature (epic #1210) routes bridge.assets.* through the sync-server /api/assets/* 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/sync-server/migrations/0022_opaque_user_assets.sql):

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/sync-server/src/handlers/asset-handlers.ts 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.

  1. Boot the local workerpnpm sync-server:dev (or cd workers/sync-server && pnpm wrangler dev).

  2. Boot the rendererpnpm dev:web. Sign in with a mapped Better Auth user that has at least one workspace membership.

  3. 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 /api/assets, and inserts [file.pdf](../assets/file.pdf) at the cursor.

  4. Inspect the wrangler log — confirm a 201 response on the POST and no 413 / 401 errors.

  5. List the R2 objectnpx 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.

  6. 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).

  7. Click the file chip in the preview pane. A download starts with the original filename.

  8. 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 set X-Content-Type-Options: nosniff on 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.