diff --git a/.gitignore b/.gitignore
index 3b7a85f..af13e13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,7 @@ dist/
# macOS
.DS_Store
+
+# AI
+.claude/
+.env
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..0ad68a8
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,173 @@
+# Technical Writing Guide
+
+## 1. 문서 유형 정하기
+
+### 역할
+
+이 봇의 역할은 기술 문서를 작성하기 전에, 문서 유형과 각 유형에 맞는 작성법을 안내하는 것입니다.
+아래 정보를 참고하여, 내 상황에 가장 적합한 문서 유형과 그 문서 유형에 맞는 작성 가이드를 추천해 주세요. 필요하다면 복수의 문서 유형을 제안해도 괜찮지만, 최대한 하나로 정해주세요.
+
+아래 정보를 바탕으로, 가장 적합한 문서 유형(학습 중심 / 문제 해결 / 참조 / 깊은 이해)과 작성 시 유의해야 할 점을 제안해 주세요.
+이모지는 사용하지 마세요.
+
+- 문서 목표 (예: “React의 Hook 개념을 자세히 알리고 싶다” / “Webpack 설정을 잡아주고 싶다” / “에러 발생 시 해결 방법을 제공하고 싶다” 등)
+- 독자 수준 (예: “React를 처음 접하는 초급 개발자” / “이미 Webpack 사용 경험이 있는 중급 개발자” / “비개발자 포함” 등)
+- 프로젝트 상황 (예: “새로운 기술을 도입해 보고 싶다” / “기존 프로젝트를 개선 중이라 빠른 해결이 필요하다” / “참조 문서가 너무 길어 핵심만 요약해야 한다” 등)
+- 추가 고려 사항 (예: “짧은 시간 안에 완성해야 한다” / “시각 자료를 많이 활용하고 싶다” / “다양한 OS 환경을 고려해야 한다” 등)
+
+위 정보를 종합해서, 내가 어떤 문서 유형을 쓰면 좋을지, 그리고 그 유형에 맞춰 작성할 때 주의해야 할 사항을 알려 주세요. 필요하다면 복수의 문서 유형을 제안해도 괜찮습니다.
+
+#### 학습을 위한 문서를 작성할 때 주의해야 할 사항
+
+문서에 포함해야 할 사항:
+
+1. 명확한 학습 목표 및 완료 후 얻게 될 능력
+2. 사전 준비 사항 및 환경 설정 방법
+3. 단계별 안내와 설명(단계마다 무엇을 하는지, 왜 하는지 설명)
+4. 실행할 수 있는 코드 예제(간단한 것부터 점진적으로 난이도 상승)
+5. 문서 마지막에 FAQ 섹션 또는 자주 발생하는 문제와 해결책
+
+독자가 막힘없이 따라 할 수 있도록 구성하고, 모든 예제 코드는 실제로 실행할 수 있어야 합니다.
+
+#### 깊은 이해를 위한 문서를 작성할 때 주의해야 할 사항
+
+문서에 포함해야 할 사항:
+
+1. 이 기술/개념이 등장한 배경과 해결하려는 문제
+2. 기본 원리와 동작 방식에 대한 상세 설명
+3. 다른 접근 방식과의 비교 및 장단점
+4. 시각적 요소(다이어그램, 흐름도 등)를 활용한 개념 설명
+5. 실제 사용 사례 및 응용 방법
+
+문서는 독자가 단순한 사용법을 넘어 기술의 원리와 철학을 이해할 수 있도록 작성해 주세요.
+
+#### 문제 해결 문서를 작성할 때 주의해야 할 사항
+
+문서에 포함해야 할 사항:
+
+1. 명확한 문제 상황 또는 작업 목표 정의
+2. 문제의 원인 또는 작업 수행에 필요한 배경지식
+3. 단계별 해결 방법 또는 수행 절차
+4. 실행할 수 있는 코드 예제나 명령어
+5. 환경별 차이점(OS, 라이브러리 버전 등에 따른 주의 사항)
+6. 해결책이 어떤 원리로 문제를 해결하는지에 대한 설명
+
+문서는 독자가 바로 적용할 수 있는 실용적인 해결책을 제공해야 합니다.
+
+#### 참조 문서를 작성할 때 주의해야 할 사항
+
+문서에 포함해야 할 사항:
+
+1. 간결한 개요 및 주요 기능 설명
+2. 구문 및 파라미터 설명(타입, 기본값, 필수 여부 포함)
+3. 반환 값 및 타입 설명
+4. 사용 예제 코드(기본 사용법부터 다양한 활용 사례까지)
+5. 관련 API/함수/컴포넌트와의 연계 방법
+6. 주의 사항 및 제한사항
+
+문서는 일관된 구조로 정확하고 완전한 정보를 제공하며, 독자가 필요한 정보를 빠르게 찾을 수 있도록 구성해 주세요.
+
+## 2. 정보 구조 만들기
+
+### 역할
+
+이 봇의 역할은 기술 문서의 구조를 분석하고, 아래의 원칙들을 반영하여 문서를 개선할 수 있는 피드백과 개선안을 제안하는 것입니다.
+아래 정보를 참고하여, 내가 작성한 문서 초안 혹은 문서 구조에 대해 피드백과 구체적인 개선안을 추천해 주세요.
+여러 개선 옵션을 모두 반영한 하나의 좋은 안을 만들어 주세요.
+
+#### 참고할 원칙 및 체크리스트
+
+1. 한 페이지에서는 하나의 목표만 다루기
+
+- 핵심 원칙: 한 페이지에서 하나의 주제나 목표에 집중해야 독자가 핵심 내용을 빠르게 파악할 수 있습니다.
+- 체크리스트:
+ - 제목 깊이가 #### (H4) 이상이면 문서를 분리할 필요가 있음
+ - 개요를 통해 핵심 목표를 명확하게 전달하고 있는지 확인
+ - 너무 많은 개념이 혼합되어 있지는 않은지 점검
+
+2. 개요 빠트리지 않기
+
+- 핵심 원칙: 문서의 핵심 내용을 요약하는 개요를 반드시 포함하여 독자가 전체 흐름을 미리 파악할 수 있도록 해야 합니다.
+- 체크리스트:
+ - 문서 시작 부분에 명확한 개요가 배치되어 있는지
+ - 독자가 “이 문서를 읽으면 무엇을 얻을 수 있는가?”를 바로 이해할 수 있는지
+
+3. 예측 가능한 문서 구조
+
+- 핵심 원칙: 문서의 제목, 형식, 정보 배치가 일관되고 논리적인 순서를 유지하여 독자가 정보를 쉽게 탐색할 수 있어야 합니다.
+- 체크리스트:
+ - 동일한 수준의 제목과 소제목이 일관된 패턴을 따르는지
+ - 기본 개념부터 점진적으로 상세 내용이 배치되어 있는지
+ - 용어가 일관되게 사용되는지
+
+4. 가치를 먼저 제공하기
+
+- 핵심 원칙: 기능이나 세부 설정보다, 독자가 문서를 통해 얻을 수 있는 구체적인 가치나 문제 해결 효과를 먼저 전달해야 합니다.
+- 체크리스트:
+ - 문서 도입부에서 독자가 얻을 이점이 명확하게 제시되어 있는지
+ - 부수적인 세부 정보는 후순위로 배치되어 있는지
+
+5. 효과적인 제목 쓰기
+
+- 핵심 원칙: 제목은 문서의 핵심을 간결하고 명확하게 전달해야 하며, 검색과 탐색에 용이하도록 구성되어야 합니다.
+- 체크리스트:
+ - 제목에 핵심 키워드가 포함되어 있는지
+ - 제목의 길이가 적절하고(예: 30자 이내), 일관된 스타일(동사형 또는 명사형)로 작성되었는지
+ - 평서문 형태로 작성되어 있는지
+
+#### 제공할 정보
+
+- 문서 초안 혹은 구조: (예: “React 컴포넌트 생성” 문서의 현재 구조 혹은 목차)
+- (optional) 문서 목표 및 독자: (예: “독자가 React 컴포넌트 생성의 기본 원리를 이해하고 직접 코드를 작성할 수 있도록 돕는 것”)
+- (optional)현재 겪고 있는 문제점: (예: “한 페이지에 너무 많은 내용이 혼합되어 있어 독자가 원하는 정보를 찾기 어렵다” 또는 “개요가 없어서 문서 전체 흐름이 파악되지 않는다”)
+
+위 정보를 종합하여, 문서 구조를 개선할 수 있는 구체적인 피드백과 개선안을 제안해 주세요.
+
+## 3. 문장 다듬기
+
+### 역할
+
+이 봇의 역할은 기술 문서의 문장을 효과적이고 간결하게 개선하는 것입니다. 아래 정보를 참고하여, 입력된 문장을 더 명확하고 이해하기 쉬운 문장으로 수정할 수 있도록 피드백과 개선안을 제안해 주세요.
+여러 개선 옵션을 모두 반영한 하나의 좋은 안을 만들어 주세요.
+
+#### 참고할 원칙 및 체크리스트
+
+1. 필요한 정보만 남기기
+
+- 핵심 원칙: 문장은 짧고 간결해야 하며, 한 문장에 하나의 생각만 담아야 합니다.
+- 체크리스트:
+ - 문장이 불필요하게 길거나 복잡한가?
+ - 한 문장에 여러 개의 아이디어가 혼재되어 있지는 않은가?
+
+2. 메타 담화를 최소화하기
+
+- 핵심 원칙: 핵심 메시지를 전달하는 데 방해가 되는 ‘말에 대한 말’을 제거합니다.
+- 체크리스트:
+ - 문장 내 불필요한 서술(예: "앞서 설명했듯이", "여러분도 아실 것입니다")는 없는가?
+
+3. 구체적으로 쓰기
+
+- 핵심 원칙: 모호한 표현 대신 구체적이고 직접적인 언어를 사용하여 독자가 바로 이해할 수 있도록 합니다.
+- 체크리스트:
+ - 명확하지 않은 표현이나 불필요한 추상적 용어가 사용되지는 않았는가?
+ - 동사를 사용하여 명확한 행동 지시를 제공하고 있는가?
+
+4. 일관되게 쓰기
+
+- 핵심 원칙: 용어와 표현을 일관되게 사용하여 독자가 혼란 없이 정보를 받아들일 수 있도록 합니다.
+- 체크리스트:
+ - 동일한 개념이 다양한 표현으로 나타나지는 않는가?
+ - 약어나 외래어 표기는 처음 등장할 때 풀어서 표기하고 있는가?
+
+5. 문장의 주체를 분명하게 하기
+
+- 핵심 원칙: 문장의 주어가 명확해야 독자가 어떤 행동을 해야 하는지 쉽게 파악할 수 있습니다.
+- 체크리스트:
+ - 수동형 문장은 능동형으로 개선할 수 있는가?
+ - 도구나 기술 자체가 주체가 되지 않고, 독자(개발자)가 주체가 되는지 확인
+
+#### 제공할 정보
+
+- 입력 문장: (예: “이 API를 호출할 때 요청 헤더와 인증 정보를 포함해야 정상적으로 응답을 받을 수 있습니다.”)
+
+위 정보를 종합하여, 주어진 문장을 효과적이고 간결하게 개선할 수 있는 피드백과 수정안을 제안해 주세요.
diff --git a/INDEX.md b/INDEX.md
new file mode 100644
index 0000000..783d1e7
--- /dev/null
+++ b/INDEX.md
@@ -0,0 +1,51 @@
+# Guide
+
+## Introduction
+
+- Getting Started
+ - Overview
+ - Platform Support
+ - Electron
+ - Tauri (+mobile)
+ - Android
+ - iOS 15+
+ - Deno Desktop (experimental)
+- Why webview bundle?
+ - Offline-first
+ - OTA(Over-the-air)
+ - For Web Developer
+
+## Guide
+
+- Features
+ - Webview Bundle format (.wvb)
+ - Bundle sources (builtin/remote)
+ - Protocol handling (bundle/local)
+ - Remote bundles (updating)
+ - Integrity/Signature
+ - Platform Integration
+- Platforms
+ - Electron
+ - Tauri (+mobile)
+ - Android
+ - iOS
+ - Deno Desktop
+- CLI
+ - (each cli commands...)
+ - Programmatically usage
+- Remote
+ - Building remote
+ - Providers
+ - AWS
+ - Cloudflare
+ - Local
+
+# References
+
+- Rust docs (https://docs.rs/wvb / external)
+- Node API
+- Deno API
+
+# Config
+
+- Config file types
diff --git a/content/docs/changelog/index.mdx b/content/docs/changelog/index.mdx
new file mode 100644
index 0000000..f4e6ab1
--- /dev/null
+++ b/content/docs/changelog/index.mdx
@@ -0,0 +1,43 @@
+---
+title: Changelog
+description: Where Webview Bundle releases are tracked, and the versions published so far.
+---
+
+Releases are cut per package, so the authoritative release notes live with each registry and the
+GitHub release pages.
+
+
+
+
+
+
+## Published versions
+
+The current published versions, by package:
+
+| Package | Registry | Version |
+| ------------------------ | ----------------------------------------------------------- | ------- |
+| `wvb` (Rust core) | [crates.io](https://crates.io/crates/wvb) | 0.2.0 |
+| `wvb-tauri` | [crates.io](https://crates.io/crates/wvb-tauri) | 0.1.0 |
+| `@wvb/cli` | [npm](https://www.npmjs.com/package/@wvb/cli) | 0.1.0 |
+| `@wvb/config` | [npm](https://www.npmjs.com/package/@wvb/config) | 0.1.0 |
+| `@wvb/node` | [npm](https://www.npmjs.com/package/@wvb/node) | 0.1.0 |
+| `@wvb/bridge` | [npm](https://www.npmjs.com/package/@wvb/bridge) | 0.1.0 |
+| `@wvb/electron` | [npm](https://www.npmjs.com/package/@wvb/electron) | 0.1.0 |
+| `@wvb/remote-aws` | [npm](https://www.npmjs.com/package/@wvb/remote-aws) | 0.1.0 |
+| `@wvb/remote-cloudflare` | [npm](https://www.npmjs.com/package/@wvb/remote-cloudflare) | 0.1.0 |
+
+## Versioning
+
+Packages follow [semantic versioning](https://semver.org). Pin exact versions for reproducible
+builds. The `.wvb`
+bundle format carries its own format version (`v1`) independent of package versions — see the
+[bundle format](/docs/guide/bundle-format).
diff --git a/content/docs/changelog/meta.json b/content/docs/changelog/meta.json
new file mode 100644
index 0000000..48c9822
--- /dev/null
+++ b/content/docs/changelog/meta.json
@@ -0,0 +1,5 @@
+{
+ "root": true,
+ "title": "Changelog",
+ "pages": ["index"]
+}
diff --git a/content/docs/cli.mdx b/content/docs/cli.mdx
deleted file mode 100644
index 9cd84fc..0000000
--- a/content/docs/cli.mdx
+++ /dev/null
@@ -1,186 +0,0 @@
----
-title: CLI reference
-description: The wvb command-line tool — pack, serve, upload, deploy, download, and the local remote.
----
-
-The `wvb` command-line tool (package `@wvb/cli`, also installed as `webview-bundle`) packs bundles
-and drives the remote update workflow. Install it as a dev dependency:
-
-```sh
-npm install -D @wvb/cli
-npx wvb --help
-```
-
-Most commands read defaults from a [`wvb.config.ts`](/docs/configuration) found in the working
-directory. Pass `--config ` to use a specific file and `--cwd ` to change the working
-directory.
-
-## Authoring bundles
-
-### `wvb pack [SRC_DIR]`
-
-Pack a directory of web assets into a `.wvb` archive.
-
-```sh
-wvb pack ./dist
-wvb pack ./dist --outfile ./app.wvb
-wvb pack ./dist --ignore '*.map' --ignore 'node_modules/**'
-wvb pack ./dist --header '*.html' 'cache-control' 'max-age=3600'
-```
-
-| Option | Description |
-| --------------- | ------------------------------------------------------------------------------------- |
-| `SRC_DIR` | Source directory (or `pack.srcDir` in config). |
-| `--outfile, -O` | Output file name. Defaults to the `package.json` name; `.wvb` is appended if missing. |
-| `--outdir` | Output directory. Defaults to `.wvb`. |
-| `--ignore` | Glob(s) of files to exclude (repeatable). |
-| `--header, -H` | Set headers for matching files: `--header ` (repeatable). |
-| `--write` | Set `--no-write` to simulate without writing. |
-| `--overwrite` | Overwrite an existing output file. Default `true`. |
-
-### `wvb extract [FILE]`
-
-Extract a `.wvb` archive's files back onto disk.
-
-```sh
-wvb extract ./app.wvb --outdir ./unpacked
-```
-
-| Option | Description |
-| -------------- | ------------------------------------------------ |
-| `FILE` | Bundle file to extract. |
-| `--outdir, -O` | Destination directory. |
-| `--clean` | Remove the out directory first. Default `false`. |
-| `--write` | Set `--no-write` to simulate. |
-
-### `wvb serve [FILE]`
-
-Serve a single bundle's files over HTTP — useful for previewing a packed bundle in a browser.
-
-```sh
-wvb serve ./app.wvb # http://localhost:4312
-wvb serve ./app.wvb --port 8080 --hostname 0.0.0.0
-```
-
-| Option | Description |
-| ---------------- | ---------------------------------------------------- |
-| `FILE` | Bundle to serve (or `serve.file` in config). |
-| `--hostname, -H` | Bind hostname. Default `localhost`. (`HOSTNAME` env) |
-| `--port, -P` | Port. Default `4312`. (`PORT` env) |
-| `--silent` | Disable request logging. |
-
-## Publishing & updating
-
-These commands require `remote.*` settings in your [config](/docs/configuration). See
-[Remote updates](/docs/remote-updates) for the full workflow and a local-testing walkthrough.
-
-### `wvb upload [BUNDLE] [VERSION]`
-
-Pack (by default) and upload a bundle to the remote, optionally computing integrity, signing it, and
-deploying.
-
-```sh
-wvb upload # uses config defaults
-wvb upload app 1.2.0 --deploy
-wvb upload app 1.2.0 --deploy --channel beta
-wvb upload --file ./dist/app.wvb --force
-```
-
-| Option | Description |
-| ------------------- | --------------------------------------------------------------------------------------------------------- |
-| `BUNDLE`, `VERSION` | Bundle name and version (else from config / `package.json`). |
-| `--file, -F` | Path to the `.wvb` to upload. |
-| `--pack, -P` | Pack from `pack.srcDir` before uploading. Default `true` (pass `--no-pack` to upload an existing `.wvb`). |
-| `--force` | Overwrite if the version already exists. |
-| `--deploy` | Deploy after upload. Default `true`. |
-| `--channel` | Channel to deploy to (with `--deploy`). |
-| `--skip-integrity` | Don't compute an integrity hash. |
-| `--skip-signature` | Don't sign the bundle. |
-
-### `wvb deploy [BUNDLE] VERSION`
-
-Deploy a previously uploaded version (make it the current version clients receive).
-
-```sh
-wvb deploy app 1.2.0
-wvb deploy app 1.2.0 --channel beta
-```
-
-### `wvb download [BUNDLE] [VERSION]`
-
-Download a bundle from the remote and (by default) save it to disk.
-
-```sh
-wvb download app --endpoint https://updates.example.com
-wvb download app 1.2.0 --out ./bundles/app.wvb --overwrite
-wvb download app --no-write # fetch + print info only
-```
-
-| Option | Description |
-| ---------------- | -------------------------------------------- |
-| `--out, -O` | Output path. Defaults to `.wvb`. |
-| `--endpoint, -E` | Remote endpoint (else `remote.endpoint`). |
-| `--channel` | Channel to download from. |
-| `--write` | Set `--no-write` to skip saving. |
-| `--overwrite` | Overwrite an existing file. Default `false`. |
-| `--progress` | Show a progress bar. Default `true`. |
-
-### `wvb builtin`
-
-Download the currently deployed bundles from the remote into a local directory, to ship as
-[builtin](/docs/concepts#sources-builtin-vs-remote) fallbacks with your app.
-
-```sh
-wvb builtin --endpoint https://updates.example.com --out .wvb/builtin/bundles
-wvb builtin --include 'app*' --exclude 'internal*'
-```
-
-| Option | Description |
-| ------------------------- | ------------------------------------------------- |
-| `--out, -O` | Output directory. Default `.wvb/builtin/bundles`. |
-| `--endpoint, -E` | Remote endpoint. |
-| `--channel` | Channel to pull from. |
-| `--include` / `--exclude` | Glob filters over remote bundles (repeatable). |
-| `--clean` | Clear the output directory first. Default `true`. |
-| `--concurrency` | Parallel downloads. |
-
-## Inspecting & testing the remote
-
-### `wvb remote list`
-
-List bundles deployed on the remote.
-
-```sh
-wvb remote list --endpoint https://updates.example.com
-wvb remote list --channel beta
-```
-
-### `wvb remote current [BUNDLE]`
-
-Show the current deployed version and metadata for a bundle.
-
-```sh
-wvb remote current app --endpoint https://updates.example.com
-```
-
-### `wvb remote local`
-
-Start a local update server backed by a directory (default `~/.wvb/local`). It implements the same
-[HTTP contract](/docs/remote-updates#the-remote-http-contract) as a production server, so you can test
-the full update loop offline.
-
-```sh
-wvb remote local # http://localhost:4313, serving ~/.wvb/local
-wvb remote local --base-dir ./.wvb/local --port 4313 --allow-other-versions
-```
-
-| Option | Description |
-| ------------------------ | -------------------------------------------------------- |
-| `--base-dir` | Directory to serve. Default `~/.wvb/local`. |
-| `--allow-other-versions` | Allow downloading non-current versions. Default `false`. |
-| `--hostname, -H` | Bind hostname. Default `localhost`. (`HOSTNAME` env) |
-| `--port, -P` | Port. Default `4313`. (`PORT` env) |
-| `--silent` | Disable request logging. |
-
-See [Remote updates → Testing locally](/docs/remote-updates#testing-locally) for how to wire this up
-with `@wvb/remote-local`.
diff --git a/content/docs/concepts.mdx b/content/docs/concepts.mdx
deleted file mode 100644
index 642ac74..0000000
--- a/content/docs/concepts.mdx
+++ /dev/null
@@ -1,122 +0,0 @@
----
-title: Concepts
-description: The .wvb format, bundle sources, the manifest, channels, integrity, and signatures.
----
-
-This page defines the vocabulary used throughout the guides. If a term in a platform guide is
-unclear, it's probably explained here.
-
-## The `.wvb` archive
-
-A Webview Bundle is a single file with three parts laid out back to back:
-
-| Header (17 bytes) | Index (variable) | Data (variable) |
-| -------------------------------------------------- | -------------------------------- | ---------------------------- |
-| magic number, format version, index size, checksum | path → offset/length/headers map | LZ4-compressed file contents |
-
-- **Header** — starts with the magic number `0xF09F8C90F09F8E81` (the UTF-8 bytes for 🌐🎁), then a
- one-byte format version, a `u32` index size, and a checksum.
-- **Index** — a map from each file path (e.g. `/index.html`) to where its bytes live in the data
- section, plus its content type and any HTTP headers to replay when served.
-- **Data** — each file's bytes are compressed with [LZ4](https://github.com/lz4/lz4) and followed by
- an [xxHash-32](https://github.com/Cyan4973/xxHash) checksum.
-
-Every section is checksummed, so a truncated or corrupted archive is detected before its contents
-are trusted. The full byte-level specification is in the
-[`wvb` crate docs](https://docs.rs/wvb).
-
-By convention a packed file is named `_.wvb`, e.g. `app_1.0.0.wvb`.
-
-## Bundles and bundle names
-
-A **bundle** is one logical web app, identified by a **bundle name** (e.g. `app`). A bundle has
-many **versions** (`1.0.0`, `1.1.0`, …), each a separate `.wvb` file. At any moment one version is
-the **current** version that gets served.
-
-## Sources: `builtin` vs `remote`
-
-A **source** is a directory on the device that stores bundles, plus a `manifest.json` that tracks
-versions. Apps typically use two sources:
-
-- **`builtin`** — bundles shipped _inside_ the app package. Read-only, and used as the fallback the
- very first time the app runs (before anything has been downloaded).
-- **`remote`** — bundles downloaded from your update server. **Remote wins**: when a bundle exists
- in both, the remote version is served.
-
-```text
-{source_dir}/
-├── app/
-│ ├── app_1.0.0.wvb
-│ └── app_1.1.0.wvb
-└── manifest.json
-```
-
-This split is what makes updates safe: you always have the shipped `builtin` version to fall back
-to, and downloaded `remote` versions transparently shadow it once verified and installed.
-
-## The manifest
-
-`manifest.json` lives at the root of a source directory and records, for each bundle, the versions
-present and which one is current:
-
-```json
-{
- "manifestVersion": 1,
- "entries": {
- "app": {
- "versions": {
- "1.0.0": {},
- "1.1.0": { "integrity": "sha384:…", "signature": "…", "etag": "…" }
- },
- "currentVersion": "1.1.0"
- }
- }
-}
-```
-
-The per-version metadata (`integrity`, `signature`, `etag`, `lastModified`) is filled in from the
-remote server's response headers when a bundle is downloaded.
-
-## Channels
-
-A **channel** lets you deploy different versions to different audiences from the same server —
-`stable`, `beta`, `canary`, `internal`, and so on. Clients ask for a channel when listing,
-downloading, or checking for updates; if they don't specify one, they get the default channel.
-Channels are how you do staged rollouts and pre-release testing.
-
-## Integrity
-
-**Integrity** is a hash over the _serialized bytes_ of a bundle, so a client can confirm it
-downloaded exactly what the server published. Webview Bundle uses SHA-3, serialized as
-`:` — for example `sha384:Ws2q…`. Supported algorithms are `sha256`, `sha384`,
-and `sha512`.
-
-When updating, an **integrity policy** controls how strict verification is:
-
-- **Strict** — a hash must be present and must match, or the update fails.
-- **Optional** (default) — verify when a hash is present; allow the download when it isn't.
-- **None** — skip integrity verification.
-
-## Signatures
-
-A **signature** proves _who_ published a bundle, not just that it is intact. The publisher signs
-the bundle's integrity string with a private key; clients verify it with the matching public key.
-Supported algorithms: **ECDSA** (secp256r1 / secp384r1), **Ed25519**, and **RSA** (PKCS#1 v1.5 /
-PSS). Keys are supplied as PEM/DER. Signature verification is layered on top of integrity: if you
-configure a verifier, downloads must carry a valid signature.
-
-## The update lifecycle
-
-Putting it together, shipping an update looks like this:
-
-1. **Pack** your build output into a `.wvb` (`wvb pack`).
-2. **Upload** it to your remote server, optionally computing integrity and a signature
- (`wvb upload`).
-3. **Deploy** that version to a channel so clients can see it (`wvb deploy`, or `wvb upload
---deploy`).
-4. On the device, the **updater** checks for a newer deployed version, downloads it, verifies
- integrity/signature, and installs it into the `remote` source — after which it's served instead
- of the builtin version.
-
-See [Remote updates & local testing](/docs/remote-updates) for the full walkthrough, including
-how to run the whole loop on your own machine.
diff --git a/content/docs/config/index.mdx b/content/docs/config/index.mdx
new file mode 100644
index 0000000..2985ebc
--- /dev/null
+++ b/content/docs/config/index.mdx
@@ -0,0 +1,314 @@
+---
+title: Configuration
+description: The wvb.config file that the CLI and the programmatic API read for pack, remote, serve, and builtin options.
+---
+
+A single `wvb.config` file drives every Webview Bundle workflow. Both the `wvb` CLI
+and the programmatic API (`@wvb/cli/api`) load it, so one config keeps packing,
+serving, remote publishing, and builtin installs consistent. Author it with
+`defineConfig` from `@wvb/config` to get full type-checking and editor autocompletion.
+
+This page covers the config file itself and the top-level `pack`, `serve`, and
+`builtin` sections. Remote publishing, integrity, and signature options live on a
+dedicated page.
+
+
+
+
+
+
+
+## The config file
+
+Place the config in your project root. The CLI auto-discovers the first matching
+file in the working directory, in this order:
+
+```text
+wvb.config.js
+wvb.config.cjs
+wvb.config.mjs
+wvb.config.ts
+wvb.config.cts
+wvb.config.mts
+webview-bundle.config.js
+webview-bundle.config.cjs
+webview-bundle.config.mjs
+webview-bundle.config.ts
+webview-bundle.config.cts
+webview-bundle.config.mts
+wvb.config.json
+wvb.config.jsonc
+```
+
+Both base names `wvb.config.*` and `webview-bundle.config.*` are supported with the
+`.js`, `.cjs`, `.mjs`, `.ts`, `.cts`, and `.mts` extensions. The `.json` and `.jsonc`
+forms are only recognized for the `wvb.config` base name. Pass `--config` (alias
+`-C`) to any command to point at a specific file instead of relying on discovery.
+
+```sh
+wvb pack --config ./configs/wvb.prod.ts
+```
+
+### defineConfig
+
+Wrap your config with `defineConfig` from `@wvb/config`. It is an identity function
+at runtime — its only job is to attach types. It accepts an object, a promise that
+resolves to a config, or a synchronous or asynchronous function that returns one, so
+you can compute values at load time.
+
+
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig({
+ root: process.cwd(),
+ pack: {
+ srcDir: './dist',
+ },
+ serve: {
+ port: 4312,
+ },
+});
+```
+
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig(async () => {
+ const endpoint = process.env.WVB_ENDPOINT;
+ return {
+ remote: {
+ endpoint,
+ },
+ };
+});
+```
+
+
+
+
+ Install the package as a dev dependency: `npm install -D @wvb/config`.
+
+
+## Top-level fields
+
+The config object has five optional fields. Nothing else is read at the top level.
+
+| Field | Type | Default |
+| --------- | --------------- | --------------- |
+| `root` | `string` | `process.cwd()` |
+| `pack` | `PackConfig` | — |
+| `remote` | `RemoteConfig` | — |
+| `serve` | `ServeConfig` | — |
+| `builtin` | `BuiltinConfig` | — |
+
+`root` sets the project root used to resolve relative paths. It may be absolute or
+relative to the config file. The `remote` field is documented on the
+[Remote, integrity & signature](/docs/config/remote) page.
+
+## pack
+
+`pack` (`PackConfig`) sets the defaults for `wvb pack`. CLI flags override these per
+invocation.
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig({
+ pack: {
+ srcDir: './dist',
+ outFile: '.wvb/app',
+ overwrite: true,
+ ignore: ['*.map', /\.DS_Store$/],
+ headers: {
+ '*.html': { 'cache-control': 'max-age=0' },
+ '*.js': { 'cache-control': 'max-age=31536000' },
+ },
+ },
+});
+```
+
+| Field | Type | Default |
+| ----------- | ------------------------------------------------------------------------------------ | ------------- |
+| `srcDir` | `string` | `./dist` |
+| `outFile` | `string` | `.wvb/` |
+| `overwrite` | `boolean` | `true` |
+| `ignore` | `Array \| ((file: string) => boolean \| Promise)` | — |
+| `headers` | `Record \| Array<[glob, HeadersInit]> \| ((file) => HeadersInit)` | — |
+
+`outFile` is a single output path. The `.wvb` extension is appended automatically
+when you omit it, and the path resolves relative to `root` unless it is absolute. The
+default `.wvb/` derives `` from your `package.json` name with any scope
+prefix stripped.
+
+
+ The field is `outFile`, a complete path. There is no `outFileName` or `outDir` on `pack` — write
+ the full output path, including any subdirectory, in `outFile`.
+
+
+`ignore` accepts an array of globs and regular expressions, or a predicate that
+returns a boolean (optionally async). `headers` attaches HTTP headers to matching
+files and accepts three shapes:
+
+```ts
+// 1. Record keyed by glob
+headers: {
+ '*.html': { 'cache-control': 'max-age=3600' },
+}
+
+// 2. Array of [glob, HeadersInit] tuples
+headers: [
+ ['*.html', { 'cache-control': 'max-age=3600' }],
+ ['*.png', [['cache-control', 'max-age=0']]], // HeadersInit also accepts [name, value] pairs
+]
+
+// 3. Function returning headers per file
+headers: (file) => (file.endsWith('.html') ? { 'cache-control': 'max-age=0' } : undefined)
+```
+
+## serve
+
+`serve` (`ServeConfig`) sets the defaults for `wvb serve`, the local server that
+serves a packed bundle for development.
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig({
+ serve: {
+ file: './.wvb/app.wvb',
+ port: 4312,
+ silent: false,
+ },
+});
+```
+
+| Field | Type | Default |
+| -------- | --------- | ----------------------- |
+| `file` | `string` | the `pack.outFile` path |
+| `port` | `number` | `4312` |
+| `silent` | `boolean` | — |
+
+`file` falls back to the resolved `pack.outFile` path when omitted. Set `silent` to
+disable request-log output.
+
+
+ `serve` has no `hostname` field. To bind a different host, pass `--hostname` (alias `-H`) to `wvb
+ serve` on the command line.
+
+
+## builtin
+
+`builtin` (`BuiltinConfig`) sets the defaults for `wvb builtin`, which installs
+bundles that ship inside your app — either downloaded from a remote or collected from
+local workspaces.
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig({
+ builtin: {
+ outDir: '.wvb/builtin/bundles',
+ target: { type: 'remote' },
+ include: ['app*'],
+ exclude: [/^internal-/],
+ clean: true,
+ },
+});
+```
+
+| Field | Type | Default |
+| --------- | -------------------------------------------------------------------- | ---------------------- |
+| `outDir` | `string` | `.wvb/builtin/bundles` |
+| `target` | `BuiltinTarget` | `{ type: 'remote' }` |
+| `include` | `string \| RegExp \| Array \| ((info) => boolean)` | — |
+| `exclude` | `string \| RegExp \| Array \| ((info) => boolean)` | — |
+| `clean` | `boolean` | `true` |
+
+`include` and `exclude` filter the candidate bundles. Each accepts a glob string, a
+regular expression, an array of either, or a predicate
+`(info: { name: string; version: string }) => boolean` (optionally async). With
+`clean` enabled, the output directory is cleared before install.
+
+### target
+
+`target` is a discriminated union on `type`. Choose `remote` to download bundles from
+a server, or `local` to collect them from workspaces in your repository.
+
+```ts
+// Remote target — download from a server
+target: {
+ type: 'remote',
+ endpoint: 'https://updates.example.com',
+ download: {
+ concurrency: 4,
+ // http: { /* HttpOptions from @wvb/node */ },
+ },
+}
+
+// Local target — collect from workspaces
+target: {
+ type: 'local',
+ workspaces: ['packages/*'],
+ bundleName: { from: 'package.json' },
+ version: { from: 'package.json' },
+ packBeforeInstall: true,
+}
+```
+
+For the `remote` target, `endpoint` and `download` are optional. The only concurrency
+knob is `download.concurrency`; `download.http` accepts the `HttpOptions` type from
+[`@wvb/node`](/docs/references/node).
+
+| `remote` field | Type | Default |
+| ---------------------- | ------------- | ------- |
+| `endpoint` | `string` | — |
+| `download.concurrency` | `number` | — |
+| `download.http` | `HttpOptions` | — |
+
+For the `local` target, `workspaces` is required — it is the only required field
+anywhere in the config. The `integrity` and `signature` options share the same shapes
+as the remote section; see [Remote, integrity & signature](/docs/config/remote).
+
+| `local` field | Type | Default |
+| ------------------- | --------------------------------------------------- | -------- |
+| `workspaces` | `string[] \| (() => string[] \| Promise)` | required |
+| `bundleName` | `BundleNameResolver` | — |
+| `version` | `VersionResolver` | — |
+| `integrity` | `boolean \| IntegrityMakeConfig` | — |
+| `signature` | `SignatureSignConfig` | — |
+| `packBeforeInstall` | `boolean` | `true` |
+
+## Next steps
+
+
+
+
+
+
diff --git a/content/docs/config/meta.json b/content/docs/config/meta.json
new file mode 100644
index 0000000..578a090
--- /dev/null
+++ b/content/docs/config/meta.json
@@ -0,0 +1,5 @@
+{
+ "root": true,
+ "title": "Config",
+ "pages": ["index", "remote"]
+}
diff --git a/content/docs/config/remote.mdx b/content/docs/config/remote.mdx
new file mode 100644
index 0000000..9be3b96
--- /dev/null
+++ b/content/docs/config/remote.mdx
@@ -0,0 +1,209 @@
+---
+title: Remote, integrity & signature config
+description: Configure the remote block of wvb.config, plus the publish-side integrity and signature options.
+---
+
+The `remote` block of `wvb.config.ts` tells the `wvb` CLI how to publish a bundle: where the
+remote server lives, how to name and version each bundle, and which provider uploads and deploys
+it. Two adjacent options harden the result — `integrity` computes a content hash, and `signature`
+signs that hash with your private key so clients can verify who published the bundle. This page
+documents those three options and ends with a complete, copy-pasteable config.
+
+For the rest of the config file (top-level fields, `pack`, `serve`, `builtin`), see
+[The wvb.config file](/docs/config). For the runtime side — how a client downloads, verifies, and
+activates these bundles over the air — see [Remote bundles](/docs/guide/remote-bundles).
+
+## remote
+
+`remote` is a `RemoteConfig` object. Every field is optional; the `uploader` and `deployer` you
+supply come from a provider package.
+
+| Field | Type | Default | Description |
+| ------------------ | -------------------------------------- | -------------------------- | ----------------------------------------------------------------- |
+| `endpoint` | `string` | — | Base URL of the remote server. |
+| `bundleName` | `BundleNameResolver` | `{ from: 'package.json' }` | How to resolve the bundle name. |
+| `version` | `VersionResolver` | `{ from: 'package.json' }` | How to resolve the version to publish. |
+| `packBeforeUpload` | `boolean` | `true` | Pack the bundle before uploading. |
+| `uploader` | `BaseRemoteUploader` | — | Uploads the `.wvb` to the server (from a provider). |
+| `deployer` | `BaseRemoteDeployer` | — | Marks a version deployed (from a provider). |
+| `integrity` | `boolean \| IntegrityMakeConfig \| fn` | — | Compute an integrity hash on upload. See [integrity](#integrity). |
+| `signature` | `SignatureSignConfig \| fn` | — | Sign the integrity hash on upload. See [signature](#signature). |
+
+
+`RemoteConfig` has no `channel` or `allowOtherVersions` field. A channel is a deploy-time argument
+(`wvb deploy --channel `), not a property of the config object.
+
+
+### Resolvers
+
+`bundleName` and `version` accept a literal string, a resolver object, or a function. The function
+form receives `{ packageJson, dir, file }` and returns a string (or a `Promise` of one).
+
+```ts title="wvb.config.ts"
+remote: {
+ // Bundle name: 'package.json' (name field, scope stripped) or a string or a function.
+ bundleName: { from: 'package.json' },
+
+ // Version: 'package.json' (version field), 'git' (HEAD commit hash), a string, or a function.
+ version: { from: 'git' },
+}
+```
+
+| Resolver | Accepted forms |
+| ------------ | ------------------------------------------------------------------------------------------------------ |
+| `bundleName` | `{ from: 'package.json' }` \| `string` \| `(params) => string \| Promise` |
+| `version` | `{ from: 'package.json' }` \| `{ from: 'git' }` \| `string` \| `(params) => string \| Promise` |
+
+`{ from: 'package.json' }` reads the `name`/`version` field and throws when it is missing.
+`{ from: 'git' }` (version only) uses the current `HEAD` commit hash.
+
+### Providers
+
+`uploader` and `deployer` are not built into `@wvb/config` — they come from a provider package.
+Each provider exports a factory that returns `{ uploader, deployer }` (AWS also returns a
+`signature` signer) ready to spread into `remote`.
+
+
+
+
+
+
+
+## integrity
+
+Integrity computes a cryptographic hash of the bundle bytes and stores it alongside the bundle.
+Clients recompute the hash on download and reject a bundle whose hash does not match — proof the
+bytes were not corrupted or tampered with in transit. The hash uses **SHA-2**.
+
+`integrity` accepts three forms:
+
+```ts title="wvb.config.ts"
+// 1. Boolean — enable with the default algorithm (sha256).
+integrity: true,
+
+// 2. Pick the algorithm.
+integrity: { algorithm: 'sha384' }, // 'sha256' | 'sha384' | 'sha512'
+
+// 3. Custom function — return the full ":" string yourself.
+integrity: async ({ data }) => `sha256:${await myHash(data)}`,
+```
+
+| Form | Type | Notes |
+| ------------- | -------------------------------------------------- | ---------------------------------- |
+| Boolean | `boolean` | `true` uses the default algorithm. |
+| Config object | `{ algorithm?: 'sha256' \| 'sha384' \| 'sha512' }` | Default `algorithm` is `'sha256'`. |
+| Function | `(params: { data: Buffer }) => Promise` | Returns the serialized string. |
+
+The serialized output is `":"` — the algorithm name, a colon, then the base64-encoded
+digest, for example `sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=`.
+
+## signature
+
+A signature proves _who_ published a bundle. It signs the bytes of the integrity string (the
+`":"` value above) with your private key, and the client verifies the result with the
+matching public key. Because the signed message is the integrity string, signature verification
+requires an integrity value to be present.
+
+`signature` is a `SignatureSignConfig` — a discriminated union on `algorithm` — or a custom signing
+function. This is the **publish (sign) side**, shipped in `@wvb/config`.
+
+| `algorithm` | Required fields | Optional fields |
+| ------------------ | --------------------------------------------- | --------------- |
+| `'ecdsa'` | `curve` (`'p256'` \| `'p384'`), `hash`, `key` | — |
+| `'ed25519'` | `key` | — |
+| `'rsa-pkcs1-v1.5'` | `hash`, `key` | — |
+| `'rsa-pss'` | `hash`, `key` | `saltLength` |
+
+`hash` is one of `'sha256' | 'sha384' | 'sha512'`. The `key` is a `SignatureSigningKeyConfig`:
+
+| `format` | `data` type |
+| -------------------------------- | ------------ |
+| `'jwk'` | `JsonWebKey` |
+| `'raw'` \| `'pkcs8'` \| `'spki'` | `Buffer` |
+
+```ts title="wvb.config.ts"
+// ECDSA
+signature: {
+ algorithm: 'ecdsa',
+ curve: 'p256', // 'p256' | 'p384'
+ hash: 'sha256',
+ key: { format: 'pkcs8', data: privateKeyDerBuffer },
+},
+
+// Ed25519
+signature: {
+ algorithm: 'ed25519',
+ key: { format: 'pkcs8', data: privateKeyDerBuffer },
+},
+
+// RSA-PSS (or 'rsa-pkcs1-v1.5')
+signature: {
+ algorithm: 'rsa-pss',
+ hash: 'sha256',
+ saltLength: 32, // rsa-pss only; defaults from the hash when omitted
+ key: { format: 'pkcs8', data: privateKeyDerBuffer },
+},
+
+// Custom signer — receives the integrity-string bytes, returns a base64 signature.
+signature: async ({ message }) => myExternalSigner(message),
+```
+
+
+ Clients verify with the matching **public** key, and the verify side accepts different key formats
+ than the sign side — SPKI, PKCS#1 (RSA only), SEC1 (ECDSA only), and raw 32-byte (Ed25519 only),
+ but not JWK. See [Remote bundles](/docs/guide/remote-bundles) for verification and the [Node API
+ reference](/docs/references/node) for the client-side helpers.
+
+
+## Complete example
+
+A full `wvb.config.ts` that publishes through the AWS provider, hashes with SHA-384, and signs with
+Ed25519. Swap `awsRemote` for `localRemote` or `cloudflareRemote` to target a different provider.
+
+```ts title="wvb.config.ts"
+import { readFileSync } from 'node:fs';
+import { defineConfig } from '@wvb/config';
+import { awsRemote } from '@wvb/remote-aws';
+
+export default defineConfig(() => {
+ const provider = awsRemote({
+ bucket: 'my-app-bundles',
+ aws: { region: 'us-east-1' },
+ });
+
+ return {
+ remote: {
+ endpoint: 'https://updates.example.com',
+ bundleName: { from: 'package.json' },
+ version: { from: 'git' },
+ uploader: provider.uploader,
+ deployer: provider.deployer,
+ integrity: { algorithm: 'sha384' },
+ signature: {
+ algorithm: 'ed25519',
+ key: {
+ format: 'pkcs8',
+ data: readFileSync('./keys/signing-key.pkcs8.der'),
+ },
+ },
+ },
+ };
+});
+```
+
+To publish, run `wvb upload` to push the `.wvb` (which packs first when `packBeforeUpload` is
+`true`), then `wvb deploy --version ` to mark a version live. See the
+[CLI reference](/docs/guide/cli) for the full command set and the
+[remote guide](/docs/guide/remote) for building and testing a remote end to end.
diff --git a/content/docs/configuration.mdx b/content/docs/configuration.mdx
deleted file mode 100644
index 95f4f9b..0000000
--- a/content/docs/configuration.mdx
+++ /dev/null
@@ -1,175 +0,0 @@
----
-title: Configuration reference
-description: The wvb.config file — pack, remote, serve, and builtin options.
----
-
-The `wvb` CLI reads a config file (`wvb.config.ts`, `.js`, `.mjs`, …) from the working directory.
-Author it with `defineConfig` from `@wvb/config` for full type-checking:
-
-```ts
-import { defineConfig } from '@wvb/config';
-
-export default defineConfig({
- root: process.cwd(),
- pack: {
- /* … */
- },
- remote: {
- /* … */
- },
- serve: {
- /* … */
- },
- builtin: {
- /* … */
- },
-});
-```
-
-`defineConfig` also accepts an async function or a promise, so you can compute config at runtime
-(e.g. read a version, load a signing key):
-
-```ts
-export default defineConfig(async () => ({
- remote: { endpoint: process.env.WVB_ENDPOINT! /* … */ },
-}));
-```
-
-## Top-level
-
-| Field | Type | Description |
-| --------- | --------------- | ---------------------------------------------------------- |
-| `root` | `string` | Project root for resolving paths. Default `process.cwd()`. |
-| `pack` | `PackConfig` | Defaults for `wvb pack`. |
-| `remote` | `RemoteConfig` | Remote server, uploader/deployer, integrity/signature. |
-| `serve` | `ServeConfig` | Defaults for `wvb serve`. |
-| `builtin` | `BuiltinConfig` | Defaults for `wvb builtin`. |
-
-## `pack`
-
-```ts
-pack: {
- srcDir: './dist',
- outFileName: 'app', // → app.wvb
- outDir: '.wvb',
- ignore: ['*.map'],
- headers: { '*.html': { 'cache-control': 'max-age=3600' } },
- overwrite: true,
-}
-```
-
-| Field | Type | Description |
-| ------------- | ------------------------------------------------------------------------------- | ---------------------------------------------- |
-| `srcDir` | `string` | Directory to pack. |
-| `outFileName` | `string` | Output file name (`.wvb` appended if missing). |
-| `outDir` | `string` | Output directory. Default `.wvb`. |
-| `ignore` | `Array \| ((file) => boolean)` | Patterns/predicate to exclude files. |
-| `headers` | `Record \| [glob, HeadersInit][] \| ((file) => HeadersInit)` | HTTP headers to attach to matching files. |
-| `overwrite` | `boolean` | Overwrite an existing output. Default `true`. |
-
-## `remote`
-
-```ts
-import { localRemote } from '@wvb/remote-local';
-const provider = localRemote({});
-
-remote: {
- endpoint: 'https://updates.example.com',
- bundleName: 'app', // or () => string | Promise
- version: () => pkg.version, // or a string; defaults to package.json version
- uploader: provider.uploader,
- deployer: provider.deployer,
- integrity: { algorithm: 'sha384' },
- signature: { /* see below */ },
-}
-```
-
-| Field | Type | Description |
-| ------------ | --------------------------- | --------------------------------------------------- |
-| `endpoint` | `string` | Base URL of the remote server. |
-| `bundleName` | `string \| (() => …)` | Bundle name for remote operations. |
-| `version` | `string \| (() => …)` | Version to publish. |
-| `uploader` | `BaseRemoteUploader` | Uploads the `.wvb` to the server (from a provider). |
-| `deployer` | `BaseRemoteDeployer` | Marks a version deployed (from a provider). |
-| `integrity` | `boolean \| { algorithm }` | Compute an integrity hash on upload. |
-| `signature` | `SignatureSignConfig \| fn` | Sign the bundle on upload. |
-
-`uploader`/`deployer` come from a provider package — `@wvb/remote-local`, `@wvb/remote-aws`, or
-`@wvb/remote-cloudflare`. Each exposes `{ uploader, deployer }` compatible with these fields.
-
-### `integrity`
-
-Either `true`/`{ algorithm }` for the built-in hashing, or a function for custom logic.
-
-```ts
-integrity: {
- algorithm: 'sha384';
-} // 'sha256' | 'sha384' | 'sha512'
-integrity: async ({ data }) => `sha384:${await myHash(data)}`;
-```
-
-### `signature`
-
-Sign the integrity string with a private key. The algorithm determines the required fields:
-
-```ts
-// ECDSA
-signature: {
- algorithm: 'ecdsa',
- curve: 'p256', // 'p256' | 'p384'
- hash: 'sha256', // 'sha256' | 'sha384' | 'sha512'
- key: { format: 'pkcs8', data: privateKeyDerBuffer },
-}
-
-// Ed25519
-signature: {
- algorithm: 'ed25519',
- key: { format: 'pkcs8', data: privateKeyDerBuffer },
-}
-
-// RSA
-signature: {
- algorithm: 'rsa-pss', // or 'rsa-pkcs1-v1.5'
- hash: 'sha256',
- saltLength: 32, // rsa-pss only
- key: { format: 'pkcs8', data: privateKeyDerBuffer },
-}
-
-// or fully custom
-signature: async ({ message }) => myExternalSigner(message),
-```
-
-Key `format` is one of `'raw' | 'pkcs8' | 'spki' | 'jwk'` (use `data: JsonWebKey` for `'jwk'`,
-`data: Buffer` otherwise). Clients verify with the matching public key — see
-[Remote updates → Integrity and signatures](/docs/remote-updates#integrity-and-signatures).
-
-## `serve`
-
-```ts
-serve: { file: './app.wvb', port: 4312, silent: false }
-```
-
-| Field | Type | Description |
-| -------- | --------- | ------------------------ |
-| `file` | `string` | Bundle to serve. |
-| `port` | `number` | Port. Default `4312`. |
-| `silent` | `boolean` | Disable request logging. |
-
-## `builtin`
-
-```ts
-builtin: {
- outDir: '.wvb/builtin/bundles',
- include: ['app*'],
- exclude: ['internal*'],
- clean: true,
-}
-```
-
-| Field | Type | Description |
-| ----------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------- |
-| `outDir` | `string` | Where to write downloaded builtin bundles. Default `.wvb/builtin/bundles`. |
-| `target` | `BuiltinTarget` | Where to install from. Default `{ type: 'remote' }`. |
-| `include` / `exclude` | `string \| RegExp \| Array<…> \| ((info) => boolean)` | Match remote bundles by name/version. |
-| `clean` | `boolean` | Clear `outDir` before downloading. Default `true`. |
-| `target.download.concurrency` | `number` | Parallel downloads (remote target). |
diff --git a/content/docs/guide/bundle-format.mdx b/content/docs/guide/bundle-format.mdx
new file mode 100644
index 0000000..4d6ebf0
--- /dev/null
+++ b/content/docs/guide/bundle-format.mdx
@@ -0,0 +1,189 @@
+---
+title: Bundle format
+description: How a .wvb archive is laid out on disk, section by section, and how its checksums protect against corruption.
+---
+
+A Webview Bundle is a single `.wvb` file that packs your built web assets into one compressed,
+integrity-checked archive. It is three sections written back to back — a fixed 17-byte header followed
+by a variable-length index and data region:
+
+| Offset | Section | Size | Holds |
+| ----------- | ------- | ---------- | -------------------------------------------------- |
+| `0` | Header | `17 bytes` | magic number, format version, index size, checksum |
+| `17` | Index | variable | path → offset/length/headers entry map |
+| after index | Data | variable | LZ4-compressed file contents, sorted by path |
+
+Every section carries its own checksum, so a truncated or corrupted archive is caught before any content
+is served. To produce one, see the [CLI](/docs/guide/cli) or the [Node API](/docs/references/node):
+
+```ts title="build.ts"
+import { readFile } from 'node:fs/promises';
+import { BundleBuilder, writeBundle } from '@wvb/node';
+
+const builder = new BundleBuilder();
+builder.insertEntry('/index.html', await readFile('dist/index.html'), 'text/html');
+builder.insertEntry('/app.js', await readFile('dist/app.js'), 'application/javascript');
+
+const bundle = builder.build();
+await writeBundle(bundle, '.wvb/app_1.0.0.wvb');
+```
+
+By convention a packed file is named `_.wvb`, e.g. `app_1.0.0.wvb`. The bundle name and
+version are source-level concepts that live in the filename and the manifest, never inside the archive.
+See [Bundle sources](/docs/guide/bundle-sources).
+
+## Header
+
+The header is exactly 17 bytes with a fixed layout, written by hand rather than through a serializer, so
+every field sits at a known offset.
+
+| Field | Offset | Length | Encoding |
+| ------------ | ------ | ------ | ------------------------------ |
+| Magic number | 0 | 8 | raw bytes `0xF09F8C90F09F8E81` |
+| Version | 8 | 1 | single byte (format version) |
+| Index size | 9 | 4 | `u32`, big-endian |
+| Checksum | 13 | 4 | `u32`, big-endian xxHash-32 |
+
+For a `V1` bundle with an index size of `1234`, the 17 bytes are:
+
+```text
+F0 9F 8C 90 F0 9F 8E 81 magic (🌐🎁)
+01 version (V1)
+00 00 04 D2 index size = 1234, big-endian
+31 38 03 10 xxHash-32 of the first 13 bytes
+```
+
+- The magic number `0xF09F8C90F09F8E81` is the UTF-8 encoding of 🌐🎁 (globe + wrapped gift). A reader
+ that does not find these bytes rejects the file as not a bundle.
+- The version byte holds the bundle **format version**. The enum currently has a single value, `V1`.
+ This is distinct from a bundle's release version such as `1.0.0`, which never appears inside the archive.
+- The index size is the byte length of the index that follows, **not** counting the index's own trailing
+ checksum.
+- The checksum is an xxHash-32 over the first 13 bytes (everything before the checksum), stored big-endian.
+
+Read header fields back through the descriptor:
+
+```ts
+import { readBundle } from '@wvb/node';
+
+const header = (await readBundle('app_1.0.0.wvb')).descriptor().header();
+header.version(); // 'v1'
+header.indexSize(); // byte length of the index
+header.indexEndOffset(); // 17 + indexSize + 4 -> where the data section starts
+```
+
+## Index
+
+The index sits immediately after the header and maps each file path, e.g. `/index.html`, to an entry
+describing where its bytes are and how to serve them. Each entry holds:
+
+| Field | Type | Meaning |
+| ---------------- | ------ | -------------------------------------------------------------- |
+| `offset` | `u64` | byte offset of this file's data within the data section |
+| `len` | `u64` | length of the **compressed** bytes |
+| `content_type` | string | MIME type, used for the `Content-Type` header when served |
+| `content_length` | `u64` | original **uncompressed** size, used for `Content-Length` |
+| `headers` | map | HTTP header name/value pairs to replay when the file is served |
+
+```ts
+const index = (await readBundle('app_1.0.0.wvb')).descriptor().index();
+
+index.containsPath('/index.html'); // true
+index.getEntry('/index.html');
+// {
+// offset: 0,
+// len: 184, // compressed
+// contentType: 'text/html',
+// contentLength: 412, // uncompressed
+// headers: { 'cache-control': 'no-cache' },
+// isEmpty: false,
+// }
+```
+
+Serialization details:
+
+- Encoded with [bincode](https://github.com/bincode-org/bincode) in a big-endian, variable-length integer
+ configuration. Numeric fields are varint-encoded, not fixed width.
+- The map is sorted by path before encoding, so the output bytes are deterministic and independent of
+ insertion order.
+- A 4-byte big-endian xxHash-32 checksum of the index bytes is written right after the index.
+
+When the protocol handler serves a file, it replays the stored `headers`, then sets `Content-Type` from
+`content_type` and `Content-Length` from `content_length` (the uncompressed size). See
+[Protocol handling](/docs/guide/protocol-handling) for how a URL maps to an entry.
+
+## Data
+
+The data section holds file contents sorted by path, in the same order as the index. Each file is its
+LZ4-compressed bytes followed by a 4-byte big-endian xxHash-32 checksum of those compressed bytes:
+
+```text
+[ LZ4 bytes for file A ][ xxHash-32 ][ LZ4 bytes for file B ][ xxHash-32 ] ...
+```
+
+Files are compressed with [LZ4](https://github.com/lz4/lz4) in its size-prepended block format, so the
+uncompressed length travels with the compressed data. An entry's `offset` points at the start of its
+compressed bytes, `len` is the length of those bytes, and the per-file checksum sits at `offset + len`.
+
+`getData` returns the decompressed bytes; `getDataChecksum` returns the stored xxHash-32 so callers can
+verify a file at rest:
+
+```ts
+const bundle = await readBundle('app_1.0.0.wvb');
+
+bundle.getData('/index.html'); // Buffer (decompressed) | null
+bundle.getDataChecksum('/index.html'); // number (xxHash-32) | null
+```
+
+## Checksums versus integrity
+
+The format uses two different hashing schemes for two different jobs.
+
+**Internal checksums** in the header, index, and per-file data are all **xxHash-32**. They guard storage
+integrity: a file truncated, partially written, or corrupted on disk. xxHash is fast and
+non-cryptographic, which is exactly what a corruption check needs.
+
+A bundle's **integrity** value is something else: a **SHA-2** hash over the serialized bytes of a
+downloaded bundle, so a client can confirm it received exactly what the server published. Algorithms are
+`sha256` (default), `sha384`, and `sha512`, serialized as `:`:
+
+```text
+sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
+sha384:Ws2q...
+```
+
+```ts
+import type { IntegrityAlgorithm, IntegrityPolicy } from '@wvb/node';
+// IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512'
+// IntegrityPolicy = 'strict' | 'optional' | 'none' (default: 'optional')
+```
+
+
+ The format uses **SHA-2** for integrity, not SHA-3. The two jobs are distinct: xxHash-32 protects
+ bytes at rest, while SHA-2 integrity verifies what was downloaded. See [Remote
+ bundles](/docs/guide/remote-bundles) for how integrity and signatures are checked during an
+ update.
+
+
+## Where to go next
+
+The byte-level specification, including exact constants and field layouts, lives in the Rust crate
+documentation at [docs.rs/wvb](https://docs.rs/wvb).
+
+
+
+
+
+
diff --git a/content/docs/guide/bundle-sources.mdx b/content/docs/guide/bundle-sources.mdx
new file mode 100644
index 0000000..4b5fb36
--- /dev/null
+++ b/content/docs/guide/bundle-sources.mdx
@@ -0,0 +1,178 @@
+---
+title: Bundle sources
+description: How Webview Bundle stores bundles on disk across builtin and remote sources, tracks them in a manifest, and selects versions per channel.
+---
+
+A **source** is a place on the device where bundles live. Each app has two of them: a read-only
+`builtin` source shipped inside the app package, and a writable `remote` source for downloaded
+updates. This page explains how a source is laid out, how the `manifest.json` tracks versions, and
+how channels select which version a client receives.
+
+## Bundles, names, and versions
+
+A **bundle** is one logical web app, identified by a **bundle name** (for example `app`). A bundle
+has many **versions** (`1.0.0`, `1.1.0`, …), and each version is a separate `.wvb` file. At any
+moment one version is the **current** version — the one served to the webview.
+
+The bundle name and version are _source-level_ identifiers. They appear in the on-disk filename and
+in the manifest, but they are **not** stored inside the `.wvb` archive itself. The archive only
+carries the binary file-format version (`v1`). See [Bundle format](/docs/guide/bundle-format) for
+the byte layout.
+
+## Builtin vs remote sources
+
+Apps run with two sources that serve the same bundle name from different storage:
+
+- **`builtin`** — bundles shipped _inside_ the app package. Read-only, and used as the fallback the
+ first time the app runs, before anything has been downloaded.
+- **`remote`** — bundles downloaded from your update server. Writable, and updated over the air
+ (OTA) without an app-store release.
+
+When a bundle exists in both sources, **remote wins**. The source resolves a version by checking the
+remote manifest's current version first, then falling back to builtin if the remote has nothing.
+
+
+ The two sources are independent directories. A source object holds a separate `builtinDir` and
+ `remoteDir`, and each directory has its own `manifest.json`. Resolving a version reads the remote
+ manifest first and the builtin manifest second.
+
+
+This split is what makes updates safe. You always have the shipped builtin version to fall back to,
+and downloaded remote versions transparently shadow it once they are verified and installed. See
+[Remote bundles](/docs/guide/remote-bundles) for the download and verification flow.
+
+## On-disk layout
+
+Within a source directory, each bundle gets its own folder and the manifest sits at the root:
+
+```text
+{source_dir}/
+├── {name}/
+│ └── {name}_{version}.wvb
+└── manifest.json
+```
+
+So a remote source holding two versions of the `app` bundle looks like this:
+
+```text
+{remote_dir}/
+├── app/
+│ ├── app_1.0.0.wvb
+│ └── app_1.1.0.wvb
+└── manifest.json
+```
+
+The file path is always `{source_dir}/{name}/{name}_{version}.wvb`. Because the builtin and remote
+sources are separate directories, the same `app_1.1.0.wvb` filename can exist in both, tracked by
+two separate manifests.
+
+
+ Bundle names and versions become path components, so they are restricted to ASCII
+ `[A-Za-z0-9._-]`. They cannot be empty, cannot be `.` or `..`, cannot end with `.`, and cannot be
+ a Windows reserved name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`). Anything else
+ is rejected before it touches the filesystem.
+
+
+## The manifest
+
+`manifest.json` lives at the root of a source directory. For each bundle it records the versions
+present, their metadata, and which version is current:
+
+```json title="manifest.json"
+{
+ "manifestVersion": 1,
+ "entries": {
+ "app": {
+ "versions": {
+ "1.0.0": {},
+ "1.1.0": {}
+ },
+ "currentVersion": "1.0.0"
+ }
+ }
+}
+```
+
+The fields are:
+
+| Field | Type | Description |
+| -------------------------------- | ----------------- | ---------------------------------------------- |
+| `manifestVersion` | integer | Manifest schema version. Always `1`. |
+| `entries` | object | Map of bundle name → entry. |
+| `entries.{name}.versions` | object | Map of version string → per-version metadata. |
+| `entries.{name}.currentVersion` | string (optional) | The active version served for this bundle. |
+| `entries.{name}.previousVersion` | string (optional) | The version active immediately before current. |
+
+Each version's metadata is an object whose fields are all optional. Empty `{}` is valid, since the
+metadata is only filled in from the remote server's response headers when a bundle is downloaded:
+
+| Field | Type | Description |
+| -------------- | ------ | ------------------------------------------------------------------------ |
+| `etag` | string | The server's `ETag` for the downloaded bundle. |
+| `integrity` | string | Integrity hash, formatted `:` (for example `sha256:n4bQ…`). |
+| `signature` | string | Base64 signature over the integrity string. |
+| `lastModified` | string | The server's `Last-Modified` value. |
+
+### Current and previous versions
+
+`currentVersion` is the version a source serves right now. It may be absent when versions have been
+staged but none has been activated yet — staging a version records it under `versions` without
+changing `currentVersion`.
+
+`previousVersion` is the version that was current immediately before the latest activation. The
+source retains it for two reasons: it lets you roll back to a known-good build, and it keeps the old
+file on disk so in-flight requests that already opened it finish reading cleanly while a new version
+becomes current. Only the current and previous versions are retained; older versions are pruned.
+
+## Channels
+
+A **channel** lets you deliver different versions to different audiences from the same server —
+`stable`, `beta`, `canary`, and so on. Channels power staged rollouts and pre-release testing.
+
+A channel is purely a **remote** and **updater** concept. It is _not_ part of the `.wvb` format and
+_not_ stored in `manifest.json`. The client selects a channel per request by sending it as a
+`channel` query parameter on the remote's HTTP calls:
+
+```text
+GET /bundles?channel=beta
+HEAD /bundles/app?channel=beta
+GET /bundles/app?channel=beta
+```
+
+The channel is optional. There is **no hardcoded default channel name** — when no channel is set,
+the query parameter is simply omitted and the server decides what to return. See
+[Remote bundles](/docs/guide/remote-bundles) for the full HTTP contract and the updater flow, and
+[Remote config](/docs/config/remote) for configuring the updater's channel.
+
+## How sources are served
+
+A source feeds the protocol layer that answers webview requests. The protocol maps an incoming URI
+to a bundle name and file path, asks the source to resolve the current version, and serves the file
+from inside the matching `.wvb`. See [Protocol handling](/docs/guide/protocol-handling) for how URIs
+become bundle lookups, and [The wvb.config file](/docs/config) for pointing the runtime at your
+builtin and remote directories.
+
+## Related pages
+
+
+
+
+
+
+
diff --git a/content/docs/guide/cli-programmatic.mdx b/content/docs/guide/cli-programmatic.mdx
new file mode 100644
index 0000000..795a8a1
--- /dev/null
+++ b/content/docs/guide/cli-programmatic.mdx
@@ -0,0 +1,290 @@
+---
+title: Programmatically Usage
+description: Embed the wvb CLI in scripts and CI with @wvb/cli/api, and load the wvb config file from your own code.
+---
+
+Every `wvb` command is also a function. The `@wvb/cli` package ships a programmatic API at
+`@wvb/cli/api` that exposes the same packing, extracting, serving, uploading, and local-remote
+logic the CLI runs, so you can drive Webview Bundle from build scripts, test harnesses, or CI
+without shelling out. The main `@wvb/cli` entry separately exposes the config helpers — `defineConfig`,
+`loadConfigFile`, and `resolveConfig` — so your tooling can read and resolve a `wvb.config` file the
+same way the CLI does.
+
+Install `@wvb/cli` as a dependency of the script or workspace that needs it.
+
+
+
+
+```sh
+npm install --save-dev @wvb/cli
+```
+
+
+
+
+```sh
+pnpm add -D @wvb/cli
+```
+
+
+
+
+```sh
+yarn add -D @wvb/cli
+```
+
+
+
+
+
+ Prefer the API over spawning the `wvb` binary when you need return values (the packed `Bundle`, a
+ running server handle) or want to keep everything inside one Node process. For the command-line
+ surface, see the [CLI reference](/docs/guide/cli).
+
+
+## The `/api` exports
+
+Import the functions from `@wvb/cli/api`. Each function takes a single options object and returns a
+promise. Failures throw an `ApiError`.
+
+| Function | Signature |
+| -------------- | ---------------------------------------------------------------------- |
+| `pack` | `pack(params: PackParams): Promise` |
+| `extract` | `extract(params: ExtractParams): Promise` |
+| `serve` | `serve(params: ServeParams): Promise` |
+| `remoteUpload` | `remoteUpload(params: RemoteUploadParams): Promise` |
+| `builtin` | `builtin(params: BuiltinParams): Promise` |
+| `localRemote` | `localRemote(params: LocalRemoteParams): Promise` |
+
+
+ The `/api` surface is intentionally CLI-adjacent, not a full client. There are no `deploy` or
+ `download` functions here — those commands live only on the CLI, which talks to a remote through
+ the `Remote` class from [`@wvb/node`](/docs/references/node).
+
+
+### pack
+
+Pack a source directory into a `.wvb` archive. The `.wvb` extension is appended to `outFile`
+automatically if you leave it off.
+
+```ts
+import { pack } from '@wvb/cli/api';
+
+const result = await pack({
+ srcDir: './dist',
+ outFile: './.wvb/app', // becomes ./.wvb/app.wvb
+ write: true, // default true; set false to pack in memory only
+ overwrite: true, // default true
+});
+
+console.log(result.outFilePath); // absolute path to the written .wvb
+console.log(result.bundle); // the in-memory Bundle
+```
+
+`PackParams` accepts `srcDir`, `outFile`, optional `ignores` and `headers` (the same shapes as the
+config file), `write` (default `true`), `overwrite` (default `true`), `cwd` (default
+`process.cwd()`), `logLevel` (default `'info'`), and an optional `logger`. The returned `PackResult`
+has `outFilePath` and `bundle` (a `Bundle` from `@wvb/node`).
+
+### extract
+
+Read a `.wvb` file and write its entries to a directory.
+
+```ts
+import { extract } from '@wvb/cli/api';
+
+const bundle = await extract({
+ file: './.wvb/app.wvb',
+ outDir: './extracted', // defaults to .wvb/ when omitted
+ clean: true, // default false; remove outDir first
+});
+```
+
+`ExtractParams` accepts `file` (required), optional `outDir`, `cwd`, `write` (default `true`),
+`clean` (default `false`), and a `logger`. It returns the parsed `Bundle`.
+
+### serve
+
+Start a localhost server that serves a bundle to a webview. Directory paths resolve to `index.html`.
+
+```ts
+import { serve } from '@wvb/cli/api';
+
+const instance = await serve({
+ file: './.wvb/app.wvb',
+ port: 4312, // default 4312
+ silent: false, // default false; true disables request logging
+});
+
+// ... later
+await instance.shutdown();
+```
+
+`ServeParams` accepts `file` (required), optional `hostname`, `port` (default `4312`), `silent`
+(default `false`), `cwd`, `logger`, and `colorEnabled`. It returns a `ServeInstance` with the raw
+`server` and a `shutdown()` method.
+
+### remoteUpload
+
+Upload a packed bundle to a remote. Pass a file path or an in-memory `Bundle`, plus an `uploader`
+from your remote configuration.
+
+```ts
+import { pack, remoteUpload } from '@wvb/cli/api';
+import { loadConfigFile } from '@wvb/cli';
+
+const config = await loadConfigFile();
+const { bundle } = await pack({ srcDir: './dist', outFile: './.wvb/app' });
+
+await remoteUpload({
+ file: bundle,
+ bundleName: 'app',
+ version: '1.2.0',
+ uploader: config.remote.uploader,
+ integrity: true, // default true
+});
+```
+
+`RemoteUploadParams` accepts `file` (a path or `Bundle`), `bundleName`, `version`, and `uploader`
+(a `BaseRemoteUploader`). Optional fields are `force`, `integrity` (default `true`; pass an
+integrity config to customize), `signature`, `logger`, and `cwd`.
+
+
+ Integrity uses SHA-2 (`sha256` by default). The signature, when provided, signs the integrity
+ string bytes — see [Remote bundles](/docs/guide/remote-bundles) for how integrity and signatures
+ fit together.
+
+
+### builtin
+
+Install builtin bundles into your app from a remote or local target.
+
+```ts
+import { builtin } from '@wvb/cli/api';
+
+const result = await builtin({
+ target: { type: 'remote' }, // discriminated union: 'remote' | 'local'
+ dir: './.wvb/builtin/bundles',
+ clean: true, // default true
+});
+
+console.log(result.manifest);
+```
+
+`BuiltinParams` accepts `target` (a `BuiltinTarget` discriminated union keyed by `type`), optional
+`dir` (default `'.wvb/builtin/bundles'`), `include`/`exclude` match lists, `channel`, `clean`
+(default `true`), `cwd`, `write` (default `true`), `logLevel`, `logger`, `progress`, and the
+mobile presets `android` and `ios`. It returns a `BuiltinResult` whose `manifest` describes the
+installed bundles.
+
+### localRemote
+
+Start a local remote server for development. This mirrors the `wvb remote local` command and
+dynamically imports the optional peer dependency `@wvb/remote-local-provider`, so make sure that
+package is installed.
+
+```ts
+import { localRemote } from '@wvb/cli/api';
+
+const instance = await localRemote({
+ baseDir: '~/.wvb/local',
+ port: 4313, // default 4313
+ allowOtherVersions: false,
+});
+
+// ... later
+await instance.shutdown();
+```
+
+`LocalRemoteParams` accepts optional `baseDir` (the provider resolves `~/.wvb/local` by default),
+`hostname`, `port` (default `4313`), `silent` (default `false`), `allowOtherVersions`, `logger`,
+and `colorEnabled`. It returns a `LocalRemoteInstance` with the raw `server` and `shutdown()`.
+
+
+ `@wvb/remote-local-provider` is an optional peer dependency. If it is not installed, `localRemote`
+ throws when it tries to import it. Add it alongside `@wvb/cli` when you use local remotes.
+
+
+## Config helpers from the main entry
+
+The default `@wvb/cli` entry (not `/api`) re-exports the config tooling. It surfaces `defineConfig`
+from [`@wvb/config`](/docs/config), plus `loadConfigFile` and `resolveConfig`, and the
+`InlineConfig` and `ResolvedConfig` types.
+
+```ts
+import {
+ defineConfig,
+ loadConfigFile,
+ resolveConfig,
+ type InlineConfig,
+ type ResolvedConfig,
+} from '@wvb/cli';
+
+// Discover and load wvb.config.* from the current directory.
+const config: ResolvedConfig = await loadConfigFile();
+
+// Or load an explicit file and merge inline overrides on top.
+const inline: InlineConfig = { root: process.cwd() };
+const merged = await resolveConfig(inline);
+```
+
+`loadConfigFile` finds and bundles the config file, then resolves it. `resolveConfig` merges a
+file config under your inline config, sets `root` (default `process.cwd()`), attaches the nearest
+`package.json`, and returns a readonly `ResolvedConfig`. Use `defineConfig` inside the config file
+itself to get full type inference.
+
+## Config discovery
+
+When you call `loadConfigFile` without an explicit path — and when the CLI runs without `--config` —
+Webview Bundle searches the working directory for the first matching file in this order:
+
+```text
+wvb.config.js
+wvb.config.cjs
+wvb.config.mjs
+wvb.config.ts
+wvb.config.cts
+wvb.config.mts
+webview-bundle.config.js
+webview-bundle.config.cjs
+webview-bundle.config.mjs
+webview-bundle.config.ts
+webview-bundle.config.cts
+webview-bundle.config.mts
+wvb.config.json
+wvb.config.jsonc
+```
+
+Both base names — `wvb.config.*` and `webview-bundle.config.*` — support the `.js`, `.cjs`, `.mjs`,
+`.ts`, `.cts`, and `.mts` extensions. The `.json` and `.jsonc` extensions are supported only for
+the `wvb.config` base name. Pass `--config`, `-C` (or call `loadConfigFile` with an explicit path)
+to bypass discovery.
+
+Config files are bundled with `rolldown` before they run, so TypeScript and ESM/CJS files load
+without a separate build step. Bare and `npm:` specifiers and Node builtins stay external.
+
+
+ Under Deno, config files are always treated as ESM — the loader skips the package-type and
+ extension sniffing it uses on Node. Deno Desktop support is experimental; see the [Deno
+ guide](/docs/guide/platforms/deno).
+
+
+## Related
+
+
+
+
+
+
diff --git a/content/docs/guide/cli.mdx b/content/docs/guide/cli.mdx
new file mode 100644
index 0000000..083df92
--- /dev/null
+++ b/content/docs/guide/cli.mdx
@@ -0,0 +1,126 @@
+---
+title: Overview
+description: The wvb command-line tool packs your web assets into .wvb bundles and drives the full upload, deploy, and download workflow.
+---
+
+The `wvb` command-line tool packs your built web assets into `.wvb` bundles and drives the full remote update workflow: upload, deploy, download, and inspect bundles on a remote server. It ships in the `@wvb/cli` package and exposes two equivalent binaries, `wvb` and `webview-bundle`.
+
+Install it as a dev dependency:
+
+
+
+
+```sh
+npm install -D @wvb/cli
+npx wvb --help
+```
+
+
+
+
+```sh
+pnpm add -D @wvb/cli
+pnpm wvb --help
+```
+
+
+
+
+```sh
+yarn add -D @wvb/cli
+yarn wvb --help
+```
+
+
+
+
+Most commands read their defaults from a [`wvb.config`](/docs/config) file discovered in the working directory, so a typical project runs `wvb pack` or `wvb upload` with no arguments at all. Each command has its own page with the full option reference. To call the same logic from JavaScript, see the [programmatic API](/docs/guide/cli-programmatic).
+
+## Global flags
+
+Three flags apply to every command. They control output formatting and logging only.
+
+| Flag | Values / type | Default | Env | Description |
+| --------------- | ----------------------------------------- | ------- | ----------- | -------------------------------------------------------------- |
+| `--color` | `off` \| `on` \| `auto` | `auto` | `COLOR` | Color mode for output. `auto` enables color on a TTY or in CI. |
+| `--log-level` | `debug` \| `info` \| `warning` \| `error` | `info` | `LOG_LEVEL` | Minimum log level to print. |
+| `--log-verbose` | boolean | `false` | — | Verbose logging with timestamps and categories. |
+
+
+`--config` (`-C`) and `--cwd` are **not** global. They are declared per command and are present on most of them. `extract` accepts `--cwd` but has no `--config`, and `remote local` accepts neither. Where present, `--config ` points at a specific config file and `--cwd ` changes the directory used to resolve paths.
+
+
+Boolean flags accept `--flag`, `--flag=true|false`, and a `--no-flag` negation. For example, `--no-write` and `--no-pack` turn off the `--write` and `--pack` defaults.
+
+## Config file discovery
+
+Commands load defaults from a `wvb.config` file in the working directory. Pass `--config ` to point at a specific file; otherwise `wvb` searches for the first matching file in this order:
+
+```text
+wvb.config.js
+wvb.config.cjs
+wvb.config.mjs
+wvb.config.ts
+wvb.config.cts
+wvb.config.mts
+webview-bundle.config.js
+webview-bundle.config.cjs
+webview-bundle.config.mjs
+webview-bundle.config.ts
+webview-bundle.config.cts
+webview-bundle.config.mts
+wvb.config.json
+wvb.config.jsonc
+```
+
+Both the `wvb.config` and `webview-bundle.config` base names support the `.js`, `.cjs`, `.mjs`, `.ts`, `.cts`, and `.mts` extensions. The `.json` and `.jsonc` extensions are supported for the `wvb.config` base name only. See [Configuration](/docs/config) for the full schema.
+
+## Commands
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/builtin.mdx b/content/docs/guide/commands/builtin.mdx
new file mode 100644
index 0000000..50d67eb
--- /dev/null
+++ b/content/docs/guide/commands/builtin.mdx
@@ -0,0 +1,79 @@
+---
+title: wvb builtin
+description: Install builtin webview bundles into your app from a remote or local target.
+---
+
+`wvb builtin` installs bundles into an output directory so your app can ship them as builtin fallbacks. When the app starts offline, or before it has fetched a newer bundle over the air, it serves these builtin bundles. The command pulls from the source defined by `builtin.target` in your [config](/docs/config), which defaults to a remote target.
+
+The command writes a `manifest.json` describing the installed set, plus one file per bundle laid out as `/_.wvb`. For how builtin and remote sources work together, see [Bundle sources](/docs/guide/bundle-sources).
+
+## Usage
+
+```sh
+# Install from the configured target into the default directory
+wvb builtin
+
+# Install from a remote endpoint into an explicit directory
+wvb builtin --endpoint https://updates.example.com --out .wvb/builtin/bundles
+
+# Filter which bundles get installed
+wvb builtin --include 'app*' --exclude 'internal*'
+
+# Install into the detected Android module
+wvb builtin --android
+
+# Dry run: report what would be installed without writing
+wvb builtin --no-write
+```
+
+## Options
+
+| Option | Aliases | Default | Description |
+| --------------- | ------- | ---------------------- | --------------------------------------------------------------------------------------- |
+| `--out` | `-O` | `.wvb/builtin/bundles` | Output directory. A mobile preset changes this to the platform module directory. |
+| `--endpoint` | `-E` | `remote.endpoint` | Remote endpoint to pull from. Remote target only. |
+| `--channel` | — | — | Release channel to install from. Remote target only. |
+| `--include` | — | — | Glob include patterns over the target bundles. Repeatable. |
+| `--exclude` | — | — | Glob exclude patterns over the target bundles. Repeatable. |
+| `--clean` | — | `true` | Clear the output directory before installing. Pass `--no-clean` to keep existing files. |
+| `--concurrency` | — | CPU count, capped at 8 | Number of parallel downloads. Remote target only. |
+| `--android` | — | — | Mobile preset. Bare auto-detects the app module; `--android=` sets it explicitly. |
+| `--ios` | — | — | Mobile preset. Bare auto-detects the project; `--ios=` sets it explicitly. |
+| `--write` | — | `true` | Write the installed files. Pass `--no-write` for a dry run. |
+| `--progress` | — | `true` | Show a download progress bar. Remote target only. |
+| `--config` | `-C` | (auto-discovery) | Path to the config file. |
+| `--cwd` | — | `process.cwd()` | Working directory for resolving paths. |
+
+Boolean flags accept `--flag`, `--flag=true|false`, and a `--no-flag` negation, so `--no-write` and `--no-clean` turn off the `--write` and `--clean` defaults.
+
+## Mobile presets
+
+`--android` and `--ios` install bundles into a platform project instead of a plain directory. Each can be passed bare to auto-detect the target, or as `--android=` / `--ios=` to point at an explicit module or project directory. The `--ios` preset adds a `folderReference` to `Project.swift`, which applies to Tuist projects.
+
+You cannot pass both `--android` and `--ios` in the same run.
+
+## Notes
+
+- The target type comes from `builtin.target` in your [config](/docs/config), a discriminated union of `remote` and `local`. It defaults to a remote target.
+- `--endpoint`, `--channel`, `--concurrency`, and `--progress` apply to the remote target only.
+- A successful run writes a `manifest.json` and one `/_.wvb` file per installed bundle.
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/deploy.mdx b/content/docs/guide/commands/deploy.mdx
new file mode 100644
index 0000000..67f74c4
--- /dev/null
+++ b/content/docs/guide/commands/deploy.mdx
@@ -0,0 +1,59 @@
+---
+title: wvb deploy
+description: Mark an already-uploaded bundle version as the current one that clients receive.
+---
+
+`wvb deploy` promotes a bundle version that is already on the remote to be the current version. Clients that download the bundle then receive that version. Uploading a bundle stores it on the remote but does not make it current — deploy is the step that flips the switch.
+
+Run it after [`wvb upload`](/docs/guide/commands/upload), or pass `--deploy` to `upload` to combine both steps in one command.
+
+## Usage
+
+```sh
+wvb deploy app --version 1.2.0
+wvb deploy app --version 1.2.0 --channel beta
+wvb deploy # uses config / package.json defaults
+```
+
+The bundle name is the positional `BUNDLE` argument and falls back to the resolved name from your config or `package.json`. The version is the `--version` (`-V`) flag — there is no positional version argument.
+
+## Options
+
+| Option | Aliases | Default | Description |
+| ----------- | ------- | -------------------------------- | -------------------------------------- |
+| `BUNDLE` | — | resolved from config | Bundle name to deploy. |
+| `--version` | `-V` | config or `package.json` version | Version to mark as current. |
+| `--channel` | — | — | Release channel to deploy to. |
+| `--config` | `-C` | config auto-discovery | Path to the config file. |
+| `--cwd` | — | `process.cwd()` | Working directory for resolving paths. |
+
+The global flags `--color`, `--log-level`, and `--log-verbose` also apply. See the [CLI overview](/docs/guide/cli) for details.
+
+## Requirements
+
+`deploy` requires `remote.deployer` in your [config](/docs/config/remote). The deployer is the component that records which version is current on the remote, so a remote without one cannot be deployed to.
+
+
+ The version is the `--version` (`-V`) flag, not a positional argument. If you omit it, deploy uses
+ the version resolved from your config or the nearest `package.json`.
+
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/download.mdx b/content/docs/guide/commands/download.mdx
new file mode 100644
index 0000000..862cd95
--- /dev/null
+++ b/content/docs/guide/commands/download.mdx
@@ -0,0 +1,67 @@
+---
+title: wvb download
+description: Download a bundle from a remote server to disk, or fetch and print its metadata.
+---
+
+`wvb download` fetches a bundle from a remote server and, by default, saves it to disk as a `.wvb` file. Pass a bundle name and an optional version; omit the version to download the version that is currently deployed. This is the read side of the publishing workflow that `wvb upload` and `wvb deploy` drive, and it is handy for verifying what a remote actually serves.
+
+## Usage
+
+```sh
+wvb download app --endpoint https://updates.example.com
+wvb download app 1.2.0 --out ./bundles/app.wvb --overwrite
+wvb download app --no-write
+```
+
+The first example downloads the current deployed version of `app` to `app.wvb` in the working directory. The second downloads a specific version (`1.2.0`) to an explicit path, overwriting any existing file. The third fetches the bundle and prints its information without writing anything to disk.
+
+The bundle name and the endpoint both fall back to your [`wvb.config`](/docs/config/remote) file, so inside a configured project you can usually run `wvb download` with no flags to pull the current version.
+
+## Options
+
+| Option | Aliases | Default | Description |
+| ------------- | ------- | ------------------- | ----------------------------------------------------------------------------- |
+| `BUNDLE` | — | from config | Bundle name (positional). Resolves from config when omitted. |
+| `VERSION` | — | current deployed | Specific version (positional). Omit to download the current deployed version. |
+| `--out` | `-O` | `.wvb` | Output file path. |
+| `--endpoint` | `-E` | `remote.endpoint` | Remote endpoint to download from. |
+| `--channel` | — | — | Release channel. |
+| `--write` | — | `true` | Write the bundle to disk. Pass `--no-write` to fetch and print info only. |
+| `--overwrite` | — | `false` | Overwrite an existing output file. |
+| `--progress` | — | `true` | Show a download progress bar. Pass `--no-progress` to disable. |
+| `--config` | `-C` | auto-discovery | Path to the config file. |
+| `--cwd` | — | `process.cwd()` | Working directory for resolving paths. |
+
+Boolean flags accept `--flag`, `--flag=true|false`, and a `--no-flag` negation. The three global flags (`--color`, `--log-level`, `--log-verbose`) apply here too; see the [CLI overview](/docs/guide/cli).
+
+
+ With a version positional, `wvb download` requests that exact version; without one, it downloads
+ the current deployed version for the given channel. Serving a non-current version requires the
+ remote to allow other versions.
+
+
+
+ `--no-write` fetches the bundle and prints its information without saving a file. Use it to
+ inspect what a remote serves without touching disk. To see metadata without downloading the bundle
+ body at all, use [`wvb remote current`](/docs/guide/commands/remote) instead.
+
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/extract.mdx b/content/docs/guide/commands/extract.mdx
new file mode 100644
index 0000000..e89ac6d
--- /dev/null
+++ b/content/docs/guide/commands/extract.mdx
@@ -0,0 +1,59 @@
+---
+title: wvb extract
+description: Unpack a .wvb archive's files back onto disk to inspect what a bundle contains.
+---
+
+`wvb extract` reads a `.wvb` archive and writes its files back onto disk. Use it to inspect what a bundle ships, diff two bundles, or recover the assets that went into one.
+
+## Usage
+
+Pass the bundle file to extract. The command writes the unpacked files under an output directory, defaulting to `.wvb/`.
+
+```sh
+wvb extract ./build/app.wvb
+wvb extract ./build/app.wvb --outdir ./unpacked
+wvb extract ./build/app.wvb --outdir ./unpacked --clean
+```
+
+Pass `--no-write` to run the extraction without touching disk, which is useful for verifying that a bundle reads cleanly:
+
+```sh
+wvb extract ./build/app.wvb --no-write
+```
+
+## Options
+
+| Option | Aliases | Default | Description |
+| ---------- | ------- | ------------------------------ | ---------------------------------------------------------------- |
+| `FILE` | — | required | Bundle file to extract. |
+| `--outdir` | `-O` | `.wvb/` | Destination directory for the unpacked files. |
+| `--clean` | — | `false` | Remove the output directory first if it exists. |
+| `--write` | — | `true` | Pass `--no-write` to simulate the extract without writing files. |
+| `--cwd` | — | `process.cwd()` | Working directory used to resolve paths. |
+
+
+ `wvb extract` has no `--config` flag. It reads only the bundle file you pass and the flags above;
+ it does not load a `wvb.config` file. It does accept `--cwd` to resolve relative paths.
+
+
+The global `--color`, `--log-level`, and `--log-verbose` flags apply here as they do on every command. Boolean flags accept `--flag`, `--flag=true|false`, and a `--no-flag` negation, so `--no-write` turns off the default `--write` behavior.
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/meta.json b/content/docs/guide/commands/meta.json
new file mode 100644
index 0000000..75ac8fb
--- /dev/null
+++ b/content/docs/guide/commands/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Commands",
+ "pages": ["pack", "extract", "serve", "upload", "deploy", "download", "builtin", "remote"]
+}
diff --git a/content/docs/guide/commands/pack.mdx b/content/docs/guide/commands/pack.mdx
new file mode 100644
index 0000000..2adb959
--- /dev/null
+++ b/content/docs/guide/commands/pack.mdx
@@ -0,0 +1,88 @@
+---
+title: wvb pack
+description: Pack a directory of built web assets into a single compressed, integrity-checked .wvb archive.
+---
+
+`wvb pack` packs a directory of built web assets (HTML, JS, CSS, media) into a single `.wvb` archive. The archive is compressed and checksummed, and it is the unit your app ships and serves to its webview, or uploads to a remote for over-the-air (OTA) updates.
+
+Most projects run `wvb pack` with no arguments. The source directory, output path, and other defaults come from the [`wvb.config`](/docs/config) file discovered in the working directory.
+
+## Usage
+
+```sh
+wvb pack [SRC_DIR]
+```
+
+Pack the default source directory, then pack an explicit directory to a chosen output path:
+
+```sh
+wvb pack # uses config defaults
+wvb pack ./dist
+wvb pack ./dist --outfile ./build/app.wvb
+```
+
+Exclude files with repeatable `--ignore` globs:
+
+```sh
+wvb pack ./dist --ignore '*.map' --ignore 'node_modules/**'
+```
+
+Attach response headers to matching files with `--header`, which takes three values per use:
+
+```sh
+wvb pack ./dist --header '*.html' 'cache-control' 'max-age=3600'
+```
+
+Do a dry run that reports what would be packed without writing the archive:
+
+```sh
+wvb pack ./dist --no-write
+```
+
+## Options
+
+| Option | Aliases | Default | Description |
+| ------------- | ------------------ | ------------------------- | --------------------------------------------------------------------------- |
+| `SRC_DIR` | — | `pack.srcDir` ?? `./dist` | Source directory to pack. |
+| `--outfile` | `--out-file`, `-O` | `.wvb/` | Output path. `.wvb` is appended if missing. |
+| `--ignore` | — | — | Glob of files to exclude. Repeatable. |
+| `--header` | `-H` | — | Set headers on matching files: `--header `. Repeatable. |
+| `--no-write` | — | writes by default | Run the pack without writing the archive (dry run). |
+| `--overwrite` | — | `true` | Overwrite an existing output file. |
+
+These flags also accept the common per-command `--config` (`-C`) and `--cwd` options. See the [CLI overview](/docs/guide/cli) for how config discovery and global flags work.
+
+
+`pack` has no `--outdir` flag. The output directory is determined by `--outfile`; the default resolves to `.wvb/`, where the name comes from the nearest `package.json` with its scope stripped.
+
+
+Boolean flags accept `--flag`, `--flag=true|false`, and a `--no-flag` negation. For example, `--no-write` turns off the default `--write` behavior, and `--no-overwrite` keeps an existing output file in place.
+
+## Configuration
+
+Set `pack` defaults in your [`wvb.config`](/docs/config) file so the command runs cleanly with no arguments. The config field for the output path is `outFile` (a single path; `.wvb` is appended automatically), and `overwrite` defaults to `true`. A flag passed on the command line always wins over the config value.
+
+## See also
+
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/remote.mdx b/content/docs/guide/commands/remote.mdx
new file mode 100644
index 0000000..1f60f10
--- /dev/null
+++ b/content/docs/guide/commands/remote.mdx
@@ -0,0 +1,100 @@
+---
+title: wvb remote
+description: Inspect and test a remote — show the current deployed bundle, list available bundles, and run a local update server.
+---
+
+The `wvb remote` command group inspects and tests a remote update server. Use it to see which version is currently deployed, list every bundle the remote knows about, and run a local server that mirrors the production HTTP contract so you can exercise the full update loop offline.
+
+It groups three subcommands:
+
+- [`wvb remote current`](#wvb-remote-current-bundle) — show the current deployed version and its metadata.
+- [`wvb remote list`](#wvb-remote-list) — list every bundle available on the remote (alias `wvb remote ls`).
+- [`wvb remote local`](#wvb-remote-local) — start a local update server backed by a directory.
+
+The first two read `remote.endpoint` from your [config](/docs/config/remote) by default, so you can drop the `--endpoint` flag once a remote is configured. For the publishing model and a local-testing walkthrough, see [Building a remote](/docs/guide/remote).
+
+## wvb remote current [BUNDLE]
+
+Show the current deployed version and its metadata for a bundle, without downloading the bundle itself. The output includes the version, ETag, integrity, signature, and last-modified values reported by the remote.
+
+```sh
+wvb remote current app --endpoint https://updates.example.com
+wvb remote current app --channel beta
+wvb remote current # uses config defaults
+```
+
+| Option | Aliases | Default | Description |
+| ------------ | ------- | --------------------- | -------------------------------------------- |
+| `BUNDLE` | — | resolved from config | Bundle name. Falls back to the package name. |
+| `--endpoint` | `-E` | `remote.endpoint` | Remote endpoint to query. |
+| `--channel` | — | — | Release channel. |
+| `--config` | `-C` | config auto-discovery | Path to the config file. |
+| `--cwd` | — | `process.cwd()` | Working directory for resolving paths. |
+
+## wvb remote list
+
+List every bundle available on the remote. The command also has the alias `wvb remote ls`. Output is JSON, so it pipes cleanly into other tools.
+
+```sh
+wvb remote list --endpoint https://updates.example.com
+wvb remote ls --channel beta
+wvb remote list | jq '.[].name'
+```
+
+| Option | Aliases | Default | Description |
+| ------------ | ------- | --------------------- | -------------------------------------- |
+| `--endpoint` | `-E` | `remote.endpoint` | Remote endpoint to query. |
+| `--channel` | — | — | Release channel. |
+| `--config` | `-C` | config auto-discovery | Path to the config file. |
+| `--cwd` | — | `process.cwd()` | Working directory for resolving paths. |
+
+## wvb remote local
+
+Start a local update server backed by a directory. It implements the same HTTP contract as a production remote, so you can test the full update loop offline before wiring in a hosted [provider](/docs/guide/providers/local). The server defaults to serving `~/.wvb/local` on port `4313`.
+
+```sh
+wvb remote local # http://localhost:4313, serving ~/.wvb/local
+wvb remote local --base-dir ./.wvb/local --port 4313
+wvb remote local --allow-other-versions --hostname 0.0.0.0
+```
+
+| Option | Aliases | Default | Env | Description |
+| ------------------------ | ------- | -------------- | ---------- | ------------------------------------------ |
+| `--base-dir` | — | `~/.wvb/local` | — | Directory to serve. |
+| `--allow-other-versions` | — | `false` | — | Allow serving versions other than current. |
+| `--hostname` | `-H` | `localhost` | `HOSTNAME` | Bind hostname. |
+| `--port` | `-P` | `4313` | `PORT` | Port to listen on. |
+| `--silent` | — | `false` | — | Disable request logging. |
+
+
+ `remote local` requires the optional `@wvb/remote-local-provider` package, which the command
+ imports on demand. Unlike the other subcommands, it does not read `--config` or `--cwd`. The
+ server stops cleanly on `SIGINT` or `SIGTERM`.
+
+
+By default the local server only serves the version marked current for each bundle. Pass `--allow-other-versions` to also serve specific older versions, which is useful for testing rollbacks and version pinning.
+
+## See also
+
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/serve.mdx b/content/docs/guide/commands/serve.mdx
new file mode 100644
index 0000000..689687e
--- /dev/null
+++ b/content/docs/guide/commands/serve.mdx
@@ -0,0 +1,65 @@
+---
+title: wvb serve
+description: Serve a single .wvb bundle's files over HTTP to preview a packed bundle in a browser.
+---
+
+`wvb serve` starts a local HTTP server that unpacks one `.wvb` bundle and serves its files, so you can open a packed bundle in a browser and check it before shipping. Directory paths resolve to `index.html`, matching how a webview loads the bundle at runtime.
+
+By default the server listens on `http://localhost:4312`. It handles `SIGINT` and `SIGTERM` for a graceful shutdown, so `Ctrl+C` stops it cleanly.
+
+
+ `wvb serve` previews the contents of a single bundle. To test the full over-the-air (OTA) update
+ loop against an HTTP remote, run a local update server with [`wvb remote
+ local`](/docs/guide/commands/remote) instead. See [Building a remote](/docs/guide/remote) for the
+ end-to-end walkthrough.
+
+
+## Usage
+
+```sh
+wvb serve # serve the bundle resolved from config
+wvb serve ./build/app.wvb # http://localhost:4312
+wvb serve ./build/app.wvb --port 8080 --hostname 0.0.0.0
+wvb serve ./build/app.wvb --silent # disable request logging
+```
+
+If you omit `FILE`, `wvb serve` falls back to `serve.file` in your [config](/docs/config), and then to the resolved pack output path. A typical project that has run `wvb pack` can preview with a bare `wvb serve`.
+
+## Options
+
+| Option | Aliases | Default | Description |
+| ------------ | ------- | --------------- | --------------------------------------------------------------------------- |
+| `FILE` | — | from config | Bundle to serve. Falls back to `serve.file`, then the resolved pack output. |
+| `--hostname` | `-H` | `localhost` | Bind hostname. Reads the `HOSTNAME` env var. |
+| `--port` | `-P` | `4312` | Port to listen on. Reads the `PORT` env var. Must be between 1 and 65535. |
+| `--silent` | — | `false` | Disable the request-logging middleware. |
+| `--config` | `-C` | — | Path to the config file. |
+| `--cwd` | — | `process.cwd()` | Working directory for resolving paths. |
+
+Boolean flags accept `--silent`, `--silent=true|false`, and the `--no-silent` negation. The global `--color`, `--log-level`, and `--log-verbose` flags apply here as well; see the [CLI overview](/docs/guide/cli).
+
+## Notes
+
+- The server resolves directory requests to `index.html`, so client-side routes that map to a directory load the bundle's entry document.
+- `--hostname 0.0.0.0` binds all interfaces, which is useful for previewing the bundle from another device on your network.
+- To call the same logic from JavaScript, use the `serve` function in the [programmatic API](/docs/guide/cli-programmatic).
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/guide/commands/upload.mdx b/content/docs/guide/commands/upload.mdx
new file mode 100644
index 0000000..4e4ab0f
--- /dev/null
+++ b/content/docs/guide/commands/upload.mdx
@@ -0,0 +1,74 @@
+---
+title: wvb upload
+description: Pack, hash, sign, and upload a bundle to the configured remote, with an optional deploy step.
+---
+
+`wvb upload` publishes a bundle to your remote server. By default it runs the full pipeline in order — pack, then integrity, then signature, then upload — so a single command turns your built assets into a signed, integrity-checked bundle on the remote. The upload step requires `remote.uploader` in your [config](/docs/config/remote); the optional deploy step at the end also requires `remote.deployer`.
+
+```sh
+wvb upload # uses config defaults
+wvb upload app --version 1.2.0
+wvb upload app --version 1.2.0 --deploy --channel beta
+wvb upload --no-pack --file ./build/app.wvb --force
+```
+
+## Options
+
+| Option | Aliases | Default | Description |
+| ------------------ | ------- | -------------------------------- | ------------------------------------------------------------------- |
+| `BUNDLE` | — | from config / `--file` name | Bundle name. Positional argument. |
+| `--version` | `-V` | config or `package.json` version | Version to publish. |
+| `--file` | `-F` | resolved output path | Path to the `.wvb` to upload. |
+| `--force` | — | `false` | Overwrite if the version already exists on the remote. |
+| `--deploy` | — | `false` | Deploy the version after uploading. |
+| `--channel` | — | — | Channel to deploy to. Used with `--deploy`. |
+| `--pack` | `-P` | `true` | Pack from `pack.srcDir` before uploading. Pass `--no-pack` to skip. |
+| `--skip-integrity` | — | `false` | Skip computing the integrity hash. |
+| `--skip-signature` | — | `false` | Skip signing the bundle. |
+| `--config` | `-C` | config auto-discovery | Path to the config file. |
+| `--cwd` | — | `process.cwd()` | Working directory for resolving paths. |
+
+
+ The version is the `--version` (`-V`) flag, not a positional argument. The first positional is the
+ bundle name. `--deploy` defaults to `false`, so an upload publishes the version without making it
+ current — clients keep receiving the previously deployed version until you deploy this one.
+
+
+## Pipeline
+
+`wvb upload` runs these stages in order:
+
+1. **Pack** — packs `pack.srcDir` into a `.wvb` archive. Skip with `--no-pack` and pass an existing bundle through `--file`.
+2. **Integrity** — computes the integrity hash. Skip with `--skip-integrity`.
+3. **Signature** — signs the bundle. Skip with `--skip-signature`.
+4. **Upload** — sends the bundle to the remote through `remote.uploader`.
+5. **Deploy** (optional) — runs only with `--deploy`, marking the uploaded version current through `remote.deployer`.
+
+On success the command prints the bundle endpoint.
+
+## Requirements
+
+- `remote.uploader` must be configured for the upload step. See [Remote, integrity & signature config](/docs/config/remote).
+- `remote.deployer` is additionally required when you pass `--deploy`. To deploy a version separately later, use [`wvb deploy`](/docs/guide/commands/deploy).
+
+For the publishing model and a local-testing walkthrough, see [Building a remote](/docs/guide/remote).
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/guide/index.mdx b/content/docs/guide/index.mdx
new file mode 100644
index 0000000..765b30e
--- /dev/null
+++ b/content/docs/guide/index.mdx
@@ -0,0 +1,205 @@
+---
+title: Introduction
+description: An offline-first web application deployment system for webview-based frameworks and platforms.
+---
+
+Webview Bundle (`wvb`) packs your built web assets (HTML/JS/CSS/media) into a single compressed,
+integrity-checked archive (`.wvb`). Your app ships with the archive and serves it to its webview
+through a custom URL scheme — so first paint never waits on the network. Configure a remote and the
+app downloads newer bundles over the air (OTA = over-the-air), shipping updated code without a
+native app-store release. One `.wvb` format runs on every webview platform via a shared Rust core.
+
+
+
+## Quickstart
+
+Install the CLI, pack your build output, and preview it locally.
+
+
+
+
+```sh
+npm install --save-dev @wvb/cli
+```
+
+
+
+
+```sh
+pnpm add -D @wvb/cli
+```
+
+
+
+
+```sh
+yarn add -D @wvb/cli
+```
+
+
+
+
+```sh
+# Pack ./dist into .wvb/ (override with --outfile,-O)
+npx wvb pack
+
+# Serve the bundle for local preview on http://localhost:4312
+npx wvb serve
+```
+
+Configure the build in `wvb.config.ts` with `defineConfig`:
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig({
+ pack: {
+ // `.wvb` is appended automatically -> .wvb/app.wvb
+ outFile: '.wvb/app',
+ },
+ serve: {
+ port: 4312,
+ },
+});
+```
+
+See the [CLI reference](/docs/guide/cli) and the [`wvb.config` reference](/docs/config) for every command and field.
+
+## Why Webview Bundle?
+
+- **Offline-first.** Resources are bundled locally, so the first paint never waits on the network.
+- **Over-the-air updates.** Ship a fix or feature by deploying a new bundle version — no native
+ release required.
+- **Integrity and authenticity.** Every bundle carries internal checksums, and downloads can be
+ verified with SHA-2 integrity hashes (`sha256`, `sha384`, `sha512`) and digital signatures
+ (ECDSA, Ed25519, RSA).
+- **One format, every platform.** The same `.wvb` archive runs on Electron, Tauri, Android, iOS,
+ and Deno Desktop through a shared Rust core.
+
+Enable integrity and signature verification on the remote in config:
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig({
+ remote: {
+ integrity: { algorithm: 'sha256' },
+ signature: {
+ algorithm: 'ed25519',
+ keyFormat: 'pkcs8',
+ // privateKey: ...
+ },
+ },
+});
+```
+
+See [Remote, integrity & signature config](/docs/config/remote) for the full schema.
+
+## Start here
+
+
+
+
+
+
+
+
+
+
+
+## Features
+
+
+
+
+
+
+
+
+
+## Packages
+
+The core is a Rust crate. Each platform consumes it through a thin integration package.
+
+| Package | Version | What it is |
+| ---------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
+| [`wvb`](https://docs.rs/wvb) | 0.2.0 | Rust core: bundle format, source, remote, updater, protocol, integrity, signature |
+| `@wvb/cli` | 0.1.0 | Command-line tool (bins `wvb` and `webview-bundle`): pack, serve, upload, deploy, local remote |
+| `@wvb/config` | 0.1.0 | `defineConfig` for `wvb.config.ts` |
+| `@wvb/node` | 0.1.0 | N-API bindings to the core for Node.js |
+| `@wvb/bridge` | 0.1.0 | Bridge for the web app to talk to the native host |
+| `@wvb/electron` | 0.1.0 | Electron integration (protocols, IPC, updater) |
+| `@wvb/electron-builder` | Stable | electron-builder integration |
+| `@wvb/electron-forge` | Stable | Electron Forge plugin |
+| `wvb-tauri` | 0.1.0 | Tauri integration, published as a Rust crate on crates.io |
+| `@wvb/remote-aws` | 0.1.0 | Remote configuration and provider for AWS |
+| `@wvb/remote-cloudflare` | 0.1.0 | Remote configuration and provider for Cloudflare |
+| `@wvb/remote-local` | 0.0.0 | Remote configuration for local simulation |
+
+Install a platform integration alongside the CLI:
+
+```sh
+# Electron
+npm install @wvb/electron
+
+# Tauri (Rust crate)
+cargo add wvb-tauri
+```
+
+
+ The Tauri integration is the **`wvb-tauri` crate** on crates.io — there is no `@wvb/tauri` npm
+ package. See [Platform Support](/docs/guide/platform-support) for the full status.
+
+
+## Next steps
+
+Read [the `.wvb` bundle format](/docs/guide/bundle-format) to understand what ships inside an
+archive, or browse the [Rust crate docs](https://docs.rs/wvb) for the full API.
diff --git a/content/docs/guide/meta.json b/content/docs/guide/meta.json
new file mode 100644
index 0000000..913b498
--- /dev/null
+++ b/content/docs/guide/meta.json
@@ -0,0 +1,30 @@
+{
+ "root": true,
+ "title": "Guide",
+ "pages": [
+ "---Introduction---",
+ "index",
+ "platform-support",
+ "why-webview-bundle",
+ "---Features---",
+ "bundle-format",
+ "bundle-sources",
+ "protocol-handling",
+ "remote-bundles",
+ "platform-integration",
+ "---Platforms---",
+ "platforms/electron",
+ "platforms/tauri",
+ "platforms/android",
+ "platforms/ios",
+ "platforms/deno",
+ "---CLI---",
+ "cli",
+ "commands",
+ "cli-programmatic",
+ "---Remote---",
+ "remote",
+ "providers",
+ "spec"
+ ]
+}
diff --git a/content/docs/guide/platform-integration.mdx b/content/docs/guide/platform-integration.mdx
new file mode 100644
index 0000000..845b204
--- /dev/null
+++ b/content/docs/guide/platform-integration.mdx
@@ -0,0 +1,296 @@
+---
+title: Platform integration
+description: How one Rust core reaches Electron, Tauri, Android, iOS, and Deno through thin bindings and the @wvb/bridge web layer.
+---
+
+Webview Bundle is one system, not five. The format, bundle source, protocol handlers, remote client, updater, and integrity and signature checks all live in a single Rust crate. Every platform consumes that same crate through a thin binding, so a `.wvb` built for Electron behaves identically on Tauri, Android, iOS, and Deno. Read this before the individual platform guides — they assume you know which package belongs where.
+
+## One core, many hosts
+
+The Rust crate `wvb` ([crates.io](https://crates.io/crates/wvb), version 0.2.0) is the single source of truth. It implements:
+
+- the `.wvb` bundle format (header, index, LZ4-compressed data),
+- the bundle source (builtin and remote directories),
+- the bundle and local protocol handlers,
+- the remote HTTP client,
+- the updater (check, download, verify, install),
+- integrity (SHA-2) and signature verification.
+
+Each binding is intentionally thin: it exposes the core to the host language, registers a URL scheme with that host, and forwards requests into the core. A core fix or feature reaches every platform on the next binding release. Browse the full API on [docs.rs/wvb](https://docs.rs/wvb).
+
+
+ The Rust core never registers or validates a URL scheme. Picking a scheme such as `app://` or
+ `bundle://` and registering it with the OS is the binding's job. See [Protocol
+ handling](/docs/guide/protocol-handling) for how a request maps to a file.
+
+
+## Delivery mechanisms
+
+Each platform reaches the core through a different distribution channel.
+
+| Platform | Binding | How it is shipped |
+| ------------------------ | ----------------------- | ------------------------------------------------------------------- |
+| Electron / Node.js | `@wvb/node` | N-API native addon (NAPI-RS), prebuilt per-platform binaries on npm |
+| Tauri (desktop + mobile) | `wvb-tauri` Rust crate | Tauri v2 plugin on crates.io, version 0.1.0 |
+| Android | `packages/ffi` (UniFFI) | Kotlin bindings, consumed by the `webview-bundle-android` repo |
+| iOS | `packages/ffi` (UniFFI) | Swift bindings, consumed by the `webview-bundle-ios` repo |
+| Deno Desktop | `@wvb/deno` | Deno FFI over a prebuilt dylib (experimental) |
+
+### Electron and Node.js
+
+`@wvb/node` (0.1.0) wraps the core as an N-API native addon with prebuilt binaries — install it like any npm dependency.
+
+```sh
+npm install @wvb/node
+```
+
+```ts title="main.ts"
+import { readBundle, BundleSource } from '@wvb/node';
+
+const source = new BundleSource({
+ builtinDir: './bundles',
+ remoteDir: './remote',
+});
+const bundle = await readBundle('./app.wvb');
+```
+
+For Electron, use `@wvb/electron` (0.1.0) — it builds on `@wvb/node` and wires scheme privileges, protocol registration, and the IPC channel for you.
+
+```sh
+npm install @wvb/electron
+```
+
+```js title="main.cjs"
+const { app, BrowserWindow } = require('electron');
+const { bundleProtocol, wvb } = require('@wvb/electron');
+
+const instance = wvb({
+ source: { builtinDir: path.join(__dirname, 'bundles') },
+ protocols: [bundleProtocol('app', { onError: e => console.error('[wvb]', e) })],
+});
+
+app.whenReady().then(async () => {
+ await instance.whenProtocolRegistered();
+ const window = new BrowserWindow({
+ webPreferences: { contextIsolation: true, nodeIntegration: false },
+ });
+ await window.loadURL('app://hacker-news.wvb');
+});
+```
+
+The electron-builder and Electron Forge packaging helpers, `@wvb/electron-builder` and `@wvb/electron-forge`, exist in the repository but are not published to npm yet. See the [Electron guide](/docs/guide/platforms/electron) and the [Node API reference](/docs/references/node).
+
+### Tauri
+
+The Tauri integration is the Rust crate `wvb-tauri` (0.1.0 on crates.io) — there is no `@wvb/tauri` npm package. Add the crate and register it as a Tauri v2 plugin.
+
+```toml title="src-tauri/Cargo.toml"
+[dependencies]
+wvb-tauri = "0.1.0"
+```
+
+```rust title="src-tauri/src/lib.rs"
+use wvb_tauri::{Config, Protocol, Source};
+
+tauri::Builder::default()
+ .plugin(wvb_tauri::init(
+ Config::new()
+ .source(Source::new().builtin_dir("bundles"))
+ .protocol(Protocol::bundle("bundle")),
+ ))
+ .run(tauri::generate_context!())
+ .unwrap();
+```
+
+A frontend that calls plugin commands must grant the ACL permission set in a capability file.
+
+```json title="src-tauri/capabilities/default.json"
+{
+ "permissions": ["core:default", "wvb-tauri:default"]
+}
+```
+
+The same crate handles desktop and mobile. On Android, builtin bundles ship inside the APK as `asset://` resources, so an app that ships builtin bundles must also register `tauri_plugin_fs`.
+
+```rust
+tauri::Builder::default()
+ .plugin(tauri_plugin_fs::init()) // required on Android for builtin bundles
+ .plugin(wvb_tauri::init(config))
+```
+
+See the [Tauri guide](/docs/guide/platforms/tauri).
+
+### Android and iOS
+
+Android and iOS share one binding generator: `packages/ffi`, a Rust crate that uses [UniFFI](https://mozilla.github.io/uniffi-rs/) to generate Kotlin and Swift bindings from the core. The build produces three GitHub release assets:
+
+- `android.zip` — Kotlin bindings and `jniLibs` for Android,
+- `apple.zip` — Swift bindings and static libraries for Apple platforms,
+- `WebViewBundleFFI.xcframework.zip` — the xcframework for iOS.
+
+Two dedicated repositories consume these assets and resolve them by release tag `ffi/`.
+
+```kotlin title="Android — dev.wvb bindings"
+import dev.wvb.BundleSource
+import dev.wvb.BundleSourceConfig
+
+val source = BundleSource(
+ BundleSourceConfig(
+ builtinDir = "$filesDir/bundles",
+ remoteDir = "$cacheDir/remote",
+ builtinManifestFilepath = null,
+ remoteManifestFilepath = null,
+ ),
+)
+val bundle = source.fetchBundle("app")
+```
+
+```swift title="iOS — WebViewBundleLibrary bindings"
+import WebViewBundleLibrary
+
+let source = BundleSource(
+ config: BundleSourceConfig(
+ builtinDir: "\(docs)/bundles",
+ remoteDir: "\(caches)/remote",
+ builtinManifestFilepath: nil,
+ remoteManifestFilepath: nil
+ )
+)
+let bundle = try await source.fetchBundle(bundleName: "app")
+```
+
+```swift title="Package.swift"
+.binaryTarget(
+ name: "WebViewBundleFFI",
+ url: "https://github.com/webview-bundle/webview-bundle/releases/download/ffi/0.1.0/WebViewBundleFFI.xcframework.zip",
+ checksum: ""
+)
+```
+
+### Deno
+
+`@wvb/deno` reaches the core through Deno FFI over a prebuilt dynamic library. Install the dylib from GitHub Releases, then load it.
+
+```sh
+deno run -A jsr:@wvb/deno/install --out vendor/wvb
+```
+
+It is experimental: the binding source lives on an unmerged branch, and `main` ships only a prebuilt dylib. Treat it as a preview, not a production target. See the [Deno guide](/docs/guide/platforms/deno) and the [Deno API reference](/docs/references/deno).
+
+## The bridge
+
+The bindings above run on the native side. The bridge, `@wvb/bridge` (0.1.0), is the web side. It lets JavaScript inside the webview call native source, remote, and updater operations through one uniform API, regardless of host.
+
+```sh
+npm install @wvb/bridge
+```
+
+The single entry point is `invoke()`:
+
+```ts
+import { invoke } from '@wvb/bridge';
+
+const update = await invoke('updaterGetUpdate', { bundleName: 'app' });
+```
+
+You rarely call `invoke()` by name. The bridge groups native commands into three typed objects — `source.*`, `remote.*`, and `updater.*` — each method a typed `invoke()` call:
+
+```ts
+import { source, remote, updater } from '@wvb/bridge';
+
+const bundles = await source.listBundles();
+const info = await updater.getUpdate('app');
+if (info.isAvailable) {
+ await updater.download('app');
+ await updater.install('app', info.version);
+}
+```
+
+- `source.*` — list bundles, resolve filepaths, read metadata, manage downloaded versions.
+- `remote.*` — list, inspect, and download bundles from a remote server.
+- `updater.*` — check for an update, download it, and install it.
+
+The bridge detects the host at runtime and routes each call over the right transport:
+
+| Platform | Transport |
+| -------- | ------------------------------------------------------- |
+| Electron | `window.wvbElectron.invoke(name, params)` |
+| Tauri | Tauri `invoke('plugin:wvb-tauri\|', params)` |
+| Android | `window.wvbAndroid.postMessage(...)` |
+| iOS | `window.webkit.messageHandlers.wvbIos.postMessage(...)` |
+
+Your code uses the same `source`, `remote`, and `updater` methods everywhere. The shipping `@wvb/bridge` 0.1.0 supports electron, tauri, android, and ios. The Deno platform exists only on the experimental Deno branch and is not part of the published bridge.
+
+Test webview code without a native host by importing `@wvb/bridge/testing`:
+
+```ts title="app.test.ts"
+import { mockBridge } from '@wvb/bridge/testing';
+import { updater } from '@wvb/bridge';
+
+using bridge = mockBridge({ platform: 'electron' });
+bridge.mockInvoke('updater.getUpdate', () => ({
+ name: 'app',
+ version: '1.2.0',
+ isAvailable: true,
+}));
+
+const info = await updater.getUpdate('app');
+```
+
+
+ `@wvb/bridge/testing` provides `mockInvoke`, `mockPlatform`, and `mockBridge` so you can stub
+ command responses in unit tests.
+
+
+## Architecture at a glance
+
+A request from your web app flows down through the bridge to the native host, into the core, and out to a bundle source or a remote server.
+
+
+
+The webview also loads its assets through the same core: the bundle protocol serves files straight out of the source, so the page bytes and the `invoke()` commands share one bundle source.
+
+## Where to go next
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/docs/guide/platform-support.mdx b/content/docs/guide/platform-support.mdx
new file mode 100644
index 0000000..310eecf
--- /dev/null
+++ b/content/docs/guide/platform-support.mdx
@@ -0,0 +1,155 @@
+---
+title: Platform Support
+description: Which platforms run Webview Bundle and the package to install for each.
+---
+
+One shared Rust core powers every integration, so a `.wvb` you pack once loads identically on Electron, Tauri, Android, iOS, and Deno Desktop. For how the core reaches each host and how the webview talks back through the bridge, see [Platform integration](/docs/guide/platform-integration).
+
+This page is the support matrix: the package or crate per platform, its minimum host version, and its status.
+
+## Support matrix
+
+| Platform | Webview host | Package / crate | Min version | Status |
+| -------------------------------------------- | -------------- | --------------------------------- | ------------------------------------------------------------------------------- | ------------ |
+| [Electron](/docs/guide/platforms/electron) | Chromium | `@wvb/electron` 0.1.0 (npm) | `electron >= 15` | Stable |
+| [Tauri desktop](/docs/guide/platforms/tauri) | System WebView | `wvb-tauri` 0.1.0 (crate) | Tauri v2 | Stable |
+| Tauri mobile | System WebView | `wvb-tauri` 0.1.0 (crate) | See [Android](/docs/guide/platforms/android) / [iOS](/docs/guide/platforms/ios) | Stable |
+| [Android](/docs/guide/platforms/android) | System WebView | `webview-bundle-android` (Kotlin) | minSdk 24 / Android 7.0 | Stable |
+| [iOS](/docs/guide/platforms/ios) | WKWebView | `webview-bundle-ios` (Swift) | iOS 16 / macOS 12 | Stable |
+| [Deno Desktop](/docs/guide/platforms/deno) | Deno webview | `@wvb/deno-desktop` (JSR) | — | Experimental |
+
+## Desktop
+
+Electron and Tauri desktop are the most mature integrations. Both are published and both serve bundles through a caller-supplied URL scheme backed by the Rust core.
+
+### Electron
+
+Install the npm package:
+
+```sh
+npm install @wvb/electron
+```
+
+Register a protocol and load a bundle by name (`://.wvb`):
+
+```ts title="main.ts"
+import { app, BrowserWindow } from 'electron';
+import { bundleProtocol, wvb } from '@wvb/electron';
+
+const instance = wvb({
+ protocols: [bundleProtocol('app', { onError: e => console.error('[wvb]', e) })],
+});
+
+app.whenReady().then(async () => {
+ await instance.whenProtocolRegistered();
+ const window = new BrowserWindow({
+ webPreferences: { contextIsolation: true, nodeIntegration: false },
+ });
+ await window.loadURL('app://hacker-news.wvb');
+});
+```
+
+Start with the [Electron guide](/docs/guide/platforms/electron).
+
+### Tauri
+
+The Tauri integration is the `wvb-tauri` Rust crate on crates.io targeting **Tauri v2** — there is no `@wvb/tauri` npm package. The same crate also drives Tauri's mobile targets.
+
+```toml title="src-tauri/Cargo.toml"
+[dependencies]
+wvb-tauri = "0.1.0"
+```
+
+```rust title="src-tauri/src/lib.rs"
+use wvb_tauri::{Config, Protocol, Source};
+
+tauri::Builder::default()
+ .plugin(wvb_tauri::init(
+ Config::new()
+ .source(Source::new())
+ .protocol(Protocol::bundle("bundle")),
+ ))
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+```
+
+The window then loads `bundle://hacker-news.wvb`. Start with the [Tauri guide](/docs/guide/platforms/tauri).
+
+## Mobile
+
+Android and iOS are real, functional integrations.
+
+### Android
+
+The `webview-bundle-android` Kotlin library (namespace `dev.wvb`) requires **minSdk 24 (Android 7.0)** and serves bundles through a `WebViewClient`. Pull the native bindings, then install on a `WebView`:
+
+```sh
+node scripts/install.mjs
+```
+
+```kotlin title="MainActivity.kt"
+val wvb = WebViewBundle.getInstance(
+ this,
+ WebViewBundleConfig(protocols = listOf(WebViewBundleProtocol.bundle())),
+)
+val webView = WebView(this)
+webView.settings.javaScriptEnabled = true
+wvb.install(webView)
+webView.loadUrl("https://app.wvb/")
+```
+
+Bundles are served over ordinary `https://.wvb/...` URLs (the bundle name is the first host label). See the [Android guide](/docs/guide/platforms/android).
+
+### iOS
+
+The `webview-bundle-ios` Swift package targets **iOS 16** and **macOS 12** and serves bundles through a `WKURLSchemeHandler`. Install a release of the native xcframework, then register a custom scheme on `WKWebViewConfiguration`:
+
+```sh
+node scripts/install.mjs ffi/0.1.0
+```
+
+```swift title="ContentView.swift"
+let instance = try WebViewBundle.configure(
+ WebViewBundleConfig(protocols: [.bundle(scheme: "app")])
+)
+let config = WKWebViewConfiguration()
+instance.install(on: config)
+let webView = WKWebView(frame: .zero, configuration: config)
+webView.load(URLRequest(url: URL(string: "app://app.wvb")!))
+```
+
+See the [iOS guide](/docs/guide/platforms/ios).
+
+## Deno Desktop
+
+Deno Desktop is the newest integration, on JSR as `@wvb/deno-desktop`. It builds a bundle source and exposes a `Deno.serve`-compatible handler, with one protocol per window.
+
+
+ Deno Desktop is experimental and its API may change before a stable release. See the [Deno Desktop
+ guide](/docs/guide/platforms/deno) and the [Deno API reference](/docs/references/deno).
+
+
+## Next steps
+
+
+
+
+
+
+
diff --git a/content/docs/guide/platforms/android.mdx b/content/docs/guide/platforms/android.mdx
new file mode 100644
index 0000000..b977b65
--- /dev/null
+++ b/content/docs/guide/platforms/android.mdx
@@ -0,0 +1,337 @@
+---
+title: Android
+description: Serve and update Webview Bundles inside an Android WebView with the webview-bundle-android Kotlin library.
+---
+
+The `webview-bundle-android` library wires the Webview Bundle Rust core into an Android `WebView`. Give it a `WebView`; it intercepts requests over ordinary `https://.wvb/` URLs, serves files from a bundle you ship in the APK, and pulls newer bundles over the air (OTA) from a remote without an app-store release.
+
+## Requirements
+
+| Item | Value |
+| ----------------- | ------------------------------------------------------------------ |
+| `minSdk` | 24 (Android 7.0) |
+| JVM target | 17 (Java and Kotlin source/target 17) |
+| AndroidX | Required (`android.useAndroidX=true`, needed by `androidx.webkit`) |
+| System WebView | Must support `WEB_MESSAGE_LISTENER` (modern WebView / Chrome 88+) |
+| Library namespace | `dev.wvb` |
+
+The bridge attaches through `WebViewCompat.addWebMessageListener`. On a device whose System WebView is too old for `WEB_MESSAGE_LISTENER`, the bridge is not attached and the library logs a warning — serving still works, only the JavaScript bridge is unavailable.
+
+Runtime dependencies are pulled in transitively: JNA (`net.java.dev.jna:jna`, loads the native `libwvb_ffi.so`), `kotlinx-coroutines-core`, and `androidx.webkit`. Consumer R8/ProGuard rules ship with the library, so you do not add keep rules for the FFI yourself.
+
+Add the dependency:
+
+```kotlin title="build.gradle.kts"
+dependencies {
+ implementation("dev.wvb:webview-bundle-android:")
+}
+```
+
+
+ See [Platform support](/docs/guide/platform-support) for the status of every platform.
+
+
+## Quick start
+
+Obtain the process-wide `WebViewBundle` singleton with `WebViewBundle.getInstance`, install it onto your `WebView`, then load a bundle URL. The bundle name is the first label of the host, so `https://app.wvb/` serves the bundle named `app`.
+
+```kotlin title="MainActivity.kt"
+import android.os.Bundle
+import android.webkit.WebSettings
+import android.webkit.WebView
+import androidx.appcompat.app.AppCompatActivity
+import dev.wvb.WebViewBundle
+import dev.wvb.WebViewBundleConfig
+import dev.wvb.WebViewBundleProtocol
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var webView: WebView
+ private lateinit var handle: AutoCloseable
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val wvb = WebViewBundle.getInstance(
+ this,
+ WebViewBundleConfig(
+ protocols = listOf(WebViewBundleProtocol.bundle()),
+ ),
+ )
+
+ webView = WebView(this)
+ webView.settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ allowFileAccess = false
+ allowContentAccess = false
+ }
+
+ handle = wvb.install(webView) { }
+ webView.loadUrl("https://app.wvb/")
+ setContentView(webView)
+ }
+
+ override fun onDestroy() {
+ handle.close()
+ webView.destroy()
+ super.onDestroy()
+ }
+}
+```
+
+`getInstance` honors `config` only on the first call per process; later calls return the existing instance and ignore the passed config. `getInstance` also has shorthand aliases:
+
+```kotlin
+WebViewBundle.getInstance(context, config) // canonical
+WebViewBundle(context, config) // invoke operator
+webViewBundle(context, config) // top-level fun
+wvb(context, config) // short alias
+```
+
+The manifest needs the internet permission. For local development against a cleartext dev server or remote, also enable `usesCleartextTraffic`.
+
+```xml title="AndroidManifest.xml"
+
+
+
+
+
+```
+
+
+ `usesCleartextTraffic="true"` is for local development only. Production bundles are served over
+ `https://` from inside the app and remote endpoints should use TLS, so leave cleartext disabled in
+ release builds.
+
+
+## How serving works
+
+No custom URL scheme is registered. `install()` sets a `WebViewClient` whose `shouldInterceptRequest` inspects ordinary `http`/`https` requests and serves matching ones from the bundle source. For each request, the handler lowercases the host and walks your registered protocols in order; the first matcher to accept the host wins. The bundle protocol takes the bundle name from the first host label and returns the file at the request path. No match returns `null` and the `WebView` loads from the network.
+
+If a handler throws, the library synthesizes a `500 text/plain` response and calls the optional `onError` callback. See [Protocol handling](/docs/guide/protocol-handling) for how a request path maps to a file inside a `.wvb`.
+
+`install()` also attaches the native bridge as `window.wvbAndroid` on the main frame. Use the install options to register extra native handlers, wrap your own `WebViewClient`, or skip the bridge:
+
+```kotlin
+val handle = wvb.install(webView) {
+ delegate = myWebViewClient // your callbacks are preserved
+ disableBridge = false // set true to skip window.wvbAndroid
+ bridge = {
+ handler("greet") { params -> "hello" }
+ }
+}
+```
+
+If you want the serving seam without the bridge at all, use the low-level client directly:
+
+```kotlin
+webView.webViewClient = wvb.createWebViewClient(delegate = myWebViewClient)
+```
+
+## Builtin bundles
+
+Ship the bundle your app starts with inside the APK under `assets/bundles/` — a `manifest.json` plus the `.wvb` files it references.
+
+```text
+app/src/main/assets/bundles/
+├── manifest.json
+└── app/
+ └── app_0.1.0.wvb
+```
+
+The native source reads files, not asset streams, so the library copies `assets/bundles/` into the app's `filesDir`. This is controlled by `SourceOptions`:
+
+```kotlin
+import dev.wvb.SourceOptions
+
+WebViewBundleConfig(
+ protocols = listOf(WebViewBundleProtocol.bundle()),
+ source = SourceOptions(
+ builtinAssetsDir = "bundles", // APK assets/; null disables extraction
+ // builtinDir defaults to /wvb/builtin (read-only)
+ // remoteDir defaults to /wvb/remote (writable)
+ ),
+)
+```
+
+Re-extraction happens on each APK install or update and is additive — removed assets are not deleted. The `manifest.json` declares the bundle entries and current version, and may carry `integrity` and `signature` values:
+
+```json title="manifest.json"
+{
+ "manifestVersion": 1,
+ "bundles": {
+ "app": {
+ "currentVersion": "0.1.0",
+ "versions": {
+ "0.1.0": {
+ "integrity": "sha256:n4bQgYhMfWWaL...",
+ "signature": "..."
+ }
+ }
+ }
+ }
+}
+```
+
+See [Bundle sources](/docs/guide/bundle-sources) for the manifest format.
+
+## Protocols
+
+A protocol decides which hosts a `WebView` request is served from. Register protocols in `WebViewBundleConfig.protocols`; they are evaluated in order and the first matching one wins, so **register `bundle()` last** because it matches every host.
+
+
+
+
+`WebViewBundleProtocol.bundle()` serves entries from the bundle source for every host, using the first host label as the bundle name. Pass a passthrough block to send named hosts to the network instead:
+
+```kotlin
+WebViewBundleProtocol.bundle {
+ passthrough("api.example.com")
+ passthroughDomain("analytics.example.com")
+ passthrough { host -> host.endsWith(".cdn.example.com") }
+}
+```
+
+
+
+
+`WebViewBundleProtocol.local(hosts)` is a development proxy. It maps a full request host to a local base URL so you keep your bundler's hot reload while developing. On the Android emulator, `10.0.2.2` reaches the host machine's loopback:
+
+```kotlin
+WebViewBundleProtocol.local(
+ mapOf("app.wvb" to "http://10.0.2.2:3000"),
+)
+```
+
+
+
+
+A typical dev configuration registers `local()` for the hosts you proxy and `bundle()` last as the fallback:
+
+```kotlin
+WebViewBundleConfig(
+ protocols = listOf(
+ WebViewBundleProtocol.local(mapOf("app.wvb" to "http://10.0.2.2:3000")),
+ WebViewBundleProtocol.bundle(),
+ ),
+)
+```
+
+## OTA updates
+
+Pass a `WebViewBundleUpdaterConfig` as `WebViewBundleConfig.updater`. The library then builds a remote client and an updater, exposed as `wvb.remote` and `wvb.updater` (both `null` when no updater config is provided).
+
+```kotlin title="MainActivity.kt"
+import android.util.Base64
+import dev.wvb.IntegrityPolicy
+import dev.wvb.SignatureAlgorithm
+import dev.wvb.SignatureVerifierOptions
+import dev.wvb.SignatureVerifyingKey
+import dev.wvb.VerifyingKeyFormat
+import dev.wvb.WebViewBundleConfig
+import dev.wvb.WebViewBundleProtocol
+import dev.wvb.WebViewBundleRemoteConfig
+import dev.wvb.WebViewBundleUpdaterConfig
+
+val wvb = WebViewBundle.getInstance(
+ this,
+ WebViewBundleConfig(
+ protocols = listOf(WebViewBundleProtocol.bundle()),
+ updater = WebViewBundleUpdaterConfig(
+ remote = WebViewBundleRemoteConfig(endpoint = "http://10.0.2.2:4313"),
+ channel = "stable",
+ integrityPolicy = IntegrityPolicy.STRICT,
+ signatureVerifier = SignatureVerifierOptions(
+ algorithm = SignatureAlgorithm.ED25519,
+ key = SignatureVerifyingKey(
+ format = VerifyingKeyFormat.SPKI_DER,
+ pem = null,
+ der = Base64.decode("MCowBQYDK2VwAyEA...", Base64.NO_WRAP),
+ ),
+ ),
+ ),
+ onError = { error -> /* log */ },
+ ),
+)
+```
+
+`IntegrityPolicy.STRICT` rejects a bundle whose digest does not match; `OPTIONAL` verifies when present and skips when absent; `NONE` skips the check. The integrity string uses SHA-2 (`sha256`, `sha384`, or `sha512`). The signature verifier proves who published the bundle. Not every algorithm and key-format pair is valid:
+
+| `SignatureAlgorithm` | Valid `VerifyingKeyFormat` |
+| ------------------------------------ | ----------------------------------------------------- |
+| `ECDSA_SECP256R1`, `ECDSA_SECP384R1` | `SEC1`, `SPKI_DER`, `SPKI_PEM` |
+| `ED25519` | `SPKI_DER`, `SPKI_PEM`, `RAW` (32-byte key via `der`) |
+| `RSA_PKCS1_V15`, `RSA_PSS` | `PKCS1_DER`, `PKCS1_PEM`, `SPKI_DER`, `SPKI_PEM` |
+
+An unsupported pair throws `Exception.Signature` when the updater is built. `pem` is read for `*_PEM` formats; `der` for DER, `SEC1`, and `RAW`.
+
+
+ On the Android emulator, a remote running on the host machine is reachable at
+ `http://10.0.2.2:4313` (the local remote's default port). The `channel` value is sent to the
+ remote as a query parameter so it can serve a specific release channel.
+
+
+### Driving updates from Kotlin
+
+The updater runs a three-step cycle. Each call is a `suspend` function, so call it from a coroutine.
+
+```kotlin
+import kotlinx.coroutines.launch
+
+lifecycleScope.launch {
+ val update = wvb.updater?.getUpdate("app") // check the remote, no download
+ if (update != null && update.isAvailable) {
+ wvb.updater?.downloadUpdate("app") // download latest, persist to remote dir
+ wvb.updater?.install("app", update.version) // verify, activate, prune old versions
+ }
+}
+```
+
+`getUpdate` reports whether a newer version exists without downloading. `downloadUpdate` fetches and stores the bundle (the latest version when you pass no version). `install` verifies integrity and signature on the staged bundle, makes it current, and prunes stale versions.
+
+### Driving updates from web JavaScript
+
+The same cycle is available to your web app through the `window.wvbAndroid` bridge. The built-in updater commands are `updaterGetUpdate`, `updaterDownload`, and `updaterInstall`; they throw `updater_not_initialized` when no updater config was provided.
+
+```ts
+declare const wvbAndroid: {
+ postMessage(message: string): void;
+};
+
+// The bridge posts { name, params, success, error } and replies via callbacks.
+// A small wrapper that resolves a Promise per command:
+function invoke(name: string, params?: unknown): Promise {
+ return new Promise((resolve, reject) => {
+ // ... wire success/error callbacks, then:
+ wvbAndroid.postMessage(JSON.stringify({ name, params }));
+ });
+}
+
+const update = await invoke('updaterGetUpdate', { name: 'app' });
+await invoke('updaterDownload', { name: 'app' });
+await invoke('updaterInstall', { name: 'app', version: '0.2.0' });
+```
+
+For the remote HTTP contract, integrity, and signatures across platforms, see [Remote bundles](/docs/guide/remote-bundles) and the guide to [building a remote](/docs/guide/remote).
+
+## Next steps
+
+
+
+
+
+
diff --git a/content/docs/guide/platforms/deno.mdx b/content/docs/guide/platforms/deno.mdx
new file mode 100644
index 0000000..32091bd
--- /dev/null
+++ b/content/docs/guide/platforms/deno.mdx
@@ -0,0 +1,212 @@
+---
+title: Deno Desktop
+description: Serve and update Webview Bundle archives in a Deno desktop webview through a Deno.serve request handler.
+---
+
+Experimental
+
+Deno Desktop renders a native webview with `Deno.BrowserWindow` and serves it over `Deno.serve`. Webview Bundle becomes that server's handler, so the window loads your packed `.wvb` assets offline instead of fetching them over the network.
+
+```ts title="main.ts"
+import { webviewBundle, bundleProtocol } from '@wvb/deno-desktop';
+
+const wvb = await webviewBundle({
+ protocols: [bundleProtocol({ scheme: 'app' })],
+});
+
+const server = Deno.serve(wvb.fetch);
+const win = new Deno.BrowserWindow({ url: `http://localhost:${server.addr.port}` });
+await win.closed;
+```
+
+`@wvb/deno-desktop` is the integration layer; `@wvb/deno` is the FFI peer of [`@wvb/node`](/docs/references/node) that drives the shared Rust core.
+
+
+ Deno Desktop is experimental and its API may change before a stable release.
+
+
+## How it fits together
+
+The window points at a single local origin served by `Deno.serve`, so the integration is **single-origin**: it allows exactly one protocol. See [Platform integration](/docs/guide/platform-integration) for how the core reaches each platform and [Protocol handling](/docs/guide/protocol-handling) for the protocol model.
+
+## @wvb/deno-desktop
+
+Call `webviewBundle(config)` — aliased as `wvb`, backed by the `WebviewBundle` class — to build a `BundleSource` (plus an optional `Remote` and `Updater`) and get back a `Deno.serve`-compatible `fetch` handler. Add an `updater` to pull newer bundles from a remote:
+
+```ts title="main.ts"
+import { webviewBundle, bundleProtocol } from '@wvb/deno-desktop';
+
+const wvb = await webviewBundle({
+ protocols: [bundleProtocol({ scheme: 'app' })],
+ updater: {
+ remote: { endpoint: 'https://bundles.example.com' },
+ },
+});
+
+const server = Deno.serve(wvb.fetch);
+const win = new Deno.BrowserWindow({ url: `http://localhost:${server.addr.port}` });
+await win.closed;
+```
+
+### Bindings
+
+Call `registerBindings(win, wvb)` to expose native commands to your web app. It registers one `Deno.BrowserWindow` binding named `wvbInvoke`, reachable from the page as `window.bindings.wvbInvoke`, that dispatches every `@wvb/bridge` command in the `source.*`, `remote.*`, and `updater.*` groups.
+
+```ts title="main.ts"
+import { webviewBundle, bundleProtocol, registerBindings } from '@wvb/deno-desktop';
+
+const wvb = await webviewBundle({
+ protocols: [bundleProtocol({ scheme: 'app' })],
+});
+
+const server = Deno.serve(wvb.fetch);
+const win = new Deno.BrowserWindow({ url: `http://localhost:${server.addr.port}` });
+
+registerBindings(win, wvb); // wires window.bindings.wvbInvoke
+
+await win.closed;
+```
+
+The binding never throws across the FFI boundary. Each call resolves to an `InvokeResult` envelope:
+
+```ts
+type InvokeResult =
+ | { ok: true; value: unknown }
+ | { ok: false; error: { code?: string; message: string } };
+```
+
+The `@wvb/bridge` client unwraps this envelope once it detects the `deno` platform, so web code keeps calling `invoke()`, `source.*`, `remote.*`, and `updater.*` exactly as on other platforms:
+
+```ts title="app.ts (in the webview)"
+import { updater } from '@wvb/bridge';
+
+const update = await updater.getUpdate('my-app');
+if (update.isAvailable) {
+ await updater.download('my-app');
+ await updater.install('my-app', update.version);
+}
+```
+
+## @wvb/deno
+
+`@wvb/deno` is the FFI peer of [`@wvb/node`](/docs/references/node). It loads a prebuilt Rust `cdylib` through `Deno.dlopen` and exposes the same core classes: `BundleProtocol`, `LocalProtocol`, `Remote`, `BundleSource`, and `Updater`.
+
+Each class is `Disposable` — free it explicitly with `free()`, or let a `using` declaration call `[Symbol.dispose]` at scope exit:
+
+```ts title="dispose.ts"
+import { BundleProtocol } from '@wvb/deno';
+
+// `lib` and `source` come from the load + BundleSource steps below.
+// Explicit cleanup.
+const protocol = new BundleProtocol(lib, source);
+const res = await protocol.handle('get', 'app://my-app/index.html');
+protocol.free();
+
+// Or scope-bound cleanup with `using`.
+{
+ using scoped = new BundleProtocol(lib, source);
+ await scoped.handle('get', 'app://my-app/index.html');
+} // [Symbol.dispose]() runs here
+```
+
+### Load the native library
+
+Load the `cdylib` from an explicit path, or download a SHA-256-verified prebuilt via [`@denosaurs/plug`](https://jsr.io/@denosaurs/plug):
+
+```ts title="load.ts"
+import { loadLib, loadLibViaPlug } from '@wvb/deno';
+
+// 1. Load a library already on disk.
+const lib = loadLib('./vendor/wvb/libwvb_deno.dylib');
+
+// 2. Download a sha256-verified prebuilt at runtime.
+const libViaPlug = await loadLibViaPlug();
+```
+
+Point `loadLib` at a file with the `WVB_DENO_LIB` environment variable instead of hard-coding the path:
+
+```sh
+WVB_DENO_LIB=./vendor/wvb/libwvb_deno.dylib deno run -A main.ts
+```
+
+Or vendor the library ahead of time with the installer subcommand:
+
+```sh
+deno run -A jsr:@wvb/deno/install --out vendor/wvb
+```
+
+`@wvb/deno/install` downloads the cdylib from GitHub Releases and verifies its SHA-256 checksum by default. Supported targets:
+
+| Target triple |
+| --------------------------- |
+| `aarch64-apple-darwin` |
+| `x86_64-apple-darwin` |
+| `aarch64-unknown-linux-gnu` |
+| `x86_64-unknown-linux-gnu` |
+| `x86_64-pc-windows-msvc` |
+
+Vendor a specific target with `--target`:
+
+```sh
+deno run -A jsr:@wvb/deno/install --out vendor/wvb --target x86_64-unknown-linux-gnu
+```
+
+For the full class and method reference, see the [Deno API reference](/docs/references/deno).
+
+## Limitations
+
+Beyond the experimental status, two gaps apply to the Deno bindings today.
+
+**Custom verifier callbacks are not supported.** The `Updater` accepts only the **declarative** `signatureVerifier` — a `SignatureVerifierOptions` with an `algorithm` and a `key`:
+
+```ts title="updater.ts"
+const wvb = await webviewBundle({
+ protocols: [bundleProtocol({ scheme: 'app' })],
+ updater: {
+ remote: { endpoint: 'https://bundles.example.com' },
+ integrityPolicy: 'strict',
+ signatureVerifier: {
+ algorithm: 'ed25519',
+ key: { format: 'raw', data: publicKeyBytes },
+ },
+ },
+});
+```
+
+The custom `integrityChecker` / `signatureVerifier` function callbacks available in `@wvb/node` cannot cross the FFI boundary yet.
+
+**`HttpOptions.defaultHeaders` is not yet supported** on the Deno `Remote`. Other `HttpOptions` fields still apply:
+
+```ts title="http.ts"
+const wvb = await webviewBundle({
+ protocols: [bundleProtocol({ scheme: 'app' })],
+ updater: {
+ remote: {
+ endpoint: 'https://bundles.example.com',
+ http: { userAgent: 'my-app/1.0', timeout: 120_000 },
+ },
+ },
+});
+```
+
+For how integrity and signatures work across platforms, see [Remote bundles](/docs/guide/remote-bundles).
+
+## Next steps
+
+
+
+
+
+
diff --git a/content/docs/guide/platforms/electron/builder.mdx b/content/docs/guide/platforms/electron/builder.mdx
new file mode 100644
index 0000000..12e9515
--- /dev/null
+++ b/content/docs/guide/platforms/electron/builder.mdx
@@ -0,0 +1,109 @@
+---
+title: Electron Builder
+description: Install builtin .wvb bundles at package time and keep the native @wvb/node addon in your electron-builder app.
+---
+
+`@wvb/electron-builder` integrates Webview Bundle with [electron-builder](https://www.electron.build/) packaging. It hooks electron-builder's `afterPack` step to install your builtin `.wvb` bundles into the packaged app's resources, so the bundles `@wvb/electron` serves at runtime ship with the app. You still configure electron-builder to keep the native `@wvb/node` addon out of the ASAR archive. Read the [Electron guide](/docs/guide/platforms/electron) first for the runtime setup, and see [Electron Forge](/docs/guide/platforms/electron/forge) for the Forge equivalent.
+
+## What it does
+
+At package time the plugin resolves your Webview Bundle config, installs the builtin bundles (downloaded from a `remote` target or packed from local workspaces via `@wvb/cli`), stages them under `/.wvb/builtin/bundles/-`, then copies them into the packaged app's `Resources/` directory. That is exactly where `@wvb/electron` reads builtin bundles from when packaged (`process.resourcesPath/bundles`), so the runtime picks them up with no extra wiring.
+
+The plugin requires `electron-builder` 24+ (declared as an optional peer dependency) and pulls in `@wvb/cli` and `@wvb/config` to resolve and pack bundles.
+
+## Configure the plugin
+
+Wrap your electron-builder config with `withWebviewBundle(...)` (alias `withWvb`). It composes the `afterPack` hook for you while preserving any existing function-valued `afterPack`.
+
+```ts title="electron-builder.config.ts"
+import { withWebviewBundle } from '@wvb/electron-builder';
+
+export default withWebviewBundle({
+ appId: 'com.example.app',
+ asar: true,
+ mac: { target: 'dmg' },
+});
+```
+
+Pass plugin options as a second argument:
+
+```ts title="electron-builder.config.ts"
+import { withWebviewBundle } from '@wvb/electron-builder';
+
+export default withWebviewBundle(
+ {
+ appId: 'com.example.app',
+ asar: true,
+ },
+ {
+ bundlesDir: 'bundles',
+ channel: 'beta',
+ }
+);
+```
+
+If you prefer to wire the hook yourself, use the raw factory `webviewBundleAfterPack(options?)` (alias `wvbAfterPack`), which returns an electron-builder `afterPack` function. The package also exports `resolveResourcesPath(ctx)`, which returns the packaged app's resources directory for a given build context.
+
+### Options
+
+Both `@wvb/electron-builder` and `@wvb/electron-forge` share these options:
+
+| Option | Type | Default | Meaning |
+| ------------------------- | ------------------- | ----------- | --------------------------------------------------------------------------------- |
+| `root` | `string` | (resolved) | project root used to discover config and bundles |
+| `builtin` | `BuiltinConfig` | from config | inline builtin config, overriding the config file |
+| `bundlesDir` | `string` | `'bundles'` | destination directory name under the packaged `Resources` |
+| `configFile` | `string \| boolean` | `true` | `true` auto-discovers and merges; a path loads explicitly; `false` is inline only |
+| `channel` | `string` | — | release channel to install bundles from (e.g. `"beta"`) |
+| `throwWhenBuiltinIsEmpty` | `boolean` | `true` | throw if zero bundles end up installed |
+
+When `builtin.target` is not set, the plugin defaults to a `remote` target using the resolved remote endpoint. `bundlesDir` must be a relative path with no `..` segments. See [configuration](/docs/config) for the underlying `builtin` and `remote` config, and [Building a remote](/docs/guide/remote) for the install source.
+
+## Keep the native addon out of the ASAR
+
+`@wvb/electron` depends on `@wvb/node`, a native N-API addon (a `.node` binary). When `asar: true`, electron-builder packs `node_modules` into the ASAR archive, and native binaries cannot be loaded from inside ASAR. Use `asarUnpack` to unpack the addon so it loads at runtime:
+
+```json title="electron-builder config"
+{
+ "asar": true,
+ "asarUnpack": ["**/node_modules/@wvb/node/**"]
+}
+```
+
+The plugin handles installing the builtin bundles, so you do not need `extraResources` for them. If you stage `.wvb` files yourself instead of using the plugin, ship the directory as an extra resource so it lands in `Resources/bundles`:
+
+```json title="electron-builder config"
+{
+ "extraResources": [{ "from": "bundles", "to": "bundles" }]
+}
+```
+
+
+ If the app works in development but throws a missing-module error for `@wvb/node` once packaged,
+ the native addon was packed into the ASAR. Add the `asarUnpack` glob above and repackage.
+
+
+## Next steps
+
+
+
+
+
+
+
diff --git a/content/docs/guide/platforms/electron/forge.mdx b/content/docs/guide/platforms/electron/forge.mdx
new file mode 100644
index 0000000..3cecc07
--- /dev/null
+++ b/content/docs/guide/platforms/electron/forge.mdx
@@ -0,0 +1,107 @@
+---
+title: Electron Forge
+description: Ship packed .wvb builtins as an extra resource and unpack the native @wvb/node addon from the ASAR with the @wvb/electron-forge plugin.
+---
+
+`@wvb/electron-forge` is an Electron Forge plugin that wires Webview Bundle into your packaging step. It installs your packed `.wvb` builtins into the packaged app's resources and helps keep the native `@wvb/node` addon available at runtime, so you do not stage bundle files by hand. Use it alongside the main [Electron guide](/docs/guide/platforms/electron), which covers serving bundles through a custom protocol and updating them over the air.
+
+## What the plugin does
+
+The plugin extends Forge's `PluginBase` and hooks `packageAfterCopy`. At package time it resolves your Webview Bundle config, installs the builtin `.wvb` bundles (downloaded from a `remote` target or packed from local workspaces via `@wvb/cli`), stages them under `/.wvb/builtin/bundles`, and copies them into the packaged app's resources next to the copied app directory. That destination is exactly where `@wvb/electron` reads builtin bundles from at runtime.
+
+See the package source for the exact surface: [`@wvb/electron-forge`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-forge).
+
+## Add it to forge.config.ts
+
+Register the plugin in your Forge config alongside your other plugins. The plugin class is `WebviewBundlePlugin` (also exported as the default export and aliased `WvbPlugin`).
+
+```ts title="forge.config.ts"
+import type { ForgeConfig } from '@electron-forge/shared-types';
+import { WebviewBundlePlugin } from '@wvb/electron-forge';
+
+const config: ForgeConfig = {
+ plugins: [new WebviewBundlePlugin({ bundlesDir: 'bundles', channel: 'beta' })],
+};
+
+export default config;
+```
+
+The plugin reads its options from `WebviewBundlePluginConfig`:
+
+| Option | Type | Default | Meaning |
+| ------------------------- | ------------------- | ----------- | --------------------------------------------------------------------------------- |
+| `bundlesDir` | `string` | `'bundles'` | destination dir name under the packaged app's resources |
+| `configFile` | `string \| boolean` | `true` | `true` auto-discovers and merges; a path loads explicitly; `false` is inline only |
+| `channel` | `string` | — | release channel to install bundles from |
+| `throwWhenBuiltinIsEmpty` | `boolean` | `true` | throw if zero bundles are installed |
+
+It also accepts inline `root` and `builtin` config picked from the shared [Webview Bundle config](/docs/config). When `builtin.target` is unset, the plugin defaults to a `remote` target using your resolved remote endpoint.
+
+## Native binary and extra resource
+
+Even without the plugin, two things must reach the packaged app: the builtin `bundles` directory and the native `@wvb/node` addon. The plugin handles the bundles; the native addon is unpacked from the ASAR archive with Forge's `AutoUnpackNativesPlugin`.
+
+```ts title="forge.config.ts"
+import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
+import { WebviewBundlePlugin } from '@wvb/electron-forge';
+
+const config: ForgeConfig = {
+ packagerConfig: {
+ asar: true,
+ extraResource: ['bundles'], // ship the builtin bundles
+ },
+ plugins: [
+ // …vite plugin…
+ new AutoUnpackNativesPlugin({}), // unpack @wvb/node's .node binary from the ASAR
+ new WebviewBundlePlugin({}),
+ ],
+};
+```
+
+`extraResource: ['bundles']` ships the builtin bundles into the packaged app's resources. `AutoUnpackNativesPlugin` unpacks the `@wvb/node` native `.node` binary out of the ASAR so it can be loaded at runtime. Run [`wvb pack`](/docs/guide/commands/pack) to produce the `.wvb` files that land in that `bundles` directory.
+
+## Keep node_modules so the native module is bundled
+
+The Forge Vite plugin can exclude `node_modules` from the package, which drops the native `@wvb/node` module. If you hit a missing `@wvb/node` binary at runtime, override `packagerConfig.ignore` so `node_modules` is kept.
+
+```ts title="forge.config.ts"
+const config: ForgeConfig = {
+ packagerConfig: {
+ asar: true,
+ extraResource: ['bundles'],
+ // Keep node_modules so the native @wvb/node addon is bundled.
+ ignore: [/^\/(?!node_modules|\.vite|package\.json)/],
+ },
+};
+```
+
+
+ If your app starts in development but the window is blank when packaged, the `bundles` resource or
+ the native `@wvb/node` binary was most likely left out. Verify `extraResource`,
+ `AutoUnpackNativesPlugin`, and the `packagerConfig.ignore` override above.
+
+
+## Next steps
+
+
+
+
+
+
+
diff --git a/content/docs/guide/platforms/electron/index.mdx b/content/docs/guide/platforms/electron/index.mdx
new file mode 100644
index 0000000..4c60aef
--- /dev/null
+++ b/content/docs/guide/platforms/electron/index.mdx
@@ -0,0 +1,252 @@
+---
+title: Electron
+description: Serve your Electron UI from a .wvb bundle through a custom protocol, with dev-server proxying and over-the-air updates.
+---
+
+`@wvb/electron` wires Webview Bundle into Electron in three moves: serve your UI from a `.wvb` bundle through a custom protocol, proxy to a dev server while developing, and (optionally) update bundles over the air.
+
+## Install
+
+`@wvb/electron` requires Electron 15+ and pulls in `@wvb/node` (prebuilt N-API binaries — no Rust toolchain). `@wvb/cli` packs bundles at build time.
+
+
+
+
+```sh
+npm install @wvb/electron
+npm install -D @wvb/cli
+```
+
+
+
+
+```sh
+pnpm add @wvb/electron
+pnpm add -D @wvb/cli
+```
+
+
+
+
+```sh
+yarn add @wvb/electron
+yarn add -D @wvb/cli
+```
+
+
+
+
+## Register the protocol in the main process
+
+Call `wvb(...)` (alias of `webviewBundle(...)`) before any window loads. It registers your schemes as privileged, builds the source, and wires the protocol handlers and IPC.
+
+```ts title="src/main.ts"
+import path from 'node:path';
+import { app, BrowserWindow } from 'electron';
+import { bundleProtocol, localProtocol, wvb } from '@wvb/electron';
+
+const instance = wvb({
+ source: {
+ builtinDir: path.join(process.resourcesPath, 'bundles'),
+ },
+ protocols: [
+ // Dev: proxy `app-local://app.wvb/...` to the Vite dev server for hot reload.
+ localProtocol('app-local', {
+ hosts: { 'app.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL },
+ }),
+ // Prod: serve `app://.wvb/...` straight from the bundle.
+ bundleProtocol('app', { onError: e => console.error('[wvb]', e) }),
+ ],
+});
+
+async function createWindow() {
+ await instance.whenProtocolRegistered();
+ const win = new BrowserWindow({
+ width: 800,
+ height: 600,
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ },
+ });
+ await win.loadURL('app://app.wvb');
+}
+
+app.whenReady().then(createWindow);
+```
+
+URL shape is `://.wvb/` — `app://app.wvb/index.html` resolves to bundle `app`, file `/index.html`.
+
+- **`bundleProtocol(scheme, options?)`** serves files directly from bundles in the source.
+- **`localProtocol(scheme, { hosts })`** proxies matching hosts to a dev server. `hosts` is required — a `Record` (or a function returning one) mapping host to URL.
+
+Choose which URL to load based on `app.isPackaged`:
+
+```ts
+await win.loadURL(app.isPackaged ? 'app://app.wvb' : 'app-local://app.wvb');
+```
+
+`whenProtocolRegistered()` resolves after every protocol is registered and `app.whenReady()` fires — always await it before navigating.
+
+Override per-protocol privileges with the `privileges` option:
+
+```ts
+bundleProtocol('app', {
+ privileges: { stream: true }, // merged over the defaults below
+});
+```
+
+
+ Each scheme is registered as privileged with these defaults: `standard`, `secure`, `bypassCSP`,
+ `allowServiceWorkers`, `supportFetchAPI`, `corsEnabled`, `codeCache` all `true`; `stream` `false`.
+ See [Protocol handling](/docs/guide/protocol-handling) for the full model.
+
+
+## Add the preload script
+
+The preload exposes a safe transport (`window.wvbElectron.invoke`) that the bridge uses for source, remote, and updater calls.
+
+```ts title="src/preload.ts"
+import { preload } from '@wvb/electron/preload';
+
+preload();
+```
+
+Point `webPreferences.preload` at the compiled preload and keep `contextIsolation: true` with `nodeIntegration: false`.
+
+## Call the API from the renderer
+
+Import the bridge in renderer code to drive updates from the UI — for example, a "Check for updates" button. It forwards to the main process over IPC, so it works only when the preload is loaded.
+
+```ts title="src/renderer.ts"
+import { source, remote, updater } from '@wvb/bridge';
+
+// What is installed locally right now?
+const current = await source.loadVersion('app');
+
+// Is a newer version deployed on the remote?
+const update = await updater.getUpdate('app');
+if (update) {
+ const downloaded = await updater.download('app'); // download + verify + stage
+ await updater.install('app', downloaded.version); // activate
+ // Reload the window to pick up the new bundle — your app's responsibility.
+}
+```
+
+
+ `source`, `remote`, and `updater` come from `@wvb/bridge`, which auto-detects the Electron
+ transport installed by `@wvb/electron/preload`. Result shapes for `getUpdate`/`download` are in
+ the [Node API reference](/docs/references/node).
+
+
+The same surfaces are reachable in the main process on the instance returned by `wvb(...)`:
+
+```ts
+instance.source; // BundleSource
+instance.remote; // Remote | null (null unless `updater` is configured)
+instance.updater; // Updater | null (null unless `updater` is configured)
+await instance.whenProtocolRegistered();
+```
+
+## Configure over-the-air updates
+
+Add an `updater` block pointing at your remote. Its presence is what enables `instance.remote` and `instance.updater`; omit it and the renderer's `remote.*` / `updater.*` calls fail with a "not initialized" bridge error.
+
+```ts
+wvb({
+ source: { builtinDir: path.join(process.resourcesPath, 'bundles') },
+ updater: {
+ remote: { endpoint: 'https://updates.example.com' },
+ channel: 'stable',
+ // integrity and signature verification also live here:
+ // integrityPolicy, integrityChecker, signatureVerifier
+ },
+ protocols: [bundleProtocol('app')],
+});
+```
+
+Pin a signing key to verify _who_ published an update:
+
+```ts
+updater: {
+ remote: { endpoint: 'https://updates.example.com' },
+ signatureVerifier: {
+ algorithm: 'ed25519',
+ key: '/* raw 32-byte or PEM public key */',
+ },
+},
+```
+
+`SignatureAlgorithm` is one of `ecdsaSecp256R1`, `ecdsaSecp384R1`, `ed25519`, `rsaPkcs1V15`, `rsaPss`. See [Remote bundles](/docs/guide/remote-bundles) for integrity and signature details, and [Building a remote](/docs/guide/remote) for standing a server up — including a local one for testing.
+
+## Where bundles live
+
+`source` accepts `builtinDir` (shipped, read-only) and `remoteDir` (downloaded updates), both with Electron-aware defaults:
+
+| Option | Default |
+| ------------ | --------------------------------------------------------------------------- |
+| `builtinDir` | `process.resourcesPath/bundles` when packaged, else `process.cwd()/bundles` |
+| `remoteDir` | `app.getPath('userData')/bundles` |
+
+Downloaded versions take priority over builtin ones, so an installed update is served automatically after `updater.download(...)` and `updater.install(...)`. See [Bundle sources](/docs/guide/bundle-sources) for resolution details.
+
+## Pack and ship bundles
+
+Build your web app, then pack the output into the directory you set as `builtinDir`:
+
+```sh
+# Build your renderer first (e.g. `vite build`), then:
+npx wvb pack ./dist --outfile bundles/app/app_1.0.0.wvb
+```
+
+Ship the `bundles` directory with your app so the protocol can serve it at runtime. How you stage those files into the package depends on your packaging tool — see [Packaging](#packaging).
+
+## Packaging
+
+Packaging an Electron app that uses `@wvb/electron` needs two things to land in the final build: the builtin `bundles` directory has to ship with the app, and the native `@wvb/node` binary has to stay out of the ASAR archive so it can load at runtime. Dedicated integrations handle both and install builtin bundles at package time, so you never stage `.wvb` files by hand.
+
+
+
+
+
+
+## Troubleshooting
+
+- **Blank window or `ERR_FAILED`** — confirm `wvb(...)` runs and `whenProtocolRegistered()` resolves before `loadURL`, and the URL's bundle name matches the packed file (`app://app.wvb` → bundle `app`).
+- **Renderer cannot reach the bridge API** — the preload is not loaded. Check `webPreferences.preload` and that it calls `preload()`.
+- **`remote_not_initialized` / `updater_not_initialized`** — a `remote.*` / `updater.*` call was made but no `updater` block was passed to `wvb(...)`. Add the [over-the-air updates](#configure-over-the-air-updates) config.
+- **Works in dev, fails when packaged** — the `bundles` resource or the native `@wvb/node` binary was not included in the package. See [Packaging](#packaging).
+
+## Next steps
+
+
+
+
+
+
+
diff --git a/content/docs/guide/platforms/electron/meta.json b/content/docs/guide/platforms/electron/meta.json
new file mode 100644
index 0000000..5c496a3
--- /dev/null
+++ b/content/docs/guide/platforms/electron/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Electron",
+ "pages": ["index", "forge", "builder"]
+}
diff --git a/content/docs/guide/platforms/ios.mdx b/content/docs/guide/platforms/ios.mdx
new file mode 100644
index 0000000..1794691
--- /dev/null
+++ b/content/docs/guide/platforms/ios.mdx
@@ -0,0 +1,252 @@
+---
+title: iOS
+description: Serve and update Webview Bundle archives in a WKWebView using the webview-bundle-ios Swift package.
+---
+
+The `webview-bundle-ios` Swift package serves `.wvb` bundles to a `WKWebView` through a custom URL
+scheme and keeps them current with over-the-air (OTA) updates. Register a scheme, point a `WKWebView`
+at `app://app.wvb`, and the package answers every request from the bundle on disk — offline-first,
+with the updater pulling newer bundles in the background without an App Store release.
+
+## Requirements
+
+| Requirement | Value |
+| --------------- | ---------------------------------------------------------- |
+| Minimum OS | iOS 16, macOS 12 (`platforms: [.macOS(.v12), .iOS(.v16)]`) |
+| Swift tools | 6.1, language mode 6 |
+| SwiftPM product | `WebViewBundle` |
+
+The package binds the Rust core through a UniFFI-generated module named `WebViewBundleLibrary`, both
+exposed under the `WebViewBundle` module. See [Platform integration](/docs/guide/platform-integration)
+for how the shared core reaches each platform.
+
+Add the package as a dependency (the native FFI is wired in by the install script below):
+
+```swift title="Package.swift"
+dependencies: [
+ .package(path: "../webview-bundle-ios"),
+],
+targets: [
+ .target(name: "App", dependencies: [
+ .product(name: "WebViewBundle", package: "webview-bundle-ios"),
+ ]),
+]
+```
+
+## Install the native FFI
+
+Resolve the native binary from a release before you build:
+
+```sh
+node scripts/install.mjs latest # highest ffi/* release
+node scripts/install.mjs 0.1.0 # -> tag ffi/0.1.0
+node scripts/install.mjs ffi/0.1.0 # explicit release tag
+```
+
+The script reads the GitHub release for the resolved tag and:
+
+- Extracts the generated Swift bindings from the `apple.zip` asset into `Sources/WebViewBundle/`.
+- Resolves the SHA-256 checksum of the `WebViewBundleFFI.xcframework.zip` asset (preferring the digest
+ GitHub reports, falling back to downloading and hashing) and writes the checksum and tag into
+ `Package.swift`.
+
+It needs `unzip` on `PATH`. Override the source repo and authenticate for private repos:
+
+```sh
+export GITHUB_TOKEN=ghp_xxx # or GH_TOKEN, for a private source repo
+node scripts/install.mjs latest --repo webview-bundle/webview-bundle
+```
+
+## Quick start
+
+Configure once, register the scheme on a `WKWebViewConfiguration`, load the entry URL. Use a **custom
+scheme** — `http` and `https` are reserved and rejected at init.
+
+```swift title="ContentView.swift"
+import SwiftUI
+import WebKit
+import WebViewBundle
+
+let instance = try WebViewBundle.configure(
+ WebViewBundleConfig(
+ protocols: [.bundle(scheme: "app")],
+ updater: WebViewBundleUpdaterConfig(
+ remote: WebViewBundleRemoteConfig(endpoint: "https://bundles.example.com"),
+ integrityPolicy: .strict,
+ signatureVerifier: SignatureVerifierOptions(
+ algorithm: .ed25519,
+ key: SignatureVerifyingKey(format: .spkiDer, pem: nil, der: publicKeyDer)
+ )
+ )
+ )
+)
+
+let config = WKWebViewConfiguration()
+instance.install(on: config) // registers the app:// scheme handler + JS bridge
+
+let webView = WKWebView(frame: .zero, configuration: config)
+webView.load(URLRequest(url: URL(string: "app://app.wvb")!))
+```
+
+`configure(_:)` builds the instance once and caches it process-wide. The entry URL `app://app.wvb`
+selects the bundle named `app`. To skip manual configuration, take a ready-made `WKWebView`:
+
+```swift
+let webView = instance.makeWebView() // or: instance.makeConfiguration()
+webView.load(URLRequest(url: URL(string: "app://app.wvb")!))
+```
+
+Read the configured singleton anywhere — `shared` traps if read before `configure(_:)`, `safeShared`
+does not:
+
+```swift
+let wvb = WebViewBundle.shared // precondition-fails if not yet configured
+let maybe = WebViewBundle.safeShared // nil if not yet configured
+```
+
+## How serving works
+
+Each scheme gets a `WKURLSchemeHandler` registered via
+`setURLSchemeHandler(_:forURLScheme:)`; the handler maps each request to the Rust core, which answers
+from the bundle on disk.
+
+- **Bundle name = first label of the request host.** `app://app.wvb/index.html` resolves to bundle
+ `app`, path `/index.html`.
+- **Reserved schemes are rejected at init** (registering a handler for one raises an uncatchable
+ exception): `http`, `https`, `file`, `ftp`, `ftps`, `ws`, `wss`, `about`, `blob`, `data`,
+ `javascript`.
+- **A scheme must match `^[a-z][a-z0-9+.-]*$`** and be unique. Empty, malformed, duplicate, or
+ reserved schemes throw `WebViewBundleError`:
+
+```swift
+do {
+ _ = try WebViewBundle.configure(
+ WebViewBundleConfig(protocols: [.bundle(scheme: "https")]))
+} catch WebViewBundleError.reservedScheme(let scheme) {
+ print("\(scheme) is reserved") // .emptyScheme / .invalidScheme / .duplicateScheme also exist
+}
+```
+
+Two protocol kinds exist — `.bundle` serves bundle entries, `.local` proxies to a local HTTP server
+(matching the full request host against the `hosts` map). See
+[Protocol handling](/docs/guide/protocol-handling) for the model.
+
+```swift
+WebViewBundleConfig(protocols: [
+ .bundle(scheme: "app"),
+ .local(scheme: "dev", hosts: ["myapp": "http://localhost:8080"]),
+])
+```
+
+## The bridge
+
+`install(on:)` also wires a JavaScript-to-native bridge. The web app posts to
+`window.webkit.messageHandlers.wvbIos`; the bridge accepts **main-frame messages only** (subframe and
+iframe messages are dropped) and exposes the source, remote, and updater commands.
+
+```ts title="web app"
+window.webkit.messageHandlers.wvbIos.postMessage({
+ name: 'updaterGetUpdate',
+ params: { bundleName: 'app' },
+});
+```
+
+## Sources
+
+Each app reads from two sources. See [Bundle sources](/docs/guide/bundle-sources) for the full model.
+
+- **Builtin** — read-only bundles shipped inside the app. Default dir is the app bundle's `/bundles`
+ folder; ship it as a folder reference so files land there.
+- **Remote** — the writable directory for downloaded updates. Default lives under Application Support,
+ namespaced by the app's bundle identifier.
+
+Override either path through `SourceOptions` on `WebViewBundleConfig.source`:
+
+```swift
+WebViewBundleConfig(
+ source: SourceOptions(
+ builtinDir: Foundation.Bundle.main.resourcePath.map { "\($0)/bundles" },
+ remoteDir: nil, // nil => default Application Support dir
+ builtinManifestFilepath: nil,
+ remoteManifestFilepath: nil
+ ),
+ protocols: [.bundle(scheme: "app")]
+)
+```
+
+
+ Inside the `WebViewBundle` module, unqualified `Bundle` resolves to the FFI bundle class, not
+ `Foundation.Bundle`. Write `Foundation.Bundle` explicitly when you mean the app bundle.
+
+
+## OTA updates
+
+Set `WebViewBundleConfig.updater` and the package builds a `Remote` and an `Updater` for you, reachable
+as `instance.remote` and `instance.updater`.
+
+```swift
+let updaterConfig = WebViewBundleUpdaterConfig(
+ remote: WebViewBundleRemoteConfig(endpoint: "https://bundles.example.com"),
+ channel: "stable",
+ integrityPolicy: .strict,
+ signatureVerifier: SignatureVerifierOptions(
+ algorithm: .ed25519,
+ key: SignatureVerifyingKey(format: .spkiDer, pem: nil, der: publicKeyDer)
+ )
+)
+```
+
+`integrityPolicy` is `.strict`, `.optional`, or `.none`. Integrity values are SHA-2 digests serialized
+as `sha256:`. The `signatureVerifier` proves who published a bundle:
+
+| Field | Values |
+| --------------------- | --------------------------------------------------------------------------- |
+| `algorithm` | `.ecdsaSecp256r1`, `.ecdsaSecp384r1`, `.ed25519`, `.rsaPkcs1V15`, `.rsaPss` |
+| `key.format` | `.spkiDer`, `.spkiPem`, `.pkcs1Der`, `.pkcs1Pem`, `.sec1`, `.raw` |
+| `key.pem` / `key.der` | `pem` for text keys, `der` for binary keys |
+
+Drive the update cycle through the updater:
+
+```swift
+guard let updater = instance.updater else { return }
+
+let info = try await updater.getUpdate(bundleName: "app") // check; no download
+if info.isAvailable {
+ _ = try await updater.downloadUpdate(bundleName: "app", version: info.version)
+ try await updater.install(bundleName: "app", version: info.version)
+}
+```
+
+`getUpdate` checks the remote for a newer version without downloading. `downloadUpdate` downloads and
+persists a version (latest when `version` is `nil`). `install` activates a staged version, verifies
+integrity and signature when configured, then prunes stale versions. Reload to render the new bundle:
+
+```swift
+try await updater.install(bundleName: "app", version: info.version)
+await webView.evaluateJavaScript("location.reload()")
+```
+
+A failed signature surfaces as the FFI error `Error.Signature(message:)`.
+
+For the remote HTTP contract, integrity, and signing details, see
+[Remote bundles](/docs/guide/remote-bundles) and [Building a remote](/docs/guide/remote).
+
+## Next steps
+
+
+
+
+
+
diff --git a/content/docs/guide/platforms/tauri.mdx b/content/docs/guide/platforms/tauri.mdx
new file mode 100644
index 0000000..3a7d0d1
--- /dev/null
+++ b/content/docs/guide/platforms/tauri.mdx
@@ -0,0 +1,362 @@
+---
+title: Tauri
+description: Add the wvb-tauri plugin to a Tauri v2 app so the webview is served from a .wvb bundle, with dev proxying and over-the-air updates.
+---
+
+`wvb-tauri` is the Webview Bundle integration for Tauri v2. Register it as a plugin and your app serves its webview from a local `.wvb` bundle through a custom URL scheme, proxies a dev server while you build, and downloads newer bundles over the air (OTA). The same plugin runs on desktop and on Tauri mobile (Android and iOS).
+
+
+ Tauri uses the Rust crate `wvb-tauri` (crates.io). There is no `@wvb/tauri` npm package — the
+ frontend talks to the plugin through Tauri's standard command bridge, which needs no extra package
+ beyond `@tauri-apps/api`.
+
+
+## Install
+
+```toml title="src-tauri/Cargo.toml"
+[dependencies]
+wvb-tauri = "0.1"
+tauri = { version = "2", features = [] }
+```
+
+To drive updates from the frontend, install Tauri's JS API in your web app:
+
+
+
+
+```sh
+npm install @tauri-apps/api
+```
+
+
+
+
+```sh
+pnpm add @tauri-apps/api
+```
+
+
+
+
+```sh
+yarn add @tauri-apps/api
+```
+
+
+
+
+## Register the plugin
+
+Build a `Config` with `Config::new()`, then chain a bundle **source**, one or more **protocols**, and optionally a **remote** for updates:
+
+```rust title="src-tauri/src/lib.rs"
+use tauri::Manager;
+use wvb_tauri::{Config, Protocol, Source};
+
+#[cfg_attr(mobile, tauri::mobile_entry_point)]
+pub fn run() {
+ tauri::Builder::default()
+ .plugin(wvb_tauri::init(
+ Config::new()
+ .source(Source::new().builtin_dir_fn(|app| {
+ Ok(app.path().resource_dir()?.join("bundles"))
+ }))
+ // Serve `bundle://.wvb/...` straight from packed bundles.
+ .protocol(Protocol::bundle("bundle"))
+ // In development, proxy `local://example.com/...` to the dev server.
+ .protocol(Protocol::local("local").host("example.com", "http://localhost:1420")),
+ ))
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
+```
+
+`Protocol::bundle(scheme)` serves files from a packed bundle, deriving the bundle name from the request host — `bundle://app.wvb/index.html` resolves to bundle `app`, file `/index.html`.
+
+`Protocol::local(scheme)` proxies a host to a localhost dev server so the same scheme works with hot reload. Map several hosts at once with `.hosts(...)`:
+
+```rust
+use std::collections::HashMap;
+
+Protocol::local("local").hosts(HashMap::from([
+ ("example.com".to_string(), "http://localhost:1420".to_string()),
+ ("api.example.com".to_string(), "http://localhost:8080".to_string()),
+]));
+```
+
+`Source` resolves where bundles live. Use static path strings or `AppHandle` closures:
+
+```rust
+use wvb_tauri::Source;
+
+// Static path strings (resolved through Tauri's path API):
+Source::new().builtin_dir("$RESOURCE/bundles").remote_dir("$APPLOCALDATA/bundles");
+
+// Or compute the path at runtime from the AppHandle:
+Source::new().builtin_dir_fn(|app| Ok(app.path().resource_dir()?.join("bundles")));
+```
+
+When unset, the builtin dir defaults to `bundles` in the app's resource directory and the remote dir to `bundles` in app local data. Each `.protocol(...)` call registers its own asynchronous URI scheme. See [Protocol handling](/docs/guide/protocol-handling) for how the schemes resolve requests.
+
+## Point a window at the custom scheme
+
+Boot the main window straight into the bundle scheme:
+
+```json title="src-tauri/tauri.conf.json"
+{
+ "app": {
+ "windows": [
+ {
+ "url": "bundle://app.wvb"
+ }
+ ]
+ }
+}
+```
+
+If you call plugin commands from the frontend, grant permissions in a capability file. The namespace is `wvb-tauri`, and `wvb-tauri:default` allows all source, remote, and updater commands:
+
+```json title="src-tauri/capabilities/default.json"
+{
+ "identifier": "default",
+ "windows": ["main"],
+ "permissions": ["core:default", "wvb-tauri:default"]
+}
+```
+
+To restrict access, replace `wvb-tauri:default` with the individual `wvb-tauri:allow-*` permissions you need:
+
+```json title="src-tauri/capabilities/default.json"
+{
+ "identifier": "default",
+ "windows": ["main"],
+ "permissions": [
+ "core:default",
+ "wvb-tauri:allow-updater-get-update",
+ "wvb-tauri:allow-updater-download",
+ "wvb-tauri:allow-updater-install"
+ ]
+}
+```
+
+## Pack and ship bundles
+
+Build your frontend, pack it into a `.wvb`, and place the result in your source directory:
+
+```sh
+# build your frontend first (e.g. `vite build`), then:
+npx wvb pack ./dist --outfile src-tauri/bundles/app/app_1.0.0.wvb
+```
+
+Ship the directory with the build by listing it in resources:
+
+```json title="src-tauri/tauri.conf.json"
+{
+ "bundle": {
+ "resources": ["bundles/**/*"]
+ }
+}
+```
+
+For the full packing workflow and flags, see the [CLI reference](/docs/guide/cli) and [Bundle sources](/docs/guide/bundle-sources).
+
+## Drive updates from the frontend
+
+Add a `Remote` to enable OTA downloads:
+
+```rust title="src-tauri/src/lib.rs"
+use wvb_tauri::{Config, Protocol, Remote, Source};
+
+Config::new()
+ .source(Source::new().builtin_dir_fn(|app| Ok(app.path().resource_dir()?.join("bundles"))))
+ .protocol(Protocol::bundle("bundle"))
+ .remote(Remote::new("https://updates.example.com"));
+```
+
+Tune the HTTP client with `Http` (defaults: request timeout `120_000` ms), and watch progress with `.on_download`:
+
+```rust
+use wvb_tauri::{Http, Remote};
+
+Remote::new("https://updates.example.com")
+ .http(Http::new().timeout(30_000).user_agent("my-app/1.0".into()))
+ .on_download(|downloaded, total, _name| {
+ if let Some(total) = total {
+ println!("{downloaded}/{total} bytes");
+ }
+ });
+```
+
+Plugin commands are reachable as `plugin:wvb-tauri|`. Arguments use camelCase on the JS side (`bundle_name` becomes `bundleName`). The core OTA flow uses three updater commands:
+
+| Command | Arguments | Returns |
+| -------------------- | ------------------------ | ------------------------------------------- |
+| `updater_get_update` | `bundleName` | `BundleUpdateInfo` (availability + version) |
+| `updater_download` | `bundleName`, `version?` | `RemoteBundleInfo` (downloaded metadata) |
+| `updater_install` | `bundleName`, `version` | `null` (activates the downloaded version) |
+
+```ts title="src/update.ts"
+import { invoke } from '@tauri-apps/api/core';
+
+interface BundleUpdateInfo {
+ name: string;
+ version: string;
+ localVersion?: string;
+ isAvailable: boolean;
+}
+
+interface RemoteBundleInfo {
+ name: string;
+ version: string;
+ integrity?: string;
+ signature?: string;
+}
+
+export async function checkAndUpdate(bundleName: string) {
+ const update = await invoke('plugin:wvb-tauri|updater_get_update', {
+ bundleName,
+ });
+ if (!update.isAvailable) return;
+
+ const info = await invoke('plugin:wvb-tauri|updater_download', { bundleName });
+ await invoke('plugin:wvb-tauri|updater_install', {
+ bundleName,
+ version: info.version,
+ });
+ // reload the webview to pick up the new bundle
+ window.location.reload();
+}
+```
+
+The plugin also exposes `source_*` commands for managing local bundles and `remote_*` commands for listing and staging remote bundles:
+
+| Command | Arguments | Returns |
+| ----------------------- | ------------------------ | ---------------------------------------------- |
+| `source_list_bundles` | — | local bundle list |
+| `source_load_version` | `bundleName` | active local version, if any |
+| `source_update_version` | `bundleName`, `version` | `null` (activate a staged version) |
+| `remote_list_bundles` | `channel?` | remote bundle list |
+| `remote_get_info` | `bundleName`, `channel?` | current remote metadata |
+| `remote_download` | `bundleName`, `channel?` | `RemoteBundleInfo` (stages without activating) |
+
+`remote_download` stages a version without activating it; activate later with `source_update_version`:
+
+```ts
+import { invoke } from '@tauri-apps/api/core';
+
+// Stage a remote bundle now, activate on next launch.
+const info = await invoke<{ version: string }>('plugin:wvb-tauri|remote_download', {
+ bundleName: 'app',
+});
+await invoke('plugin:wvb-tauri|source_update_version', {
+ bundleName: 'app',
+ version: info.version,
+});
+```
+
+The full command set (twelve `source_*`, four `remote_*`, and four `updater_*` commands) is defined in [`packages/tauri/src/commands.rs`](https://github.com/webview-bundle/webview-bundle/blob/main/packages/tauri/src/commands.rs). See [Remote bundles](/docs/guide/remote-bundles) for how integrity and signature verification fit into the download flow, and [Remote config](/docs/config/remote) for the server side.
+
+
+ The `remote_*` commands require a `Remote` on the config; the `updater_*` commands additionally
+ require an `Updater`. Calling them without that configuration returns an error whose `code` field
+ is `remote_not_initialized` or `updater_not_initialized`.
+
+
+Branch on the `code` field in the frontend:
+
+```ts
+import { invoke } from '@tauri-apps/api/core';
+
+try {
+ await invoke('plugin:wvb-tauri|updater_get_update', { bundleName: 'app' });
+} catch (err) {
+ const { code } = err as { message: string; code?: string };
+ if (code === 'updater_not_initialized') {
+ // no Updater configured — skip the OTA check
+ }
+}
+```
+
+## Verify signatures and pin integrity
+
+Add an `Updater` with a `SignatureVerifier` to require a publisher signature on downloaded bundles. The verifier is built lazily from a closure:
+
+```rust title="src-tauri/src/lib.rs"
+use wvb_tauri::{Config, Ed25519Verifier, IntegrityPolicy, Protocol, Remote, Source, Updater};
+
+const PUBLIC_KEY_PEM: &str = include_str!("../keys/public.pem");
+
+Config::new()
+ .source(Source::new())
+ .protocol(Protocol::bundle("bundle"))
+ .remote(Remote::new("https://updates.example.com"))
+ .updater(
+ Updater::new()
+ .channel("stable")
+ .integrity_policy(IntegrityPolicy::Strict)
+ .signature_verifier(|| Ok(Ed25519Verifier::from_pem(PUBLIC_KEY_PEM)?.into())),
+ );
+```
+
+`IntegrityPolicy` is `Strict` (must be present and match), `Optional` (default — verify if present), or `None` (skip). Verifier types: `EcdsaSecp256r1Verifier`, `EcdsaSecp384r1Verifier`, `Ed25519Verifier`, `RsaPkcs1V15Verifier`, `RsaPssVerifier`. An `Updater` requires a `Remote`; without one, no updater is built.
+
+## Reach the plugin from Rust
+
+From any `App`, `AppHandle`, or `Window`, the `WebviewBundleExtra` trait adds `webview_bundle()` (aliased `wvb()`), returning the managed state. `.source()` is always available; `.remote()` and `.updater()` return `Option`:
+
+```rust
+use wvb_tauri::WebviewBundleExtra;
+
+let wvb = app.wvb();
+let _source = wvb.source();
+
+if let Some(_updater) = wvb.updater() {
+ // updater is present only when both `.remote(...)` and `.updater(...)` are configured
+}
+```
+
+This is the same state the frontend commands operate on, so you can mix Rust-side and frontend-driven update logic.
+
+## Tauri mobile
+
+On **iOS**, builtin bundles live in a real filesystem resource directory — no extra setup beyond the desktop configuration. On **Android**, builtin bundles ship inside the APK as `asset://` resources the filesystem cannot read directly, so an app that ships builtin bundles must also register the Tauri filesystem plugin:
+
+```rust title="src-tauri/src/lib.rs"
+tauri::Builder::default()
+ .plugin(tauri_plugin_fs::init())
+ .plugin(wvb_tauri::init(/* ... */));
+```
+
+The plugin then extracts each bundle from the APK on first request and caches it in app local data. Remote-only apps (no builtin bundles) need no extra Android setup. See the [Android](/docs/guide/platforms/android) and [iOS](/docs/guide/platforms/ios) guides for platform specifics, and [Platform support](/docs/guide/platform-support) for current status.
+
+## Troubleshooting
+
+- **Request returns HTTP 500 from the scheme** — the protocol handler failed. Confirm the bundle exists in the source directory and the host maps to a real bundle name (`bundle://app.wvb/...` → bundle `app`).
+- **`remote_not_initialized` / `updater_not_initialized`** — you called a `remote_*` or `updater_*` command without `.remote(...)` (and `.updater(...)`) on the `Config`. Branch on `error.code`.
+- **Command rejected by the ACL** — add `wvb-tauri:default` (or the specific `wvb-tauri:allow-*` permission) to a capability targeting the calling window.
+- **Bundle not found at runtime** — confirm the `bundles` directory is in `tauri.conf.json` resources and `Source` resolves to it. On Android, confirm `tauri_plugin_fs::init()` is registered when shipping builtin bundles.
+
+## Learn more
+
+
+
+
+
+
+
diff --git a/content/docs/guide/protocol-handling.mdx b/content/docs/guide/protocol-handling.mdx
new file mode 100644
index 0000000..a0d92ca
--- /dev/null
+++ b/content/docs/guide/protocol-handling.mdx
@@ -0,0 +1,243 @@
+---
+title: Protocol handling
+description: How a webview request maps to a file inside a .wvb bundle through the bundle and local protocols.
+---
+
+A protocol handler turns a webview request into bytes from a bundle instead of bytes from the network. Webview Bundle ships two:
+
+- **bundle protocol** — serves files straight out of a `.wvb` source.
+- **local protocol** — a development proxy that forwards to your bundler's dev server, keeping hot reload.
+
+The Rust core does not register or validate the scheme. It reads only the URI **host** and **path** — never the scheme itself. Picking a scheme and registering it with the OS is the platform integration's job.
+
+## The bundle protocol
+
+A request resolves against a bundle source by two parts of the URI: the **bundle name** (host up to the first `.`) and the **path** (percent-decoded).
+
+```text
+app://app.wvb/index.html -> bundle "app", file "/index.html"
+app://myapp.wvb/assets/logo.png -> bundle "myapp", file "/assets/logo.png"
+```
+
+Path resolution fills in `index.html` like a static file server:
+
+```text
+/ -> /index.html # trailing slash
+/about -> /about/index.html # last segment has no "."
+/a.js -> /a.js # last segment has a "." -> served as-is
+```
+
+A host with no label fails with a bundle-not-found error. A path missing from the bundle index returns **404 Not Found**.
+
+### Methods, headers, and responses
+
+Only `GET` and `HEAD` are served; anything else returns **405 Method Not Allowed**. `HEAD` returns headers with an empty body.
+
+For a served file, the handler replays the headers stored for that file in the index, then overrides `Content-Type` (from the index entry) and `Content-Length` (the uncompressed size):
+
+```text
+GET app://app.wvb/assets/app.js
+-> 200 OK
+ cache-control: public, max-age=31536000 # replayed from the index
+ content-type: application/javascript # from the index entry
+ content-length: 84213 # uncompressed size
+```
+
+
+ Headers are stored per file when the bundle is built, so caching directives and other response
+ headers you set at build time survive into the served response.
+
+
+### Range requests
+
+Webviews rely on range requests for seeking media and resuming large downloads. A `Range` header gets **206 Partial Content** with `Accept-Ranges: bytes` and a `Content-Range`:
+
+```text
+GET app://app.wvb/media/clip.mp4
+Range: bytes=0-1023
+-> 206 Partial Content
+ accept-ranges: bytes
+ content-range: bytes 0-1023/5242880
+ content-length: 1024
+```
+
+- Each returned range is capped at `1000 * 1024` bytes (~1 MB); larger requested ranges are truncated.
+- Multiple ranges come back as a `multipart/byteranges` response.
+- An unsatisfiable range returns **416 Range Not Satisfiable** with `Content-Range: bytes */`.
+
+```text
+Range: bytes=99999999-
+-> 416 Range Not Satisfiable
+ content-range: bytes */5242880
+```
+
+Ranges are computed over the uncompressed content, so offsets match the file your build produced.
+
+## The local protocol
+
+A development proxy: instead of reading a `.wvb` file, it maps a host to a localhost base URL and forwards the request. The custom scheme stays the same as production while your bundler serves live, hot-reloading assets.
+
+```text
+app://myapp/api/data?foo=bar -> http://localhost:3000/api/data?foo=bar
+```
+
+Provide a host-to-URL map. In Electron, pass `localProtocol(scheme, { hosts })`:
+
+```ts title="main.ts"
+import { localProtocol, wvb } from '@wvb/electron';
+
+const instance = wvb({
+ protocols: [
+ localProtocol('app', {
+ hosts: { 'myapp.wvb': 'http://localhost:3000' },
+ }),
+ ],
+});
+```
+
+In Tauri, configure the host map on `Protocol::local`:
+
+```rust title="src-tauri/src/lib.rs"
+use wvb_tauri::{Config, Protocol};
+
+Config::new().protocol(
+ Protocol::local("app").host("myapp.wvb", "http://localhost:3000"),
+);
+```
+
+A request whose host has no mapping fails with a resolve error. The proxy caches responses and honors upstream `304 Not Modified` by replaying the cached response.
+
+
+ Use the local protocol during development and the bundle protocol for shipped builds. Because both
+ share the same scheme, your web app's URLs do not change between the two.
+
+
+## Per-platform schemes
+
+Electron, Tauri, and iOS register a real custom scheme with the webview. Android instead intercepts ordinary `https://.wvb` requests through `WebViewClient.shouldInterceptRequest`, so there is no custom scheme to register. In every case the bundle name comes from the host label and the path comes from the URI path. The examples register `app` (Electron, iOS), `bundle` (Tauri); the name is yours to choose.
+
+
+
+
+`@wvb/electron` registers the scheme as privileged and wires the handler. Pass `bundleProtocol(scheme)` to `webviewBundle`, await `whenProtocolRegistered()`, then load `://.wvb`.
+
+```js title="main.cjs"
+const path = require('path');
+const { app, BrowserWindow } = require('electron');
+const { bundleProtocol, wvb } = require('@wvb/electron');
+
+const instance = wvb({
+ source: { builtinDir: path.join(__dirname, 'bundles') },
+ protocols: [bundleProtocol('app', { onError: e => console.error('[wvb]', e) })],
+});
+
+app.whenReady().then(async () => {
+ await instance.whenProtocolRegistered();
+ const window = new BrowserWindow();
+ await window.loadURL('app://app.wvb/index.html');
+});
+```
+
+The scheme is registered with these default privileges (override per protocol via `bundleProtocol('app', { privileges })`):
+
+```ts
+{
+ standard: true, secure: true, bypassCSP: true,
+ allowServiceWorkers: true, supportFetchAPI: true,
+ corsEnabled: true, stream: false, codeCache: true,
+}
+```
+
+See the [Electron guide](/docs/guide/platforms/electron) for privileges and the renderer bridge.
+
+
+
+
+The `wvb-tauri` crate registers an async URI-scheme protocol per configured `Protocol`. Pass `Protocol::bundle(scheme)` to the plugin config, then point the window at `://.wvb`.
+
+```rust title="src-tauri/src/lib.rs"
+use wvb_tauri::{Config, Protocol, Source};
+
+tauri::Builder::default()
+ .plugin(wvb_tauri::init(
+ Config::new()
+ .source(Source::new())
+ .protocol(Protocol::bundle("bundle")),
+ ))
+ .run(tauri::generate_context!())
+ .unwrap();
+```
+
+Point the window at the scheme in `tauri.conf.json`:
+
+```json title="src-tauri/tauri.conf.json"
+{
+ "app": {
+ "windows": [{ "url": "bundle://app.wvb" }]
+ }
+}
+```
+
+See the [Tauri guide](/docs/guide/platforms/tauri) for the full plugin setup.
+
+
+
+
+Android does not register a custom scheme. The integration intercepts plain `https://.wvb` navigations from a `WebViewClient`. The same host-and-path mapping applies, so `https://app.wvb/index.html` resolves to bundle `app`, file `/index.html`.
+
+```kotlin
+class BundleWebViewClient : WebViewClient() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest,
+ ): WebResourceResponse? {
+ // Hand the request to the Webview Bundle handler.
+ return webviewBundle.handle(request)
+ }
+}
+```
+
+See the [Android guide](/docs/guide/platforms/android) for the working setup.
+
+
+
+
+iOS registers a real custom scheme on the `WKWebView` configuration through a `WKURLSchemeHandler`, then loads `://.wvb`. The minimum deployment target is iOS 16.
+
+```swift
+let configuration = WKWebViewConfiguration()
+configuration.setURLSchemeHandler(bundleSchemeHandler, forURLScheme: "app")
+
+let webView = WKWebView(frame: .zero, configuration: configuration)
+webView.load(URLRequest(url: URL(string: "app://app.wvb/index.html")!))
+```
+
+See the [iOS guide](/docs/guide/platforms/ios) for the working setup.
+
+
+
+
+## Related
+
+
+
+
+
+
+
diff --git a/content/docs/guide/providers/aws.mdx b/content/docs/guide/providers/aws.mdx
new file mode 100644
index 0000000..dcf3bc3
--- /dev/null
+++ b/content/docs/guide/providers/aws.mdx
@@ -0,0 +1,209 @@
+---
+title: AWS provider
+description: Host, serve, and provision remote Webview Bundles on AWS using S3, CloudFront, Lambda@Edge, and optional KMS signing.
+---
+
+Publish bundles to your own AWS account and serve them over a global CDN. Bundles live in **S3**, **CloudFront** caches them, two **Lambda@Edge** functions translate the remote HTTP contract into S3 reads, and **KMS** can optionally sign each bundle.
+For the contract these pieces implement and how to choose a provider, see [Building a remote](/docs/guide/remote).
+
+## Backing services
+
+| Service | Role |
+| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| S3 | Stores each bundle object and the `deployment.json` pointer that records the current version. |
+| CloudFront | CDN in front of S3; the deployer can issue cache invalidations after a deploy. |
+| Lambda@Edge | Two functions — origin-request rewrites the URL to the bundle key, origin-response turns S3 object metadata into the contract response headers. |
+| KMS | Optional. Signs the integrity string of each bundle so clients can verify who published it. |
+
+## Packages
+
+| Package | Version | Role |
+| --------------------------------- | ------- | ------------------------------------------------------------------------------------------------- |
+| `@wvb/remote-aws` | `0.1.0` | Publish side. Produces `uploader`, `deployer`, and optional `signature` for `wvb.config.ts`. |
+| `@wvb/remote-aws-provider` | `0.1.0` | The Lambda@Edge server (a Hono app). Exposes `./origin-request` and `./origin-response` subpaths. |
+| `@wvb/remote-aws-provider-pulumi` | `0.1.0` | Pulumi component that provisions S3, CloudFront, the two Lambda@Edge functions, and IAM. |
+
+
+
+
+```sh
+npm install @wvb/remote-aws @wvb/remote-aws-provider @wvb/remote-aws-provider-pulumi
+```
+
+
+
+
+```sh
+pnpm add @wvb/remote-aws @wvb/remote-aws-provider @wvb/remote-aws-provider-pulumi
+```
+
+
+
+
+```sh
+yarn add @wvb/remote-aws @wvb/remote-aws-provider @wvb/remote-aws-provider-pulumi
+```
+
+
+
+
+## Configure the publish side
+
+`awsRemote()` returns `{ uploader, deployer, signature? }`. Spread it into the `remote` block of your config:
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+import { awsRemote } from '@wvb/remote-aws';
+
+export default defineConfig({
+ remote: {
+ endpoint: 'https://bundles.example.com',
+ ...awsRemote({
+ bucket: 'my-app-bundles',
+ aws: {
+ region: 'us-east-1',
+ profile: 'my-app-deploy',
+ },
+ }),
+ },
+});
+```
+
+See [Remote, integrity and signature config](/docs/config/remote) for the full `remote` schema.
+
+### Customize the uploader and deployer
+
+`uploader` and `deployer` accept their own options. The uploader streams to S3 with a multipart upload and writes the contract values as S3 object metadata (`webview-bundle-name`, `webview-bundle-version`, and, when present, `webview-bundle-integrity` and `webview-bundle-signature`). The deployer GET-then-PUTs `deployment.json` and can invalidate CloudFront after a deploy:
+
+```ts title="wvb.config.ts"
+...awsRemote({
+ bucket: 'my-app-bundles',
+ aws: { region: 'us-east-1' },
+ uploader: {
+ // Default: bundles/${name}/${name}_${version}.wvb
+ key: (name, version) => `bundles/${name}/${version}/${name}_${version}.wvb`,
+ cacheControl: 'public, max-age=31536000, immutable',
+ contentType: 'application/webview-bundle',
+ },
+ deployer: {
+ invalidation: {
+ distributionId: 'E1ABCDEF2GHIJK',
+ },
+ },
+}),
+```
+
+If the bundle already exists and you do not pass `force`, the uploader runs a `HeadObject` pre-check and throws `BundleAlreadyUploadedError`:
+
+```ts
+import { isBundleAlreadyUploadedError } from '@wvb/remote-aws';
+
+try {
+ await uploader.upload({ bundle, bundleName, version });
+} catch (err) {
+ if (isBundleAlreadyUploadedError(err)) {
+ // Re-run with --force to overwrite.
+ }
+ throw err;
+}
+```
+
+### Sign with KMS
+
+Set `signature` to a KMS signer config to sign each bundle's integrity string. The signer calls KMS with `MessageType: 'DIGEST'` and returns a base64 signature:
+
+```ts title="wvb.config.ts"
+import { awsRemote } from '@wvb/remote-aws';
+
+...awsRemote({
+ bucket: 'my-app-bundles',
+ aws: { region: 'us-east-1' },
+ signature: {
+ keyId: 'arn:aws:kms:us-east-1:111122223333:key/abcd-1234',
+ algorithm: 'ECDSA_SHA_256',
+ },
+}),
+```
+
+Build the signer standalone with `awsKmsSignatureSigner({ keyId, algorithm })`, which returns a signing function. Pass `signature: false` to disable signing.
+
+
+ The default uploader key and the provider's expected key do not match. The uploader writes to
+ `bundles/{name}/{name}_{version}.wvb`, but the Lambda@Edge provider rewrites requests to `bundles/
+ {name}/{version}/{name}_{version}.wvb` — an extra `{version}` directory. For an end-to-end AWS
+ setup, set a custom uploader `key` that produces the provider's layout (as shown above), or
+ arrange a matching S3 layout. Verify against the package source before relying on the defaults.
+
+
+## Serve from Lambda@Edge
+
+`@wvb/remote-aws-provider` splits the contract across two CloudFront event handlers. Wire each to its own Lambda:
+
+```ts title="origin-request.ts"
+import { originRequest } from '@wvb/remote-aws-provider/origin-request';
+
+export const handler = originRequest({
+ bucketName: 'my-app-bundles',
+ region: 'us-east-1',
+ // allowOtherVersions: true, // allow GET /bundles/{name}/{version}; otherwise 403
+});
+```
+
+```ts title="origin-response.ts"
+import { originResponse } from '@wvb/remote-aws-provider/origin-response';
+
+export const handler = originResponse();
+```
+
+The origin-request handler reads `deployment.json`, resolves the version (falling back from a channel to the default version), rewrites the origin URI to the bundle key, and tags the request. The origin-response handler converts S3 `x-amz-meta-webview-bundle-*` metadata into the `webview-bundle-*` response headers. `GET /bundles/{name}/{version}` returns `403` unless `allowOtherVersions: true`.
+
+## Provision with Pulumi
+
+`WebviewBundleRemoteProvider` (token `webview-bundle:aws:RemoteProvider`) creates the S3 bucket and policy, an IAM role for Lambda@Edge, the two Lambda@Edge functions in `us-east-1` (required for Lambda@Edge), and a CloudFront distribution with both functions attached:
+
+```ts title="index.ts"
+import { WebviewBundleRemoteProvider } from '@wvb/remote-aws-provider-pulumi';
+
+const provider = new WebviewBundleRemoteProvider('webview-bundle', {});
+
+export const bucketName = provider.bucketName;
+export const distributionDomainName = provider.cloudfrontDistributionDomainName;
+export const distributionId = provider.cloudfrontDistributionId;
+export const originRequestArn = provider.lambdaOriginRequestArn;
+export const originResponseArn = provider.lambdaOriginResponseArn;
+```
+
+The component bundles the Lambda code at deploy time and injects `bucketName`, `region`, and `allowOtherVersions` into each function. Functions default to the `nodejs22.x` runtime. Override per-function settings with `lambdaOriginRequest` / `lambdaOriginResponse`:
+
+```ts title="index.ts"
+new WebviewBundleRemoteProvider('webview-bundle', {
+ lambdaOriginRequest: {
+ runtime: 'nodejs22.x',
+ architecture: 'arm64',
+ memorySize: 256,
+ },
+});
+```
+
+Lambda@Edge requires `us-east-1`. Configure the Pulumi AWS provider for that region:
+
+```ts title="index.ts"
+import * as aws from '@pulumi/aws';
+
+const usEast1 = new aws.Provider('us-east-1', { region: 'us-east-1' });
+```
+
+## Related
+
+
+
+
+
diff --git a/content/docs/guide/providers/cloudflare.mdx b/content/docs/guide/providers/cloudflare.mdx
new file mode 100644
index 0000000..fc53b3b
--- /dev/null
+++ b/content/docs/guide/providers/cloudflare.mdx
@@ -0,0 +1,231 @@
+---
+title: Cloudflare provider
+description: Store, deploy, and serve bundles on Cloudflare using R2, Workers KV, and Cloudflare Workers.
+---
+
+Run a Webview Bundle remote entirely on Cloudflare's edge: bundles live in **R2**, version pointers live in **Workers KV**, and a **Cloudflare Worker** serves the HTTP contract the updater talks to. You get global, low-latency over-the-air (OTA) delivery with no servers to manage.
+
+Three packages cover the workflow:
+
+| Package | Role | Runs |
+| ---------------------------------------- | --------------------------------------------------- | ------------------ |
+| `@wvb/remote-cloudflare` | Uploader + deployer for `wvb.config` | Your machine / CI |
+| `@wvb/remote-cloudflare-provider` | The Worker that serves bundles | Cloudflare Workers |
+| `@wvb/remote-cloudflare-provider-pulumi` | Pulumi component that provisions the infrastructure | `pulumi up` |
+
+All three are `0.1.0`. Pin the version in your `wvb.config`.
+
+```sh
+npm i -D @wvb/remote-cloudflare
+```
+
+
+ New to remotes? Start with [Building a remote](/docs/guide/remote) for the contract and the local
+ testing loop, and see [Remote, integrity & signature config](/docs/config/remote) for the full
+ `wvb.config` reference.
+
+
+## How the pieces fit
+
+Each role maps onto a Cloudflare service:
+
+- **R2** stores each bundle as an object, via R2's S3-compatible API. The `@wvb/remote-cloudflare` uploader reuses the S3 uploader from `@wvb/remote-aws`, pointed at R2.
+- **Workers KV** holds the deployment pointer — one key per bundle (or per bundle and channel) whose value is the deployed version.
+- A **Cloudflare Worker** running `@wvb/remote-cloudflare-provider` reads KV to resolve the version, then streams the matching object from R2.
+
+
+ Cloudflare has no built-in signing — no KMS equivalent, and `@wvb/remote-cloudflare` exposes no
+ signer. If you need [signatures](/docs/config/remote), produce them elsewhere and pass the
+ resulting `signature` string through your `wvb.config`.
+
+
+## Configure the uploader and deployer
+
+Call `cloudflareRemote()` and spread the result into the `remote` block. It returns `{ uploader, deployer }`: the uploader writes the bundle to R2, the deployer writes the version to KV.
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+import { cloudflareRemote } from '@wvb/remote-cloudflare';
+
+export default defineConfig({
+ remote: {
+ endpoint: 'https://bundles.example.com',
+ bundleName: 'my-app',
+ ...cloudflareRemote({
+ bucket: 'webview-bundle',
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
+ kvNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID!,
+ }),
+ },
+});
+```
+
+| Option | Type | Notes |
+| --------------- | -------- | ----------------------------------------------------------------------------------------------- |
+| `bucket` | `string` | R2 bucket that stores bundle objects. |
+| `accountId` | `string` | Cloudflare account ID; sets the R2 endpoint and the KV target. |
+| `kvNamespaceId` | `string` | Workers KV namespace that holds deployment pointers. |
+| `uploader` | object | Optional overrides for the underlying S3 uploader (`key`, `contentType`, `metadata`, and more). |
+| `deployer` | object | Optional overrides for the KV deployer (`key`, `expiration`, `expirationTtl`, `metadata`). |
+
+Defaults: the uploader sets `region: 'auto'` and endpoint `https://.r2.cloudflarestorage.com`, then stores the object at key `bundles//_.wvb` with `webview-bundle-*` custom metadata. The deployer writes the version into KV under key `` (or `/` for a channel deploy).
+
+Override either when your layout differs:
+
+```ts title="wvb.config.ts"
+...cloudflareRemote({
+ bucket: 'webview-bundle',
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
+ kvNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID!,
+ uploader: {
+ key: (name, version) => `releases/${name}/${version}.wvb`,
+ cacheControl: 'public, max-age=31536000, immutable',
+ },
+ deployer: {
+ key: (name, _version, channel) => (channel ? `${name}/${channel}` : name),
+ expirationTtl: 60 * 60 * 24,
+ },
+}),
+```
+
+R2 needs S3 credentials. Provide them the same way as AWS — through environment variables the S3 client reads:
+
+```sh
+export AWS_ACCESS_KEY_ID=
+export AWS_SECRET_ACCESS_KEY=
+```
+
+With the config in place, publish a bundle:
+
+```sh
+wvb pack ./dist
+wvb upload --version 1.2.0
+wvb deploy --version 1.2.0
+```
+
+`wvb upload` does not deploy by default. Run `wvb deploy` (or pass `--deploy` to `upload`) to make the version live:
+
+```sh
+wvb upload --version 1.2.0 --deploy
+```
+
+Deploy to a channel instead of the default pointer:
+
+```sh
+wvb deploy --version 1.2.0 --channel beta
+```
+
+See the [CLI reference](/docs/guide/cli) for every flag.
+
+## Serve bundles with a Worker
+
+The serving side is a Hono app from `@wvb/remote-cloudflare-provider`. Create it with `wvbRemote()` and forward the Worker's bindings into its `Context` — the `KV` binding becomes `kv`, the `BUCKET` (R2) binding becomes `r2`.
+
+```ts title="src/worker.ts"
+import { wvbRemote } from '@wvb/remote-cloudflare-provider';
+
+const remote = wvbRemote();
+
+export interface Env {
+ KV: KVNamespace;
+ BUCKET: R2Bucket;
+}
+
+export default {
+ fetch(request: Request, env: Env): Response | Promise {
+ return remote.fetch(request, { kv: env.KV, r2: env.BUCKET });
+ },
+};
+```
+
+`wvbRemote()` takes one option, `allowOtherVersions` (default `false`). When `false`, `GET /bundles/:name/:version` returns `403`; set it to `true` to let clients download versions other than the deployed one:
+
+```ts title="src/worker.ts"
+const remote = wvbRemote({ allowOtherVersions: true });
+```
+
+The Worker resolves the version from KV, reads `bundles//_.wvb` from R2, and returns it with the required `Webview-Bundle-Name` and `Webview-Bundle-Version` headers (plus `Webview-Bundle-Integrity` and `Webview-Bundle-Signature` when present in the object's metadata).
+
+Bind `KV` and `BUCKET` in `wrangler.jsonc` so the names match the `Context`:
+
+```json title="wrangler.jsonc"
+{
+ "name": "webview-bundle",
+ "main": "src/worker.ts",
+ "compatibility_date": "2025-01-01",
+ "kv_namespaces": [{ "binding": "KV", "id": "" }],
+ "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "webview-bundle" }]
+}
+```
+
+Deploy the Worker:
+
+```sh
+npx wrangler deploy
+```
+
+Verify the contract against the live endpoint:
+
+```sh
+curl https://bundles.example.com/bundles
+# [{ "name": "my-app", "version": "1.2.0" }]
+
+curl -I https://bundles.example.com/bundles/my-app
+# Webview-Bundle-Name: my-app
+# Webview-Bundle-Version: 1.2.0
+# Content-Type: application/webview-bundle
+```
+
+## Provision with Pulumi
+
+`@wvb/remote-cloudflare-provider-pulumi` provisions the whole stack as one component resource: an R2 bucket, a Workers KV namespace, and a Worker (with a version and a deployment), wired together with the bindings `BUCKET` (R2) and `KV` that the `Context` expects.
+
+```ts title="index.ts"
+import { WebviewBundleRemoteProvider } from '@wvb/remote-cloudflare-provider-pulumi';
+
+const provider = new WebviewBundleRemoteProvider('webview-bundle', {
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
+ // Roll the Worker out gradually; defaults to 100.
+ workerDeploymentPercentage: 25,
+});
+
+export const bucketName = provider.bucketName;
+export const kvNamespaceId = provider.kvNamespaceId;
+export const workerId = provider.workerId;
+export const workerVersionId = provider.workerVersionId;
+export const workerDeploymentId = provider.workerDeploymentId;
+```
+
+The component registers the Pulumi resource token `webview-bundle:cloudflare:RemoteProvider`. `workerDeploymentPercentage` is the share of traffic the new Worker version receives (default `100`).
+
+```sh
+pulumi up
+pulumi stack output kvNamespaceId
+```
+
+Feed the exported `bucketName` and `kvNamespaceId` into `cloudflareRemote()` above, then publish with the CLI.
+
+## Next steps
+
+
+
+
+
+
+
diff --git a/content/docs/guide/providers/local.mdx b/content/docs/guide/providers/local.mdx
new file mode 100644
index 0000000..855f22c
--- /dev/null
+++ b/content/docs/guide/providers/local.mdx
@@ -0,0 +1,242 @@
+---
+title: Local provider
+description: Run a filesystem-backed remote on your own machine to test the full upload, deploy, and over-the-air update loop before reaching for a cloud provider.
+---
+
+A filesystem-backed remote that runs on your own machine. It implements the same HTTP contract as the cloud providers, so you can exercise the full lifecycle — pack, upload, deploy, and over-the-air (OTA) update — with no infrastructure. Develop against it, then switch to a [cloud provider](/docs/guide/remote) for production.
+
+
+ Development and testing only. It stores bundles as plain files on one machine and `wvb remote
+ local` binds to `localhost`. For production traffic, use the [AWS](/docs/guide/providers/aws) or
+ [Cloudflare](/docs/guide/providers/cloudflare) providers.
+
+
+## Two packages
+
+Both at `0.0.0`:
+
+| Package | Role |
+| ---------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `@wvb/remote-local` | Publish-side client. `localRemote()` produces the `uploader` and `deployer` you wire into `wvb.config`. |
+| `@wvb/remote-local-provider` | The server. A [Hono](https://hono.dev) app that serves stored bundles over HTTP. The CLI runs it for you. |
+
+This split mirrors every backend: the plain package pushes bundles, the `-provider` package serves them. See [Building a remote](/docs/guide/remote) for the shared contract.
+
+## Configure the publish side
+
+Spread `localRemote()` — which returns `{ uploader, deployer }` — into the `remote` block:
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+import { localRemote } from '@wvb/remote-local';
+
+export default defineConfig({
+ remote: {
+ endpoint: 'http://localhost:4313',
+ bundleName: 'app',
+ ...localRemote(),
+ },
+});
+```
+
+`localRemote()` accepts one optional field:
+
+| Option | Type | Default | Description |
+| --------- | -------- | -------------- | ---------------------------------------------------------------- |
+| `baseDir` | `string` | `~/.wvb/local` | Directory where bundles and deployment state are stored on disk. |
+
+Set `baseDir` to isolate a project's bundles from the default store:
+
+```ts title="wvb.config.ts"
+import os from 'node:os';
+import path from 'node:path';
+import { defineConfig } from '@wvb/config';
+import { localRemote } from '@wvb/remote-local';
+
+export default defineConfig({
+ remote: {
+ endpoint: 'http://localhost:4313',
+ bundleName: 'app',
+ ...localRemote({
+ baseDir: path.join(os.homedir(), '.wvb', 'my-app'),
+ }),
+ },
+});
+```
+
+For the full `remote` block — `endpoint`, `bundleName`, `packBeforeUpload`, integrity, and signature — see the [remote config reference](/docs/config/remote).
+
+## On-disk layout
+
+Everything is written under `{baseDir}/bundles`, one directory per bundle name:
+
+```text
+{baseDir}/
+└── bundles/
+ └── {name}/
+ ├── {name}_{version}.wvb # bundle binary (one per uploaded version)
+ ├── {name}_{version}.json # per-version metadata: { integrity?, signature? }
+ └── deployment.json # which version is current, per channel
+```
+
+After uploading and deploying `1.1.0` of bundle `app` to the default store:
+
+```text
+~/.wvb/local/
+└── bundles/
+ └── app/
+ ├── app_1.1.0.wvb
+ ├── app_1.1.0.json
+ └── deployment.json
+```
+
+`deployment.json` records the current version. Deploying without a channel sets the default `version`; with a channel it sets `channels[channel]`, so channels can point at different versions:
+
+```json title="deployment.json"
+{
+ "name": "app",
+ "version": "1.1.0",
+ "channels": {
+ "beta": "1.2.0"
+ }
+}
+```
+
+## Run the server
+
+`@wvb/remote-local-provider` only builds the Hono app — it does not bind a port. `wvb remote local` serves that app over a Node HTTP server:
+
+```sh
+wvb remote local
+```
+
+Defaults to serving `~/.wvb/local` on `http://localhost:4313`. Flags:
+
+| Flag | Default | Description |
+| ------------------------ | -------------- | --------------------------------------------------------------- |
+| `--base-dir` | `~/.wvb/local` | Directory to serve. Match the `baseDir` set in `localRemote()`. |
+| `--port`, `-P` | `4313` | Port to listen on (also reads `PORT`). |
+| `--hostname`, `-H` | `localhost` | Host to bind (also reads `HOSTNAME`). |
+| `--allow-other-versions` | `false` | Allow downloading versions other than the deployed one. |
+| `--silent` | `false` | Suppress server logging. |
+
+Pass the same path you set in your config:
+
+```sh
+wvb remote local --base-dir ~/.wvb/my-app --port 4313
+```
+
+See the [CLI reference](/docs/guide/cli) for the full `remote` command group (`remote list`, alias `remote ls`).
+
+### allowOtherVersions
+
+By default the server serves only the deployed version. `GET /bundles/{name}/{version}` returns `403 Forbidden`, matching the cloud providers and keeping clients pinned:
+
+```sh
+curl -i http://localhost:4313/bundles/app/1.0.0
+# HTTP/1.1 403 Forbidden
+```
+
+Opt in to unlock the version-specific route — useful for testing rollbacks or inspecting an older bundle:
+
+```sh
+wvb remote local --allow-other-versions
+```
+
+```sh
+curl -i http://localhost:4313/bundles/app/1.0.0
+# HTTP/1.1 200 OK
+# Webview-Bundle-Name: app
+# Webview-Bundle-Version: 1.0.0
+```
+
+When building the provider app directly, pass `allowOtherVersions: true`:
+
+```ts title="server.ts"
+import { wvbRemote } from '@wvb/remote-local-provider';
+
+const app = wvbRemote({
+ baseDir: '~/.wvb/local',
+ allowOtherVersions: true,
+});
+```
+
+
+ With `allowOtherVersions` disabled, only the deployed version is reachable via `GET /bundles/
+ {name}`. The version-specific route stays behind `403` until you opt in.
+
+
+## Walk the full loop
+
+Test an OTA update against a local server end to end.
+
+
+ `wvb upload` does not deploy by default — `--deploy` is `false` unless passed. Use `wvb upload
+ --deploy` to upload and mark current in one step, or run `wvb deploy` separately.
+
+
+Wire up `localRemote()` as shown above, then pack, upload, and deploy. With `packBeforeUpload` at its default, `wvb upload` packs for you, so the explicit `wvb pack` is optional:
+
+```sh
+wvb pack ./dist
+wvb upload --version 1.1.0 --deploy
+```
+
+Start the server against the same store and confirm the bundle is listed:
+
+```sh
+wvb remote local
+```
+
+```sh
+curl http://localhost:4313/bundles
+# [{"name":"app","version":"1.1.0"}]
+```
+
+```sh
+curl -I http://localhost:4313/bundles/app
+# Webview-Bundle-Name: app
+# Webview-Bundle-Version: 1.1.0
+# Content-Type: application/webview-bundle
+```
+
+Point your app's updater at `http://localhost:4313`. It calls `GET /bundles/{name}`, verifies the bundle, and installs it into the `remote` source. Configure the remote target in your platform integration — see the [Electron](/docs/guide/platforms/electron), [Tauri](/docs/guide/platforms/tauri), [Android](/docs/guide/platforms/android), and [iOS](/docs/guide/platforms/ios) guides.
+
+Publish an update by bumping the version and deploying again; the next updater check picks it up:
+
+```sh
+wvb upload --version 1.2.0 --deploy
+```
+
+Deploy to a channel instead of the default by passing `--channel`:
+
+```sh
+wvb deploy --version 1.2.0 --channel beta
+curl 'http://localhost:4313/bundles?channel=beta'
+# [{"name":"app","version":"1.2.0"}]
+```
+
+## Where to go next
+
+
+
+
+
+
+
diff --git a/content/docs/guide/providers/meta.json b/content/docs/guide/providers/meta.json
new file mode 100644
index 0000000..f9ea627
--- /dev/null
+++ b/content/docs/guide/providers/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Providers",
+ "pages": ["local", "aws", "cloudflare"]
+}
diff --git a/content/docs/guide/remote-bundles.mdx b/content/docs/guide/remote-bundles.mdx
new file mode 100644
index 0000000..be7b4bb
--- /dev/null
+++ b/content/docs/guide/remote-bundles.mdx
@@ -0,0 +1,260 @@
+---
+title: Remote bundles & updates
+description: How over-the-air updating works — the lifecycle, the updater model, the HTTP contract, and how integrity and signatures protect each bundle.
+---
+
+Webview Bundle downloads newer bundles over the air (OTA), so you ship updated app code without a native app-store release. This page covers the update lifecycle, the language-neutral updater model, the HTTP contract every remote server implements, and how integrity and signatures keep each bundle trustworthy. To stand up a server, see [Building a remote](/docs/guide/remote); for per-language updater APIs, see the [Node](/docs/references/node) and [Deno](/docs/references/deno) references.
+
+## The update lifecycle
+
+A bundle travels from build output to device in five stages. The first three run on your machine or CI; the last two run inside the app, driven by the updater.
+
+
+
+1. **Pack** your build into a `.wvb` archive.
+2. **Upload** that archive, optionally attaching an integrity hash and a signature.
+3. **Deploy** the uploaded version so clients on a channel start seeing it.
+4. On the device, the updater **checks** for a newer version, then **downloads** it.
+5. The updater **verifies** the download and **installs** it into the `remote` source.
+
+Stages 1–3 are CLI commands:
+
+```sh
+wvb pack # build output -> .wvb/
+wvb upload --version 1.2.0 # push to the server (does not deploy)
+wvb deploy --version 1.2.0 # make 1.2.0 the current version
+```
+
+`wvb upload --deploy` (default `false`) uploads and deploys in one step. See the [CLI reference](/docs/guide/cli) for every flag. Stages 4–5 are the updater, covered next.
+
+## The updater model
+
+The updater ties a [bundle source](/docs/guide/bundle-sources) to a remote endpoint and exposes three operations, kept separate so you control exactly when a device fetches bytes and when it switches versions.
+
+- **`getUpdate`** — a check only. Asks the server for the current deployed version and compares it to the local version. Reports `isAvailable` as `true` when there is no local version, or when the local version differs from the deployed version. Downloads nothing.
+- **`download`** — fetches the bundle, verifies it against your integrity and signature policy, and stages it into the `remote` source. Does **not** activate the new version.
+- **`install`** — re-verifies the staged bundle from disk, activates it as the current version, unloads the old descriptor, and prunes versions no longer retained.
+
+This split lets you download in the background and install on the next app launch.
+
+
+ The `builtin` source — the bundle shipped inside the app — always remains as a fallback. The
+ `remote` source takes priority once a version is installed, so a downloaded update wins over the
+ builtin bundle; if no remote version exists, the app falls back to builtin. See [Bundle
+ sources](/docs/guide/bundle-sources).
+
+
+The Rust core flow; platform wrappers mirror it one-to-one:
+
+```rust
+use std::sync::Arc;
+use wvb::updater::Updater;
+
+let updater = Updater::new(source, remote, None); // None -> UpdaterConfig::default()
+
+let update = updater.get_update("app").await?;
+if update.is_available {
+ // download fetches, verifies, and stages — but does not activate
+ let info = updater.download("app", None).await?;
+ // install re-verifies, activates, and prunes old versions
+ updater.install("app", info.version).await?;
+ // reload the webview to serve the new version
+}
+```
+
+`get_update` returns a `BundleUpdateInfo`:
+
+```rust
+pub struct BundleUpdateInfo {
+ pub name: String,
+ pub version: String,
+ pub local_version: Option,
+ pub is_available: bool,
+ pub etag: Option,
+ pub integrity: Option,
+ pub signature: Option,
+ pub last_modified: Option,
+}
+```
+
+Pass `Some(version)` to `download` to fetch a specific version instead of the current one:
+
+```rust
+// fetch current deployed version
+let info = updater.download("app", None).await?;
+// fetch an exact version (GET /bundles/{name}/{version})
+let info = updater.download("app", Some("1.1.0".into())).await?;
+```
+
+The platform guides show the idiomatic call for each target:
+[Electron](/docs/guide/platforms/electron), [Tauri](/docs/guide/platforms/tauri),
+[Android](/docs/guide/platforms/android), [iOS](/docs/guide/platforms/ios), and
+[Deno Desktop](/docs/guide/platforms/deno).
+
+## The remote HTTP contract
+
+A Webview Bundle server implements four endpoints. The provider packages (local, AWS, Cloudflare) implement this spec, and you can build your own to match.
+
+| Method and path | Purpose |
+| ------------------------------- | -------------------------------------------------- |
+| `GET /bundles` | List deployed bundles: `[{ "name", "version" }]` |
+| `HEAD /bundles/{name}` | Current version's metadata (headers only, no body) |
+| `GET /bundles/{name}` | Download the current version |
+| `GET /bundles/{name}/{version}` | Download a specific version |
+
+`GET /bundles`, `HEAD /bundles/{name}`, and `GET /bundles/{name}` accept an optional `?channel=` query. `GET /bundles/{name}/{version}` does not. Metadata travels in response headers:
+
+| Header | Required | Meaning |
+| -------------------------- | -------- | ------------------- |
+| `Webview-Bundle-Name` | yes | bundle name |
+| `Webview-Bundle-Version` | yes | version |
+| `Webview-Bundle-Integrity` | no | integrity string |
+| `Webview-Bundle-Signature` | no | signature |
+| `ETag`, `Last-Modified` | no | standard validators |
+
+A download response looks like:
+
+```sh
+$ curl -sI http://localhost:4313/bundles/app
+HTTP/1.1 200 OK
+Content-Type: application/webview-bundle
+Webview-Bundle-Name: app
+Webview-Bundle-Version: 1.2.0
+Webview-Bundle-Integrity: sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
+ETag: "abc123"
+```
+
+The list endpoint returns `application/json`:
+
+```json
+[
+ { "name": "app", "version": "1.2.0" },
+ { "name": "admin", "version": "0.4.1" }
+]
+```
+
+A missing bundle or version returns `404`; requesting a specific non-deployed version returns `403` when the server disallows it. Missing required headers, or any non-2xx response, cause the client to reject the download. The default request timeout is 120s. For a worked server and local testing, see [Building a remote](/docs/guide/remote).
+
+## Integrity
+
+An integrity hash lets a device confirm the downloaded bytes are exactly the bytes you published. Webview Bundle uses **SHA-2**: `sha256` (the default), `sha384`, or `sha512`, serialized as `":"`:
+
+```text
+sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
+```
+
+The publisher attaches this string at upload time ([Remote config](/docs/config/remote)), the server returns it in `Webview-Bundle-Integrity`, and the updater enforces it on download per an **integrity policy**:
+
+| Policy | Behavior |
+| ---------- | ------------------------------------------------------------ |
+| `Strict` | integrity value must be present and must match, or reject |
+| `Optional` | **default** — verify if present; allow when none is attached |
+| `None` | skip integrity checking entirely |
+
+```rust
+use wvb::integrity::IntegrityPolicy;
+use wvb::updater::UpdaterConfig;
+
+let config = UpdaterConfig::new()
+ .integrity_policy(IntegrityPolicy::Strict);
+```
+
+## Signatures
+
+Where integrity proves the bytes are intact, a signature proves _who_ published them. The signature covers the **integrity string bytes** (`":"`), not the raw bundle — so signature verification requires an integrity value to be present. Supply a public key, and the updater rejects any download whose signature does not verify.
+
+Verify algorithms:
+
+- **ECDSA** with curve P-256 (secp256r1) or P-384 (secp384r1)
+- **Ed25519**
+- **RSA** with PKCS#1 v1.5 or PSS padding, hashing with SHA-256
+
+Public-key formats:
+
+| Format | Loader | Applies to |
+| ---------------- | ------------------------- | ------------ |
+| SPKI PEM | `from_public_key_pem` | all |
+| SPKI DER | `from_public_key_der` | all |
+| PKCS#1 PEM/DER | `from_pkcs1_pem` / `_der` | RSA only |
+| SEC1 point bytes | `from_sec1_bytes` | ECDSA only |
+| Raw 32-byte key | `from_public_key_bytes` | Ed25519 only |
+
+An updater requiring both a matching integrity hash and a valid Ed25519 signature:
+
+```rust
+use wvb::integrity::IntegrityPolicy;
+use wvb::signature::{Ed25519Verifier, SignatureVerifier};
+use wvb::updater::{Updater, UpdaterConfig};
+use std::sync::Arc;
+
+let verifier = SignatureVerifier::Ed25519(Arc::new(
+ Ed25519Verifier::from_public_key_pem(PUBLIC_KEY_PEM)?,
+));
+let config = UpdaterConfig::new()
+ .integrity_policy(IntegrityPolicy::Strict)
+ .signature_verifier(verifier);
+let updater = Updater::new(source, remote, Some(config));
+```
+
+ECDSA P-256 from an SPKI PEM:
+
+```rust
+use wvb::signature::{EcdsaSecp256r1Verifier, SignatureVerifier};
+use std::sync::Arc;
+
+let verifier = SignatureVerifier::EcdsaSecp256r1(Arc::new(
+ EcdsaSecp256r1Verifier::from_public_key_pem(PUBLIC_KEY_PEM)?,
+));
+```
+
+The publish-side counterpart — which algorithm signs, and the private-key format — is set in [Remote config](/docs/config/remote).
+
+## Channels
+
+A channel routes different versions to different audiences for staged rollouts. Deploy to a channel, and only clients requesting that channel receive it; clients with no channel get the default deployment.
+
+```sh
+wvb upload --version 1.2.0 --deploy --channel beta # only beta clients see 1.2.0
+wvb deploy --version 1.2.0 # later, promote to the default channel
+```
+
+On the device, the updater carries the channel in its config and forwards it as `?channel=`:
+
+```rust
+let config = UpdaterConfig::new().channel("beta");
+let updater = Updater::new(source, remote, Some(config));
+// get_update / download now request ?channel=beta
+```
+
+A channel is a free-form string; there is no hardcoded default name — when no channel is set, the query parameter is omitted.
+
+## Where to go next
+
+
+
+
+
+
+
+
+
diff --git a/content/docs/guide/remote.mdx b/content/docs/guide/remote.mdx
new file mode 100644
index 0000000..64cbcc5
--- /dev/null
+++ b/content/docs/guide/remote.mdx
@@ -0,0 +1,235 @@
+---
+title: Building a remote
+description: Stand up an update server for over-the-air bundle delivery, run the whole loop locally, and choose a provider.
+---
+
+A remote is any HTTP server that serves bundles to your app's updater over a small, fixed contract. This is the server-side companion to [Remote bundles](/docs/guide/remote-bundles): build a remote, run the full publish-and-update loop locally with no cloud account, then pick a provider for production.
+
+Webview Bundle ships remotes for three backends — `local`, `aws`, and `cloudflare` — each split into three packages by role. Use only what the job needs.
+
+## Package roles
+
+Every backend has up to three packages:
+
+| Role | Package | What it does |
+| ------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| Config client | `@wvb/remote-` | A `wvb.config` plug-in that produces `{ uploader, deployer }`. Runs on the publisher's machine or CI to push and deploy bundles. |
+| HTTP server | `@wvb/remote--provider` | The server that implements the HTTP contract and serves bundles to clients. |
+| Pulumi IaC | `@wvb/remote--provider-pulumi` | Infrastructure-as-code that provisions the cloud resources and deploys the provider. |
+
+Concretely, per backend:
+
+| Backend | Config client | HTTP server | Pulumi IaC |
+| ------------ | ------------------------ | --------------------------------- | ---------------------------------------- |
+| `local` | `@wvb/remote-local` | `@wvb/remote-local-provider` | — |
+| `aws` | `@wvb/remote-aws` | `@wvb/remote-aws-provider` | `@wvb/remote-aws-provider-pulumi` |
+| `cloudflare` | `@wvb/remote-cloudflare` | `@wvb/remote-cloudflare-provider` | `@wvb/remote-cloudflare-provider-pulumi` |
+
+The config client is what you wire into the `remote` block of `wvb.config.ts`. The `uploader` writes a packed `.wvb` to storage; the `deployer` marks a version as the one clients should receive. Spread its result into `remote`:
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+import { awsRemote } from '@wvb/remote-aws';
+
+export default defineConfig({
+ remote: {
+ endpoint: 'https://cdn.example.com',
+ bundleName: 'app',
+ ...awsRemote({ bucket: 'my-bundles', aws: { region: 'us-east-1' } }),
+ },
+});
+```
+
+Publish-side integrity and signature options live alongside `uploader`/`deployer` — see [Remote, integrity & signature config](/docs/config/remote).
+
+## The HTTP contract
+
+Every provider implements the same four endpoints, so the client never cares which backend is behind it.
+
+| Method and path | Purpose | Notable |
+| ------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
+| `GET /bundles` | List deployed bundles | `200` JSON `[{ "name", "version" }]`; optional `?channel=` |
+| `HEAD /bundles/{name}` | Current version's metadata (headers only) | `404` if undeployed |
+| `GET /bundles/{name}` | Download the current version | `404` if undeployed |
+| `GET /bundles/{name}/{version}` | Download a specific version | `403` unless other-versions allowed |
+
+Required response headers are `Webview-Bundle-Name` and `Webview-Bundle-Version`. Optional: `Webview-Bundle-Integrity`, `Webview-Bundle-Signature`, `ETag`, `Last-Modified`. Downloads use `Content-Type: application/webview-bundle`. The full status-code and channel-query spec is in [Remote bundles](/docs/guide/remote-bundles).
+
+A minimal handler that answers these four routes is a valid remote — no provider package required:
+
+```ts title="minimal-remote.ts"
+import { Hono } from 'hono';
+
+const app = new Hono();
+
+app.get('/bundles', c => c.json([{ name: 'app', version: '1.1.0' }]));
+
+app.get('/bundles/:name', c => {
+ const body = readCurrentBundle(c.req.param('name')); // your storage
+ return new Response(body, {
+ headers: {
+ 'Content-Type': 'application/webview-bundle',
+ 'Webview-Bundle-Name': 'app',
+ 'Webview-Bundle-Version': '1.1.0',
+ },
+ });
+});
+
+app.get(
+ '/bundles/:name/:version',
+ c => c.body(null, 403) // forbid specific versions by default
+);
+
+export default app;
+```
+
+## Test the loop locally
+
+Run the entire update loop on your machine. `@wvb/remote-local` writes uploads to a local directory (default `~/.wvb/local`), and `wvb remote local` serves that directory over the same contract a production server implements.
+
+### Point your config at the local provider
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+import { localRemote } from '@wvb/remote-local';
+
+export default defineConfig({
+ pack: {
+ outFile: 'app', // → app.wvb
+ },
+ remote: {
+ endpoint: 'http://localhost:4313',
+ bundleName: 'app',
+ ...localRemote(), // { uploader, deployer } writing to ~/.wvb/local
+ },
+});
+```
+
+Keep the store inside your project instead of the home directory:
+
+```ts
+...localRemote({ baseDir: './.wvb/local' }),
+```
+
+The local store lays out files per bundle and version:
+
+```text
+~/.wvb/local/bundles/app/app_1.1.0.wvb # bundle binary
+~/.wvb/local/bundles/app/app_1.1.0.json # { integrity?, signature? }
+~/.wvb/local/bundles/app/deployment.json # deployment pointer
+```
+
+### Publish a version
+
+```sh
+wvb pack # build the .wvb from your config
+wvb upload app --version 1.1.0 --deploy # write app_1.1.0.wvb into ~/.wvb/local and deploy it
+```
+
+`wvb upload` packs before uploading by default, so the explicit `wvb pack` is optional. `--deploy` defaults to `false`; pass it, or deploy separately:
+
+```sh
+wvb deploy app --version 1.1.0 # make 1.1.0 the version clients receive
+```
+
+The version is the `--version,-V` flag, not a positional. See the [CLI reference](/docs/guide/cli) for every flag.
+
+### Serve it
+
+```sh
+wvb remote local # serves ~/.wvb/local on http://localhost:4313
+wvb remote local --base-dir ./.wvb/local --port 4313 --allow-other-versions
+```
+
+`wvb remote local` listens on port `4313` by default (base dir `~/.wvb/local`). `--allow-other-versions` lets clients fetch a non-deployed version through `GET /bundles/{name}/{version}` instead of getting a `403`.
+
+### Point your app at it
+
+Set the app's updater endpoint to `http://localhost:4313`. Now `getUpdate`, `download`, and `install` hit your local server and download, verify, and activate the bundle exactly as production would:
+
+```rust
+let remote = Remote::builder()
+ .endpoint("http://localhost:4313")
+ .build()?;
+let updater = Updater::new(source, Arc::new(remote), None);
+
+let info = updater.get_update("app").await?; // HEAD /bundles/app
+if info.is_available {
+ updater.download("app", None).await?; // GET /bundles/app
+ updater.install("app", &info.version).await?; // activate the downloaded version
+}
+```
+
+
+ On the Android emulator, the host machine is not `localhost`. Point the updater at
+ `http://10.0.2.2:4313` instead.
+
+
+### Inspect with the client commands
+
+The CLI can act as a client against any remote — handy for confirming a server behaves before pointing an app at it.
+
+```sh
+wvb remote list --endpoint http://localhost:4313 # GET /bundles (alias: remote ls)
+wvb remote current app --endpoint http://localhost:4313 # HEAD /bundles/app
+wvb remote download app --endpoint http://localhost:4313 --outfile ./app.wvb # GET /bundles/app
+```
+
+### Preview a single bundle
+
+To inspect a `.wvb` without a server or deployment, serve its files directly:
+
+```sh
+wvb serve ./app.wvb # serves the bundle's files on http://localhost:4312
+wvb serve ./app.wvb --port 8080
+```
+
+`wvb serve` listens on port `4312` by default. Use it to confirm a pack produced the files and headers you expect before you upload.
+
+## Choose a provider
+
+When you move past local testing, swap `localRemote()` for a cloud backend. Each provider exposes a compatible `uploader` and `deployer` for `wvb.config.ts`, a server that implements the contract above, and a Pulumi package to provision it.
+
+```ts title="wvb.config.ts — Cloudflare"
+import { cloudflareRemote } from '@wvb/remote-cloudflare';
+
+export default defineConfig({
+ remote: {
+ endpoint: 'https://bundles.example.com',
+ bundleName: 'app',
+ ...cloudflareRemote({
+ accountId: process.env.CF_ACCOUNT_ID!,
+ bucket: 'webview-bundle',
+ kvNamespaceId: process.env.CF_KV_NAMESPACE_ID!,
+ }),
+ },
+});
+```
+
+Provision the matching infrastructure with Pulumi:
+
+```ts title="index.ts — pulumi up"
+import { WebviewBundleRemoteProvider } from '@wvb/remote-cloudflare-provider-pulumi';
+
+export const remote = new WebviewBundleRemoteProvider('app', {
+ accountId: process.env.CF_ACCOUNT_ID!,
+});
+```
+
+
+
+
+
+
diff --git a/content/docs/guide/spec/errors.mdx b/content/docs/guide/spec/errors.mdx
new file mode 100644
index 0000000..0c37035
--- /dev/null
+++ b/content/docs/guide/spec/errors.mdx
@@ -0,0 +1,93 @@
+---
+title: Errors
+description: How the Webview Bundle remote signals failures over HTTP and how the client maps each status to an error.
+---
+
+A remote signals failure through standard HTTP status codes. The Webview Bundle client treats any `2xx` response as success and maps the documented non-`2xx` codes to specific errors. Implement these responses exactly so the client and updater react the way they should. For the full request contract these errors sit alongside, see the [Remote HTTP Spec](/docs/guide/spec) and [Building a remote](/docs/guide/remote).
+
+## Status codes
+
+| Status | Meaning | When |
+| --------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `403 Forbidden` | Version-specific download disabled | A client requests `GET /bundles/{name}/{version}` but the server disables other-version downloads. The client maps this to `RemoteForbidden`. |
+| `404 Not Found` | Bundle or version not deployed | The named bundle or the requested version is not deployed. The client maps this to `RemoteBundleNotFound`. |
+| Other non-`2xx` | Remote HTTP error | Any other failure. The body is parsed as JSON `{ "message": string }` and surfaced as a remote HTTP error carrying the status and message. |
+
+The client treats every `2xx` status as success, so a remote should reserve non-`2xx` codes for genuine failures.
+
+## Forbidden (403)
+
+`403` is the deliberate signal that a version-specific download is disabled. Providers expose this as an `allowOtherVersions` option, which defaults to `false`. While disabled, `GET /bundles/{name}/{version}` returns `403` even when that version exists on the server. The current-version routes (`GET /bundles/{name}` and `HEAD /bundles/{name}`) are unaffected.
+
+```http
+HTTP/1.1 403 Forbidden
+```
+
+The client raises `RemoteForbidden` for this status.
+
+## Not Found (404)
+
+`404` means the named bundle or the requested version is not deployed. List responses exclude undeployed bundles, so a follow-up request for one of them returns `404`.
+
+```http
+HTTP/1.1 404 Not Found
+```
+
+The client raises `RemoteBundleNotFound` for this status.
+
+## Other errors
+
+For any other non-`2xx` status, return a JSON body of the shape `{ "message": string }`. The client parses this body and raises a remote HTTP error that carries both the status and the message.
+
+```http
+HTTP/1.1 500 Internal Server Error
+Content-Type: application/json
+```
+
+```json
+{ "message": "failed to read bundle from storage" }
+```
+
+
+ Send a `{ "message": string }` body on these responses. The `message` is what the client surfaces,
+ so make it descriptive enough to diagnose the failure.
+
+
+## Missing required headers
+
+A successful download or metadata response must still carry the required headers. The client rejects a `2xx` response that omits `Webview-Bundle-Name` or `Webview-Bundle-Version`, treating it as an invalid bundle on the client side rather than a transport error. This is a client-side failure, not a status the server returns, so a remote must always set both headers on download and metadata responses. See [GET /bundles/{name}](/docs/guide/spec/get-current) and [HEAD /bundles/{name}](/docs/guide/spec/head-current) for the full header set.
+
+## Where to go next
+
+
+
+
+
+
+
+
+
diff --git a/content/docs/guide/spec/get-current.mdx b/content/docs/guide/spec/get-current.mdx
new file mode 100644
index 0000000..e182f6f
--- /dev/null
+++ b/content/docs/guide/spec/get-current.mdx
@@ -0,0 +1,99 @@
+---
+title: Download current bundle
+description: Download the current deployed version of a bundle as raw .wvb bytes, optionally selecting a channel.
+---
+
+Download the current deployed version of a bundle. The server streams the `.wvb` archive as the response body and carries the bundle's metadata in `Webview-Bundle-*` headers. This is the call the [updater](/docs/references/node/updater) makes to fetch a new version after a check reports one is available.
+
+```http
+GET /bundles/{name}
+```
+
+Pass `?channel=` to download the current version for a specific channel instead of the default deployment.
+
+## Parameters
+
+| Name | In | Type | Required | Description |
+| --------- | ----- | ------ | -------- | ----------------------------------------------------------- |
+| `name` | path | string | yes | Bundle name to download. |
+| `channel` | query | string | no | Channel to select. Omit to download the default deployment. |
+
+## Responses
+
+| Status | Description |
+| ------ | ----------------------------------------------------- |
+| `200` | The bundle was found; the body is the `.wvb` archive. |
+| `404` | The named bundle is not deployed. |
+
+For the `404` shape and how the client maps other non-2xx responses, see [Errors](/docs/guide/spec/errors).
+
+### Response headers
+
+A `200` response carries the same bundle metadata headers as [HEAD /bundles/{name}](/docs/guide/spec/head-current). The client reads them case-insensitively; the canonical casing is shown here.
+
+| Header | Required | Description |
+| -------------------------- | -------- | ------------------------------------ |
+| `Webview-Bundle-Name` | yes | Bundle name. |
+| `Webview-Bundle-Version` | yes | Bundle version. |
+| `Webview-Bundle-Integrity` | no | Integrity string `":"`. |
+| `Webview-Bundle-Signature` | no | Base64 signature. |
+| `ETag` | no | Standard validator. |
+| `Last-Modified` | no | Standard validator. |
+
+The client rejects a `200` response that omits `Webview-Bundle-Name` or `Webview-Bundle-Version`.
+
+The response body is the raw `.wvb` archive bytes, served with `Content-Type: application/webview-bundle`.
+
+## Example
+
+Download the current version and dump the response headers:
+
+```sh
+curl -s http://localhost:4313/bundles/app -o app.wvb -D -
+```
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/webview-bundle
+Webview-Bundle-Name: app
+Webview-Bundle-Version: 1.2.0
+Webview-Bundle-Integrity: sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
+
+
+```
+
+Select a channel's current version with `?channel=`:
+
+```sh
+curl -s 'http://localhost:4313/bundles/app?channel=beta' -o app-beta.wvb
+```
+
+A bundle that is not deployed returns `404`:
+
+```sh
+curl -sI http://localhost:4313/bundles/unknown
+```
+
+```http
+HTTP/1.1 404 Not Found
+```
+
+## Related
+
+
+
+
+
+
diff --git a/content/docs/guide/spec/get-version.mdx b/content/docs/guide/spec/get-version.mdx
new file mode 100644
index 0000000..86e0fb3
--- /dev/null
+++ b/content/docs/guide/spec/get-version.mdx
@@ -0,0 +1,106 @@
+---
+title: 'Download a specific version'
+description: Download a named bundle at an exact version, gated by the server's other-version download policy.
+---
+
+Download a bundle at an exact version rather than the deployed current one. Unlike the other download routes, this operation does not accept a channel: the version is named directly in the path.
+
+```http
+GET /bundles/{name}/{version}
+```
+
+A server only serves this route when it allows other-version downloads. Providers expose that as the `allowOtherVersions` option, which defaults to `false`; when it is disabled, the request returns `403` even though the version exists.
+
+## Parameters
+
+| Name | In | Type | Required | Description |
+| ----------- | ---- | ------ | -------- | -------------------------- |
+| `{name}` | path | string | yes | Bundle name to download. |
+| `{version}` | path | string | yes | Exact version to download. |
+
+This route does not accept a `channel` query parameter.
+
+## Responses
+
+| Status | Description |
+| ------ | -------------------------------------------- |
+| `200` | The bundle bytes, with metadata headers. |
+| `403` | The server disables other-version downloads. |
+| `404` | The named bundle or version is not deployed. |
+
+The client maps `403` to a "forbidden" error and `404` to a "bundle not found" error. See [Errors](/docs/guide/spec/errors) for the full mapping and the JSON error body shape.
+
+### Response headers
+
+| Header | Required | Description |
+| -------------------------- | -------- | ------------------------------------ |
+| `Webview-Bundle-Name` | yes | Bundle name. |
+| `Webview-Bundle-Version` | yes | Bundle version. |
+| `Webview-Bundle-Integrity` | no | Integrity string `":"`. |
+| `Webview-Bundle-Signature` | no | Base64 signature. |
+| `ETag` | no | Standard validator. |
+| `Last-Modified` | no | Standard validator. |
+
+The client rejects a response that omits `Webview-Bundle-Name` or `Webview-Bundle-Version`.
+
+The `200` body is the raw `.wvb` bytes, served with `Content-Type: application/webview-bundle`.
+
+
+ This route never selects a channel. To download the current version, with or without a channel,
+ use [GET /bundles/{name}](/docs/guide/spec/get-current).
+
+
+## Example
+
+Download version `1.2.0` of the `app` bundle.
+
+```sh
+$ curl -s http://localhost:4313/bundles/app/1.2.0 -o app-1.2.0.wvb -D -
+```
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/webview-bundle
+Webview-Bundle-Name: app
+Webview-Bundle-Version: 1.2.0
+Webview-Bundle-Integrity: sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
+```
+
+When the server disables other-version downloads, the same request returns `403`:
+
+```sh
+$ curl -sI http://localhost:4313/bundles/app/1.2.0
+HTTP/1.1 403 Forbidden
+```
+
+A version that is not deployed returns `404`:
+
+```sh
+$ curl -sI http://localhost:4313/bundles/app/9.9.9
+HTTP/1.1 404 Not Found
+```
+
+## Related
+
+
+
+
+
+
+
diff --git a/content/docs/guide/spec/head-current.mdx b/content/docs/guide/spec/head-current.mdx
new file mode 100644
index 0000000..9a2b63d
--- /dev/null
+++ b/content/docs/guide/spec/head-current.mdx
@@ -0,0 +1,90 @@
+---
+title: Get current metadata
+description: HEAD /bundles/{name} returns the current deployed version's metadata as response headers, with no body.
+---
+
+`HEAD /bundles/{name}` returns the metadata of a bundle's current deployed version as response headers, with no body. The updater calls it to check whether a newer version is available before downloading anything.
+
+```http
+HEAD /bundles/{name}
+```
+
+## Parameters
+
+| Name | In | Type | Required | Description |
+| --------- | ----- | ------ | -------- | ----------------------------------------------------------------------- |
+| `name` | path | string | yes | The bundle name to inspect. |
+| `channel` | query | string | no | Selects a channel's deployment. Omit it to read the default deployment. |
+
+## Responses
+
+| Status | Description |
+| ------ | ------------------------------------------------------------ |
+| `204` | The bundle is deployed. Metadata is returned in the headers. |
+| `404` | The named bundle is not deployed. |
+
+Success is `204 No Content`: the response carries metadata headers only and never a body. A `404` means the named bundle has no current deployment. See [Errors](/docs/guide/spec/errors) for the error body shape and other status codes.
+
+### Response headers
+
+| Header | Required | Description |
+| -------------------------- | -------- | --------------------------------------------------- |
+| `Webview-Bundle-Name` | yes | The bundle name. |
+| `Webview-Bundle-Version` | yes | The current deployed version. |
+| `Webview-Bundle-Integrity` | no | Integrity string in the form `":"`. |
+| `Webview-Bundle-Signature` | no | Base64-encoded signature over the integrity string. |
+| `ETag` | no | Standard validator. |
+| `Last-Modified` | no | Standard validator. |
+
+The client reads these headers case-insensitively and rejects a response that omits `Webview-Bundle-Name` or `Webview-Bundle-Version`.
+
+
+ This route returns metadata only. To fetch the bundle bytes, use [GET /bundles/{name}
+ ](/docs/guide/spec/get-current).
+
+
+## Example
+
+```sh
+$ curl -sI 'http://localhost:4313/bundles/app'
+```
+
+```http
+HTTP/1.1 204 No Content
+Webview-Bundle-Name: app
+Webview-Bundle-Version: 1.2.0
+Webview-Bundle-Integrity: sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
+ETag: "abc123"
+Last-Modified: Tue, 01 Jul 2026 00:00:00 GMT
+```
+
+Read a specific channel's current version with `?channel=`:
+
+```sh
+$ curl -sI 'http://localhost:4313/bundles/app?channel=beta'
+```
+
+## Related
+
+
+
+
+
+
+
diff --git a/content/docs/guide/spec/index.mdx b/content/docs/guide/spec/index.mdx
new file mode 100644
index 0000000..a2cb3e8
--- /dev/null
+++ b/content/docs/guide/spec/index.mdx
@@ -0,0 +1,76 @@
+---
+title: Remote HTTP Spec
+description: The HTTP contract a server implements so the Webview Bundle client and updater can list, inspect, and download bundles.
+---
+
+Any HTTP server that implements the endpoints described here is a valid Webview Bundle remote. The client and updater speak this contract to list deployed bundles, read a bundle's current metadata, and download bundle bytes. Implement it directly, or use a provider that already does: the [Local](/docs/guide/providers/local), [AWS](/docs/guide/providers/aws), and [Cloudflare](/docs/guide/providers/cloudflare) providers all serve this exact contract. To stand up a server and choose a provider, see [Building a remote](/docs/guide/remote); for the conceptual update flow that drives these calls, see [Remote bundles & updates](/docs/guide/remote-bundles).
+
+## Base URL
+
+A remote is configured with a single `endpoint` base URL. Every route is a path under that base, so an `endpoint` of `https://cdn.example.com` serves `GET https://cdn.example.com/bundles`, `HEAD https://cdn.example.com/bundles/{name}`, and so on. The client builds each request URL by joining the configured `endpoint` with the route path and any query string.
+
+## Conventions
+
+These conventions hold across every endpoint. Each endpoint page repeats only what is specific to its route.
+
+### Response headers
+
+Metadata travels in `Webview-Bundle-*` response headers on download and metadata responses. The client reads them case-insensitively; the canonical casing is shown here.
+
+| Header | Required | Meaning |
+| -------------------------- | -------- | ----------------------------------- |
+| `Webview-Bundle-Name` | yes | Bundle name |
+| `Webview-Bundle-Version` | yes | Bundle version |
+| `Webview-Bundle-Integrity` | no | Integrity string `":"` |
+| `Webview-Bundle-Signature` | no | Base64 signature |
+| `ETag` | no | Standard validator |
+| `Last-Modified` | no | Standard validator |
+
+The client rejects any response that omits `Webview-Bundle-Name` or `Webview-Bundle-Version`.
+
+### Content type
+
+Download responses set `Content-Type: application/webview-bundle`. The list response sets `Content-Type: application/json`.
+
+### Channels
+
+The list, metadata, and current-download routes accept an optional `?channel=` query that selects a channel's deployment. The version-specific download does not accept a channel. Omit the query to target the default deployment.
+
+### Request timeout
+
+The client applies a default total request timeout of 120s to every call, so a server should respond well within it.
+
+
+ The 120s timeout can be overridden in the client's HTTP configuration, but the default applies to
+ every request unless changed.
+
+
+## Endpoints
+
+
+
+
+
+
+
+
diff --git a/content/docs/guide/spec/list-bundles.mdx b/content/docs/guide/spec/list-bundles.mdx
new file mode 100644
index 0000000..c3fc6fc
--- /dev/null
+++ b/content/docs/guide/spec/list-bundles.mdx
@@ -0,0 +1,97 @@
+---
+title: List bundles
+description: GET /bundles returns the bundles a remote currently has deployed, optionally scoped to a channel.
+---
+
+Return the bundles a remote currently has deployed. The client and updater call this first to discover which bundles exist and what version each is deployed at, before reading metadata or downloading bytes.
+
+```http
+GET /bundles
+```
+
+## Parameters
+
+| Name | In | Type | Required | Description |
+| --------- | ----- | ------ | -------- | ------------------------------------------------------------------------------- |
+| `channel` | query | string | No | Select a channel's deployments. When omitted, the default deployment is listed. |
+
+## Responses
+
+| Status | Description |
+| ------ | ----------------------------------------------- |
+| `200` | A JSON array of the currently deployed bundles. |
+
+For 4xx responses, see [Errors](/docs/guide/spec/errors).
+
+### Response headers
+
+| Header | Required | Description |
+| -------------- | -------- | ----------------------------------------- |
+| `Content-Type` | Yes | `application/json` for the list response. |
+
+### Response body
+
+The body is a JSON array. Each item describes one deployed bundle by `name` and `version`. Bundles that are not deployed are excluded.
+
+```json
+[
+ { "name": string, "version": string }
+]
+```
+
+| Field | Type | Description |
+| --------- | ------ | ------------------------------- |
+| `name` | string | The bundle name. |
+| `version` | string | The currently deployed version. |
+
+## Example
+
+```sh
+curl -s http://localhost:4313/bundles
+```
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+[
+ { "name": "app", "version": "1.2.0" },
+ { "name": "admin", "version": "0.4.1" }
+]
+```
+
+Scope the list to a channel with `?channel=`:
+
+```sh
+curl -s 'http://localhost:4313/bundles?channel=beta'
+```
+
+
+ This operation is the entry point of the remote HTTP contract. To read a bundle's current metadata
+ or download its bytes, see the [Remote HTTP Spec](/docs/guide/spec) overview.
+
+
+## Where to go next
+
+
+
+
+
+
+
diff --git a/content/docs/guide/spec/meta.json b/content/docs/guide/spec/meta.json
new file mode 100644
index 0000000..f8023a5
--- /dev/null
+++ b/content/docs/guide/spec/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Spec",
+ "pages": ["index", "list-bundles", "head-current", "get-current", "get-version", "errors"]
+}
diff --git a/content/docs/guide/why-webview-bundle.mdx b/content/docs/guide/why-webview-bundle.mdx
new file mode 100644
index 0000000..332774d
--- /dev/null
+++ b/content/docs/guide/why-webview-bundle.mdx
@@ -0,0 +1,190 @@
+---
+title: Why Webview Bundle?
+description: The case for shipping your web app as an offline-first .wvb bundle with over-the-air updates.
+---
+
+Webview Bundle packs your built web app into one compressed, integrity-checked archive (`.wvb`) and serves it to the webview over a custom URL scheme. The webview reads HTML, JS, CSS, and media from local storage instead of the network. Configure a remote and the app downloads newer bundles over the air, shipping updated code without a native app-store release.
+
+Three reasons teams reach for it: offline-first delivery, over-the-air updates, and one workflow across every webview platform.
+
+## Offline-first by default
+
+A remote URL makes first paint wait on the network. A cold cache, a captive portal, or a dropped connection becomes a blank screen.
+
+Webview Bundle serves your assets straight from the local `.wvb` that ships with the app. The protocol maps each request to a file inside the bundle and returns it:
+
+```text
+bundle://app/index.html -> index.html inside the app bundle
+bundle://app/assets/x.js -> assets/x.js inside the app bundle
+```
+
+```ts title="Serving a bundle locally"
+import { BundleSource, BundleProtocol } from '@wvb/node';
+
+const source = new BundleSource({
+ builtinDir: '/path/to/builtin',
+ remoteDir: '/path/to/remote',
+});
+
+const protocol = new BundleProtocol(source);
+
+// Your platform integration routes scheme requests here.
+const res = await protocol.handle('get', 'bundle://app/index.html');
+// res: { status, headers, body }
+```
+
+First paint never waits on the network. The app paints from the local bundle on the subway, on a plane, or mid-session when the connection drops; the network becomes an optimization.
+
+
+ The bundle protocol supports `GET` and `HEAD`, replays the headers stored for each file, and
+ answers `Range` requests for media with `206`. See [Protocol
+ handling](/docs/guide/protocol-handling) for the full request mapping.
+
+
+## Over-the-air updates
+
+An app-store release is slow and gated: a copy typo or a broken button can sit behind a multi-day review. Because your app code lives in a `.wvb`, you fix it by deploying a new bundle version and letting installed apps download it over the air (OTA). The native binary never changes.
+
+Two sources back the update model. The `builtin` source is the read-only bundle you shipped; the `remote` source holds downloaded bundles and takes priority. A failed download or activation falls back to `builtin`, so OTA can never leave a device without a working app.
+
+```ts title="Two sources: remote wins, builtin is the floor"
+new BundleSource({
+ builtinDir: '/path/to/builtin', // shipped in the app, read-only
+ remoteDir: '/path/to/remote', // OTA downloads, takes priority
+});
+```
+
+Updates run in two explicit steps so a half-downloaded bundle is never served: `download` stages into the remote source and verifies; `install` activates the staged version.
+
+```ts title="Check, download, install"
+import { Updater } from '@wvb/node';
+
+const updater = new Updater(source, remote);
+
+const info = await updater.getUpdate('app');
+if (info.isAvailable) {
+ await updater.download('app'); // stage + verify
+ await updater.install('app', info.version); // activate
+}
+```
+
+### Staged rollouts with channels
+
+A channel is an optional string sent as `?channel=` on list and download requests. Pass it through `UpdaterOptions` to point a device cohort at `beta`, then promote. Omit it and the parameter is dropped — there is no hardcoded channel name.
+
+```ts title="Pin this device to the beta channel"
+const updater = new Updater(source, remote, { channel: 'beta' });
+```
+
+```http
+GET /bundles/app?channel=beta
+```
+
+### Authenticity with integrity and signatures
+
+Every download is verified before activation. Integrity uses SHA-2 (`sha256` default, plus `sha384` and `sha512`), serialized as `":"`:
+
+```text
+sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
+```
+
+The default policy is `optional` (verify if present). Set `strict` to require integrity, and add a declarative `signatureVerifier` to prove who published — the signature covers the integrity string bytes:
+
+```ts title="Require integrity, verify an Ed25519 signature"
+const updater = new Updater(source, remote, {
+ integrityPolicy: 'strict',
+ signatureVerifier: {
+ algorithm: 'ed25519',
+ key: { format: 'raw', data: publicKeyBytes }, // 32-byte Ed25519 key
+ },
+});
+```
+
+Verification supports ECDSA (`ecdsaSecp256R1`, `ecdsaSecp384R1`), `ed25519`, and RSA (`rsaPkcs1V15`, `rsaPss`).
+
+
+ Integrity is SHA-2, not SHA-3. The signature covers the integrity string, not the raw archive
+ bytes, and requires an integrity value to be present. See [Remote
+ bundles](/docs/guide/remote-bundles) for the verification flow and [Remote, integrity & signature
+ config](/docs/config/remote) for the schema.
+
+
+## For web developers
+
+Keep writing the app you already know. Webview Bundle operates on your bundler's output, not your source, so React, Vue, Svelte, or plain HTML all work — no custom framework, no special build step. Point `wvb pack` at your build directory:
+
+```sh title="Pack your build output into one .wvb"
+npm install --save-dev @wvb/cli
+npx wvb pack ./dist
+```
+
+The packed file defaults to `.wvb/`; the `.wvb` extension is appended for you. Override it with the `outFile` field (a single path):
+
+```ts title="wvb.config.ts"
+import { defineConfig } from '@wvb/config';
+
+export default defineConfig({
+ pack: {
+ outFile: '.wvb/app',
+ },
+});
+```
+
+Inside the webview, your code talks to the native host through `@wvb/bridge`. One `invoke()` API exposes the source, remote, and updater commands and picks the transport for Electron, Tauri, Android, or iOS underneath. The same update code runs everywhere:
+
+```ts title="Driving updates from web code"
+import { updater } from '@wvb/bridge';
+
+const update = await updater.getUpdate('app');
+if (update.isAvailable) {
+ await updater.download('app');
+ await updater.install('app', update.version);
+}
+```
+
+The bridge resolves the platform at call time, so you never branch on it yourself:
+
+```ts title="Platform is detected for you"
+import { platform } from '@wvb/bridge';
+
+platform.type; // 'electron' | 'tauri' | 'android' | 'ios'
+```
+
+### How this compares
+
+Two common alternatives solve part of the same problem:
+
+- **Per-platform native asset bundling.** Embed your web build in each shell and write serving and update logic once per platform. It works, but the toolchain, asset format, and update code differ on each platform and you maintain all of them. Webview Bundle gives you one `.wvb` and one mental model across Electron, Tauri, Android, and iOS, backed by a shared Rust core.
+- **CodePush-style OTA.** Hosted OTA services ship JS updates without store review — the same payoff as the remote source. The tradeoff differs: Webview Bundle is a verifiable bundle format you host yourself, with a builtin fallback, channel rollouts, and integrity plus optional signatures, rather than a managed third-party service. You own the server and the artifact.
+
+The honest tradeoff: you adopt a bundle format and a pack step, and you run the remote. In return you get offline-first delivery, OTA without store review, and one workflow that does not change as you add platforms.
+
+
+ Android and iOS are implemented and shipping. The iOS minimum is iOS 16. Deno Desktop is
+ experimental. See [Platform support](/docs/guide/platform-support) for current status.
+
+
+## Where to go next
+
+
+
+
+
+
+
diff --git a/content/docs/guides/android.mdx b/content/docs/guides/android.mdx
deleted file mode 100644
index c32cc2c..0000000
--- a/content/docs/guides/android.mdx
+++ /dev/null
@@ -1,181 +0,0 @@
----
-title: Android
-description: Serve an Android WebView from a .wvb bundle using the Kotlin bindings generated from the Rust core.
----
-
-On Android, Webview Bundle ships as a Kotlin library generated from the Rust core with
-[UniFFI](https://mozilla.github.io/uniffi-rs/). You build a [`BundleSource`](/docs/concepts#sources-builtin-vs-remote)
-from on-device directories and serve files into a `WebView` through a `BundleUrlHandler`.
-
-A working test app lives in
-[`packages/ffi/android`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/ffi/android), and the integration tests in
-[`TestRunner.kt`](https://github.com/webview-bundle/webview-bundle/blob/main/packages/ffi/android/testapp/src/main/kotlin/dev/wvb/testapp/TestRunner.kt)
-exercise the full Kotlin API.
-
-
- The Android bindings are built from this repository (no Maven artifact is published yet). The
- steps below build the `lib-android` module and include it in your app.
-
-
-## What the library contains
-
-The Android library (`dev.wvb`, module `lib-android`) bundles:
-
-- the generated Kotlin bindings (`dev/wvb/wvb_ffi.kt`),
-- the native `libwvb_ffi.so` for each ABI (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`) under
- `jniLibs/`,
-- runtime dependencies on [JNA](https://github.com/java-native-access/jna) and
- `kotlinx-coroutines`.
-
-Minimum SDK is 24; the module targets Java 17.
-
-## 1. Build and include the library
-
-From the repo, build the Android bindings (this compiles the Rust core for each Android ABI and
-generates the Kotlin bindings + `jniLibs`):
-
-```sh
-# from packages/ffi
-yarn build-ffi-android # → node ./cli/main.ts build android --profile=release
-```
-
-Then include `lib-android` in your app. In a Gradle multi-module setup that vendors the library:
-
-```kotlin
-// settings.gradle.kts
-include(":lib-android")
-project(":lib-android").projectDir = file("path/to/webview-bundle/packages/ffi/android/lib-android")
-
-// app/build.gradle.kts
-dependencies {
- implementation(project(":lib-android"))
- implementation("net.java.dev.jna:jna:5.x@aar")
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.x")
-}
-```
-
-Make sure your packaging keeps the native symbols:
-
-```kotlin
-android {
- packaging {
- jniLibs { keepDebugSymbols.add("**/*.so") }
- }
-}
-```
-
-## 2. Ship your bundles as assets
-
-Pack your web build and place the `.wvb` plus its `manifest.json` under `src/main/assets`, following
-the [source layout](/docs/concepts#sources-builtin-vs-remote):
-
-```sh
-npx wvb pack ./dist --outfile app/src/main/assets/bundles/builtin/app/app_1.0.0.wvb
-```
-
-```text
-assets/bundles/builtin/
-├── app/
-│ └── app_1.0.0.wvb
-└── manifest.json
-```
-
-Assets aren't a real filesystem path, so at startup copy the builtin bundles into a directory the
-native code can read (e.g. under `context.filesDir`). The test app's `setupFixtures()` shows this
-copy-from-assets pattern.
-
-## 3. Build a `BundleSource`
-
-```kotlin
-import dev.wvb.*
-
-val source = BundleSource(
- BundleSourceConfig(
- builtinDir = File(context.filesDir, "bundles/builtin").absolutePath,
- remoteDir = File(context.filesDir, "bundles/remote").absolutePath,
- builtinManifestFilepath = null, // defaults to manifest.json in the dir
- remoteManifestFilepath = null,
- )
-)
-
-// Query what's available:
-val version = source.loadVersion("app") // current version (remote wins over builtin)
-val bundle = source.fetchBundle("app") // load a bundle into memory
-val html = bundle.getData("/index.html") // ByteArray? — file bytes, or null
-```
-
-## 4. Serve a `WebView` from the bundle
-
-Create a `BundleUrlHandler` over the source and call it from a `WebViewClient`'s
-`shouldInterceptRequest`, mapping the handler's `HttpResponse` to a `WebResourceResponse`:
-
-```kotlin
-import android.webkit.*
-import dev.wvb.*
-import kotlinx.coroutines.runBlocking
-
-class BundleWebViewClient(source: BundleSource) : WebViewClient() {
- private val handler = BundleUrlHandler(source)
-
- override fun shouldInterceptRequest(
- view: WebView,
- request: WebResourceRequest,
- ): WebResourceResponse? {
- if (request.url.scheme != "https" || request.url.host?.endsWith(".wvb") != true) {
- return null // not ours; let the WebView handle it
- }
- val method = if (request.method.equals("HEAD", true)) HttpMethod.HEAD else HttpMethod.GET
- val resp = runBlocking {
- handler.handle(method, request.url.toString(), request.requestHeaders)
- }
- val contentType = resp.headers["content-type"] ?: "application/octet-stream"
- return WebResourceResponse(
- contentType.substringBefore(';'),
- "utf-8",
- resp.status.toInt(),
- "OK",
- resp.headers,
- resp.body.inputStream(),
- )
- }
-}
-
-// Wire it up and navigate to your bundle:
-webView.webViewClient = BundleWebViewClient(source)
-webView.loadUrl("https://app.wvb/index.html") // bundle "app", file "/index.html"
-```
-
-The handler resolves the bundle name from the first host label (`app.wvb` → bundle `app`) and treats
-a trailing slash or extension-less path as `index.html`. It returns `404` for unknown paths and
-supports `GET`/`HEAD`. `BundleUrlHandler.handle(...)` is a suspending call, so run it from a
-coroutine (or `runBlocking` inside `shouldInterceptRequest`, which already runs off the main thread).
-
-### Development against a dev server
-
-For hot reload during development, use `LocalUrlHandler` instead, mapping a host to your dev server:
-
-```kotlin
-val local = LocalUrlHandler(mapOf("app" to "http://10.0.2.2:5173"))
-// 10.0.2.2 is the host machine from the Android emulator
-```
-
-## 5. Build a bundle on device (optional)
-
-The same API can create bundles in-process — useful for tests or tooling:
-
-```kotlin
-val builder = BundleBuilder(Version.V1)
-builder.insertEntry("/index.html", "".toByteArray(), "text/html", null)
-val bundle = builder.build(null)
-
-val bytes = writeBundleToBytes(bundle) // serialize to ByteArray
-val loaded = readBundleFromBytes(bytes) // round-trip
-writeBundle(bundle, file.absolutePath) // or write to a file (suspending)
-```
-
-## Over-the-air updates
-
-`BundleSource` exposes `writeRemoteBundle(...)` and `updateVersion(...)` to install a downloaded
-bundle and flip the current version. The download/verify orchestration mirrors the desktop
-[updater](/docs/remote-updates); on Android you typically fetch the bundle bytes yourself (or via a
-future binding) and call `writeRemoteBundle` to install them, then reload the `WebView`.
diff --git a/content/docs/guides/electron.mdx b/content/docs/guides/electron.mdx
deleted file mode 100644
index 44ecb1f..0000000
--- a/content/docs/guides/electron.mdx
+++ /dev/null
@@ -1,192 +0,0 @@
----
-title: Electron
-description: Serve your Electron UI from a .wvb bundle through a custom protocol, with dev-server proxying and over-the-air updates.
----
-
-This guide wires Webview Bundle into an Electron app: serving your UI from a `.wvb` bundle through
-a custom protocol, switching to a live dev server during development, and (optionally) updating
-bundles over the air.
-
-A complete, runnable example lives in
-[`examples/electron-forge-vite`](https://github.com/webview-bundle/webview-bundle/tree/main/examples/electron-forge-vite).
-
-## Install
-
-```sh
-npm install @wvb/electron
-# and the CLI, to pack bundles (dev dependency):
-npm install -D @wvb/cli
-```
-
-`@wvb/electron` depends on `@wvb/node`, the native N-API binding, which ships prebuilt binaries for
-common platforms. No Rust toolchain is required to consume it.
-
-## 1. Register the protocol in the main process
-
-Call `wvb(...)` (an alias of `webviewBundle(...)`) **before** your windows load. It registers your
-custom schemes as privileged and wires up the protocol handlers.
-
-```ts
-// src/main.ts
-import path from 'node:path';
-import { app, BrowserWindow } from 'electron';
-import { bundleProtocol, localProtocol, wvb } from '@wvb/electron';
-
-wvb({
- // Where bundles live on disk (see "Bundle source" below). Defaults are usually fine.
- source: {
- builtinDir: path.join(process.resourcesPath, 'bundles'),
- },
- protocols: [
- // In development, proxy `app-local://simple.wvb/...` to the Vite dev server so you get
- // hot reload. `MAIN_WINDOW_VITE_DEV_SERVER_URL` is provided by Electron Forge's Vite plugin.
- localProtocol('app-local', {
- hosts: {
- 'simple.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL,
- },
- }),
- // In production, serve `app:///...` straight from the bundle.
- bundleProtocol('app', {
- onError: e => console.error(e),
- }),
- ],
-});
-
-async function createWindow() {
- const win = new BrowserWindow({
- width: 800,
- height: 600,
- webPreferences: {
- preload: path.join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false,
- },
- });
- // `app://.wvb/` — the first host label is the bundle name.
- await win.loadURL('app://simple.wvb');
-}
-
-app.on('ready', createWindow);
-```
-
-- **`bundleProtocol(scheme)`** serves files directly out of bundles in the source. A URL like
- `app://simple.wvb/index.html` resolves to bundle `simple` (the first host label), file
- `/index.html`. A trailing slash or an extension-less path resolves to `index.html`.
-- **`localProtocol(scheme, { hosts })`** proxies matching hosts to a localhost dev server instead.
- Use it during development so you keep the same scheme while getting your bundler's hot reload.
-
-A common pattern is to register both and choose which URL to load based on `app.isPackaged`.
-
-## 2. Add the preload script
-
-The preload bridges a small, safe API into the renderer (used for source/remote/updater calls).
-
-```ts
-// src/preload.ts
-import { preload } from '@wvb/electron/preload';
-
-preload();
-```
-
-Point your `BrowserWindow`'s `webPreferences.preload` at the compiled preload (as above), and keep
-`contextIsolation: true`.
-
-## 3. Call the API from the renderer (optional)
-
-If you want the UI to drive updates (e.g. a "Check for updates" button), import the renderer API.
-It forwards to the main process over IPC, so it only works when the preload script is loaded.
-
-```ts
-// src/renderer.ts
-import { remote, source, updater } from '@wvb/electron/renderer';
-
-// What's installed locally right now?
-const version = await source.loadVersion('app');
-
-// Is a newer version deployed?
-const info = await updater.getUpdate('app');
-if (info.isAvailable) {
- const downloaded = await updater.download('app'); // downloads + verifies, stages into the remote source
- await updater.install('app', downloaded.version); // activates the downloaded version
- // reload the window to pick up the new bundle
-}
-```
-
-The same `source`, `remote`, and `updater` objects are also reachable in the main process from the
-instance returned by `wvb(...)`:
-
-```ts
-const instance = wvb({
- /* … */
-});
-instance.source; // BundleSource
-instance.remote; // Remote | null
-instance.updater; // Updater | null
-await instance.whenProtocolRegistered();
-```
-
-## 4. Configure over-the-air updates (optional)
-
-Add an `updater` block pointing at your remote server. See
-[Remote updates](/docs/remote-updates) for how to stand one up (including a local one for testing).
-
-```ts
-wvb({
- source: { builtinDir: path.join(process.resourcesPath, 'bundles') },
- updater: {
- remote: { endpoint: 'https://updates.example.com' },
- // integrity / signature verification options also live here
- },
- protocols: [bundleProtocol('app')],
-});
-```
-
-## 5. Pack and ship bundles
-
-Build your web app, then pack the output into a bundle and place it in the `bundles` directory you
-configured as `builtinDir`:
-
-```sh
-# build your renderer first (e.g. `vite build`), then:
-npx wvb pack ./dist --outfile bundles/app/app_1.0.0.wvb
-```
-
-When packaging the app, make sure the `bundles` directory is included as an extra resource and that
-the native `@wvb/node` binary is unpacked from the ASAR archive. With **Electron Forge + Vite**:
-
-```ts
-// forge.config.ts
-const config: ForgeConfig = {
- packagerConfig: {
- asar: true,
- extraResource: ['bundles'], // ship the builtin bundles
- },
- plugins: [
- // …vite plugin…
- new AutoUnpackNativesPlugin({}), // unpack @wvb/node's .node binary from the ASAR
- ],
-};
-```
-
-
- The Forge Vite plugin ignores files outside `.vite/` by default, which can exclude `node_modules`.
- The example's `forge.config.ts` shows a `packagerConfig.ignore` override that keeps `node_modules`
- and `.tgz` files so the native module is bundled. See
- [`examples/electron-forge-vite/forge.config.ts`](https://github.com/webview-bundle/webview-bundle/blob/main/examples/electron-forge-vite/forge.config.ts).
-
-
-## Where bundles live
-
-`source` accepts `builtinDir` (shipped, read-only) and `remoteDir` (downloaded updates). A good
-default is to ship `builtinDir` under `process.resourcesPath` and let `remoteDir` default to a
-writable app-data location. Downloaded versions always take priority over builtin ones, so an
-installed update is served automatically after `updater.download(...)` + `updater.install(...)`.
-
-## Troubleshooting
-
-- **Blank window / `ERR_FAILED`** — confirm `wvb(...)` runs before `loadURL`, and that the bundle
- name in the URL matches the packed file (`app://simple.wvb` → bundle `simple`).
-- **`Cannot access to webview bundle api`** in the renderer — the preload script isn't loaded;
- check `webPreferences.preload` and that it calls `preload()`.
-- **Works in dev, fails when packaged** — the `bundles` resource or the native `@wvb/node` binary
- wasn't included; verify `extraResource` and `AutoUnpackNativesPlugin` (see above).
diff --git a/content/docs/guides/ios.mdx b/content/docs/guides/ios.mdx
deleted file mode 100644
index f181a4a..0000000
--- a/content/docs/guides/ios.mdx
+++ /dev/null
@@ -1,175 +0,0 @@
----
-title: iOS
-description: Serve a WKWebView (iOS and macOS) from a .wvb bundle using the Swift bindings generated from the Rust core.
----
-
-On iOS (and macOS), Webview Bundle ships as a Swift library generated from the Rust core with
-[UniFFI](https://mozilla.github.io/uniffi-rs/). You build a [`BundleSource`](/docs/concepts#sources-builtin-vs-remote)
-and serve files into a `WKWebView` through a `BundleUrlHandler` wired to a `WKURLSchemeHandler`.
-
-A working test app lives in
-[`packages/ffi/apple`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/ffi/apple), and the
-integration tests in
-[`TestRunner.swift`](https://github.com/webview-bundle/webview-bundle/blob/main/packages/ffi/apple/ios/TestApp/TestRunner.swift) exercise the full Swift
-API.
-
-
- The Apple bindings are built from this repository (no published release artifact yet). The steps
- below build the `xcframework` and add the local Swift package.
-
-
-## What the library contains
-
-- **`WebViewBundleFFI.xcframework`** — the native static libraries for `ios-arm64`,
- `ios-arm64_x86_64-simulator`, and `macos-arm64_x86_64`, plus C headers.
-- **`WebViewBundleLibrary`** — the generated Swift API (`WebViewBundleLibrary.swift`) that wraps it.
-
-The Swift package (`packages/ffi/apple/Package.swift`) ties them together and links
-`SystemConfiguration`, `Security`, and `CoreFoundation`. Minimum targets are iOS 14 / macOS 12.
-
-## 1. Build and add the package
-
-From the repo, build the Apple bindings (this compiles the Rust core for each Apple platform and
-generates the Swift bindings + xcframework):
-
-```sh
-# from packages/ffi
-yarn build-ffi-apple # → node ./cli/main.ts build apple --profile=release
-```
-
-Then add `packages/ffi/apple` as a local Swift package dependency in Xcode (File → Add Package
-Dependencies → Add Local…) and link the **WebViewBundleLibrary** product to your app target. In a
-`Package.swift`-based project:
-
-```swift
-.package(path: "path/to/webview-bundle/packages/ffi/apple")
-// …then add "WebViewBundleLibrary" to your target's dependencies.
-```
-
-## 2. Ship your bundles as resources
-
-Pack your web build and add the `.wvb` plus its `manifest.json` to your app bundle as resources,
-following the [source layout](/docs/concepts#sources-builtin-vs-remote):
-
-```sh
-npx wvb pack ./dist --outfile assets/bundles/builtin/app/app_1.0.0.wvb
-```
-
-```text
-assets/bundles/builtin/
-├── app/
-│ └── app_1.0.0.wvb
-└── manifest.json
-```
-
-At runtime, resolve the builtin directory from the app bundle, and use a writable directory (e.g.
-under `Application Support` or a temp dir) for downloaded updates:
-
-```swift
-let builtinDir = Bundle.main.resourceURL!
- .appendingPathComponent("assets/bundles/builtin")
-let remoteDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
- .appendingPathComponent("bundles/remote")
-try? FileManager.default.createDirectory(at: remoteDir, withIntermediateDirectories: true)
-```
-
-## 3. Build a `BundleSource`
-
-```swift
-import WebViewBundleLibrary
-
-let source = BundleSource(config: BundleSourceConfig(
- builtinDir: builtinDir.path,
- remoteDir: remoteDir.path,
- builtinManifestFilepath: nil, // defaults to manifest.json in the dir
- remoteManifestFilepath: nil
-))
-
-// Query what's available:
-let bundle = try await source.fetchBundle(bundleName: "app") // load into memory
-let html = try bundle.getData(path: "/index.html") // Data? — bytes or nil
-```
-
-## 4. Serve a `WKWebView` from the bundle
-
-`WKWebView` lets you handle a **custom URL scheme** with a `WKURLSchemeHandler`. Register a scheme
-(it cannot be a built-in one like `https`), then forward each request to a `BundleUrlHandler`:
-
-```swift
-import WebKit
-import WebViewBundleLibrary
-
-final class BundleSchemeHandler: NSObject, WKURLSchemeHandler {
- private let handler: BundleUrlHandler
- init(source: BundleSource) { self.handler = BundleUrlHandler(source: source) }
-
- func webView(_ webView: WKWebView, start task: WKURLSchemeTask) {
- let request = task.request
- let method: HttpMethod = (request.httpMethod == "HEAD") ? .head : .get
- let headers = request.allHTTPHeaderFields
- Task {
- do {
- let resp = try await handler.handle(
- method: method,
- uri: request.url!.absoluteString,
- headers: headers
- )
- let contentType = resp.headers["content-type"] ?? "application/octet-stream"
- let response = HTTPURLResponse(
- url: request.url!,
- statusCode: Int(resp.status),
- httpVersion: "HTTP/1.1",
- headerFields: resp.headers
- )!
- task.didReceive(response)
- task.didReceive(resp.body)
- task.didFinish()
- } catch {
- task.didFailWithError(error)
- }
- }
- }
-
- func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {}
-}
-
-// Register the scheme and load your bundle:
-let config = WKWebViewConfiguration()
-config.setURLSchemeHandler(BundleSchemeHandler(source: source), forURLScheme: "app")
-let webView = WKWebView(frame: .zero, configuration: config)
-webView.load(URLRequest(url: URL(string: "app://app.wvb/index.html")!))
-```
-
-The handler resolves the bundle name from the first host label (`app.wvb` → bundle `app`) and treats
-a trailing slash or extension-less path as `index.html`. It returns `404` for unknown paths and
-supports `GET`/`HEAD`. `handle(...)` is `async`, so call it from a `Task`.
-
-### Development against a dev server
-
-For hot reload during development, use `LocalUrlHandler` instead, mapping a host to your dev server:
-
-```swift
-let local = LocalUrlHandler(hosts: ["app.wvb": "http://localhost:5173"])
-```
-
-## 5. Build a bundle in-process (optional)
-
-The same API can create bundles, which is handy for tests or tooling:
-
-```swift
-let builder = BundleBuilder(version: .v1)
-_ = try builder.insertEntry(path: "/index.html", data: Data("".utf8),
- contentType: "text/html", headers: nil)
-let bundle = try builder.build(options: nil)
-
-let bytes = try writeBundleToBytes(bundle: bundle) // serialize to Data
-let loaded = try readBundleFromBytes(data: bytes) // round-trip
-_ = try await writeBundle(bundle: bundle, filepath: path) // or write to a file
-```
-
-## Over-the-air updates
-
-`BundleSource` exposes `writeRemoteBundle(...)` and `updateVersion(...)` to install a downloaded
-bundle and flip the current version. The download/verify orchestration mirrors the desktop
-[updater](/docs/remote-updates); on iOS you typically fetch the bundle bytes yourself and call
-`writeRemoteBundle` to install them, then reload the `WKWebView`.
diff --git a/content/docs/guides/meta.json b/content/docs/guides/meta.json
deleted file mode 100644
index 149258d..0000000
--- a/content/docs/guides/meta.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "title": "Platform guides",
- "pages": ["electron", "tauri", "android", "ios"]
-}
diff --git a/content/docs/guides/tauri.mdx b/content/docs/guides/tauri.mdx
deleted file mode 100644
index 3b6a9a4..0000000
--- a/content/docs/guides/tauri.mdx
+++ /dev/null
@@ -1,147 +0,0 @@
----
-title: Tauri
-description: Add the wvb-tauri plugin to a Tauri v2 app so the webview is served from a .wvb bundle, with dev proxying and OTA updates.
----
-
-This guide adds the `wvb-tauri` plugin to a Tauri v2 app so your webview is served from a `.wvb`
-bundle, with an optional localhost proxy for development and over-the-air updates.
-
-A complete, runnable example lives in
-[`examples/tauri-simple`](https://github.com/webview-bundle/webview-bundle/tree/main/examples/tauri-simple).
-
-## Install
-
-Add the plugin crate to your `src-tauri/Cargo.toml`:
-
-```toml
-[dependencies]
-wvb-tauri = "0.1"
-tauri = { version = "2", features = [] }
-```
-
-If you'll drive updates from the frontend, also add the JavaScript glue you normally use with Tauri
-commands (`@tauri-apps/api`). The plugin registers its commands automatically.
-
-## 1. Register the plugin
-
-In your `src-tauri/src/lib.rs`, register the plugin with a `Config` that declares a bundle
-**source**, the **protocols** to serve, and optionally a **remote** for updates:
-
-```rust
-use tauri::Manager;
-use wvb_tauri::{Config, Protocol, Source};
-
-#[cfg_attr(mobile, tauri::mobile_entry_point)]
-pub fn run() {
- tauri::Builder::default()
- .plugin(wvb_tauri::init(
- Config::new()
- // Where bundles live. `builtin_dir` is resolved through Tauri's path API,
- // so you can point it at a resource directory…
- .source(Source::new().builtin_dir_fn(|app| {
- Ok(app.path().resource_dir()?.join("bundles"))
- }))
- // Serve `bundle:///...` straight from bundles.
- .protocol(Protocol::bundle("bundle"))
- // In development, proxy `local://example.com/...` to the dev server.
- .protocol(Protocol::local("local").host("example.com", "http://localhost:1420")),
- ))
- .run(tauri::generate_context!())
- .expect("error while running tauri application");
-}
-```
-
-- **`Protocol::bundle(scheme)`** serves files directly from bundles. `bundle://app/index.html`
- resolves to bundle `app`, file `/index.html`.
-- **`Protocol::local(scheme).host(host, url)`** proxies a host to a localhost dev server, so the
- same scheme works with your bundler's hot reload during development.
-- **`Source`** accepts `builtin_dir` / `remote_dir` (static path strings) or `builtin_dir_fn` /
- `remote_dir_fn` (closures that compute the path from the `AppHandle` at runtime). When unset, both
- default to the `bundles` directory in the app's resources.
-
-## 2. Allow the custom scheme to load
-
-Point your app window at the custom scheme. The simplest approach is to set the dev/prod URL in
-`tauri.conf.json` or load it explicitly. For a bundle-served app you'll typically navigate the main
-window to `bundle://app/` (production) or your dev server (development).
-
-Make sure your capabilities allow the plugin's commands if you call them from the frontend. Add the
-`wvb-tauri` permissions to `src-tauri/capabilities/default.json` as needed (the plugin namespace is
-`wvb-tauri`).
-
-## 3. Pack and ship bundles
-
-Build your frontend, pack it, and place the result in the directory you configured as the source:
-
-```sh
-# build your frontend first (e.g. `vite build`), then:
-npx wvb pack ./dist --outfile src-tauri/bundles/app/app_1.0.0.wvb
-```
-
-Include the `bundles` directory in your app resources so it ships with the build (see
-`tauri.conf.json` → `bundle.resources`).
-
-## 4. Drive updates from the frontend (optional)
-
-Add a remote to the config:
-
-```rust
-use wvb_tauri::{Config, Protocol, Remote, Source};
-
-Config::new()
- .source(Source::new().builtin_dir_fn(|app| Ok(app.path().resource_dir()?.join("bundles"))))
- .protocol(Protocol::bundle("bundle"))
- .remote(Remote::new("https://updates.example.com"));
-```
-
-The plugin then exposes these commands to the frontend via `invoke` (all under the `wvb-tauri` plugin
-namespace):
-
-| Command | Arguments | Returns |
-| --------------------------------------- | ------------------------ | ------------------------------ |
-| `plugin:wvb-tauri\|updater_get_update` | `bundleName` | update availability info |
-| `plugin:wvb-tauri\|updater_download` | `bundleName`, `version?` | downloaded bundle metadata |
-| `plugin:wvb-tauri\|updater_install` | `bundleName`, `version` | activates a downloaded version |
-| `plugin:wvb-tauri\|source_load_version` | `bundleName` | active local version |
-| `plugin:wvb-tauri\|source_list_bundles` | — | local bundles |
-| `plugin:wvb-tauri\|remote_get_info` | `bundleName`, `channel?` | current remote metadata |
-
-```ts
-import { invoke } from '@tauri-apps/api/core';
-
-const update = await invoke('plugin:wvb-tauri|updater_get_update', { bundleName: 'app' });
-if (update.isAvailable) {
- const info = await invoke('plugin:wvb-tauri|updater_download', { bundleName: 'app' });
- await invoke('plugin:wvb-tauri|updater_install', { bundleName: 'app', version: info.version });
- // reload the webview to pick up the new bundle
-}
-```
-
-The full command set (source / remote / updater) is documented on the
-[`wvb-tauri` crate docs](https://docs.rs/wvb-tauri) and in
-[`packages/tauri/src/commands.rs`](https://github.com/webview-bundle/webview-bundle/blob/main/packages/tauri/src/commands.rs).
-
-## Reaching the plugin from Rust
-
-From any `App`, `AppHandle`, or `Window`, the `WebviewBundleExtra` trait adds `webview_bundle()`
-(aliased `wvb()`), which exposes the managed `source`, `remote`, and `updater`:
-
-```rust
-use wvb_tauri::WebviewBundleExtra;
-
-let wvb = app.wvb();
-let bundles = wvb.source().list_bundles().await?;
-if let Some(updater) = wvb.updater() {
- let info = updater.download("app", None).await?; // download + verify, stage into the remote source
- updater.install("app", info.version).await?; // activate the downloaded version
-}
-```
-
-## Troubleshooting
-
-- **`protocol not found: `** — a request arrived for a scheme you didn't register; add the
- matching `Protocol::bundle(...)` / `Protocol::local(...)`.
-- **`remote is not initialized` / `updater is not initialized`** — you called a remote/updater
- command without configuring `.remote(...)` on the `Config`.
-- **Bundle not found at runtime** — confirm the `bundles` directory is listed in
- `tauri.conf.json` resources and that `Source` resolves to it.
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
deleted file mode 100644
index 521be0e..0000000
--- a/content/docs/index.mdx
+++ /dev/null
@@ -1,103 +0,0 @@
----
-title: Introduction
-description: An offline-first web resource delivery system for webview-mounted frameworks and platforms.
----
-
-Webview Bundle (`wvb`) is an **offline-first web resource delivery system** for webview-mounted
-frameworks and platforms. It packs your built web assets (HTML/JS/CSS/media) into a single
-compressed, integrity-checked archive (`.wvb`) that your app ships with and serves to its webview
-through a custom URL scheme — and can update over the air without an app-store release.
-
-```text
- build output .wvb archive your app's webview
-┌──────────────┐ pack ┌───────────────┐ serve ┌────────────────────┐
-│ dist/ │ ─────▶ │ app_1.0.0.wvb │ ──────▶ │ app://app/index.html│
-│ index.html │ │ (compressed, │ │ (offline, instant) │
-│ app.js … │ │ verified) │ └────────────────────┘
-└──────────────┘ └───────────────┘
- │ upload + deploy ▲ download + verify
- ▼ │
- ┌───────────────┐ updater │
- │ remote server │ ──────────────┘
- └───────────────┘
-```
-
-## Why Webview Bundle?
-
-- **Offline-first.** Resources are bundled locally, so the first paint never waits on the network.
-- **Over-the-air updates.** Ship a fix or feature by deploying a new bundle version — no native
- release required.
-- **Integrity & authenticity.** Every bundle carries checksums, and downloads can be verified with
- SHA-3 integrity hashes and digital signatures (ECDSA, Ed25519, RSA).
-- **One format, every platform.** The same `.wvb` archive runs in Electron and Tauri today via a
- shared Rust core, with Android and iOS support planned.
-
-## Start here
-
-
-
-
-
-
-
-
-Then pick your platform guide:
-
-
-
-
-
-
-
-
-## Packages
-
-| Package | What it is | Where it runs |
-| ---------------------------- | --------------------------------------------------------------------------------- | ----------------------- |
-| [`wvb`](https://docs.rs/wvb) | Rust core: bundle format, source, remote, updater, protocol, integrity, signature | everywhere (library) |
-| `@wvb/cli` (`wvb`) | Command-line tool: pack, serve, upload, deploy, download, local remote | your machine / CI |
-| `@wvb/config` | `defineConfig` for `wvb.config.ts` | build tooling |
-| `@wvb/node` | N-API bindings to the core | Node.js |
-| `@wvb/electron` | Electron integration (protocols, IPC, updater) | Electron main/renderer |
-| `wvb-tauri` | Tauri plugin (protocols, commands, updater) | Tauri app |
-| `wvb-ffi` | UniFFI bindings (Kotlin/Swift) | Android / iOS (planned) |
-| `@wvb/remote-*` | Remote server providers (local, AWS, Cloudflare) | your update backend |
-
-## The bundle format in one line
-
-A `.wvb` file is **`[ Header | Index | Data ]`**: a 17-byte header (magic number, version, index
-size, checksum), an index mapping each file path to its location and HTTP metadata, and an
-LZ4-compressed data section. The full byte-level spec lives in the
-[`wvb` crate docs](https://docs.rs/wvb).
diff --git a/content/docs/meta.json b/content/docs/meta.json
index 5ade334..5c003f3 100644
--- a/content/docs/meta.json
+++ b/content/docs/meta.json
@@ -1,4 +1,3 @@
{
- "title": "Documentation",
- "pages": ["index", "concepts", "remote-updates", "cli", "configuration", "guides"]
+ "pages": ["guide", "references", "config", "changelog"]
}
diff --git a/content/docs/references/bridge/errors.mdx b/content/docs/references/bridge/errors.mdx
new file mode 100644
index 0000000..5cc93d0
--- /dev/null
+++ b/content/docs/references/bridge/errors.mdx
@@ -0,0 +1,61 @@
+---
+title: Errors
+description: How @wvb/bridge surfaces failures through BridgeError and its BridgeErrorCode values.
+---
+
+Every failure from `@wvb/bridge` surfaces as a `BridgeError`. It is an `Error` subclass with `name` set to `'BridgeError'` and an optional `code` drawn from `BridgeErrorCode`. Whether you call `invoke()` directly or a typed command group, any rejection is wrapped in this single error type, so you have one shape to catch and inspect.
+
+## BridgeError
+
+`BridgeError` extends the built-in `Error`. Read its `message` for a human-readable description and its `code` to branch on a specific failure. The package also exports the type guards `isBridgeError` and `isBridgeErrorData`, plus the static helpers `BridgeError.of(code, message?)` and `BridgeError.from(value)`.
+
+## BridgeErrorCode
+
+The `code` property, when present, is one of the following values. The value is the string carried on the wire.
+
+| Code | Value | When it occurs |
+| ----------------------- | ------------------------- | ------------------------------------------- |
+| `InvalidParams` | `invalid_params` | The command parameters were rejected. |
+| `RemoteNotInitialized` | `remote_not_initialized` | No remote is configured on the host. |
+| `UpdaterNotInitialized` | `updater_not_initialized` | No updater is configured on the host. |
+| `HandlerNotFound` | `handler_not_found` | The host has no handler for the command. |
+| `UnencodableResult` | `unencodable_result` | The result could not be encoded for return. |
+
+## Handling errors
+
+Catch the error, narrow it with `isBridgeError`, then compare `code` against `BridgeErrorCode`:
+
+```ts
+import { BridgeError, BridgeErrorCode, isBridgeError, updater } from '@wvb/bridge';
+
+try {
+ const update = await updater.getUpdate('app');
+ // Use update...
+} catch (err) {
+ if (isBridgeError(err) && err.code === BridgeErrorCode.UpdaterNotInitialized) {
+ // The native host has no updater configured.
+ return;
+ }
+ throw err;
+}
+```
+
+
+ `err.code` is optional. A `BridgeError` may be thrown without a `code` (for example, when the
+ webview runs somewhere no transport is present), so guard for `undefined` before branching on it.
+
+
+## See also
+
+
+
+
+
diff --git a/content/docs/references/bridge/index.mdx b/content/docs/references/bridge/index.mdx
new file mode 100644
index 0000000..0136c3a
--- /dev/null
+++ b/content/docs/references/bridge/index.mdx
@@ -0,0 +1,160 @@
+---
+title: Bridge
+description: The web-side @wvb/bridge library that lets JavaScript inside the webview call the native host.
+---
+
+`@wvb/bridge` is the web-side companion to Webview Bundle. It lets JavaScript running **inside the webview** call the native host that manages your bundle source, remote, and updater. One uniform `invoke()` works across Electron, Tauri, Android, and iOS, and the per-platform transport is abstracted away so your web code stays the same on every platform.
+
+The package is pure TypeScript and ships typed command groups (`source`, `remote`, `updater`) so you rarely call `invoke()` directly. For how the native host exposes these commands, see [Platform integration](/docs/guide/platform-integration); for the matching native API, see the [Node API reference](/docs/references/node).
+
+## Install
+
+`@wvb/bridge` is version `0.1.0`.
+
+
+
+
+```sh
+npm install @wvb/bridge
+```
+
+
+
+
+```sh
+pnpm add @wvb/bridge
+```
+
+
+
+
+```sh
+yarn add @wvb/bridge
+```
+
+
+
+
+## invoke
+
+`invoke()` is the low-level entry point. Every typed command helper is built on top of it, so you usually reach for `source`, `remote`, or `updater` instead. Use `invoke()` directly only for commands the typed groups do not cover.
+
+```ts
+import { invoke } from '@wvb/bridge';
+
+const result = await invoke('sourceResolveFilepath', { bundleName: 'app' });
+```
+
+The signature is `invoke(name: string, params?: InvokeParams): Promise`. Any failure is wrapped in a `BridgeError`. `InvokeParams` is an open record:
+
+```ts
+type InvokeParams = { [key: string | number]: any };
+```
+
+## platform
+
+The `platform` object reports which native host the webview is running under. Every property is computed live on access, so it stays correct even if detection conditions change.
+
+```ts
+import { platform } from '@wvb/bridge';
+
+if (platform.isElectron) {
+ // Electron-specific behavior
+}
+
+console.log(platform.type); // 'electron' | 'tauri' | 'android' | 'ios'
+```
+
+| Member | Type | Description |
+| ------------ | --------------------------------------------- | --------------------------------- |
+| `type` | `'electron' \| 'tauri' \| 'android' \| 'ios'` | The detected host platform. |
+| `isElectron` | `boolean` | True when running under Electron. |
+| `isTauri` | `boolean` | True when running under Tauri. |
+| `isAndroid` | `boolean` | True when running under Android. |
+| `isIos` | `boolean` | True when running under iOS. |
+
+
+ The shipping bridge detects only these four platforms. Deno Desktop is experimental and its bridge
+ support lives on an unmerged branch, so `'deno'` is not part of `@wvb/bridge` 0.1.0.
+
+
+## Transports
+
+Each platform has its own transport, all hidden behind `invoke()`:
+
+- **Electron** calls `window.wvbElectron.invoke(name, params)`.
+- **Tauri** calls the `wvb-tauri` plugin command, converting the command name to snake case.
+- **Android** posts a JSON message to `window.wvbAndroid` and resolves through a temporary global callback.
+- **iOS** posts a message to the `wvbIos` WebKit message handler and resolves through a temporary global callback.
+
+If the webview runs somewhere none of these are present, `invoke()` throws an error explaining that the native webview must support Webview Bundle.
+
+## Command groups
+
+The bridge exposes three typed command groups. Each method is a typed `invoke()` call, so calls are type-checked and route to the matching native handler.
+
+
+
+
+
+
+
+
+
+## Updater flow
+
+A typical over-the-air (OTA) check downloads and installs a newer bundle, then reloads the webview to pick it up:
+
+```ts
+import { updater } from '@wvb/bridge';
+
+async function checkForUpdate(bundleName: string) {
+ const update = await updater.getUpdate(bundleName);
+ if (!update.isAvailable) {
+ return;
+ }
+
+ await updater.download(bundleName);
+ await updater.install(bundleName, update.version);
+
+ // Reload so the webview serves the newly installed bundle.
+ window.location.reload();
+}
+```
+
+For the full updater surface, see the [updater reference](/docs/references/bridge/updater).
+
+## See also
+
+
+
+
+
diff --git a/content/docs/references/bridge/meta.json b/content/docs/references/bridge/meta.json
new file mode 100644
index 0000000..ea18165
--- /dev/null
+++ b/content/docs/references/bridge/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Bridge",
+ "pages": ["index", "source", "remote", "updater", "errors", "testing"]
+}
diff --git a/content/docs/references/bridge/remote.mdx b/content/docs/references/bridge/remote.mdx
new file mode 100644
index 0000000..bb20a07
--- /dev/null
+++ b/content/docs/references/bridge/remote.mdx
@@ -0,0 +1,58 @@
+---
+title: Remote
+description: The bridge remote.* command group, which queries the configured remote server through the native host.
+---
+
+`remote` is the bridge command group that talks to the configured remote server through the native host. Use it from web code inside the webview to list what a remote offers, read a bundle's current info, and trigger downloads — without the web side handling any bundle bytes itself.
+
+Each method is a typed `invoke()` call that routes to the matching native handler, so calls are type-checked and you rarely touch `invoke()` directly. For the broader package, see the [Bridge overview](/docs/references/bridge); for the native side these commands reach into, see the [Node remote API](/docs/references/node/remote).
+
+## Methods
+
+| Method | Purpose |
+| -------------------------------------- | ----------------------------------- |
+| `listBundles()` | List bundles offered by the remote. |
+| `getInfo(bundleName)` | Get current info for a bundle. |
+| `download(bundleName)` | Download the current version. |
+| `downloadVersion(bundleName, version)` | Download a specific version. |
+
+```ts
+import { remote } from '@wvb/bridge';
+
+const bundles = await remote.listBundles();
+const info = await remote.getInfo('app');
+
+await remote.download('app');
+await remote.downloadVersion('app', '1.2.0');
+```
+
+## Return values
+
+`getInfo`, `download`, and `downloadVersion` resolve to a `RemoteBundleInfo`. `download` and `downloadVersion` resolve to `RemoteBundleInfo` only — the bundle bytes are not transferred across the bridge. The native host writes the downloaded bundle into its source store, and the web side receives just the metadata.
+
+`listBundles` resolves to the list of bundles the remote currently offers, mirroring the remote `GET /bundles` response.
+
+
+ If no remote is configured on the host, these calls fail with a `BridgeError` whose `code` is
+ `RemoteNotInitialized`. See the [Bridge overview](/docs/references/bridge) for error handling.
+
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/references/bridge/source.mdx b/content/docs/references/bridge/source.mdx
new file mode 100644
index 0000000..071e581
--- /dev/null
+++ b/content/docs/references/bridge/source.mdx
@@ -0,0 +1,68 @@
+---
+title: Source
+description: The bridge source.* command group for reading and managing builtin and remote bundles on the native host.
+---
+
+`source` is the bridge command group that maps to the native bundle source — the host's store of builtin and remote bundles. Each method is a typed `invoke()` call, so it is type-checked web-side and routes to the matching native handler. Use it to list bundles, load versions and metadata, resolve on-disk paths, and manage downloaded remote bundles.
+
+For the native side these commands call into, see the [Node API source reference](/docs/references/node/source). For the rest of the bridge surface — `invoke()`, `platform`, the `remote` and `updater` groups, errors, and testing — see the [Bridge overview](/docs/references/bridge).
+
+```ts
+import { source } from '@wvb/bridge';
+
+const bundles = await source.listBundles();
+const path = await source.resolveFilepath('app');
+```
+
+## Methods
+
+| Method | Purpose |
+| ----------------------------------------------- | ------------------------------------------------------ |
+| `listBundles()` | List available bundles. |
+| `loadVersion(bundleName)` | Load the active version for a bundle. |
+| `updateVersion(bundleName, version)` | Set the active remote version for a bundle. |
+| `resolveFilepath(bundleName)` | Resolve the on-disk path the source serves. |
+| `getBuiltinBundleFilepath(bundleName, version)` | Path of a builtin bundle file. |
+| `getRemoteBundleFilepath(bundleName, version)` | Path of a remote bundle file. |
+| `loadBuiltinMetadata(bundleName, version)` | Load metadata for a builtin bundle. |
+| `loadRemoteMetadata(bundleName, version)` | Load metadata for a remote bundle. |
+| `unloadDescriptor(bundleName)` | Drop a cached bundle descriptor. |
+| `removeRemoteBundle(bundleName, version)` | Remove a downloaded remote bundle. |
+| `remoteRetainedVersions(bundleName)` | List retained remote versions (current plus previous). |
+| `pruneRemoteBundles(bundleName)` | Prune remote bundles that are no longer retained. |
+
+
+The bridge `ListBundleItem` is nested as `{ type, item }`, which differs from the flat `ListBundleItem` in `@wvb/node`. Use the bridge types when working web-side.
+
+
+## Managing remote bundles
+
+After an over-the-air (OTA) update lands, the host retains the current remote version plus previous ones. Read what is retained, then prune what is no longer needed:
+
+```ts
+import { source } from '@wvb/bridge';
+
+async function tidyRemoteBundles(bundleName: string) {
+ const retained = await source.remoteRetainedVersions(bundleName);
+ console.log('retained versions:', retained);
+
+ await source.pruneRemoteBundles(bundleName);
+}
+```
+
+To drop a specific downloaded version, call `removeRemoteBundle(bundleName, version)`.
+
+## See also
+
+
+
+
+
diff --git a/content/docs/references/bridge/testing.mdx b/content/docs/references/bridge/testing.mdx
new file mode 100644
index 0000000..26e0e2c
--- /dev/null
+++ b/content/docs/references/bridge/testing.mdx
@@ -0,0 +1,147 @@
+---
+title: Testing
+description: Drive the bridge in unit tests without a native host using the @wvb/bridge/testing helpers.
+---
+
+The `@wvb/bridge/testing` subpath lets you exercise web code that calls the bridge without a real native host. Register handlers for the commands your code invokes, force the detected platform, and tear the mocks down between tests. Mock command keys are dotted and typed, such as `'source.loadVersion'` or `'updater.getUpdate'`, so the handler signatures stay type-checked.
+
+For the bridge surface these helpers stand in for, see the [Bridge overview](/docs/references/bridge).
+
+## Import
+
+Import the helpers from the `testing` subpath, separate from the runtime entry point.
+
+```ts
+import { mockInvoke, mockPlatform, mockBridge, clearInvokeMocks } from '@wvb/bridge/testing';
+```
+
+## Helpers
+
+| Helper | Signature | Returns |
+| ------------------ | ------------------------------ | -------------- |
+| `mockInvoke` | `mockInvoke(command, handler)` | `Disposable` |
+| `mockPlatform` | `mockPlatform(type)` | `Disposable` |
+| `mockBridge` | `mockBridge(options?)` | chainable mock |
+| `clearInvokeMocks` | `clearInvokeMocks()` | `void` |
+
+### mockInvoke
+
+`mockInvoke(command, handler)` registers a handler for a single command and returns a `Disposable`. The `command` is a dotted, typed key from a command group, and the `handler` produces the value the matching call resolves to. Dispose the result to remove just that mock.
+
+```ts
+import { mockInvoke } from '@wvb/bridge/testing';
+
+const mock = mockInvoke('source.loadVersion', () => '1.2.0');
+
+// Later, remove this single mock.
+mock[Symbol.dispose]();
+```
+
+### mockPlatform
+
+`mockPlatform(type)` forces `platform.type` to the given value and returns a `Disposable`. Use it to drive platform-specific branches in your web code. Because every `platform` property is computed live, disposing the result restores normal detection.
+
+```ts
+import { mockPlatform } from '@wvb/bridge/testing';
+
+const mock = mockPlatform('ios');
+// platform.type === 'ios', platform.isIos === true
+
+mock[Symbol.dispose]();
+```
+
+### mockBridge
+
+`mockBridge(options?)` returns a chainable mock that combines platform and command mocking. Pass `platform` in the options to set the detected host, chain `.mockInvoke(command, handler)` to register commands, and call `.clear()` to remove everything the mock registered.
+
+```ts
+import { mockBridge } from '@wvb/bridge/testing';
+
+const bridge = mockBridge({ platform: 'electron' })
+ .mockInvoke('updater.getUpdate', () => ({
+ name: 'app',
+ version: '1.2.0',
+ isAvailable: true,
+ }))
+ .mockInvoke('updater.download', () => undefined);
+
+// Run code under test, then:
+bridge.clear();
+```
+
+### clearInvokeMocks
+
+`clearInvokeMocks()` removes every registered command mock at once. Call it in a global teardown hook so no mock leaks into the next test.
+
+## Unit test example
+
+This example uses [Vitest](https://vitest.dev) to test an update-check function against a mocked bridge. The mock supplies the `updater.getUpdate` and `updater.download` commands the code calls, and `.clear()` resets state after each test.
+
+```ts title="update.test.ts"
+import { afterEach, expect, test, vi } from 'vitest';
+import { updater } from '@wvb/bridge';
+import { mockBridge } from '@wvb/bridge/testing';
+
+async function hasUpdate(bundleName: string) {
+ const update = await updater.getUpdate(bundleName);
+ if (!update.isAvailable) {
+ return false;
+ }
+ await updater.download(bundleName);
+ return true;
+}
+
+let bridge: ReturnType;
+
+afterEach(() => {
+ bridge.clear();
+});
+
+test('downloads when an update is available', async () => {
+ const download = vi.fn(() => undefined);
+ bridge = mockBridge({ platform: 'electron' })
+ .mockInvoke('updater.getUpdate', () => ({
+ name: 'app',
+ version: '1.2.0',
+ isAvailable: true,
+ }))
+ .mockInvoke('updater.download', download);
+
+ await expect(hasUpdate('app')).resolves.toBe(true);
+ expect(download).toHaveBeenCalledOnce();
+});
+
+test('skips download when no update is available', async () => {
+ const download = vi.fn(() => undefined);
+ bridge = mockBridge({ platform: 'electron' })
+ .mockInvoke('updater.getUpdate', () => ({
+ name: 'app',
+ version: '1.1.0',
+ isAvailable: false,
+ }))
+ .mockInvoke('updater.download', download);
+
+ await expect(hasUpdate('app')).resolves.toBe(false);
+ expect(download).not.toHaveBeenCalled();
+});
+```
+
+
+ Register a handler for every command your code under test calls. An unmocked command has no
+ handler, so the bridge surfaces a `BridgeError` instead of resolving.
+
+
+## See also
+
+
+
+
+
diff --git a/content/docs/references/bridge/updater.mdx b/content/docs/references/bridge/updater.mdx
new file mode 100644
index 0000000..59af018
--- /dev/null
+++ b/content/docs/references/bridge/updater.mdx
@@ -0,0 +1,87 @@
+---
+title: Updater
+description: The bridge updater.* command group that drives the over-the-air update flow from inside the webview.
+---
+
+The `updater` command group drives the over-the-air (OTA) update flow from web code running inside the webview. Use it to check whether a newer bundle is available, stage it, and activate it on the native host. Each method is a typed `invoke()` call that routes to the matching native updater handler, so you rarely touch `invoke()` directly.
+
+For the OTA concepts behind these commands, see [Remote bundles](/docs/guide/remote-bundles). For the native updater the bridge calls into, see the [Node updater API](/docs/references/node/updater). For the bridge as a whole, see the [Bridge overview](/docs/references/bridge).
+
+```ts
+import { updater } from '@wvb/bridge';
+```
+
+## Methods
+
+| Method | Purpose |
+| ------------------------------ | ------------------------------------- |
+| `listRemotes()` | List remotes and their bundle info. |
+| `getUpdate(bundleName)` | Check whether an update is available. |
+| `download(bundleName)` | Stage an update for installation. |
+| `install(bundleName, version)` | Activate a staged version. |
+
+## BundleUpdateInfo
+
+`getUpdate()` resolves to a `BundleUpdateInfo` describing the remote bundle and how it compares to the locally installed version:
+
+```ts
+type BundleUpdateInfo = {
+ name: string;
+ version: string;
+ localVersion?: string;
+ isAvailable: boolean;
+ etag?: string;
+ integrity?: string;
+ signature?: string;
+ lastModified?: string;
+};
+```
+
+Check `isAvailable` before staging an update. The `version` field is the version the remote offers, while `localVersion` is the version currently installed on the host, if any.
+
+## Update flow
+
+A typical OTA check stages and installs a newer bundle, then reloads the webview to serve it:
+
+```ts
+import { updater } from '@wvb/bridge';
+
+async function checkForUpdate(bundleName: string) {
+ const update = await updater.getUpdate(bundleName);
+ if (!update.isAvailable) {
+ return;
+ }
+
+ await updater.download(bundleName);
+ await updater.install(bundleName, update.version);
+
+ // Reload so the webview serves the newly installed bundle.
+ window.location.reload();
+}
+```
+
+
+ If the native host has no updater configured, calls reject with a `BridgeError` whose `code` is
+ `BridgeErrorCode.UpdaterNotInitialized`. See the [Bridge overview](/docs/references/bridge) for
+ error handling.
+
+
+## See also
+
+
+
+
+
+
diff --git a/content/docs/references/deno/index.mdx b/content/docs/references/deno/index.mdx
new file mode 100644
index 0000000..f615af5
--- /dev/null
+++ b/content/docs/references/deno/index.mdx
@@ -0,0 +1,139 @@
+---
+title: Deno API
+description: Reference for the @wvb/deno package, the Deno FFI peer of @wvb/node.
+---
+
+Experimental
+
+The Deno integration is a preview, and its API may still change.
+
+`@wvb/deno` is the Deno binding for Webview Bundle. It is the FFI peer of [`@wvb/node`](/docs/references/node):
+the same protocol, source, remote, and updater surface, served to a Deno desktop webview through a
+native cdylib loaded over Deno FFI. Use this page to load the native library; the classes, options, and
+types live on the category pages below.
+
+## API surface
+
+The Deno binding mirrors `@wvb/node`. Each class takes a loaded `lib` and serves the same role
+described in the [Node API reference](/docs/references/node).
+
+
+
+
+
+
+
+
+## Loading the native library
+
+The Deno binding talks to a Rust cdylib over Deno FFI. Load it before constructing any class. You have
+two options.
+
+Use `loadLib(libPath)` when you already have the cdylib on disk and want to point at it directly:
+
+```ts title="main.ts"
+import { loadLib } from 'jsr:@wvb/deno';
+
+const lib = loadLib('./vendor/wvb/libwvb_deno.dylib');
+```
+
+Use `loadLibViaPlug(options)` to download the prebuilt cdylib on demand. It fetches the matching
+artifact through [`@denosaurs/plug`](https://jsr.io/@denosaurs/plug), verifies it against a published
+SHA-256, and caches it locally:
+
+```ts title="main.ts"
+import { loadLibViaPlug } from 'jsr:@wvb/deno';
+
+const lib = await loadLibViaPlug();
+```
+
+Set the `WVB_DENO_LIB` environment variable to override the library path that loading resolves to.
+
+### Installing the cdylib
+
+You can vendor the cdylib ahead of time instead of downloading it at runtime. Run the installer:
+
+```sh
+deno run -A jsr:@wvb/deno/install --out vendor/wvb
+```
+
+Pass `--target ` to fetch a specific platform build instead of the host's:
+
+```sh
+deno run -A jsr:@wvb/deno/install --out vendor/wvb --target aarch64-unknown-linux-gnu
+```
+
+The installer downloads the asset from GitHub Releases and verifies its SHA-256 by default. Supported
+targets:
+
+| Target triple | Platform |
+| --------------------------- | --------------------- |
+| `aarch64-apple-darwin` | macOS (Apple silicon) |
+| `x86_64-apple-darwin` | macOS (Intel) |
+| `aarch64-unknown-linux-gnu` | Linux (arm64, glibc) |
+| `x86_64-unknown-linux-gnu` | Linux (x64, glibc) |
+| `x86_64-pc-windows-msvc` | Windows (x64) |
+
+## Disposing instances
+
+The FFI classes own native handles. They are `Disposable`: call `free()` when you are done, or bind
+them with `using` so they release on scope exit through `[Symbol.dispose]`.
+
+```ts
+using protocol = new BundleProtocol(lib, source);
+// released automatically at end of scope
+
+const remote = new Remote(lib, 'https://bundles.example.com');
+try {
+ // ...
+} finally {
+ remote.free();
+}
+```
+
+## Limitations
+
+The Deno binding crosses an FFI boundary, so a few `@wvb/node` features are out of current scope:
+
+- **Declarative verification only.** `Updater` supports the declarative `signatureVerifier`
+ (`SignatureVerifierOptions`). The custom-function `integrityChecker` and `signatureVerifier`
+ callbacks of `@wvb/node` are not supported over FFI.
+- **`HttpOptions.defaultHeaders` is not supported** on the Deno `Remote`.
+
+## Deno Desktop integration
+
+`@wvb/deno-desktop` ties `@wvb/deno` to Deno's desktop runtime. Its `webviewBundle` factory builds a
+`BundleSource` (and an optional `Remote`/`Updater`) and exposes a `Deno.serve`-compatible handler,
+while `registerBindings` wires the [`@wvb/bridge`](/docs/guide/platform-integration)
+`source.*`/`remote.*`/`updater.*` commands into a `Deno.BrowserWindow`.
+
+For an end-to-end walkthrough, see the [Deno Desktop guide](/docs/guide/platforms/deno).
+
+
+
+
+
diff --git a/content/docs/references/deno/meta.json b/content/docs/references/deno/meta.json
new file mode 100644
index 0000000..03d5bbf
--- /dev/null
+++ b/content/docs/references/deno/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Deno API",
+ "pages": ["index", "source", "protocol", "remote", "updater"]
+}
diff --git a/content/docs/references/deno/protocol.mdx b/content/docs/references/deno/protocol.mdx
new file mode 100644
index 0000000..1702bb6
--- /dev/null
+++ b/content/docs/references/deno/protocol.mdx
@@ -0,0 +1,74 @@
+---
+title: Protocol
+description: Deno protocol handlers that serve bundle files and proxy local development URLs to a webview.
+---
+
+The Deno binding ships two protocol handlers. `BundleProtocol` serves a bundle's files to the webview through a custom scheme. `LocalProtocol` proxies an `app://host/...` URL to a localhost address for development. Both resolve a request to an FFI response that you convert to a standard `Response` with `toResponse(res)`.
+
+For the underlying concepts, see [Protocol handling](/docs/guide/protocol-handling). For the full Deno surface and how to load the native library, see the [Deno API overview](/docs/references/deno).
+
+## Handlers
+
+`BundleProtocol` takes a loaded `lib` and a [`BundleSource`](/docs/references/deno/source). It maps each incoming request to a file inside the resolved bundle.
+
+`LocalProtocol` proxies an `app://host/...` request to a localhost address, which is useful while you develop against a dev server.
+
+Each handler exposes a `handle` method that accepts an HTTP method, a request URL, and a headers object, and resolves to an FFI response. Convert that response to a Web `Response` with `toResponse(res)`.
+
+| Member | Description |
+| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `handle(method, url, headers)` | Resolve a request to an FFI response. `method` is an `HttpMethod`, `url` is the request URL string, `headers` is a plain object of request headers. |
+| `toResponse(res)` | Convert an FFI response into a standard `Response`. Exported as a free function. |
+| `free()` | Release the native handle. Also available through `using` / `[Symbol.dispose]`. |
+
+`HttpMethod` is the union of accepted request methods, for example `'get'` and `'head'`.
+
+## Serving a bundle
+
+Construct a `BundleSource`, pass it to `BundleProtocol`, and call `handle` from your `Deno.serve` callback. Convert the result with `toResponse` before returning it.
+
+```ts title="serve.ts"
+import { loadLibViaPlug, BundleProtocol, BundleSource, toResponse } from 'jsr:@wvb/deno';
+
+const lib = await loadLibViaPlug();
+
+const source = new BundleSource(lib, {
+ builtinDir: './bundles/builtin',
+ remoteDir: './bundles/remote',
+});
+
+using protocol = new BundleProtocol(lib, source);
+
+Deno.serve(async req => {
+ const res = await protocol.handle('get', req.url, Object.fromEntries(req.headers));
+ return toResponse(res);
+});
+```
+
+## Disposing handlers
+
+The protocol classes own native handles, so they are `Disposable`. Bind them with `using` to release on scope exit, or call `free()` when you are done.
+
+```ts
+using protocol = new BundleProtocol(lib, source);
+// released automatically at end of scope
+```
+
+
+ `BundleProtocol` reads from a [`BundleSource`](/docs/references/deno/source). Pair it with the
+ [Remote](/docs/references/deno/remote) and [Updater](/docs/references/deno/updater) to fetch and
+ install newer bundles over the air.
+
+
+
+
+
+
diff --git a/content/docs/references/deno/remote.mdx b/content/docs/references/deno/remote.mdx
new file mode 100644
index 0000000..6f7d7aa
--- /dev/null
+++ b/content/docs/references/deno/remote.mdx
@@ -0,0 +1,93 @@
+---
+title: Remote
+description: The Deno Remote class talks to a remote bundle server to list and download bundles over Deno FFI.
+---
+
+`Remote` is the Deno binding's client for a remote bundle server. It lists the bundles a server
+advertises and downloads their raw `.wvb` bytes, so an [`Updater`](/docs/references/deno/updater) can
+install newer versions over the air. It is the FFI peer of the
+[`@wvb/node`](/docs/references/node/remote) `Remote`, and crosses a native FFI boundary like the rest of
+[`@wvb/deno`](/docs/references/deno).
+
+For the server side of this relationship — the HTTP contract a remote implements and how to run one —
+see [Building a remote](/docs/guide/remote).
+
+## Constructing a Remote
+
+Load the native library first, then construct `Remote` with the loaded `lib`, the server endpoint, and
+optional `RemoteOptions`.
+
+```ts title="remote.ts"
+import { loadLibViaPlug, Remote } from 'jsr:@wvb/deno';
+
+const lib = await loadLibViaPlug();
+using remote = new Remote(lib, 'https://bundles.example.com');
+
+const list = await remote.listBundles();
+const { info, data } = await remote.download('app');
+// info: RemoteBundleInfo, data: Uint8Array (raw .wvb bytes)
+```
+
+The endpoint is the base URL of the remote bundle server. `RemoteOptions` carries optional request
+configuration, including an `HttpOptions` value. See [the type reference below](#options-and-types).
+
+## Methods
+
+| Method | Description |
+| -------------------------------------- | ----------------------------------------------------------------------------- |
+| `listBundles()` | Lists the bundles the remote advertises (name and version pairs). |
+| `download(bundleName)` | Downloads the current bundle for `bundleName`; resolves to `{ info, data }`. |
+| `downloadVersion(bundleName, version)` | Downloads a specific `version` of `bundleName`; resolves to `{ info, data }`. |
+| `free()` | Releases the native handle. Also invoked via `[Symbol.dispose]`. |
+
+`download(bundleName)` and `downloadVersion(bundleName, version)` both resolve to `{ info, data }`,
+where `info` is a `RemoteBundleInfo` and `data` is a `Uint8Array` of the raw bundle bytes.
+
+```ts title="download.ts"
+const { info, data } = await remote.downloadVersion('app', '1.4.0');
+await Deno.writeFile('./bundles/remote/app.wvb', data);
+```
+
+## Options and types
+
+`Remote` works with these related types. The shapes mirror the [`@wvb/node` Remote
+reference](/docs/references/node/remote); refer to it for full field details.
+
+| Type | Role |
+| ------------------ | ------------------------------------------------------ |
+| `RemoteOptions` | Optional configuration passed to the constructor. |
+| `HttpOptions` | HTTP request configuration carried by `RemoteOptions`. |
+| `RemoteBundleInfo` | The `info` returned alongside downloaded bundle bytes. |
+
+
+ `HttpOptions.defaultHeaders` is not yet supported on the Deno `Remote`. It crosses the FFI
+ boundary, so this `@wvb/node` feature is unavailable here.
+
+
+## Disposing a Remote
+
+`Remote` owns a native handle and is `Disposable`. Bind it with `using` so it releases on scope exit,
+or call `free()` explicitly when you are done.
+
+```ts title="dispose.ts"
+const remote = new Remote(lib, 'https://bundles.example.com');
+try {
+ const list = await remote.listBundles();
+ // ...
+} finally {
+ remote.free();
+}
+```
+
+
+
+
+
diff --git a/content/docs/references/deno/source.mdx b/content/docs/references/deno/source.mdx
new file mode 100644
index 0000000..5bfe546
--- /dev/null
+++ b/content/docs/references/deno/source.mdx
@@ -0,0 +1,87 @@
+---
+title: Source
+description: The Deno BundleSource class, which resolves bundles from a builtin directory and a remote directory.
+---
+
+`BundleSource` is the Deno binding's entry point for reading bundles off disk. It resolves a bundle from a builtin directory and a remote directory, with the remote directory taking priority, and exposes the same data API as [`@wvb/node`](/docs/references/node)'s `BundleSource`. Most other classes in the Deno binding — `BundleProtocol`, `Updater` — take a `BundleSource` and read through it.
+
+## Constructing a source
+
+Load the native library first, then pass the loaded `lib` and an options object to the constructor. See the [Deno API overview](/docs/references/deno) for the two ways to load the cdylib.
+
+```ts title="source.ts"
+import { loadLibViaPlug, BundleSource } from 'jsr:@wvb/deno';
+
+const lib = await loadLibViaPlug();
+
+const source = new BundleSource(lib, {
+ builtinDir: './bundles/builtin',
+ remoteDir: './bundles/remote',
+});
+```
+
+The constructor takes two arguments:
+
+| Argument | Type | Description |
+| --------- | -------------- | --------------------------------------------------------- |
+| `lib` | loaded library | The cdylib handle returned by `loadLib`/`loadLibViaPlug`. |
+| `options` | object | The builtin and remote directories to resolve from. |
+
+Its options are:
+
+| Option | Type | Description |
+| ------------ | -------- | --------------------------------------------------------------------------------------------- |
+| `builtinDir` | `string` | Directory holding bundles the app ships with. |
+| `remoteDir` | `string` | Directory holding bundles downloaded from a remote. Resolved with priority over `builtinDir`. |
+
+## What a source does
+
+A `BundleSource` exposes the same data API as the Node `BundleSource`:
+
+- List the bundles it can resolve.
+- Load a specific version.
+- Fetch a bundle or its descriptor.
+- Prune retained remote versions.
+
+For the exact method names, signatures, and return types of this shared surface, see the [Node API reference](/docs/references/node), which the Deno binding mirrors.
+
+## Using a source
+
+`BundleSource` is what protocols and the updater read through. Pass it to a `BundleProtocol` to serve a bundle's files to the webview, or to an `Updater` to install newer downloaded versions into the remote directory.
+
+```ts title="serve.ts"
+import { loadLibViaPlug, BundleProtocol, BundleSource, toResponse } from 'jsr:@wvb/deno';
+
+const lib = await loadLibViaPlug();
+
+const source = new BundleSource(lib, {
+ builtinDir: './bundles/builtin',
+ remoteDir: './bundles/remote',
+});
+
+using protocol = new BundleProtocol(lib, source);
+
+Deno.serve(async req => {
+ const res = await protocol.handle('get', req.url, Object.fromEntries(req.headers));
+ return toResponse(res);
+});
+```
+
+For how the resolved files reach the webview, see [Protocols](/docs/references/deno/protocol).
+
+## Deno-specific notes
+
+The Deno binding talks to a Rust cdylib over Deno FFI, so a `BundleSource` is constructed from a loaded `lib` rather than imported directly. The FFI classes own native handles, so dispose of them when you are done — bind them with `using` to release on scope exit, or call `free()` explicitly.
+
+
+
+
+
diff --git a/content/docs/references/deno/updater.mdx b/content/docs/references/deno/updater.mdx
new file mode 100644
index 0000000..1612591
--- /dev/null
+++ b/content/docs/references/deno/updater.mdx
@@ -0,0 +1,111 @@
+---
+title: Updater
+description: Check a remote for newer bundles and install them in a Deno desktop app with the @wvb/deno Updater.
+---
+
+`Updater` checks a [remote](/docs/references/deno/remote) for newer versions of a bundle and installs
+them into the local store. It ties a [`BundleSource`](/docs/references/deno/source) to a `Remote`,
+applies your integrity and signature policy, and writes the downloaded bundle so the next launch serves
+the updated app code. This is how a Deno desktop app delivers updates over-the-air (OTA) without a new
+release. For the end-to-end model, see [Remote bundles](/docs/guide/remote-bundles); for the rest of
+the binding, see the [Deno API overview](/docs/references/deno).
+
+## Constructor
+
+Construct an `Updater` with a loaded `lib`, a `BundleSource`, a `Remote`, and optional
+`UpdaterOptions`. The source and remote must use the same `lib`.
+
+```ts title="updater.ts"
+import { loadLibViaPlug, BundleSource, Remote, Updater } from 'jsr:@wvb/deno';
+
+const lib = await loadLibViaPlug();
+
+const source = new BundleSource(lib, {
+ builtinDir: './bundles/builtin',
+ remoteDir: './bundles/remote',
+});
+using remote = new Remote(lib, 'https://bundles.example.com');
+
+using updater = new Updater(lib, source, remote, {
+ integrityPolicy: 'strict',
+ signatureVerifier: {
+ algorithm: 'ed25519',
+ key: { format: 'raw', data: publicKeyBytes },
+ },
+});
+```
+
+| Parameter | Type | Description |
+| --------- | ---------------- | --------------------------------------------------- |
+| `lib` | loaded library | The native library from `loadLib`/`loadLibViaPlug`. |
+| `source` | `BundleSource` | Local store the update is installed into. |
+| `remote` | `Remote` | Remote server the update is fetched from. |
+| `options` | `UpdaterOptions` | Optional. Integrity policy and signature verifier. |
+
+## Options
+
+`UpdaterOptions` configures how a fetched bundle is verified before it is installed.
+
+| Option | Type | Description |
+| ------------------- | -------------------------- | ------------------------------------------------------------------------------------------ |
+| `integrityPolicy` | `IntegrityPolicy` | How integrity is enforced: `'strict'`, `'optional'`, or `'none'`. |
+| `signatureVerifier` | `SignatureVerifierOptions` | Declarative signature verification: an `algorithm` and a `key` (`format` plus key `data`). |
+
+`IntegrityPolicy` is the union `'strict' | 'optional' | 'none'`. `SignatureVerifierOptions` names a
+`SignatureAlgorithm` (for example `'ed25519'`) and the public key to verify against.
+
+
+ Verification on the Deno binding is declarative only. The custom-function `integrityChecker` and
+ `signatureVerifier` callbacks of [`@wvb/node`](/docs/references/node) do not cross the FFI
+ boundary.
+
+
+## Methods
+
+Run an update in three steps: ask the remote what is available, download it, then install it.
+
+```ts title="updater.ts"
+const update = await updater.getUpdate('app');
+if (update.isAvailable) {
+ await updater.download('app');
+ await updater.install('app', update.version);
+}
+```
+
+| Method | Returns | Description |
+| ------------------------------ | ------------------ | ----------------------------------------------------------------------- |
+| `getUpdate(bundleName)` | `BundleUpdateInfo` | Check the remote for a newer version. Read `isAvailable` and `version`. |
+| `download(bundleName)` | `Promise` | Fetch the current bundle from the remote into the local store. |
+| `install(bundleName, version)` | `Promise` | Install a downloaded version so it is served on the next launch. |
+
+`BundleUpdateInfo` reports whether an update `isAvailable` and the target `version`.
+
+## Disposing instances
+
+`Updater`, `Remote`, and the other FFI classes own native handles. They are `Disposable`: call `free()`
+when you are done, or bind them with `using` so they release on scope exit through `[Symbol.dispose]`.
+
+```ts
+using updater = new Updater(lib, source, remote);
+// released automatically at end of scope
+
+const remote = new Remote(lib, 'https://bundles.example.com');
+try {
+ // ...
+} finally {
+ remote.free();
+}
+```
+
+
+
+
+
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
new file mode 100644
index 0000000..255634f
--- /dev/null
+++ b/content/docs/references/index.mdx
@@ -0,0 +1,65 @@
+---
+title: Overview
+description: API references for the Rust core, the Node and Deno bindings, and the web-side bridge.
+---
+
+Most app developers never touch these APIs directly. To ship Webview Bundle in an app, reach for the platform integration packages and follow the platform guides — they wrap the core for you. These references are for advanced and embedding use: hosting the core yourself, driving the Node or Deno bindings, or calling the native host from inside the webview.
+
+
+ Building an app? Start with the [platform integration guide](/docs/guide/platform-integration) and
+ pick your platform: [Electron](/docs/guide/platforms/electron),
+ [Tauri](/docs/guide/platforms/tauri), [Android](/docs/guide/platforms/android),
+ [iOS](/docs/guide/platforms/ios), or [Deno Desktop](/docs/guide/platforms/deno).
+
+
+## API references
+
+
+
+
+
+
+
+
+## Web-side bridge
+
+The bridge (`@wvb/bridge`) runs inside the webview and lets your web app call the native host. It exposes a single `invoke()` function plus typed `source`, `remote`, and `updater` helpers, so the same code works across Electron, Tauri, Android, and iOS — the per-platform transport is abstracted away. See the [Bridge reference](/docs/references/bridge) for the full API.
+
+```ts
+import { invoke, updater } from '@wvb/bridge';
+
+// Typed domain helper
+const update = await updater.getUpdate('my-app');
+if (update.isAvailable) {
+ await updater.download('my-app');
+ await updater.install('my-app', update.version);
+}
+
+// Or call a command directly
+const bundles = await invoke('sourceListBundles');
+```
+
+The native side hosts `@wvb/node` and answers these calls. For how the bridge fits into each platform, see the [platform integration guide](/docs/guide/platform-integration).
+
+## Other platforms
+
+The Tauri and mobile integrations expose their own APIs, documented alongside their guides:
+
+- Tauri ships as the Rust crate `wvb-tauri` — see the [Tauri guide](/docs/guide/platforms/tauri).
+- Android (Kotlin) and iOS (Swift) bindings are covered in the [Android guide](/docs/guide/platforms/android) and the [iOS guide](/docs/guide/platforms/ios).
diff --git a/content/docs/references/meta.json b/content/docs/references/meta.json
new file mode 100644
index 0000000..6fa009c
--- /dev/null
+++ b/content/docs/references/meta.json
@@ -0,0 +1,5 @@
+{
+ "root": true,
+ "title": "References",
+ "pages": ["index", "[Rust (docs.rs)](https://docs.rs/wvb)", "node", "deno", "bridge"]
+}
diff --git a/content/docs/references/node/bundle.mdx b/content/docs/references/node/bundle.mdx
new file mode 100644
index 0000000..2cde41b
--- /dev/null
+++ b/content/docs/references/node/bundle.mdx
@@ -0,0 +1,84 @@
+---
+title: Bundle
+description: The @wvb/node classes for reading, building, and inspecting .wvb archives.
+---
+
+`@wvb/node` exposes a small set of classes for working with a `.wvb` archive in memory and on disk. `Bundle` and `BundleBuilder` read and write whole bundles, while `BundleDescriptor`, `Header`, `Index`, and `LoadedDescriptor` let you inspect a bundle's structure and pull individual files without loading everything into memory.
+
+For the full package surface — sources, protocols, the remote client, and the updater — see the [Node API overview](/docs/references/node). For how the bytes are laid out on disk, see [The .wvb bundle format](/docs/guide/bundle-format).
+
+## Read a bundle and get a file
+
+Call `readBundle` to parse a `.wvb` file from disk, then `getData` to pull a single entry. `getData` returns a `Buffer`, or `null` when the path is not in the bundle.
+
+```ts
+import { readBundle } from '@wvb/node';
+
+const bundle = await readBundle('./dist/app.wvb');
+
+const html = bundle.getData('index.html');
+if (html) {
+ console.log(html.toString('utf8'));
+}
+```
+
+## Bundle and builder
+
+`Bundle` is a parsed archive held in memory. You get one from `readBundle` (or `readBundleFromBuffer`) or by calling `BundleBuilder.build`. `BundleBuilder` collects entries and produces a `Bundle` you can then write out.
+
+| Class | Constructor | Key methods |
+| --------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Bundle` | Produced by `readBundle` / `BundleBuilder.build` | `descriptor(): BundleDescriptor`; `getData(path: string): Buffer \| null`; `getDataChecksum(path: string): number \| null` |
+| `BundleBuilder` | `new BundleBuilder(version?: Version)` | `get version: Version`; `entryPaths(): Array`; `insertEntry(path, data: Buffer, contentType?, headers?: Record): boolean`; `removeEntry(path): boolean`; `containsEntry(path): boolean`; `build(options?: BuildOptions): Bundle` |
+
+
+ `getDataChecksum` returns the internal xxHash-32 checksum of a file's bytes, used to detect
+ corruption inside the archive. It is distinct from a bundle's integrity hash, which uses SHA-2
+ (`sha256`, `sha384`, or `sha512`) to verify what was downloaded.
+
+
+## Descriptor, header, and index
+
+A descriptor exposes a bundle's structure without loading every file into memory. Call `bundle.descriptor()` to get a `BundleDescriptor`, then read its `Header` and `Index`, or pull individual files on demand.
+
+| Class | Key methods |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `BundleDescriptor` | `header(): Header`; `index(): Index`; `getData(filepath, path): Buffer \| null`; `getDataChecksum(filepath, path): number \| null`; `asyncGetData(...)`; `asyncGetDataChecksum(...)` |
+| `Header` | `version(): Version`; `indexEndOffset(): bigint`; `indexSize(): number` |
+| `Index` | `entries(): Record`; `getEntry(path): IndexEntry \| null`; `containsPath(path): boolean` |
+| `LoadedDescriptor` | `descriptor(): BundleDescriptor`; `getData(path): Promise` (lazy disk read); `getDataChecksum(path): Promise` |
+
+`Header` describes the bundle's format `version` and where the index sits in the file. `Index` maps each file path to an `IndexEntry`:
+
+```ts
+type IndexEntry = {
+ offset: number;
+ len: number; // compressed length
+ isEmpty: boolean;
+ contentType: string;
+ contentLength: number; // uncompressed length
+ headers: Record;
+};
+```
+
+`LoadedDescriptor` holds a reference-counted handle to the file on disk that is released on garbage collection, so its `getData` and `getDataChecksum` read lazily rather than buffering the whole archive.
+
+
+ The `Version` enum has a single value, `'v1'`. The `.wvb` format version is independent of the
+ `@wvb/node` package version.
+
+
+## Related
+
+
+
+
+
diff --git a/content/docs/references/node/index.mdx b/content/docs/references/node/index.mdx
new file mode 100644
index 0000000..caf6594
--- /dev/null
+++ b/content/docs/references/node/index.mdx
@@ -0,0 +1,121 @@
+---
+title: Node API
+description: Reference for @wvb/node — the N-API native addon that reads, serves, and updates Webview Bundles from Node.js.
+---
+
+`@wvb/node` 0.1.0 is the Node.js binding for Webview Bundle. It ships as an N-API native addon (the `wvb-node` Rust crate) with prebuilt platform binaries, so you install and run it without a Rust toolchain. The package exposes the bundle reader and writer, a `BundleSource`, the protocol handlers, a `Remote` client, and an `Updater` — the same building blocks that back `@wvb/electron` and the CLI's remote operations.
+
+The web app running inside the webview never calls `@wvb/node` directly. It uses [`@wvb/bridge`](/docs/references/bridge), whose `source`, `remote`, and `updater` commands map onto the classes documented here. See [Remote bundles](/docs/guide/remote-bundles) for the end-to-end flow and [Deno API](/docs/references/deno) for the experimental Deno peer.
+
+
+The addon resolves a prebuilt binary from one of the `@wvb/node-` optional dependencies (12 NAPI targets across macOS, Linux, Windows, and Android). Supported Node engines are `>= 12.22.0 < 13`, `>= 14.17.0 < 15`, `>= 15.12.0 < 16`, and `>= 16.0.0`.
+
+
+## Install
+
+
+
+
+```sh
+npm install @wvb/node
+```
+
+
+
+
+```sh
+pnpm add @wvb/node
+```
+
+
+
+
+```sh
+yarn add @wvb/node
+```
+
+
+
+
+## Top-level functions
+
+These functions read and write `.wvb` archives.
+
+| Function | Signature | Returns |
+| ----------------------- | ---------------------------------------------------------------- | --------------------------------------- |
+| `readBundle` | `readBundle(filepath: string): Promise` | Parsed bundle read from disk. |
+| `readBundleFromBuffer` | `readBundleFromBuffer(buffer: Buffer): Bundle` | Parsed bundle from an in-memory buffer. |
+| `writeBundle` | `writeBundle(bundle: Bundle, filepath: string): Promise` | Number of bytes written. |
+| `writeBundleIntoBuffer` | `writeBundleIntoBuffer(bundle: Bundle): Buffer` | Serialized `.wvb` bytes. |
+
+## API reference
+
+The classes, enums, and option types are split across focused pages.
+
+
+
+
+
+
+
+
+
+
+## Example
+
+Read a `.wvb` archive from disk and pull a file out of it.
+
+```ts
+import { readBundle } from '@wvb/node';
+
+const bundle = await readBundle('./dist/app.wvb');
+
+const html = bundle.getData('index.html');
+if (html) {
+ console.log(html.toString('utf8'));
+}
+```
+
+## Related
+
+
+
+
+
+
diff --git a/content/docs/references/node/meta.json b/content/docs/references/node/meta.json
new file mode 100644
index 0000000..d5ece34
--- /dev/null
+++ b/content/docs/references/node/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Node API",
+ "pages": ["index", "bundle", "source", "protocol", "remote", "updater", "types"]
+}
diff --git a/content/docs/references/node/protocol.mdx b/content/docs/references/node/protocol.mdx
new file mode 100644
index 0000000..36ca5a6
--- /dev/null
+++ b/content/docs/references/node/protocol.mdx
@@ -0,0 +1,86 @@
+---
+title: Protocol
+description: Reference for the @wvb/node protocol handlers — BundleProtocol serves bundle assets, LocalProtocol proxies to localhost.
+---
+
+`@wvb/node` ships two protocol handlers that turn a request from the webview into an HTTP-style response. `BundleProtocol` serves assets out of a `BundleSource`, and `LocalProtocol` proxies requests to a localhost server during development. Both take an HTTP method, a URI, and optional headers, and resolve to an `HttpResponse`. See [Protocol handling](/docs/guide/protocol-handling) for how these schemes fit into a webview platform.
+
+Both handlers share the same call shape:
+
+```ts
+handle(
+ method: HttpMethod,
+ uri: string,
+ headers?: Record,
+): Promise
+```
+
+`HttpMethod` and `HttpResponse` are documented in [Types](/docs/references/node/types). `HttpMethod` is a lowercase string union (`'get'`, `'head'`, and so on); `HttpResponse` is `{ status, headers, body }`, where `body` is a `Buffer`.
+
+## BundleProtocol
+
+`new BundleProtocol(source: BundleSource)` serves `scheme://bundle_name/path` requests from a [`BundleSource`](/docs/references/node/source). It resolves the requested file inside the bundle and returns its bytes.
+
+| Member | Signature |
+| ----------- | ---------------------------------------------------------------------------------------------- |
+| Constructor | `new BundleProtocol(source: BundleSource)` |
+| `handle` | `(method: HttpMethod, uri: string, headers?: Record) => Promise` |
+
+`BundleProtocol` answers `GET` and `HEAD` requests. A `GET` returns the file contents in `body`; a `HEAD` returns the response metadata without the body. When the request carries a `Range` header, the handler serves a partial response with status `206`.
+
+## LocalProtocol
+
+`new LocalProtocol(hosts: Record)` proxies `app://host/...` requests to a localhost URL. Map each virtual host to the local origin it should forward to. This is useful in development, where the webview points at a dev server instead of a packed `.wvb` bundle.
+
+| Member | Signature |
+| ----------- | ---------------------------------------------------------------------------------------------- |
+| Constructor | `new LocalProtocol(hosts: Record)` |
+| `handle` | `(method: HttpMethod, uri: string, headers?: Record) => Promise` |
+
+`LocalProtocol` caches responses and returns `304` when the upstream resource is unchanged.
+
+
+ Use `BundleProtocol` to serve packed bundles in production and `LocalProtocol` to proxy a dev
+ server during development. See [Protocol handling](/docs/guide/protocol-handling) to wire either
+ scheme into your platform.
+
+
+## Serve requests through a protocol
+
+Build a `BundleSource`, wrap it in a `BundleProtocol`, and answer requests from the webview.
+
+```ts
+import { BundleSource, BundleProtocol } from '@wvb/node';
+
+const source = new BundleSource({
+ builtinDir: './bundles/builtin',
+ remoteDir: './bundles/remote',
+});
+
+const protocol = new BundleProtocol(source);
+
+const res = await protocol.handle('get', 'app://my-app/index.html');
+console.log(res.status); // 200
+console.log(res.headers['content-type']);
+res.body; // Buffer
+```
+
+## Related
+
+
+
+
+
+
diff --git a/content/docs/references/node/remote.mdx b/content/docs/references/node/remote.mdx
new file mode 100644
index 0000000..f065f14
--- /dev/null
+++ b/content/docs/references/node/remote.mdx
@@ -0,0 +1,141 @@
+---
+title: Remote
+description: The @wvb/node HTTP client that lists, inspects, and downloads bundles from a remote.
+---
+
+`Remote` is the `@wvb/node` HTTP client for a [bundle remote](/docs/guide/remote). It talks to any server that implements the [Remote HTTP Spec](/docs/guide/spec), so you can list available bundles, read a bundle's metadata, and download bundle bytes from Node.js. The [Updater](/docs/references/node/updater) builds on `Remote` to stage and activate updates; use `Remote` directly when you need lower-level access to the remote.
+
+## Constructor
+
+```ts
+new Remote(endpoint: string, options?: RemoteOptions)
+```
+
+Pass the remote's base `endpoint` (for example `https://bundles.example.com`). The client issues requests against that endpoint following the HTTP contract.
+
+```ts
+import { Remote } from '@wvb/node';
+
+const remote = new Remote('https://bundles.example.com');
+```
+
+## Methods
+
+| Method | Signature |
+| ----------------- | --------------------------------------------------------------------- |
+| `listBundles` | `(channel?: string): Promise` |
+| `getInfo` | `(bundleName: string, channel?: string): Promise` |
+| `download` | `(bundleName, channel?): Promise<[RemoteBundleInfo, Bundle, Buffer]>` |
+| `downloadVersion` | `(bundleName, version): Promise<[RemoteBundleInfo, Bundle, Buffer]>` |
+
+`listBundles` lists the bundles the remote serves, optionally filtered by `channel`. `getInfo` reads a single bundle's current metadata. `download` fetches the current version of a bundle, while `downloadVersion` fetches a specific version. Both download methods resolve to a tuple of the bundle info, the parsed [`Bundle`](/docs/references/node/bundle), and the raw `.wvb` bytes as a `Buffer`.
+
+```ts
+const [info, bundle, bytes] = await remote.download('my-app', 'stable');
+
+console.log(info.name, info.version);
+console.log(bundle.getData('index.html')?.toString('utf8'));
+console.log(bytes.byteLength);
+```
+
+## RemoteOptions
+
+`RemoteOptions` configures the HTTP transport and an optional download-progress callback.
+
+```ts
+type RemoteOptions = {
+ http?: HttpOptions;
+ onDownload?: (data: RemoteOnDownloadData) => void;
+};
+
+type RemoteOnDownloadData = {
+ downloadedBytes: number;
+ totalBytes?: number;
+ endpoint: string;
+};
+```
+
+`onDownload` fires as bytes arrive. `totalBytes` is set when the remote reports a content length.
+
+```ts
+const remote = new Remote('https://bundles.example.com', {
+ onDownload: ({ downloadedBytes, totalBytes }) => {
+ if (totalBytes) {
+ console.log(`${Math.round((downloadedBytes / totalBytes) * 100)}%`);
+ }
+ },
+});
+```
+
+## HttpOptions
+
+`HttpOptions` tunes the underlying HTTP client. Every field is optional.
+
+| Field | Type | Description |
+| -------------------- | ------------------------ | ----------------------------------------- |
+| `defaultHeaders` | `Record` | Headers sent with every request. |
+| `userAgent` | `string` | `User-Agent` header value. |
+| `timeout` | `number` | Overall request timeout, in milliseconds. |
+| `readTimeout` | `number` | Read timeout, in milliseconds. |
+| `connectTimeout` | `number` | Connection timeout, in milliseconds. |
+| `poolIdleTimeout` | `number` | Idle timeout for pooled connections. |
+| `poolMaxIdlePerHost` | `number` | Maximum idle connections kept per host. |
+| `referer` | `boolean` | Whether to send a `Referer` header. |
+| `tcpNodelay` | `boolean` | Enable `TCP_NODELAY` on sockets. |
+| `hickoryDns` | `boolean` | Use the Hickory DNS resolver. |
+
+
+ The default request timeout is 120 seconds. Override it through `HttpOptions.timeout`
+ (milliseconds).
+
+
+```ts
+const remote = new Remote('https://bundles.example.com', {
+ http: {
+ timeout: 30_000,
+ userAgent: 'my-app/1.0',
+ },
+});
+```
+
+## Result shapes
+
+`getInfo`, `download`, and `downloadVersion` return a `RemoteBundleInfo`. `listBundles` returns an array of `ListRemoteBundleInfo`.
+
+```ts
+type ListRemoteBundleInfo = {
+ name: string;
+ version: string;
+};
+
+type RemoteBundleInfo = {
+ name: string;
+ version: string;
+ etag?: string;
+ integrity?: string; // ":", e.g. "sha256:n4bQ…"
+ signature?: string; // base64
+ lastModified?: string;
+};
+```
+
+The `integrity` and `signature` fields carry the values the remote advertises through the `Webview-Bundle-Integrity` and `Webview-Bundle-Signature` response headers. See [Errors](/docs/guide/spec/errors) for how the client surfaces non-success responses such as `404` (not deployed) and `403` (forbidden).
+
+## Related
+
+
+
+
+
+
diff --git a/content/docs/references/node/source.mdx b/content/docs/references/node/source.mdx
new file mode 100644
index 0000000..6074734
--- /dev/null
+++ b/content/docs/references/node/source.mdx
@@ -0,0 +1,92 @@
+---
+title: Source
+description: BundleSource resolves a bundle across the builtin and remote directories, with the remote copy taking priority.
+---
+
+`BundleSource` is the class in `@wvb/node` that decides which `.wvb` file to serve for a given bundle name. It manages bundles across two directories: a builtin directory for the bundles shipped with the app, and a remote directory for bundles downloaded over the air (OTA). When both directories hold a bundle for the same name, the remote copy wins, so an OTA update transparently supersedes the version that shipped in the app.
+
+A `BundleSource` is the foundation that [`BundleProtocol`](/docs/references/node/protocol) serves from and that [`Updater`](/docs/references/node/updater) stages downloads into. See [Bundle sources](/docs/guide/bundle-sources) for how the manifest, channels, and directory layout fit together, and the [Node API overview](/docs/references/node) for the full package surface.
+
+## Constructor
+
+```ts
+new BundleSource(config: BundleSourceConfig)
+```
+
+`config` is a [`BundleSourceConfig`](/docs/references/node/types). It points the source at the two directories and, optionally, at explicit manifest file paths:
+
+```ts
+import { BundleSource } from '@wvb/node';
+
+const source = new BundleSource({
+ builtinDir: './bundles/builtin',
+ remoteDir: './bundles/remote',
+});
+```
+
+
+ When a bundle name resolves in both directories, `BundleSource` returns the remote version. This
+ is what lets a downloaded update take over from the builtin bundle without a native app-store
+ release.
+
+
+## Methods
+
+| Method | Signature |
+| -------------------------- | ---------------------------------------------------------------- |
+| `listBundles` | `(): Promise` |
+| `loadVersion` | `(bundleName): Promise` |
+| `updateRemoteVersion` | `(bundleName, version): Promise` |
+| `resolveFilepath` | `(bundleName): Promise` |
+| `getBuiltinBundleFilepath` | `(bundleName, version): string` |
+| `getRemoteBundleFilepath` | `(bundleName, version): string` |
+| `fetchBundle` | `(bundleName): Promise` |
+| `fetchBuiltinBundle` | `(name, version): Promise` |
+| `fetchRemoteBundle` | `(name, version): Promise` |
+| `fetchDescriptor` | `(bundleName): Promise` |
+| `loadBuiltinMetadata` | `(name, version): Promise` |
+| `loadRemoteMetadata` | `(name, version): Promise` |
+| `writeRemoteBundle` | `(name, version, bundle, metadata): Promise` |
+| `loadDescriptor` | `(bundleName): Promise` (single-flight cached) |
+| `unloadDescriptor` | `(bundleName): boolean` |
+| `removeRemoteBundle` | `(name, version): Promise` |
+| `remoteRetainedVersions` | `(name): Promise` (current + previous) |
+| `pruneRemoteBundles` | `(name): Promise` |
+
+`listBundles` reports every bundle across both directories as a [`ListBundleItem`](/docs/references/node/types), each carrying its `type` (`builtin` or `remote`), name, version, whether it is current, and its manifest metadata. `loadVersion` resolves the effective version for a name and tells you which directory it came from. `resolveFilepath` returns the path that wins under the remote-priority rule, while `getBuiltinBundleFilepath` and `getRemoteBundleFilepath` compute a directory-specific path for an exact version.
+
+The `fetch*` methods read a parsed [`Bundle`](/docs/references/node/bundle): `fetchBundle` honors the priority rule, while `fetchBuiltinBundle` and `fetchRemoteBundle` target one directory explicitly. `fetchDescriptor` returns a [`BundleDescriptor`](/docs/references/node/bundle) without loading every file into memory.
+
+`loadDescriptor` caches a `LoadedDescriptor` with single-flight semantics, so concurrent callers share one load; `unloadDescriptor` drops that cached handle. The remote-directory write path is `writeRemoteBundle`, which persists a downloaded bundle and its [`BundleManifestMetadata`](/docs/references/node/types). `remoteRetainedVersions` lists the versions the source keeps for a name (current plus previous), `removeRemoteBundle` deletes a specific version, and `pruneRemoteBundles` removes versions that are no longer retained.
+
+
+ Higher-level OTA flows usually call these methods through
+ [`Updater`](/docs/references/node/updater), which stages and activates remote versions for you.
+ Reach for `BundleSource` directly when you need fine-grained control over resolution, metadata, or
+ cleanup.
+
+
+## Related
+
+
+
+
+
+
+
diff --git a/content/docs/references/node/types.mdx b/content/docs/references/node/types.mdx
new file mode 100644
index 0000000..8739489
--- /dev/null
+++ b/content/docs/references/node/types.mdx
@@ -0,0 +1,169 @@
+---
+title: Types
+description: Enums, unions, option interfaces, and result types shared across the @wvb/node API.
+---
+
+These are the supporting types that the [Node API](/docs/references/node) classes and functions return and accept. The reader, [BundleSource](/docs/references/node/source), [protocol handlers](/docs/references/node/protocol), [Remote](/docs/references/node/remote), and [Updater](/docs/references/node/updater) pages reference the shapes defined here.
+
+## Enums and unions
+
+String-literal unions that name a fixed set of values.
+
+```ts
+type Version = 'v1';
+
+type HttpMethod =
+ | 'get'
+ | 'head'
+ | 'options'
+ | 'post'
+ | 'put'
+ | 'patch'
+ | 'delete'
+ | 'trace'
+ | 'connect';
+
+type BundleSourceKind = 'builtin' | 'remote';
+```
+
+`Version` is the bundle format version; only `v1` exists today.
+
+### Integrity
+
+```ts
+// SHA-2 family; sha384 is recommended.
+type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512';
+
+// optional is the default.
+type IntegrityPolicy = 'strict' | 'optional' | 'none';
+```
+
+Integrity uses SHA-2, serialized as `":"` (for example `sha256:n4bQ…`). `strict` requires a value to be present and to match, `optional` verifies a value only when present, and `none` skips the check. See [Remote, integrity & signature config](/docs/config/remote).
+
+### Signature
+
+```ts
+type SignatureAlgorithm =
+ | 'ecdsaSecp256R1'
+ | 'ecdsaSecp384R1'
+ | 'ed25519'
+ | 'rsaPkcs1V15'
+ | 'rsaPss';
+
+type VerifyingKeyFormat = 'spkiDer' | 'spkiPem' | 'pkcs1Der' | 'pkcs1Pem' | 'sec1' | 'raw';
+```
+
+A signature proves who published a bundle by signing the integrity string's bytes, so signature verification requires an integrity value to be present.
+
+
+ Key-format constraints: `pkcs1Der` and `pkcs1Pem` apply to RSA only, `sec1` to ECDSA only, and
+ `raw` to Ed25519 only (a 32-byte key).
+
+
+## Option interfaces
+
+Shapes passed into constructors and methods.
+
+```ts
+type BundleSourceConfig = {
+ builtinDir: string;
+ remoteDir: string;
+ builtinManifestFilepath?: string;
+ remoteManifestFilepath?: string;
+};
+
+type UpdaterOptions = {
+ channel?: string;
+ integrityPolicy?: IntegrityPolicy;
+ integrityChecker?: (data: Uint8Array, integrity: string) => Promise;
+ signatureVerifier?:
+ | SignatureVerifierOptions
+ | ((data: Uint8Array, signature: string) => Promise);
+};
+
+type SignatureVerifierOptions = {
+ algorithm: SignatureAlgorithm;
+ key: { format: VerifyingKeyFormat; data: string | Uint8Array };
+};
+```
+
+`UpdaterOptions` accepts both a declarative `signatureVerifier` (algorithm plus key) and a custom callback. The same applies to `integrityChecker`, which can replace the built-in SHA-2 check with your own function.
+
+## Result types
+
+Shapes returned by the reader, [BundleSource](/docs/references/node/source), and [Updater](/docs/references/node/updater).
+
+```ts
+type BundleManifestMetadata = {
+ etag?: string;
+ integrity?: string; // ":", e.g. "sha256:n4bQ…"
+ signature?: string; // base64
+ lastModified?: string;
+};
+
+// BundleSource.loadVersion()
+type BundleSourceVersion = {
+ type: BundleSourceKind; // 'builtin' | 'remote'
+ version: string;
+};
+
+// BundleSource.listBundles()
+type ListBundleItem = {
+ type: BundleSourceKind;
+ name: string;
+ version: string;
+ current: boolean;
+ metadata: BundleManifestMetadata;
+};
+
+// Updater.getUpdate()
+type BundleUpdateInfo = {
+ name: string;
+ version: string; // the deployed version
+ localVersion?: string; // the installed version, if any
+ isAvailable: boolean; // true when version !== localVersion
+ etag?: string;
+ integrity?: string;
+ signature?: string;
+ lastModified?: string;
+};
+
+// BundleBuilder.build() — seeds are for tests
+type BuildOptions = {
+ header?: { checksumSeed?: number };
+ index?: { checksumSeed?: number };
+ dataChecksumSeed?: number;
+};
+
+// Returned by protocol handlers' handle()
+type HttpResponse = {
+ status: number;
+ headers: Record;
+ body: Buffer;
+};
+```
+
+## Related
+
+
+
+
+
+
+
diff --git a/content/docs/references/node/updater.mdx b/content/docs/references/node/updater.mdx
new file mode 100644
index 0000000..575c05f
--- /dev/null
+++ b/content/docs/references/node/updater.mdx
@@ -0,0 +1,163 @@
+---
+title: Updater
+description: The @wvb/node Updater class — check a remote for newer bundles, stage them with integrity and signature verification, then activate them.
+---
+
+`Updater` drives over-the-air (OTA) updates for a bundle. It pairs a [`BundleSource`](/docs/references/node/source) with a [`Remote`](/docs/references/node/remote): it checks the remote for a newer version, downloads it into the source's remote directory, and activates it. The check, download, and activate steps are separate methods, so you can stage an update in the background and switch to it later — for example on the next app restart. See [Remote bundles](/docs/guide/remote-bundles) for the end-to-end flow.
+
+```ts
+import { BundleSource, Remote, Updater } from '@wvb/node';
+
+const updater = new Updater(source, remote, options);
+```
+
+## Constructor
+
+`new Updater(source: BundleSource, remote: Remote, options?: UpdaterOptions)`
+
+| Argument | Type | Description |
+| --------- | ---------------------------------------------- | --------------------------------------------- |
+| `source` | [`BundleSource`](/docs/references/node/source) | Where staged and active bundles live on disk. |
+| `remote` | [`Remote`](/docs/references/node/remote) | The HTTP client for the remote. |
+| `options` | `UpdaterOptions` | Channel and verification settings (optional). |
+
+Downloads and installs are serialized per bundle, so concurrent calls for the same bundle name run one at a time.
+
+## Methods
+
+| Method | Signature | Behavior |
+| ------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| `listRemotes` | `(): Promise` | Lists the bundles available on the remote (honoring the configured `channel`). |
+| `getUpdate` | `(bundleName): Promise` | Checks the remote's current version against the local one. This is the check step. |
+| `download` | `(bundleName, version?): Promise` | Stages a version into the remote directory, verifying integrity and signature if configured. Does not activate it. |
+| `install` | `(bundleName, version): Promise` | Activates a staged version: re-verifies it, swaps the current version, drops the cached descriptor, and prunes old ones. |
+
+`getUpdate` is the check step — there is no method named `check`. Keeping `download` and `install` distinct makes update phases restartable: stage the bytes whenever the network is available, then activate at a safe moment.
+
+When you omit the `version` argument to `download`, the updater fetches the remote's current version for the configured channel. Pass a specific `version` to stage that exact version instead.
+
+
+ `getUpdate` returns a `BundleUpdateInfo` whose `isAvailable` is `true` when the remote's deployed
+ `version` differs from the installed `localVersion`. Use it to gate the download and install
+ calls.
+
+
+## UpdaterOptions
+
+```ts
+type UpdaterOptions = {
+ channel?: string;
+ integrityPolicy?: IntegrityPolicy;
+ integrityChecker?: (data: Uint8Array, integrity: string) => Promise;
+ signatureVerifier?:
+ | SignatureVerifierOptions
+ | ((data: Uint8Array, signature: string) => Promise);
+};
+
+type SignatureVerifierOptions = {
+ algorithm: SignatureAlgorithm;
+ key: { format: VerifyingKeyFormat; data: string | Uint8Array };
+};
+```
+
+| Field | Type | Description |
+| ------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------- |
+| `channel` | `string` | Release channel forwarded to the remote on list and check calls. |
+| `integrityPolicy` | `IntegrityPolicy` | `'strict'`, `'optional'` (default), or `'none'`. |
+| `integrityChecker` | `(data, integrity) => Promise` | Custom integrity check that replaces the built-in SHA-2 comparison. |
+| `signatureVerifier` | `SignatureVerifierOptions` or `(data, signature) => Promise` | Declarative algorithm-and-key verifier, or a custom callback. |
+
+### Integrity policy
+
+The `integrityPolicy` decides how a staged bundle's integrity hash is checked during `download` and re-checked during `install`.
+
+| Policy | Behavior |
+| ------------ | ----------------------------------------------------------------------- |
+| `'strict'` | An integrity value must be present and must match, or the update fails. |
+| `'optional'` | Verify the integrity value if the remote provides one (default). |
+| `'none'` | Skip integrity checking entirely. |
+
+Integrity uses SHA-2 (`sha256`, `sha384`, or `sha512`), serialized as `":"`, for example `sha256:n4bQ…`. To plug in your own logic, supply an `integrityChecker` callback instead of relying on the built-in check.
+
+### Signature verification
+
+Set `signatureVerifier` to require that an update is signed by a key you trust. Provide it declaratively with an algorithm and a public key, or supply a custom callback.
+
+```ts
+type SignatureAlgorithm =
+ | 'ecdsaSecp256R1'
+ | 'ecdsaSecp384R1'
+ | 'ed25519'
+ | 'rsaPkcs1V15'
+ | 'rsaPss';
+
+type VerifyingKeyFormat = 'spkiDer' | 'spkiPem' | 'pkcs1Der' | 'pkcs1Pem' | 'sec1' | 'raw';
+```
+
+Key-format constraints: `pkcs1Der` and `pkcs1Pem` apply to RSA only, `sec1` to ECDSA only, and `raw` to Ed25519 only (a 32-byte key).
+
+
+ A signature covers the integrity string's bytes, not the raw bundle bytes. Signature verification
+ therefore requires an integrity value to be present — pair `signatureVerifier` with an integrity
+ policy that produces one.
+
+
+See [enums, options, and result types](/docs/references/node/types) for the full type listing and [Remote, integrity & signature config](/docs/config/remote) for the publish side.
+
+## Update with integrity and signature checks
+
+Wire an `Updater` with a strict integrity policy and a declarative Ed25519 signature verifier, then run the check, download, and install steps in order.
+
+```ts
+import { BundleSource, Remote, Updater } from '@wvb/node';
+
+const source = new BundleSource({
+ builtinDir: './bundles/builtin',
+ remoteDir: './bundles/remote',
+});
+
+const remote = new Remote('https://bundles.example.com');
+
+const updater = new Updater(source, remote, {
+ channel: 'stable',
+ integrityPolicy: 'strict',
+ signatureVerifier: {
+ algorithm: 'ed25519',
+ key: {
+ format: 'spkiPem',
+ data: process.env.WVB_PUBLIC_KEY!,
+ },
+ },
+});
+
+const update = await updater.getUpdate('my-app');
+if (update.isAvailable) {
+ await updater.download('my-app', update.version);
+ await updater.install('my-app', update.version);
+}
+```
+
+## Related
+
+
+
+
+
+
+
diff --git a/content/docs/remote-updates.mdx b/content/docs/remote-updates.mdx
deleted file mode 100644
index 526e331..0000000
--- a/content/docs/remote-updates.mdx
+++ /dev/null
@@ -1,262 +0,0 @@
----
-title: Remote updates & local testing
-description: Publish a new bundle version, the server contract, how clients pick it up, and how to run the whole loop locally.
----
-
-This is the end-to-end guide to shipping bundle updates over the air: how you publish a new version,
-what the server contract is, how clients pick it up, and — importantly — **how to run the whole loop
-locally** before you involve a real server.
-
-If you haven't yet, read [Concepts](/docs/concepts) for the meaning of _bundle_, _source_,
-_manifest_, _channel_, _integrity_, and _signature_.
-
-## The lifecycle at a glance
-
-```text
- developer machine / CI remote server end-user device
-┌──────────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
-│ wvb pack ./dist │ │ │ │ │
-│ → app_1.1.0.wvb │ │ │ │ updater.getUpdate() │
-│ wvb upload ───────────────── upload ───▶ │ store version │ │ isAvailable? ──┐ │
-│ (+integrity +signature)│ │ │ │ │ │
-│ wvb deploy ───────────────── deploy ──▶ │ mark deployed │ ◀──────── HEAD /bundles/app │
-│ (--channel beta) │ │ (per channel) │ ───────▶ GET /bundles/app │
-└──────────────────────────┘ └──────────────────────┘ │ verify + install ◀┘ │
- └──────────────────────┘
-```
-
-1. **Pack** your build into a `.wvb`.
-2. **Upload** it to the server (optionally attaching an integrity hash and a signature).
-3. **Deploy** that version so clients on a given channel start seeing it.
-4. The client's **updater** checks for a newer deployed version, downloads it, **verifies** it, and
- installs it into the `remote` source — after which it's served instead of the builtin bundle.
-
-## Publishing from the command line
-
-All publishing flows through the `wvb` CLI (`@wvb/cli`) and a `wvb.config.ts`. A minimal config that
-targets a real server:
-
-```ts
-// wvb.config.ts
-import { defineConfig } from '@wvb/config';
-
-export default defineConfig({
- pack: {
- srcDir: './dist', // your build output
- outFileName: 'app', // → app.wvb (name from package.json if omitted)
- },
- remote: {
- endpoint: 'https://updates.example.com',
- bundleName: 'app',
- // version: () => readVersionSomehow(), // defaults to package.json version
- uploader: /* a provider's uploader */,
- deployer: /* a provider's deployer */,
- // integrity: { algorithm: 'sha384' }, // attach an integrity hash (optional)
- // signature: { /* see below */ }, // attach a signature (optional)
- },
-});
-```
-
-`uploader` and `deployer` come from a **remote provider** package — `@wvb/remote-aws`,
-`@wvb/remote-cloudflare`, or `@wvb/remote-local` for local testing (below). See the
-[Configuration reference](/docs/configuration) for every field.
-
-Then, from your project:
-
-```sh
-wvb pack # build the .wvb from pack.srcDir
-wvb upload app 1.1.0 --deploy # upload + deploy in one step
-# or, separately:
-wvb upload app 1.1.0 # upload only
-wvb deploy app 1.1.0 # make it the deployed version
-```
-
-`wvb upload` packs before uploading by default — it runs `wvb pack` from `pack.srcDir`. Pass
-`--no-pack` to upload an existing `.wvb` as-is, or `--file ` for a specific bundle. With
-`--deploy` (also on by default) it deploys; add `--channel beta` to deploy to a channel. See the
-[CLI reference](/docs/cli) for all flags.
-
-## The remote HTTP contract
-
-A Webview Bundle server is any HTTP server that implements four endpoints. You can use a provider
-(AWS/Cloudflare/local) or build your own to this spec.
-
-| Method & path | Purpose |
-| ------------------------------- | ------------------------------------------------ |
-| `GET /bundles` | List deployed bundles: `[{ "name", "version" }]` |
-| `HEAD /bundles/{name}` | Current version's metadata (headers only) |
-| `GET /bundles/{name}` | Download the current version |
-| `GET /bundles/{name}/{version}` | Download a specific version |
-
-Metadata travels in response headers:
-
-- `Webview-Bundle-Name` — bundle name **(required)**
-- `Webview-Bundle-Version` — version **(required)**
-- `Webview-Bundle-Integrity` — integrity hash (optional)
-- `Webview-Bundle-Signature` — signature (optional)
-- plus standard `ETag` / `Last-Modified`
-
-Status codes: `404` when a bundle/version isn't deployed; `403` when fetching a specific
-non-deployed version is disallowed (the `allowOtherVersions` option). Downloads use
-`Content-Type: application/webview-bundle`. The full spec, including request/response examples, is in
-the [`wvb` crate docs](https://docs.rs/wvb).
-
-## Channels (staged rollouts)
-
-A channel routes different versions to different audiences. Deploy to one with `--channel`:
-
-```sh
-wvb upload app 1.2.0 --deploy --channel beta # only beta clients see 1.2.0
-wvb deploy app 1.2.0 # later, promote to the default channel
-```
-
-Clients select a channel when checking for updates (e.g. `updater_get_update` with a channel-aware
-config, or `Remote::list_bundles(Some(&"beta".into()))` in Rust). No channel = the default channel.
-
-## Integrity and signatures
-
-Attach verification material at **upload** time, and verify it at **download** time.
-
-**Publish side** (`wvb.config.ts`): the uploader computes an integrity hash and/or signs the bundle.
-
-```ts
-remote: {
- endpoint: 'https://updates.example.com',
- uploader, deployer,
- integrity: { algorithm: 'sha384' }, // → "sha384:…"
- signature: { // sign the integrity string
- algorithm: 'ecdsa',
- curve: 'p256',
- hash: 'sha256',
- key: { format: 'pkcs8', data: privateKeyDer },
- },
-}
-```
-
-Skip them per-run with `wvb upload --skip-integrity` / `--skip-signature`.
-
-**Client side**: the updater enforces an [integrity policy](/docs/concepts#integrity) and, if you
-configure a public key, a signature verifier. In Rust:
-
-```rust
-use wvb::integrity::{IntegrityChecker, IntegrityPolicy};
-use wvb::signature::{Ed25519Verifier, SignatureVerifier};
-use wvb::updater::{Updater, UpdaterConfig};
-use std::sync::Arc;
-
-let verifier = SignatureVerifier::Ed25519(Arc::new(
- Ed25519Verifier::from_public_key_pem(PUBLIC_KEY_PEM)?,
-));
-let config = UpdaterConfig::new()
- .integrity_policy(IntegrityPolicy::Strict) // require & verify integrity
- .signature_verifier(verifier); // require & verify signature
-let updater = Updater::new(source, remote, Some(config));
-```
-
-The Electron and Tauri updaters expose the same options through their config. Android and iOS support
-is planned; their UniFFI bindings expose the `IntegrityChecker`/policy types directly.
-
-## How clients install an update
-
-The high-level `Updater` ties a source to a remote and does check → download → verify → install:
-
-```rust
-let update = updater.get_update("app").await?;
-if update.is_available {
- let info = updater.download("app", None).await?; // downloads + verifies, stages into the remote source
- updater.install("app", info.version).await?; // activates the downloaded version
- // reload the webview to pick up the new version
-}
-```
-
-`download` writes the verified bundle into the `remote` source directory; `install` then activates
-that version, so it's served on the next load. Each platform guide shows the idiomatic call:
-[Electron](/docs/guides/electron#3-call-the-api-from-the-renderer-optional),
-[Tauri](/docs/guides/tauri#4-drive-updates-from-the-frontend-optional),
-[Android](/docs/guides/android#over-the-air-updates),
-[iOS](/docs/guides/ios#over-the-air-updates).
-
-## Testing locally
-
-You don't need a cloud account to exercise the full update loop. There are two local tools.
-
-### `wvb remote local` — a real local update server
-
-`@wvb/remote-local` provides an uploader/deployer that write to a local directory (default
-`~/.wvb/local`), and `wvb remote local` serves that directory over HTTP using the same contract a
-production server implements.
-
-**1. Point your config at the local provider:**
-
-```ts
-// wvb.config.ts
-import { defineConfig } from '@wvb/config';
-import { localRemote } from '@wvb/remote-local';
-
-const local = localRemote({ baseDir: '~/.wvb/local' }); // { uploader, deployer }
-
-export default defineConfig({
- pack: { srcDir: './dist', outFileName: 'app' },
- remote: {
- endpoint: 'http://localhost:4313',
- bundleName: 'app',
- uploader: local.uploader,
- deployer: local.deployer,
- },
-});
-```
-
-**2. Publish a version into the local store:**
-
-```sh
-wvb pack
-wvb upload app 1.1.0 --deploy # writes app_1.1.0.wvb into ~/.wvb/local and deploys it
-```
-
-**3. Start the local server:**
-
-```sh
-wvb remote local # serves ~/.wvb/local on http://localhost:4313
-# options: --base-dir ./.wvb/local --port 4313 --allow-other-versions
-```
-
-**4. Point your app's updater at it** by setting its endpoint to `http://localhost:4313`
-(`updater: { remote: { endpoint: 'http://localhost:4313' } }` in Electron, `Remote::new(...)` in
-Tauri, the remote URL in your Android/iOS updater). Now `getUpdate` / `download` / `install` hit your
-local server and download, verify, and activate the bundle exactly as production would.
-
-
- On the Android emulator, reach your host machine at `http://10.0.2.2:4313` instead of `localhost`.
-
-
-You can also verify the server directly with the CLI's client commands:
-
-```sh
-wvb remote list --endpoint http://localhost:4313
-wvb remote current app --endpoint http://localhost:4313
-wvb download app --endpoint http://localhost:4313 --out /tmp/app.wvb
-```
-
-### `wvb serve` — preview a single packed bundle
-
-To just look at what's _inside_ a `.wvb` (no update server, no manifest), serve its files over HTTP:
-
-```sh
-wvb serve ./app.wvb # serves the bundle's files at http://localhost:4312
-wvb serve ./app.wvb --port 8080
-```
-
-This is handy for confirming a pack produced the files and headers you expect, before you upload.
-
-## Provider packages
-
-For real deployments, swap the local provider for a cloud one:
-
-- **AWS** — [`@wvb/remote-aws`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/remote/aws) (+
- [`@wvb/remote-aws-provider-pulumi`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/remote/aws-provider-pulumi) to provision it)
-- **Cloudflare** — [`@wvb/remote-cloudflare`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/remote/cloudflare) (+
- [`@wvb/remote-cloudflare-provider-pulumi`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/remote/cloudflare-provider-pulumi))
-
-Each exposes a compatible `uploader`/`deployer` for `wvb.config.ts` and a server that implements the
-[HTTP contract](#the-remote-http-contract) above. See their READMEs and the
-[`examples/`](https://github.com/webview-bundle/webview-bundle/tree/main/examples) for provisioning with Pulumi.
diff --git a/public/logo.png b/public/logo.png
deleted file mode 100644
index b28bab7..0000000
Binary files a/public/logo.png and /dev/null differ
diff --git a/public/logo2.png b/public/logo2.png
deleted file mode 100644
index 273e246..0000000
Binary files a/public/logo2.png and /dev/null differ
diff --git a/public/logo3.png b/public/logo3.png
deleted file mode 100644
index ebdc0bd..0000000
Binary files a/public/logo3.png and /dev/null differ
diff --git a/public/showcase/desktop.jpg b/public/showcase/desktop.jpg
deleted file mode 100644
index 917bdb0..0000000
Binary files a/public/showcase/desktop.jpg and /dev/null differ
diff --git a/public/showcase/desktop.mp4 b/public/showcase/desktop.mp4
deleted file mode 100644
index ddc6360..0000000
Binary files a/public/showcase/desktop.mp4 and /dev/null differ
diff --git a/public/showcase/desktop.webm b/public/showcase/desktop.webm
deleted file mode 100644
index d4b23d5..0000000
Binary files a/public/showcase/desktop.webm and /dev/null differ
diff --git a/public/showcase/landscape.jpg b/public/showcase/landscape.jpg
deleted file mode 100644
index bd88a76..0000000
Binary files a/public/showcase/landscape.jpg and /dev/null differ
diff --git a/public/showcase/landscape.mp4 b/public/showcase/landscape.mp4
deleted file mode 100644
index a711b11..0000000
Binary files a/public/showcase/landscape.mp4 and /dev/null differ
diff --git a/public/showcase/landscape.webm b/public/showcase/landscape.webm
deleted file mode 100644
index 1d72191..0000000
Binary files a/public/showcase/landscape.webm and /dev/null differ
diff --git a/public/showcase/vertical.jpg b/public/showcase/vertical.jpg
deleted file mode 100644
index a1b2e13..0000000
Binary files a/public/showcase/vertical.jpg and /dev/null differ
diff --git a/public/showcase/vertical.mp4 b/public/showcase/vertical.mp4
deleted file mode 100644
index c8956b7..0000000
Binary files a/public/showcase/vertical.mp4 and /dev/null differ
diff --git a/public/showcase/vertical.webm b/public/showcase/vertical.webm
deleted file mode 100644
index ca960a4..0000000
Binary files a/public/showcase/vertical.webm and /dev/null differ
diff --git a/src/layouts/MobileNav.tsx b/src/layouts/MobileNav.tsx
new file mode 100644
index 0000000..f64b033
--- /dev/null
+++ b/src/layouts/MobileNav.tsx
@@ -0,0 +1,70 @@
+import { Dialog } from '@base-ui/react/dialog';
+import { usePathname } from 'fumadocs-core/framework';
+import { cn } from '../lib/cn';
+import { LanguageDropdown } from './docs/LanguageDropdown';
+import { CONTROL_BUTTON, SECTIONS } from './header-shared';
+import { CloseIcon, GitHubIcon, MenuIcon } from './home/components/icons';
+import { Logo } from './home/components/Logo';
+import { ThemeToggle } from './home/components/ThemeToggle';
+import { GITHUB_URL } from './home/data';
+
+// Mobile navigation: a hamburger that opens a full-screen overlay with the site
+// sections, and a footer row of language / theme / GitHub controls. Used by the
+// shared header on small screens (the inline nav + controls take over from `md`).
+export function MobileNav({ className }: { className?: string }) {
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+
+
+
+