From 067e9a6df4bb2e1ba86778b6a3e6b8153696519c Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Tue, 30 Jun 2026 10:44:21 +0900 Subject: [PATCH 01/15] docs: write Webview Bundle documentation (Guide / References / Config) Restructure content/docs into three Fumadocs top-header tabs and write 24 developer-facing English pages, ground-truthed against the webview-bundle, webview-bundle-android, and webview-bundle-ios source repos. Structure: - Guide: Introduction, Features (format/sources/protocol/remote/integration), Platforms (Electron/Tauri/Android/iOS/Deno), CLI, Remote + providers - References: overview, Rust (docs.rs), Node API, Deno API - Config: the wvb.config file, remote/integrity/signature Wiring: - Tabs via `root: true` folders + `tabMode="top"` in routes/docs/$.tsx - /docs redirects to /docs/guide (new routes/docs/index.tsx + splat loader) - Fix the home "Reference" nav link to /docs/references Accuracy fixes vs the previous draft: integrity is SHA-2 (not SHA-3); pack uses `outFile` (not outFileName/outdir); `wvb upload --deploy` defaults to false and version is a flag; iOS 16 minimum; Android/iOS bindings are pre-release; Deno Desktop is experimental; Tauri ships as the `wvb-tauri` crate. Remove the old flat pages (concepts/remote-updates/cli/configuration/guides). Verified: `yarn build` and `yarn lint` pass; all internal links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- .gitignore | 3 + AGENTS.md | 173 ++++++++++++ INDEX.md | 51 ++++ content/docs/cli.mdx | 186 ------------- content/docs/concepts.mdx | 122 -------- content/docs/config/index.mdx | 290 ++++++++++++++++++++ content/docs/config/meta.json | 5 + content/docs/config/remote.mdx | 197 +++++++++++++ content/docs/configuration.mdx | 175 ------------ content/docs/guide/bundle-format.mdx | 134 +++++++++ content/docs/guide/bundle-sources.mdx | 178 ++++++++++++ content/docs/guide/cli-programmatic.mdx | 278 +++++++++++++++++++ content/docs/guide/cli.mdx | 264 ++++++++++++++++++ content/docs/guide/index.mdx | 138 ++++++++++ content/docs/guide/meta.json | 30 ++ content/docs/guide/platform-integration.mdx | 159 +++++++++++ content/docs/guide/platform-support.mdx | 93 +++++++ content/docs/guide/platforms/android.mdx | 253 +++++++++++++++++ content/docs/guide/platforms/deno.mdx | 146 ++++++++++ content/docs/guide/platforms/electron.mdx | 221 +++++++++++++++ content/docs/guide/platforms/ios.mdx | 197 +++++++++++++ content/docs/guide/platforms/tauri.mdx | 265 ++++++++++++++++++ content/docs/guide/protocol-handling.mdx | 163 +++++++++++ content/docs/guide/providers/aws.mdx | 166 +++++++++++ content/docs/guide/providers/cloudflare.mdx | 175 ++++++++++++ content/docs/guide/providers/local.mdx | 225 +++++++++++++++ content/docs/guide/remote-bundles.mdx | 194 +++++++++++++ content/docs/guide/remote.mdx | 130 +++++++++ content/docs/guide/why-webview-bundle.mdx | 183 ++++++++++++ content/docs/guides/android.mdx | 181 ------------ content/docs/guides/electron.mdx | 192 ------------- content/docs/guides/ios.mdx | 175 ------------ content/docs/guides/meta.json | 4 - content/docs/guides/tauri.mdx | 147 ---------- content/docs/index.mdx | 103 ------- content/docs/meta.json | 3 +- content/docs/references/deno.mdx | 204 ++++++++++++++ content/docs/references/index.mdx | 61 ++++ content/docs/references/meta.json | 10 + content/docs/references/node.mdx | 261 ++++++++++++++++++ content/docs/remote-updates.mdx | 262 ------------------ src/layouts/home/data.ts | 2 +- src/routeTree.gen.ts | 24 +- src/routes/docs/$.tsx | 9 +- src/routes/docs/index.tsx | 8 + 45 files changed, 4885 insertions(+), 1555 deletions(-) create mode 100644 AGENTS.md create mode 100644 INDEX.md delete mode 100644 content/docs/cli.mdx delete mode 100644 content/docs/concepts.mdx create mode 100644 content/docs/config/index.mdx create mode 100644 content/docs/config/meta.json create mode 100644 content/docs/config/remote.mdx delete mode 100644 content/docs/configuration.mdx create mode 100644 content/docs/guide/bundle-format.mdx create mode 100644 content/docs/guide/bundle-sources.mdx create mode 100644 content/docs/guide/cli-programmatic.mdx create mode 100644 content/docs/guide/cli.mdx create mode 100644 content/docs/guide/index.mdx create mode 100644 content/docs/guide/meta.json create mode 100644 content/docs/guide/platform-integration.mdx create mode 100644 content/docs/guide/platform-support.mdx create mode 100644 content/docs/guide/platforms/android.mdx create mode 100644 content/docs/guide/platforms/deno.mdx create mode 100644 content/docs/guide/platforms/electron.mdx create mode 100644 content/docs/guide/platforms/ios.mdx create mode 100644 content/docs/guide/platforms/tauri.mdx create mode 100644 content/docs/guide/protocol-handling.mdx create mode 100644 content/docs/guide/providers/aws.mdx create mode 100644 content/docs/guide/providers/cloudflare.mdx create mode 100644 content/docs/guide/providers/local.mdx create mode 100644 content/docs/guide/remote-bundles.mdx create mode 100644 content/docs/guide/remote.mdx create mode 100644 content/docs/guide/why-webview-bundle.mdx delete mode 100644 content/docs/guides/android.mdx delete mode 100644 content/docs/guides/electron.mdx delete mode 100644 content/docs/guides/ios.mdx delete mode 100644 content/docs/guides/meta.json delete mode 100644 content/docs/guides/tauri.mdx delete mode 100644 content/docs/index.mdx create mode 100644 content/docs/references/deno.mdx create mode 100644 content/docs/references/index.mdx create mode 100644 content/docs/references/meta.json create mode 100644 content/docs/references/node.mdx delete mode 100644 content/docs/remote-updates.mdx create mode 100644 src/routes/docs/index.tsx diff --git a/.gitignore b/.gitignore index 3b7a85f..49885ca 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ dist/ # macOS .DS_Store + +# AI +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..baa2193 --- /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..b64846a --- /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/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..ade94f3 --- /dev/null +++ b/content/docs/config/index.mdx @@ -0,0 +1,290 @@ +--- +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']], +] + +// 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..9db6a2f --- /dev/null +++ b/content/docs/config/remote.mdx @@ -0,0 +1,197 @@ +--- +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..fcfa537 --- /dev/null +++ b/content/docs/guide/bundle-format.mdx @@ -0,0 +1,134 @@ +--- +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 file with the `.wvb` extension that packs your built web assets into one +compressed, integrity-checked archive. This page explains how that file is laid out on disk: its three +sections, the fields in each, and the checksums that catch corruption before any content is served. If +you just want to produce one, see the [CLI](/docs/guide/cli); read on to understand what it contains. + +## Overview + +A `.wvb` file is three sections written back to back: + +| Header (17 bytes) | Index (variable) | Data (variable) | +| -------------------------------------------------- | -------------------------------------- | ----------------------------- | +| magic number, format version, index size, checksum | path → offset/length/headers entry map | LZ4-compressed file contents | + +The **header** is a fixed 17-byte preamble. The **index** maps each file path to where its bytes live +and how to serve them. The **data** section holds the compressed file contents. Every section carries +its own checksum, so a truncated or corrupted archive is detected before its contents are trusted. + +By convention a packed file is named `_.wvb`, for example `app_1.0.0.wvb`. The bundle +name and version are source-level concepts that live in the filename and the manifest, not inside the +archive itself. See [Bundle sources](/docs/guide/bundle-sources) for how names and versions are tracked. + +## Header + +The header is exactly 17 bytes with a fixed layout. It is 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 | + +The magic number `0xF09F8C90F09F8E81` is the UTF-8 encoding of the two emoji 🌐🎁 (globe and 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 format 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 section that follows, not counting the index's own +trailing checksum. A reader uses it to know where the index ends and the data section begins. + +The checksum is an xxHash-32 over the first 13 bytes of the header (everything before the checksum +itself), stored big-endian. + +## Index + +The index sits immediately after the header. It is a map from each file path, such as `/index.html`, to +an entry describing where that file's bytes are and how to serve them. + +The index is serialized with [bincode](https://github.com/bincode-org/bincode) using a big-endian, +variable-length integer configuration. Numeric fields are therefore varint-encoded, not fixed width. +Before serialization the map is sorted by path, 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. + +Each entry holds the following fields: + +| 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 | + +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 one of these entries. + +## Data + +The data section holds the file contents, laid out sorted by path in the same order as the index. Each +file is stored as its 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. The `offset` in each index entry points at the +start of a file's compressed bytes, and its `len` is the length of those bytes; the per-file checksum +sits at `offset + len`. + +## Checksums versus integrity + +The format uses two different hashing schemes for two different jobs. Keeping them straight avoids a +common confusion. + +The **internal checksums** in the header, index, and per-file data are all **xxHash-32**. They guard +**storage integrity**: they catch a file that was truncated, partially written, or corrupted on disk. +xxHash is fast and non-cryptographic, which is exactly what you want for a corruption check. + +A bundle's **integrity** value is something else entirely. It is a **SHA-2** hash computed over the +serialized bytes of a downloaded bundle, so a client can confirm it received exactly what the server +published. The algorithms are `sha256` (the default), `sha384`, and `sha512`, serialized as +`:`, for example `sha384:Ws2q…`. + + +The format uses **SHA-2** for integrity, not SHA-3. The two internal 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..8f73e1d --- /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..fd3d3f2 --- /dev/null +++ b/content/docs/guide/cli-programmatic.mdx @@ -0,0 +1,278 @@ +--- +title: CLI programmatic API +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..10a60e1 --- /dev/null +++ b/content/docs/guide/cli.mdx @@ -0,0 +1,264 @@ +--- +title: CLI +description: The wvb command-line tool — pack, serve, upload, deploy, download, manage builtins, and run a local remote. +--- + +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. The sections below document each command and its flags; for calling 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. + +## Authoring bundles + +### wvb pack [SRC_DIR] + +Pack a directory of built web assets into a single `.wvb` archive. + +```sh +wvb pack ./dist +wvb pack ./dist --outfile ./build/app.wvb +wvb pack ./dist --ignore '*.map' --ignore 'node_modules/**' +wvb pack ./dist --header '*.html' 'cache-control' 'max-age=3600' +``` + +| Option | Default | Description | +| --------------- | ----------------------------- | ---------------------------------------------------------------------------- | +| `SRC_DIR` | `pack.srcDir` ?? `./dist` | Source directory to pack. | +| `--outfile, -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 file (dry run). | +| `--overwrite` | `true` | Overwrite an existing output file. | + + +`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. + + +### wvb extract FILE + +Extract a `.wvb` archive's files back onto disk — useful for inspecting what a bundle contains. + +```sh +wvb extract ./build/app.wvb --outdir ./unpacked +wvb extract ./build/app.wvb --outdir ./unpacked --clean +``` + +| Option | Default | Description | +| -------------- | ------------------------------- | ------------------------------------------------- | +| `FILE` | required | Bundle file to extract. | +| `--outdir, -O` | `.wvb/` | Destination directory. | +| `--clean` | `false` | Remove the output directory first if it exists. | +| `--no-write` | writes by default | Run the extract without writing files (dry run). | + +### wvb serve [FILE] + +Serve a single bundle's files over HTTP, so you can preview a packed bundle in a browser. Directory paths resolve to `index.html`. + +```sh +wvb serve ./build/app.wvb # http://localhost:4312 +wvb serve ./build/app.wvb --port 8080 --hostname 0.0.0.0 +``` + +| Option | Default | Env | Description | +| ---------------- | ------------- | ---------- | ---------------------------------------- | +| `FILE` | from config | — | Bundle to serve. Falls back to `serve.file`. | +| `--hostname, -H` | `localhost` | `HOSTNAME` | Bind hostname. | +| `--port, -P` | `4312` | `PORT` | Port to listen on. | +| `--silent` | `false` | — | Disable request logging. | + +## Publishing and updating + +These commands talk to a remote server. They require `remote.*` settings in your [config](/docs/config/remote). For the full publishing model and a local-testing walkthrough, see [Remote bundles](/docs/guide/remote-bundles) and [Building a remote](/docs/guide/remote). + +### wvb upload [BUNDLE] + +Pack, hash, sign, and upload a bundle to the remote. The pipeline runs in order: pack, then integrity, then signature, then upload, and an optional deploy at the end. + +```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 +``` + +| Option | Default | Description | +| ------------------ | -------------------------- | ---------------------------------------------------------------------- | +| `BUNDLE` | from config / `--file` name | Bundle name. | +| `--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. | + + +The version is the `--version` (`-V`) flag, not a positional argument. `--deploy` defaults to `false`, so an upload publishes the version without making it current until you deploy it. + + +`upload` requires `remote.uploader` in your config; with `--deploy` it also requires `remote.deployer`. On success it prints the bundle endpoint. + +### wvb deploy [BUNDLE] + +Mark an already-uploaded version as the current one that clients receive. + +```sh +wvb deploy app --version 1.2.0 +wvb deploy app --version 1.2.0 --channel beta +``` + +| Option | Default | Description | +| --------------- | -------------------------------- | --------------------- | +| `BUNDLE` | from config | Bundle name. | +| `--version, -V` | config or `package.json` version | Version to deploy. | +| `--channel` | — | Release channel. | + +The version is the `--version` (`-V`) flag — there is no positional version argument. `deploy` requires `remote.deployer` in your config. + +### wvb download [BUNDLE] [VERSION] + +Download a bundle from the remote and, by default, save it to disk. Omit `VERSION` to download the current deployed version. + +```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 and print info only +``` + +| Option | Default | Description | +| ---------------- | ------------------ | -------------------------------------------- | +| `BUNDLE` | from config | Bundle name. | +| `VERSION` | current deployed | Specific version to download. | +| `--out, -O` | `.wvb` | Output file path. | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | +| `--channel` | — | Release channel. | +| `--no-write` | writes by default | Fetch and print info without saving. | +| `--overwrite` | `false` | Overwrite an existing file. | +| `--progress` | `true` | Show a download progress bar. | + +### wvb builtin + +Install the deployed bundles into a local directory so you can ship them as builtin fallbacks with your app. The source is taken from `builtin.target` in your config, which defaults to a remote target. See [Remote bundles](/docs/guide/remote-bundles) for how builtin and remote sources work together. + +```sh +wvb builtin --endpoint https://updates.example.com --out .wvb/builtin/bundles +wvb builtin --include 'app*' --exclude 'internal*' +wvb builtin --android # install into the detected Android module +``` + +| Option | Default | Description | +| ------------------------- | ------------------------ | ------------------------------------------------------------------ | +| `--out, -O` | `.wvb/builtin/bundles` | Output directory. | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. Remote target only. | +| `--channel` | — | Release channel. Remote target only. | +| `--include` / `--exclude` | — | Glob filters over the target bundles. Repeatable. | +| `--clean` | `true` | Clear the output directory before installing. | +| `--concurrency` | CPU count, capped at 8 | Parallel downloads. Remote target only. | +| `--android` | — | Install into an Android module. Bare auto-detects; `=` sets it. | +| `--ios` | — | Install into an iOS project. Bare auto-detects; `=` sets it. | +| `--no-write` | writes by default | Run without writing files (dry run). | +| `--progress` | `true` | Show a progress bar. Remote target only. | + + +You cannot pass both `--android` and `--ios` in the same run. The `--ios` folder-reference step targets Tuist projects. + + +## Inspecting and testing the remote + +### wvb remote current [BUNDLE] + +Show the current deployed version and its metadata for a bundle, without downloading it. The output includes version, ETag, integrity, signature, and last-modified. + +```sh +wvb remote current app --endpoint https://updates.example.com +wvb remote current app --channel beta +``` + +| Option | Default | Description | +| ---------------- | ----------------- | ----------------- | +| `BUNDLE` | from config | Bundle name. | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | +| `--channel` | — | Release channel. | + +### 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 +``` + +| Option | Default | Description | +| ---------------- | ----------------- | ----------------- | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | +| `--channel` | — | Release channel. | + +### wvb remote local + +Start a local remote server backed by a directory. It implements the same HTTP contract as a production remote, so you can test the full update loop offline. See [Building a remote](/docs/guide/remote) for how to wire it into your app. + +```sh +wvb remote local # http://localhost:4313, serving ~/.wvb/local +wvb remote local --base-dir ./.wvb/local --port 4313 --allow-other-versions +``` + +| Option | 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 commands, it does not read `--config` or `--cwd`. + + +## Next steps + + + + + + + diff --git a/content/docs/guide/index.mdx b/content/docs/guide/index.mdx new file mode 100644 index 0000000..fa44f31 --- /dev/null +++ b/content/docs/guide/index.mdx @@ -0,0 +1,138 @@ +--- +title: Introduction +description: An offline-first web resource delivery system for webview-based frameworks and platforms. +--- + +Webview Bundle (`wvb`) is an offline-first web resource delivery system for webview-based +frameworks and platforms. Instead of fetching your web app over the network, it packs your built +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. Configure a remote, and your +app downloads newer bundles over the air (OTA = over-the-air) — delivering updated app code without +a native app-store release. One `.wvb` format runs on every webview platform via a shared Rust core. + +```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 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. + +## 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` | unpublished | electron-builder integration (in-repo, not yet on npm) | +| `@wvb/electron-forge` | unpublished | Electron Forge plugin (in-repo, not yet on npm) | +| `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 | + + + The Tauri integration is the **`wvb-tauri` crate** on crates.io — there is no `@wvb/tauri` npm + package. The Android (Kotlin) and iOS (Swift) bindings are built from the core via UniFFI and + are **pre-release**: they are not yet published to Maven Central or tagged for Swift Package + Manager, so install from source for now. 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..5bf1fda --- /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", + "cli-programmatic", + "---Remote---", + "remote", + "providers/local", + "providers/aws", + "providers/cloudflare" + ] +} diff --git a/content/docs/guide/platform-integration.mdx b/content/docs/guide/platform-integration.mdx new file mode 100644 index 0000000..e34310b --- /dev/null +++ b/content/docs/guide/platform-integration.mdx @@ -0,0 +1,159 @@ +--- +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, the bundle source, the protocol handlers, the remote client, the updater, and the integrity and signature checks all live in a single Rust crate. Every platform consumes that same crate through a thin binding, so a `.wvb` produced for Electron behaves identically on Tauri, Android, iOS, and Deno. This page explains how the core reaches each host and how your web app talks back to the native side through the bridge. Read it before the individual platform guides — those guides assume you know which package belongs where. + +## One core, many hosts + +The Rust crate `wvb` (published on [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 platform binding is intentionally thin. It does not reimplement the format or the update logic. It exposes the core to the host's language and runtime, registers a URL scheme with that host, and forwards requests into the core. When the core gains a feature or a fix, every platform inherits it on the next binding release. You can browse the core API on [docs.rs/wvb](https://docs.rs/wvb). + + +The Rust core never registers a URL scheme or validates one. Picking a scheme such as `app://` or `bundle://` and registering it with the operating system 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. The binding language and the package you install depend on the host. + +| 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` (version 0.1.0 on npm) wraps the core as an N-API native addon built with NAPI-RS. It ships prebuilt binaries for the common targets, so you install it like any npm dependency and get the native `.node` addon for your platform. + +```sh +npm install @wvb/node +``` + +For Electron specifically, use `@wvb/electron` (version 0.1.0), which builds on `@wvb/node` and handles scheme privileges, protocol registration, and the IPC channel for you. The two 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` (version 0.1.0 on crates.io). There is no `@wvb/tauri` npm package — you add the crate to your `src-tauri` project and register it as a Tauri v2 plugin. + +```toml title="src-tauri/Cargo.toml" +[dependencies] +wvb-tauri = "0.1.0" +``` + +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`. 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 native libraries plus language bindings, packaged as 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: `webview-bundle-android` (Kotlin) and `webview-bundle-ios` (Swift). They resolve assets by release tag — stable releases use `ffi/` (for example `ffi/0.1.0`) and prereleases use `prerelease/`. + + +Android and iOS are implemented and end-to-end tested, but the bindings are pre-release. They are not yet published to Maven Central, and there is no Swift Package Manager tag — install from source for now. The minimum supported iOS version is iOS 16. The currently committed xcframework contains a simulator-only slice; the device slice is pending. See the [Android](/docs/guide/platforms/android) and [iOS](/docs/guide/platforms/ios) guides. + + +### Deno + +`@wvb/deno` reaches the core through Deno FFI over a prebuilt dynamic library. 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` (version 0.1.0 on npm), is the web side. It lets JavaScript running inside the webview call native source, remote, and updater operations through one uniform API, regardless of which host renders the page. + +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 the native commands into three typed objects, each method of which is a typed `invoke()` call: + +- `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. + +```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); +} +``` + +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(...)` | + +Detection and transport are internal — 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. + + +Import `@wvb/bridge/testing` to test webview code without a native host. It provides `mockInvoke`, `mockPlatform`, and `mockBridge` helpers 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. + +```text + ┌─────────────────────────────────────────────┐ + │ web app (HTML / JS in the webview) │ + │ @wvb/bridge invoke() │ + └───────────────────────┬─────────────────────┘ + │ (electron / tauri / android / ios) + ┌───────────────────────▼─────────────────────┐ + │ native host │ + │ @wvb/node | wvb-tauri | UniFFI | @wvb/deno │ + └───────────────────────┬─────────────────────┘ + │ + ┌───────────────────────▼─────────────────────┐ + │ wvb core (Rust) │ + │ source · protocol · remote · updater │ + └──────────┬──────────────────────┬────────────┘ + │ │ + ┌─────────▼─────────┐ ┌────────▼───────────┐ + │ source │ │ remote server │ + │ (builtin/remote) │ │ (OTA bundles) │ + └───────────────────┘ └─────────────────────┘ +``` + +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..25bf0f2 --- /dev/null +++ b/content/docs/guide/platform-support.mdx @@ -0,0 +1,93 @@ +--- +title: Platform Support +description: Which platforms run Webview Bundle, the package to install for each, and where each one sits on the road to 1.0. +--- + +Webview Bundle runs the same `.wvb` archive on every platform that provides a webview. One shared +Rust core powers each integration, so a bundle you pack once loads identically in 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 to install per platform, its minimum host +version, and how close that integration is to a stable release. + +## Support matrix + +| Platform | Webview host | Package / crate | Min version | Status | +| --- | --- | --- | --- | --- | +| [Electron](/docs/guide/platforms/electron) | Chromium | `@wvb/electron` 0.1.0 (npm) | — | Stable (pre-1.0) | +| [Tauri desktop](/docs/guide/platforms/tauri) | System WebView | `wvb-tauri` 0.1.0 (crate) | Tauri v2 | Stable (pre-1.0) | +| Tauri mobile | System WebView | `wvb-tauri` 0.1.0 (crate) | See [Android](/docs/guide/platforms/android) / [iOS](/docs/guide/platforms/ios) | Pre-release | +| [Android](/docs/guide/platforms/android) | System WebView | `webview-bundle-android` (Kotlin) | minSdk 24 / Android 7.0 | Pre-release — not yet on Maven Central | +| [iOS](/docs/guide/platforms/ios) | WKWebView | `webview-bundle-ios` (Swift) | iOS 16 / macOS 12 | Pre-release — no SPM tag yet | +| [Deno Desktop](/docs/guide/platforms/deno) | Deno webview | `@wvb/deno-desktop` 0.0.0 (JSR) | — | Experimental | + +All integrations are still pre-1.0, so APIs may change between minor versions. + +## Desktop + +Electron and Tauri desktop are the most mature integrations. Both are published — `@wvb/electron` on +npm and the `wvb-tauri` crate on crates.io — and both serve bundles through a custom URL scheme backed +by the Rust core. + +- **Electron** ships as the `@wvb/electron` package and runs on `electron >= 15`. Start with the + [Electron guide](/docs/guide/platforms/electron). +- **Tauri** ships as the `wvb-tauri` Rust crate (there is no `@wvb/tauri` npm package) and targets + **Tauri v2**. The same crate also drives Tauri's mobile targets. Start with the + [Tauri guide](/docs/guide/platforms/tauri). + +## Mobile + +Android and iOS are real, functional integrations, exercised by end-to-end tests against a live remote. +They are **pre-release**: neither has a published artifact yet, so you install them from source for now. + +- **Android** lives in the `webview-bundle-android` Kotlin library, namespace `dev.wvb`. It requires + **minSdk 24 (Android 7.0)** and serves bundles through a `WebViewClient`. See the + [Android guide](/docs/guide/platforms/android). +- **iOS** lives in the `webview-bundle-ios` Swift package. Its minimum deployment targets are + **iOS 16** and **macOS 12**, and it serves bundles through a `WKURLSchemeHandler`. See the + [iOS guide](/docs/guide/platforms/ios). + + + Android and iOS are functional and end-to-end tested, but **pre-release**. The Android library is + not yet published to Maven Central, and the iOS package has no Swift Package Manager tag — install + both from source for now, following their guides. The committed iOS `xcframework` is currently + **simulator-only**: on-device builds will not link until a device-bearing binary is published. + + +## Deno Desktop + +Deno Desktop is the newest integration, distributed as the `@wvb/deno-desktop` package on JSR +(version 0.0.0). It builds a bundle source and exposes a `Deno.serve`-compatible handler, with one +protocol per window. + + + Deno Desktop is **experimental** and offered as a preview. Treat it as not yet production-ready, and + expect breaking changes. 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..7206823 --- /dev/null +++ b/content/docs/guide/platforms/android.mdx @@ -0,0 +1,253 @@ +--- +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`. You give it a `WebView`, it intercepts requests over ordinary `https://.wvb/` URLs and serves files from a bundle you ship in the APK, and it can pull newer bundles over the air (OTA) from a remote without an app-store release. The library lives in its own Kotlin repository and consumes a prebuilt native binding from the core repo; this page covers requirements, a quick start, how serving works, builtin bundles, protocols, and OTA updates. + + +**Pre-release.** The library is not yet published to Maven Central. There is no release tag yet, so the Maven coordinates and version below describe the intended artifact, not something you can resolve today. For now, build and consume it from the [`webview-bundle-android`](https://github.com/webview-bundle/webview-bundle-android) repository. The native FFI is pinned in a `.ffi-version` file and installed by `scripts/install.mjs`, which downloads the `android.zip` asset from a [core repo](https://github.com/webview-bundle/webview-bundle) release and unpacks the Kotlin bindings and `jniLibs` into the library module. + + +## Requirements + +The library targets a modern Android baseline: + +| 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 that connects your web app to the native host attaches through `WebViewCompat.addWebMessageListener`. On a device whose System WebView is too old to support `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 need to add keep rules for the FFI yourself. + +Once a release is cut, the intended Maven coordinates are: + +```kotlin title="build.gradle.kts" +dependencies { + implementation("dev.wvb:webview-bundle-android:") +} +``` + + +Until the artifact is on Maven Central, clone the repo and follow its README to install the pinned FFI (`node scripts/install.mjs`) and build the `:lib` module locally. 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. Call `handle.close()` and `webView.destroy()` when the `WebView` goes away. + +Your 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 + +The library does not register a custom URL scheme. Instead, `install()` sets a custom `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 protocol whose matcher accepts the host wins. The bundle protocol takes the **bundle name from the first host label** (`app.wvb` resolves to bundle `app`) and returns the file at the request path. When no protocol matches, the handler returns `null` and the `WebView` loads the request from the network as usual. + +If a handler throws while serving, the library synthesizes a `500 text/plain` response and calls the optional `onError` callback you pass in `WebViewBundleConfig`. 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, unless you opt out. Your web app posts a message with a command name and params, and the bridge dispatches to a registered handler. To register extra native handlers or to skip the bridge, use the install options: + +```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" } + } +} +``` + +## Builtin bundles + +Ship the bundle your app starts with inside the APK under `assets/bundles/`. That directory holds a `manifest.json` plus the `.wvb` files it references. + +```text +app/src/main/assets/bundles/ +├── manifest.json +└── app/ + └── app_0.1.0.wvb +``` + +On install, the native source reads files rather than asset streams, so the library copies `assets/bundles/` into the app's `filesDir` (the builtin directory). This extraction is controlled by `SourceOptions.builtinAssetsDir`, which defaults to `"bundles"`; set it to `null` to disable extraction. 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 the current version, and may carry `integrity` and `signature` values for the bundles it ships. 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 of the bundle source. + +```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 + +To enable over-the-air 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 are `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 — `ED25519` accepts `SPKI_DER`, `SPKI_PEM`, or `RAW` (a 32-byte key supplied via `der`); an unsupported pair throws when the updater is built. + + +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 them from a coroutine. + +```kotlin +val update = wvb.updater?.getUpdate("app") // check the remote, no download +if (update?.isAvailable == true) { + 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 it. `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`. These throw `updater_not_initialized` when no updater config was provided. Use the bridge when your update UI lives in the web layer. + +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..314b7f7 --- /dev/null +++ b/content/docs/guide/platforms/deno.mdx @@ -0,0 +1,146 @@ +--- +title: Deno Desktop +description: Serve and update Webview Bundle archives in a Deno desktop webview through a Deno.serve request handler. +--- + +Deno Desktop renders a native webview with `Deno.BrowserWindow` and serves it over `Deno.serve`. +Webview Bundle plugs in as the request handler, so the window loads your packed `.wvb` assets offline +instead of fetching them over the network. The `@wvb/deno-desktop` integration builds the bundle +source, wires an optional remote and updater, and exposes a `Deno.serve`-compatible `fetch` handler. +Underneath, `@wvb/deno` is the foreign-function-interface (FFI) peer of `@wvb/node` that drives the +shared Rust core. + + +Deno Desktop is **experimental / preview** and not production-ready. The integration source currently +lives on an un-merged branch, and `main` ships only a prebuilt dylib artifact. The JSR packages +`@wvb/deno` and `@wvb/deno-desktop` are published at version **`0.0.0`**, and their APIs may change. +Treat the examples below as preview snippets, not a stable contract. + + +## What it is + +A Deno desktop app opens a window with `Deno.BrowserWindow` and points it at a local server started by +`Deno.serve`. Webview Bundle becomes that server's handler: every request the window makes is answered +from a `.wvb` bundle on disk. Because the window talks to a single local origin, the integration is +**single-origin** — it allows exactly one protocol. See +[Platform integration](/docs/guide/platform-integration) for how the shared core reaches each platform, +and [Protocol handling](/docs/guide/protocol-handling) for the protocol model. + +## @wvb/deno-desktop + +`@wvb/deno-desktop` (`0.0.0`, experimental) is the desktop integration layer. It ties `@wvb/deno` to +Deno's desktop runtime and to the [bridge](/docs/guide/platform-integration). + +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 `fetch` handler you can hand +straight to `Deno.serve`. Because Deno desktop is single-origin, the config accepts **exactly one** +protocol. + +```ts title="main.ts (preview)" +// Preview: @wvb/deno-desktop is experimental (0.0.0) and the API may change. +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 the native commands to your web app. It registers a single +`Deno.BrowserWindow` binding named `wvbInvoke`, reachable from the page as `window.bindings.wvbInvoke`. +That one binding dispatches every `@wvb/bridge` command in the `source.*`, `remote.*`, and `updater.*` +groups. + +```ts title="main.ts (preview)" +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: +`{ ok: true, value }` on success, or `{ ok: false, error: { code?, message } }` on failure. The +`@wvb/bridge` client unwraps this envelope for you when it detects the `deno` platform, so web code +keeps calling `invoke()`, `source.*`, `remote.*`, and `updater.*` exactly as on other platforms. + +## @wvb/deno + +`@wvb/deno` (`0.0.0`, experimental) is the FFI peer of [`@wvb/node`](/docs/references/node). It loads a +prebuilt Rust `cdylib` through `Deno.dlopen` and exposes the same core surface: the classes +`BundleProtocol`, `LocalProtocol`, `Remote`, `BundleSource`, and `Updater`. Each class is `Disposable` +— free it with `free()` or a `using` declaration that calls `[Symbol.dispose]`. + +### Load the native library + +Load the `cdylib` one of three ways: + +```ts title="load.ts (preview)" +import { loadLib, loadLibViaPlug } from "@wvb/deno"; + +// 1. Load from an explicit path or URL. +const lib = loadLib("./vendor/wvb/libwvb_deno.dylib"); + +// 2. Download a sha256-verified prebuilt via @denosaurs/plug. +const libViaPlug = await loadLibViaPlug(); +``` + +`loadLib(libPath)` opens a library you already have on disk. `loadLibViaPlug(options?)` downloads the +prebuilt library and verifies it against its SHA-256 checksum before loading. You can also set the +`WVB_DENO_LIB` environment variable to point at a library file. + +The third option vendors 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. The supported targets are: + +| Target triple | +|---| +| `aarch64-apple-darwin` | +| `x86_64-apple-darwin` | +| `aarch64-unknown-linux-gnu` | +| `x86_64-unknown-linux-gnu` | +| `x86_64-pc-windows-msvc` | + +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`). 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`. + +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.mdx b/content/docs/guide/platforms/electron.mdx new file mode 100644 index 0000000..cd14b75 --- /dev/null +++ b/content/docs/guide/platforms/electron.mdx @@ -0,0 +1,221 @@ +--- +title: Electron +description: Serve your Electron UI from a .wvb bundle through a custom protocol, with dev-server proxying and over-the-air updates. +--- + +Webview Bundle wires into an Electron app in three moves: serve your UI from a `.wvb` bundle through a custom protocol, proxy to a live dev server while you develop, and (optionally) update bundles over the air without an app-store release. This guide walks through each step with `@wvb/electron`. + +The package and its end-to-end fixtures live in [`packages/electron`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron) — the `e2e/fixtures/app` directory there is a minimal, runnable main-process setup you can read alongside this guide. + +## Install + + + +```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 +``` + + + +`@wvb/electron` depends on `@wvb/node`, the native N-API binding that ships prebuilt binaries for common platforms. No Rust toolchain is required to consume it. `@wvb/cli` is a dev dependency you use to pack bundles. `@wvb/electron` requires Electron 15 or newer. + +## Register the protocol in the main process + +Call `wvb(...)` (an alias of `webviewBundle(...)`) **before** any window loads. It registers your custom schemes as privileged, builds the bundle 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({ + // Where bundles live on disk. Defaults are Electron-aware (see "Where bundles live"). + source: { + builtinDir: path.join(process.resourcesPath, 'bundles'), + }, + protocols: [ + // In development, proxy `app-local://simple.wvb/...` to the Vite dev server so you + // keep hot reload. `MAIN_WINDOW_VITE_DEV_SERVER_URL` is injected by Forge's Vite plugin. + localProtocol('app-local', { + hosts: { + 'simple.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL, + }, + }), + // In production, 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, + }, + }); + // `app://.wvb/` — the host label before `.wvb` is the bundle name. + await win.loadURL('app://simple.wvb'); +} + +app.whenReady().then(createWindow); +``` + +- **`bundleProtocol(scheme, options?)`** serves files directly out of bundles in the source. A URL like `app://simple.wvb/index.html` resolves to bundle `simple`, file `/index.html`. +- **`localProtocol(scheme, { hosts })`** proxies matching hosts to a localhost dev server instead. `hosts` is required: a `Record` (or a function returning one) mapping a host to a dev-server URL. Use it during development so you keep the same scheme while getting your bundler's hot reload. + +`whenProtocolRegistered()` resolves after every protocol is registered and `app.whenReady()` has fired, so await it before navigating. A common pattern is to register both a `bundleProtocol` and a `localProtocol` and choose which URL to load based on `app.isPackaged`. + + + Each scheme is registered as privileged with sensible defaults (`standard`, `secure`, `bypassCSP`, `allowServiceWorkers`, `supportFetchAPI`, `corsEnabled`, `codeCache` all on). Override them per protocol with the `privileges` option. See [Protocol handling](/docs/guide/protocol-handling) for the full model. + + +## Add the preload script + +The preload exposes a small, safe transport into the renderer that the bridge uses for source, remote, and updater calls. + +```ts title="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` with `nodeIntegration: false`. + +## Call the API from the renderer + +To let the UI drive updates — for example a "Check for updates" button — import the bridge in your renderer code. It forwards to the main process over IPC, so it only works when the preload script 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) { + // Download + verify, stage into the remote source, then activate. + const downloaded = await updater.download('app'); + await updater.install('app', downloaded.version); + // Reload the window to pick up the new bundle — that step is your app's responsibility. +} +``` + + + The renderer-facing `source`, `remote`, and `updater` objects come from `@wvb/bridge`, which auto-detects the Electron transport that `@wvb/electron/preload` installs. The exact shapes of `getUpdate`/`download` results are documented in the [Node API reference](/docs/references/node). + + +The same surfaces are reachable in the main process from the instance returned by `wvb(...)`: + +```ts +const instance = wvb({ /* … */ }); + +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 server. 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 options also live here: + // integrityPolicy, integrityChecker, signatureVerifier + }, + protocols: [bundleProtocol('app')], +}); +``` + +See [Remote bundles](/docs/guide/remote-bundles) for integrity and signature verification, and [Building a remote](/docs/guide/remote) for how to stand a server up — including a local one for testing. + +## Where bundles live + +`source` accepts `builtinDir` (shipped, read-only) and `remoteDir` (downloaded updates). The defaults are Electron-aware: + +| Option | Default | +| --- | --- | +| `builtinDir` | `process.resourcesPath/bundles` when packaged, else `process.cwd()/bundles` | +| `remoteDir` | `app.getPath('userData')/bundles` | + +Downloaded versions always 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 how builtin and remote bundles resolve. + +## Pack and ship bundles + +Build your web app, then pack the output into a bundle and place it in the 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, include the `bundles` directory as an extra resource and unpack the native `@wvb/node` binary from the ASAR archive. With Electron Forge: + +```ts title="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 can exclude `node_modules` from the package, which drops the native module. If you hit a missing `@wvb/node` binary at runtime, override `packagerConfig.ignore` to keep `node_modules` so the native module is bundled. + + +## Forge and electron-builder integrations + +Two helper packages automate installing builtin bundles at package time, so you do not stage `.wvb` files by hand: + +- **[`@wvb/electron-forge`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-forge)** — an Electron Forge plugin (`WebviewBundlePlugin`) that hooks `packageAfterCopy`, installs your configured builtin bundles, and copies them into the packaged app's resources. +- **[`@wvb/electron-builder`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-builder)** — an electron-builder `afterPack` integration. Wrap your config with `withWebviewBundle(...)` (alias `withWvb`), or compose the raw `webviewBundleAfterPack(...)` hook (alias `wvbAfterPack`) yourself. + + + Both packages are in the repository but not yet published to npm. Until they are, install from source or pin the workspace versions. The manual `extraResource` + `AutoUnpackNativesPlugin` setup above works without them. + + +## Troubleshooting + +- **Blank window or `ERR_FAILED`** — confirm `wvb(...)` runs and `whenProtocolRegistered()` resolves before `loadURL`, and that the bundle name in the URL matches the packed file (`app://simple.wvb` → bundle `simple`). +- **Renderer cannot reach the bridge API** — the preload script is not loaded. Check `webPreferences.preload` points at the compiled preload and that it calls `preload()`. +- **`remote_not_initialized` / `updater_not_initialized`** — the renderer called a `remote.*` or `updater.*` method 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. Verify `extraResource` and `AutoUnpackNativesPlugin`. + +## Next steps + + + + + + + diff --git a/content/docs/guide/platforms/ios.mdx b/content/docs/guide/platforms/ios.mdx new file mode 100644 index 0000000..ae3d9da --- /dev/null +++ b/content/docs/guide/platforms/ios.mdx @@ -0,0 +1,197 @@ +--- +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. You register a scheme such as `app`, +point a `WKWebView` at `app://app.wvb`, and the package answers every request from the bundle on +disk. Your web app runs offline-first, and the updater downloads newer bundles in the background +without an App Store release. + + +The iOS package is **pre-release**. There is no published Swift Package Manager version or git tag +yet, so you install the native FFI from source. Run `node scripts/install.mjs` to wire a release into +the package: it extracts the Swift bindings from the release `apple.zip` asset and resolves the +SHA-256 checksum of `WebViewBundleFFI.xcframework.zip`. Releases are tagged `ffi/` (for +example `ffi/0.1.0`); prereleases are tagged `prerelease/`. + + + +The xcframework committed to the package today is **simulator-only** — it ships the +`ios-arm64_x86_64-simulator` slice with no device slice. On-device builds will not link until a +device-bearing `WebViewBundleFFI.xcframework` is installed. Develop against the iOS Simulator for now. + + +## Requirements + +- iOS 16 or newer, macOS 12 or newer. `Package.swift` declares `platforms: [.macOS(.v12), .iOS(.v16)]`. +- Swift tools 6.1 (`swift-tools-version: 6.1`), Swift language mode 6. +- The SwiftPM product to depend on is **`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. + +## Install the native FFI + +Clone the package and resolve the native binary from a release before you build: + +```sh +# Install the latest release (resolves the highest ffi/* tag) +node scripts/install.mjs latest + +# Pin a specific release +node scripts/install.mjs 0.1.0 # -> tag ffi/0.1.0 +node scripts/install.mjs ffi/0.1.0 # explicit release tag + +# Install a prerelease by commit sha +node scripts/install.mjs --prerelease a3f693a # -> tag prerelease/a3f693a +``` + +The script reads the GitHub release for the resolved tag and does two things: + +- 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 the archive — and writes the checksum + and tag into `Package.swift`. + +The script needs `unzip` on your `PATH`. Set `GITHUB_TOKEN` or `GH_TOKEN` for a private source repo, +and pass `--repo ` to override the default `webview-bundle/webview-bundle`. + +## Quick start + +Configure the package once, register its scheme on a `WKWebViewConfiguration`, and 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. `install(on:)` registers the +scheme handler and the bridge on the configuration you pass. The entry URL `app://app.wvb` selects the +bundle named `app` (see below). If you prefer to skip the manual configuration, call +`instance.makeWebView()` to get a ready-to-use `WKWebView`. + + +`WebViewBundle.shared` precondition-fails if you read it before calling `configure(_:)`. Use +`WebViewBundle.safeShared`, which returns `nil` when the package has not been configured yet, for a +non-trapping read. + + +## How serving works + +The package registers a `WKURLSchemeHandler` for each scheme via +`WKWebViewConfiguration.setURLSchemeHandler(_:forURLScheme:)`. When the webview requests a URL on that +scheme, the handler maps the request to the Rust core, which answers from the bundle on disk. + +- **Bundle name = the first label of the request host.** `app://app.wvb/index.html` resolves to the + bundle `app` and the path `/index.html`. +- **Reserved schemes are rejected at init.** Registering a handler for a native scheme raises an + uncatchable exception, so `http`, `https`, `file`, `ftp`, `ftps`, `ws`, `wss`, `about`, `blob`, + `data`, and `javascript` throw `WebViewBundleError.reservedScheme` instead. +- **A scheme must match `^[a-z][a-z0-9+.-]*$`** and be unique. Empty, malformed, or duplicate schemes + throw `WebViewBundleError`. + +Two protocol kinds exist. `.bundle(scheme:)` serves entries from the bundle source. `.local(scheme:, +hosts:)` proxies requests to a local HTTP server, matching the full request host against the `hosts` +map (for example `["myapp": "http://localhost:8080"]`). See +[Protocol handling](/docs/guide/protocol-handling) for the underlying model. + +## The bridge + +`install(on:)` also wires a JavaScript-to-native bridge. The web app posts messages to +`window.webkit.messageHandlers.wvbIos`. The bridge accepts **main-frame messages only** — messages +from subframes and iframes are dropped — and exposes the source, remote, and updater commands to your +web code. + +## Sources + +Each app reads from two sources. See [Bundle sources](/docs/guide/bundle-sources) for the full model. + +- **Builtin** — the read-only bundles shipped inside the app. The default builtin directory is the app + bundle's `/bundles` folder. Ship it as a folder reference so the files land at that path. +- **Remote** — the writable directory for downloaded updates. The default remote directory lives under + Application Support, namespaced by the app's bundle identifier. + +You can override either path through `SourceOptions` (`builtinDir`, `remoteDir`, +`builtinManifestFilepath`, `remoteManifestFilepath`) on `WebViewBundleConfig.source`. + + +Inside the `WebViewBundle` module, the unqualified name `Bundle` resolves to the FFI bundle class, not +`Foundation.Bundle`. Write `Foundation.Bundle` explicitly when you mean the app bundle — for example +to locate resources. + + +## 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 one of `.strict`, `.optional`, or `.none`. Integrity values are SHA-2 digests +serialized as `sha256:`. The `signatureVerifier` proves who published a bundle. Pick a +`SignatureAlgorithm` (`.ecdsaSecp256r1`, `.ecdsaSecp384r1`, `.ed25519`, `.rsaPkcs1V15`, `.rsaPss`) and +supply a `SignatureVerifyingKey` whose `format` is one of `.spkiDer`, `.spkiPem`, `.pkcs1Der`, +`.pkcs1Pem`, `.sec1`, or `.raw`. Use the `pem` field for text keys and `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") +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, defaulting to the latest when `version` is `nil`. `install` activates a staged +version, verifies its integrity and signature when configured, then prunes stale versions. The web app +can call `location.reload()` afterward to render the new bundle. + +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..3ea3d7e --- /dev/null +++ b/content/docs/guide/platforms/tauri.mdx @@ -0,0 +1,265 @@ +--- +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. +--- + +The `wvb-tauri` crate 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). + +This page shows how to install the crate, register the plugin, point a window at the custom scheme, +drive updates from the frontend, and reach the bundle source from Rust. + + +Tauri uses the Rust crate `wvb-tauri` (published on 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 + +Add the plugin crate to your `src-tauri/Cargo.toml` alongside Tauri v2: + +```toml title="src-tauri/Cargo.toml" +[dependencies] +wvb-tauri = "0.1" +tauri = { version = "2", features = [] } +``` + +If you drive updates from the frontend, install Tauri's JS API in your web app so you can call plugin +commands: + + + + +```sh +npm install @tauri-apps/api +``` + + + + +```sh +pnpm add @tauri-apps/api +``` + + + + +```sh +yarn add @tauri-apps/api +``` + + + + +## Register the plugin + +In `src-tauri/src/lib.rs`, register the plugin with a `Config` that declares a bundle **source**, one +or more **protocols** to serve, and optionally a **remote** for updates. Build the config with +`Config::new()` and chain `.source(...)`, `.protocol(...)`, and `.remote(...)`: + +```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() + // Where bundles live. `builtin_dir_fn` resolves the path from the + // AppHandle at runtime; `builtin_dir` takes a static path string. + .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 directly from a packed bundle. The plugin derives the + bundle name from the request host: `bundle://app.wvb/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. Configure multiple hosts with repeated + `.host(...)` calls or a map via `.hosts(...)`. +- **`Source`** accepts `builtin_dir` / `remote_dir` (static path strings, resolved through Tauri's path + API) or `builtin_dir_fn` / `remote_dir_fn` (closures that compute the path from the `AppHandle`). + When unset, the builtin dir defaults to `bundles` in the app's resource directory and the remote dir + to `bundles` in app local data. + +You can call `.protocol(...)` more than once; each configured protocol is registered as its own +asynchronous URI scheme. See [Protocol handling](/docs/guide/protocol-handling) for how the bundle and +local schemes resolve requests. + +## Point a window at the custom scheme + +Load the bundle scheme in your main window. The most common approach is to set the window URL in +`tauri.conf.json` so the app boots straight into the bundle: + +```json title="src-tauri/tauri.conf.json" +{ + "app": { + "windows": [ + { + "url": "bundle://app.wvb" + } + ] + } +} +``` + +If you call plugin commands from the frontend, grant the plugin's permissions in a capability file. The +plugin's permission namespace is `wvb-tauri`, and the `wvb-tauri:default` set 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 (for example `wvb-tauri:allow-updater-get-update`). + +## Pack and ship bundles + +Build your frontend, pack it into a `.wvb`, 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`). 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 the config to enable OTA downloads, then call the plugin commands from your web app: + +```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")); +``` + +The plugin exposes its commands through Tauri's command bridge as +`plugin:wvb-tauri|`. Arguments use camelCase on the JS side (for example `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'; + +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 plugin also exposes `source_*` commands for inspecting and 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) | + +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`, so the frontend can branch on it. + + +## Reach the plugin from Rust + +From any `App`, `AppHandle`, or `Window`, the `WebviewBundleExtra` trait adds `webview_bundle()` +(aliased `wvb()`), which returns the managed state. From there, `.source()` is always available, and +`.remote()` / `.updater()` return `Option` depending on your config: + +```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 + // run your own update check / install logic here +} +``` + +This is the same state the frontend commands operate on, so you can mix Rust-side and frontend-driven +update logic. + +## Tauri mobile + +The plugin supports Tauri mobile. On **iOS**, builtin bundles live in a real filesystem resource +directory, so no extra setup is needed beyond the desktop configuration. On **Android**, builtin +bundles ship inside the APK as `asset://` resources that the filesystem cannot read directly, so an app +that ships builtin bundles must also register the Tauri filesystem plugin: + +```rust +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 for that request. Check + that the bundle exists in the configured source directory and that 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 configuring `.remote(...)` (and `.updater(...)`) on the `Config`. Branch on + `error.code` in the frontend. +- **Command rejected by the ACL** — add `wvb-tauri:default` (or the specific `wvb-tauri:allow-*` + permission) to a capability that targets the calling window. +- **Bundle not found at runtime** — confirm the `bundles` directory is listed in `tauri.conf.json` + resources and that `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..0dfff6a --- /dev/null +++ b/content/docs/guide/protocol-handling.mdx @@ -0,0 +1,163 @@ +--- +title: Protocol handling +description: How a webview request maps to a file inside a .wvb bundle through the bundle and local protocols. +--- + +When your webview asks for a URL, something has to turn that request into bytes from a bundle instead of bytes from the network. That something is a protocol handler. Webview Bundle ships two: the **bundle protocol**, which serves files straight out of a `.wvb` source, and the **local protocol**, a development proxy that forwards to your bundler's dev server so you keep hot reload. This page explains how a request is mapped to a file, what the bundle protocol guarantees, and how each platform wires a scheme to the handler. + +The Rust core does not register or validate the scheme. It does not care whether the URL starts with `app://`, `bundle://`, or `https://`. Only the URI **host** and **path** are read. Picking a scheme and registering it with the OS is the platform integration's job. + +## The bundle protocol + +The bundle protocol resolves a request against a bundle source and returns the matching file. Two parts of the URI drive resolution. + +### Mapping a request to a bundle and file + +The **bundle name** is the first label of the host, taken up to the first `.`. The **path** is the percent-decoded URI path. + +```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` the way a static file server does: + +- A path that ends with `/` resolves to `index.html` in that directory. `/` becomes `/index.html`. +- A last segment with no `.` in it is treated as a directory. `/about` becomes `/about/index.html`. +- A last segment that contains a `.` is served as-is. `/a.js` stays `/a.js`. + +If the host has no label, the request fails with a bundle-not-found error. If the resolved path is not present in the bundle index, the handler returns **404 Not Found**. + +### Methods, headers, and responses + +The bundle protocol serves only `GET` and `HEAD`. Any other method returns **405 Method Not Allowed**. A `HEAD` request returns the response headers with an empty body. + +For a served file, the handler first replays the HTTP headers stored for that file in the bundle index, then sets `Content-Type` from the index entry and `Content-Length` from the uncompressed size. These two always reflect the index, overriding anything stored in the replayed headers. + + +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 + +The bundle protocol supports HTTP range requests, which webviews rely on for seeking media and resuming large downloads. + +- A request with a `Range` header gets **206 Partial Content**, an `Accept-Ranges: bytes` header, and a `Content-Range` describing the slice. +- Each returned range is capped at roughly 1 MB (`1000 * 1024` bytes); a larger requested range is truncated. +- Multiple ranges come back as a `multipart/byteranges` response. +- A range that cannot be satisfied returns **416 Range Not Satisfiable** with `Content-Range: bytes */`. + +Ranges are computed over the uncompressed file content, so offsets always match the file your build produced. + +## The local protocol + +The local protocol is a development proxy. Instead of reading from a `.wvb` file, it maps a host to a localhost base URL and forwards the request there. You keep the same custom scheme your app uses in production while your bundler serves live, hot-reloading assets. + +```text +app://myapp/api/data?foo=bar -> http://localhost:3000/api/data?foo=bar +``` + +You provide a host-to-URL map. 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 + +Each platform decides how a request reaches the handler. Electron, Tauri, and iOS register a real custom scheme with the webview. Android takes a different route: it intercepts ordinary `https://.wvb` requests through `WebViewClient.shouldInterceptRequest`, so there is no custom scheme to register. + +In every case the bundle name still comes from the host label and the path still comes from the URI path. The examples below register a scheme named `app` (Electron) or `bundle` (Tauri); the name is yours to choose. + + + + +`@wvb/electron` registers the scheme as privileged and wires the handler for you. Pass `bundleProtocol(scheme, ...)` to `webviewBundle`, then load `://.wvb`. + +```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')], +}); + +app.whenReady().then(async () => { + await instance.whenProtocolRegistered(); + const window = new BrowserWindow(); + await window.loadURL('app://app.wvb/index.html'); +}); +``` + +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(); +``` + +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` and serves the matching file. 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. + + +Android and iOS are pre-release and not yet published to Maven Central or tagged for SPM. Install from source for now. + + + + + +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..257a99a --- /dev/null +++ b/content/docs/guide/providers/aws.mdx @@ -0,0 +1,166 @@ +--- +title: AWS provider +description: Host, serve, and provision remote Webview Bundles on AWS using S3, CloudFront, Lambda@Edge, and optional KMS signing. +--- + +The AWS provider lets you publish bundles to your own AWS account and serve them over a global CDN. +Bundles live in **S3**, **CloudFront** fronts them as a cache, two **Lambda@Edge** functions translate +the remote HTTP contract into S3 reads, and **KMS** can optionally sign each bundle. You wire the +publish side into `wvb.config.ts`, run the serving side as Lambda@Edge, and provision the whole stack +with Pulumi. + +All AWS packages are at `0.1.0` and pre-release. Treat them as preview and install from source until a +published release lands. For the contract these pieces implement and how to choose a provider, see +[Building a remote](/docs/guide/remote). + +## Backing services + +The AWS provider maps the remote contract onto four AWS 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 — an origin-request handler that rewrites the URL to the bundle key, and an origin-response handler that 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 + +Three packages cover the three roles. The publish side runs in your CI or on your machine, the provider +runs at the edge, and the Pulumi package provisions everything. + +| Package | Version | Role | +|---|---|---| +| `@wvb/remote-aws` | `0.1.0` | Publish side. Produces the `uploader`, `deployer`, and optional `signature` for `wvb.config.ts`. | +| `@wvb/remote-aws-provider` | `0.1.0` | The Lambda@Edge server (a Hono app) that serves bundles. 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 + +`@wvb/remote-aws` exports `awsRemote()`. Pass a `bucket`, optional `signature`, and shared `aws` client +defaults; it returns `{ uploader, deployer, signature? }` that you spread into the `remote` block of your +config. See [Remote, integrity and signature config](/docs/config/remote) for the full `remote` schema. + +```ts title="wvb.config.ts" +import { defineConfig } from '@wvb/config'; +import { awsRemote, awsKmsSignatureSigner } 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', + }, + // Optional: sign each bundle's integrity string with KMS. + signature: { + keyId: 'arn:aws:kms:us-east-1:111122223333:key/abcd-1234', + algorithm: 'ECDSA_SHA_256', + }, + }), + }, +}); +``` + +The `signature` field is `false` or a KMS signer config. You can also build the signer directly with +`awsKmsSignatureSigner({ keyId, algorithm })`, which returns a signing function. The signer calls KMS +with `MessageType: 'DIGEST'` and returns a base64 signature over the bundle's integrity string. + +The uploader streams the bundle to S3 with a multipart upload and stores 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 reads and rewrites +`deployment.json` in S3, then optionally issues a CloudFront invalidation if you pass an `invalidation` +with a `distributionId`. + + +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 incoming requests to +`bundles/{name}/{version}/{name}_{version}.wvb` — an extra `{version}` directory segment. For an +end-to-end AWS setup you must reconcile the two: set a custom uploader `key` (a string or a +`(bundleName, version) => string` function) that produces the provider's layout, or otherwise arrange a +matching S3 layout. Verify the intended layout against the package source before relying on the defaults. + + +## Serve from Lambda@Edge + +`@wvb/remote-aws-provider` exports a Hono app that implements the remote contract and runs as +Lambda@Edge. The app is split across two CloudFront event handlers, imported from subpaths: + +```ts title="origin-request handler" +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 handler" +import { originResponse } from '@wvb/remote-aws-provider/origin-response'; + +export const handler = originResponse(); +``` + +The origin-request handler reads `deployment.json` for the requested bundle, resolves the version +(falling back from a channel to the default version), rewrites the origin URI to the bundle key, and +tags the request so the origin-response handler can act on it. The origin-response handler converts the +S3 `x-amz-meta-webview-bundle-*` metadata into the `webview-bundle-*` response headers the client +expects. A request for `GET /bundles/{name}/{version}` returns `403` unless you set +`allowOtherVersions: true`. + +## Provision with Pulumi + +`@wvb/remote-aws-provider-pulumi` exports `WebViewBundleRemoteProvider`, a Pulumi `ComponentResource` +(token `webview-bundle:aws:RemoteProvider`). It creates the S3 bucket and bucket 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 as origin-request and origin-response associations. + +```ts title="index.ts" +import { WebViewBundleRemoteProvider } from '@wvb/remote-aws-provider-pulumi'; + +const provider = new WebViewBundleRemoteProvider('webview-bundle', { + // Override defaults here, e.g. lambda names or CloudFront settings. +}); + +export const bucketName = provider.bucketName; +export const distributionDomainName = provider.cloudfrontDistributionDomainName; +export const distributionId = provider.cloudfrontDistributionId; +``` + +The component bundles the Lambda code at deploy time and injects the bucket name, region, and +`allowOtherVersions` flag into each function. The functions default to the `nodejs22.x` runtime. Useful +outputs include `bucketName`, `cloudfrontDistributionDomainName`, `cloudfrontDistributionId`, and the +two Lambda ARNs. + + +Lambda@Edge functions must live in `us-east-1`. Configure that region for the Pulumi AWS provider when +you deploy this component. + + +## Related + + + + + diff --git a/content/docs/guide/providers/cloudflare.mdx b/content/docs/guide/providers/cloudflare.mdx new file mode 100644 index 0000000..83fd9a6 --- /dev/null +++ b/content/docs/guide/providers/cloudflare.mdx @@ -0,0 +1,175 @@ +--- +title: Cloudflare provider +description: Store, deploy, and serve bundles on Cloudflare using R2, Workers KV, and Cloudflare Workers. +--- + +The Cloudflare provider runs a Webview Bundle remote entirely on Cloudflare's edge. Bundles live in +an **R2** bucket, deployment and version pointers live in **Workers KV**, and a **Cloudflare Worker** +serves the HTTP contract that the updater talks to. You get global, low-latency delivery without +managing servers, and your published bundles reach devices over the air (OTA) without a native +app-store release. + +The provider ships as three packages, each for a different stage of 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 at version `0.1.0` and are pre-release. Pin the version and expect breaking changes. + + +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 + +The Cloudflare provider mirrors the shared remote contract, mapping each role onto a Cloudflare +service: + +- **R2** stores each bundle as an object, accessed through R2's S3-compatible API. The uploader in + `@wvb/remote-cloudflare` reuses the S3 uploader from `@wvb/remote-aws`, pointed at R2's endpoint. +- **Workers KV** holds the deployment pointer — a 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 — there is no KMS equivalent here, 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 + +In your `wvb.config.ts`, call `cloudflareRemote()` and spread the result into the `remote` block. It +returns `{ uploader, deployer }`: the uploader writes the bundle to R2, and 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!, + }), + }, +}); +``` + +`cloudflareRemote()` takes the following options: + +| 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`). | + +Under the hood the uploader defaults the S3 client to `region: 'auto'` and the endpoint +`https://.r2.cloudflarestorage.com`, then stores the object at the key +`bundles//_.wvb` with `webview-bundle-*` custom metadata. The deployer writes the +version into KV under the key `` (or `/` when you deploy to a channel). + +Provide R2 S3 credentials the same way you would for AWS, for example through environment variables +that the S3 client picks up. With the config in place, the usual commands 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. 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` and 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()` accepts a single option, `allowOtherVersions` (default `false`). When `false`, requests +for a specific version path return `403`; set it to `true` to let clients download versions other than +the deployed one. The Worker resolves the version from KV, reads the object from R2 at +`bundles//_.wvb`, 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 your `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" }] +} +``` + +## Provision with Pulumi + +`@wvb/remote-cloudflare-provider-pulumi` provisions the whole stack as a single Pulumi component +resource. Construct `WebviewBundleRemoteProvider` with your account ID; it creates an R2 bucket, a +Workers KV namespace, and a Worker (with a version and a deployment) wired together with the right +bindings. + +```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; +``` + +The component registers the Pulumi resource token `webview-bundle:cloudflare:RemoteProvider`. By +default it deploys the bundled Worker entry with the bindings `BUCKET` (R2) and `KV`, matching the +`Context` the Worker expects. `workerDeploymentPercentage` controls a gradual rollout — the share of +traffic the new Worker version receives — and defaults to `100`. The component exports `bucketName`, +`kvNamespaceId`, `workerId`, `workerVersionId`, and `workerDeploymentId`. + +After `pulumi up`, take the bucket name and KV namespace ID it exports and feed them into the +`cloudflareRemote()` config 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..965aeb3 --- /dev/null +++ b/content/docs/guide/providers/local.mdx @@ -0,0 +1,225 @@ +--- +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. +--- + +The local provider is 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 complete bundle lifecycle — pack, upload, +deploy, and over-the-air (OTA) update — without provisioning any infrastructure. Use it to develop and +test your update flow end to end, then switch to a [cloud provider](/docs/guide/remote) for production. + + +The local provider is for development and testing only. It stores bundles as plain files on a single +machine and the `wvb remote local` server binds to `localhost` by default. Do not use it to serve +production traffic — see the [AWS](/docs/guide/providers/aws) and +[Cloudflare](/docs/guide/providers/cloudflare) providers for that. + + +## Two packages + +The local provider ships as two packages, both at version `0.0.0` (pre-release, not yet published — +install from source for now): + +| Package | Role | +| --- | --- | +| `@wvb/remote-local` | The publish-side client. Its `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, and the `-provider` package serves +them. See [Building a remote](/docs/guide/remote) for the shared contract and how the pieces fit together. + +## Configure the publish side + +Add `localRemote()` to the `remote` block of your `wvb.config`. It returns an object with `uploader` +and `deployer`, which you spread into the config. + +```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 a single optional field: + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `baseDir` | `string` | `~/.wvb/local` | Directory where bundles and deployment state are stored on disk. | + +Pass `baseDir` to keep a project's bundles isolated 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 — including `endpoint`, `bundleName`, `packBeforeUpload`, integrity, and +signature — see the [remote config reference](/docs/config/remote). + +## On-disk layout + +The uploader and deployer write everything under `{baseDir}/bundles`. Each bundle name gets its own +directory holding one `.wvb` file per version, a metadata file per version, and a single deployment +pointer. + +```text +{baseDir}/ +└── bundles/ + └── {name}/ + ├── {name}_{version}.wvb # the bundle binary (one per uploaded version) + ├── {name}_{version}.json # per-version metadata: { integrity?, signature? } + └── deployment.json # which version is current, per channel +``` + +For example, after uploading and deploying version `1.1.0` of a bundle named `app` to the default +store: + +```text +~/.wvb/local/ +└── bundles/ + └── app/ + ├── app_1.1.0.wvb + ├── app_1.1.0.json + └── deployment.json +``` + +The `deployment.json` file records the current version. Deploying without a channel sets the default +`version`; deploying with a channel sets `channels[channel]` instead, so multiple channels can point at +different versions of the same bundle. + +## Run the server + +The `@wvb/remote-local-provider` package only builds the Hono app — it does not bind a port. The CLI +command `wvb remote local` serves that app with a Node HTTP server. Start it from your project: + +```sh +wvb remote local +``` + +By default it serves the store at `~/.wvb/local` on `http://localhost:4313`. The command accepts: + +| Flag | Default | Description | +| --- | --- | --- | +| `--base-dir` | `~/.wvb/local` | Directory to serve. Match the `baseDir` you set in `localRemote()`. | +| `--port`, `-P` | `4313` | Port to listen on (also reads the `PORT` env var). | +| `--hostname`, `-H` | `localhost` | Host to bind (also reads the `HOSTNAME` env var). | +| `--allow-other-versions` | `false` | Allow downloading specific versions other than the deployed one. | +| `--silent` | — | Suppress server logging. | + +If you set a custom `baseDir` in your config, pass the same path to the server so it serves the bundles +you uploaded: + +```sh +wvb remote local --base-dir ~/.wvb/my-app +``` + +See the [CLI reference](/docs/guide/cli) for the full `remote` command group, including `remote list`. + +### allowOtherVersions + +By default the server only serves the currently deployed version of each bundle. A request for a +specific version — `GET /bundles/{name}/{version}` — returns `403 Forbidden`. This matches how the +cloud providers behave and keeps clients pinned to deployed versions. + +Set `--allow-other-versions` (or `allowOtherVersions: true` when you build the provider app directly) +to let those requests succeed. This is useful for testing rollbacks or inspecting an older bundle: + +```sh +wvb remote local --allow-other-versions +``` + + +With `allowOtherVersions` disabled, only the deployed version is reachable through +`GET /bundles/{name}`. The version-specific route stays locked behind `403` until you opt in. + + +## Walk the full loop + +Put the pieces together to test an OTA update against a local server. The first three steps run on your +machine; the last points your app's updater at the server. + + +`wvb upload` does not deploy by default — `--deploy` is `false` unless you pass it. Use +`wvb upload --deploy` to upload and mark the version current in one step, or run `wvb deploy` +separately. + + +1. Wire up `localRemote()` in `wvb.config`, as shown above. + +2. Pack, upload, and deploy a version. `--deploy` makes it the current version immediately: + + ```sh + wvb pack ./dist + wvb upload --version 1.1.0 --deploy + ``` + + With `packBeforeUpload` left at its default, `wvb upload` packs for you, so you can skip the explicit + `wvb pack` step. Pass the version with the `--version`, `-V` flag. + +3. Start the server pointed at the same store: + + ```sh + wvb remote local + ``` + + Confirm the deployed bundle is listed: + + ```sh + curl http://localhost:4313/bundles + # [{"name":"app","version":"1.1.0"}] + ``` + +4. Point your app's updater at `http://localhost:4313`. The updater calls + `GET /bundles/{name}` to download the current bundle, verifies it, 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. + +To publish an update, bump the version, upload and deploy again, and the next updater check picks it up: + +```sh +wvb upload --version 1.2.0 --deploy +``` + +## Where to go next + + + + + + + diff --git a/content/docs/guide/remote-bundles.mdx b/content/docs/guide/remote-bundles.mdx new file mode 100644 index 0000000..6c1a945 --- /dev/null +++ b/content/docs/guide/remote-bundles.mdx @@ -0,0 +1,194 @@ +--- +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 can download newer bundles over the air (OTA), so you ship updated app code without a +native app-store release. This page is the conceptual hub for that capability: it explains 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 and test the loop +locally, see [Building a remote](/docs/guide/remote); for the exact per-language updater APIs, see the +[Node](/docs/references/node) and [Deno](/docs/references/deno) references. + +## The update lifecycle + +A bundle travels from your build output to a device in five stages. The first three run on your +machine or CI; the last two run inside the app, driven by the updater. + +```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` archive. +2. **Upload** that archive to the server, optionally attaching an integrity hash and a signature. +3. **Deploy** the uploaded version so clients on a given channel start seeing it. +4. On the device, the **updater checks** for a newer deployed version, then **downloads** it. +5. The updater **verifies** the download and **installs** it into the `remote` source, after which the + app serves it instead of the builtin bundle. + +Stages 1 through 3 are CLI commands — see the [CLI reference](/docs/guide/cli) for `pack`, `upload`, +`deploy`, and `download`. Stages 4 and 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. They are deliberately separate so you control exactly when a device fetches bytes and when +it switches versions. The names below are conceptual; each platform exposes the same three operations +under its own API. + +- **`getUpdate`** is a check only. It asks the server for the current deployed version and compares it + to the local version. It reports `isAvailable` as `true` when there is no local version, or when the + local version differs from the deployed version. It downloads nothing. +- **`download`** fetches the bundle, verifies it against your integrity and signature policy, and + stages it into the `remote` source. It does **not** activate the new version — the app keeps serving + the current one. +- **`install`** re-verifies the staged bundle from disk, activates it as the current version, unloads + the old descriptor, and prunes versions that are no longer retained. + +This split lets you, for example, 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). + + +A typical flow in the Rust core looks like this; platform wrappers mirror it one-to-one: + +```rust +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 +} +``` + +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 is any HTTP server that implements four endpoints. The provider packages +(local, AWS, Cloudflare) implement this spec, and you can build your own to match it. + +| 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 +to scope the response to a channel. Metadata travels in response headers: + +- `Webview-Bundle-Name` — bundle name **(required)** +- `Webview-Bundle-Version` — version **(required)** +- `Webview-Bundle-Integrity` — integrity string (optional) +- `Webview-Bundle-Signature` — signature (optional) +- `ETag` and `Last-Modified` — standard validators (optional) + +Downloads use `Content-Type: application/webview-bundle`; the list endpoint returns +`application/json`. A missing bundle or version returns `404`; requesting a specific non-deployed +version returns `403` when that server disallows it. Both missing required headers and any other +non-2xx response cause the client to reject the download. For a worked server implementation and local +testing, see [Building a remote](/docs/guide/remote). + +## Integrity + +An integrity hash lets a device confirm that the bytes it downloaded are exactly the bytes you +published. Webview Bundle uses **SHA-2**: `sha256` (the default), `sha384`, or `sha512`. The hash is +serialized as `":"`, for example: + +```text +sha256:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= +``` + +The publisher attaches this string at upload time (configured in +[Remote config](/docs/config/remote)), the server returns it in the `Webview-Bundle-Integrity` header, +and the updater enforces it on download according to an **integrity policy**: + +- **`Strict`** — an integrity value must be present and must match, or the download is rejected. +- **`Optional`** — the **default**. Verify the integrity value if one is present; allow the download + if none is attached. +- **`None`** — skip integrity checking entirely. + +## 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. Configure the updater with a public key, and it +rejects any download whose signature does not verify. + +The core verifies these 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 keys can be supplied in these formats: + +- **SPKI** as PEM or DER (all algorithms) +- **PKCS#1** as PEM or DER (RSA only) +- **SEC1** point bytes (ECDSA only) +- **Raw** 32-byte key (Ed25519 only) + +A Rust updater that requires 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)); +``` + +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, which makes staged rollouts possible. Deploy +a version to a channel, and only clients that request 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 configuration and forwards it as the `?channel=` +query parameter when it checks for and downloads updates. A channel is a free-form string; there is no +hardcoded default name — when no channel is set, the query parameter is simply 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..7332cef --- /dev/null +++ b/content/docs/guide/remote.mdx @@ -0,0 +1,130 @@ +--- +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 page is the practical, server-side companion to [Remote bundles](/docs/guide/remote-bundles): it shows the packages that build a remote, how to run the full publish-and-update loop on your machine with no cloud account, and how to pick a provider when you go to production. + +Webview Bundle ships a remote for three backends — `local`, `aws`, and `cloudflare` — and each backend is split into three packages with clear roles. You only need the ones for the job in front of you. + +## Package roles + +For every backend there are up to three packages, each playing a distinct role. + +| 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. The publish-side integrity and signature options live alongside them — see [Remote, integrity & signature config](/docs/config/remote). + + + The `local` packages and `@wvb/remote-local-provider` are pre-release (`0.0.0`); the AWS and Cloudflare packages are at `0.1.0`. Treat all of them as not-yet-published and install from source for now. + + +## The HTTP contract + +Every provider implements the same four-endpoint contract, so the client never cares which backend is behind it. + +| Method and path | Purpose | +| --- | --- | +| `GET /bundles` | List deployed bundles as `[{ "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` and `Webview-Bundle-Version` are required; `Webview-Bundle-Integrity`, `Webview-Bundle-Signature`, `ETag`, and `Last-Modified` are optional), and downloads use `Content-Type: application/webview-bundle`. The full endpoint, header, status-code, and channel-query spec is in [Remote bundles](/docs/guide/remote-bundles). + +Because the contract is small and transport-only, you can also implement your own server to it instead of using a provider — anything that answers these four routes with the required headers works. + +## Test the loop locally + +You can exercise the entire update loop on your machine. `@wvb/remote-local` gives you an uploader and 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. + +### 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 + }, +}); +``` + +`localRemote()` defaults its `baseDir` to `~/.wvb/local`; pass `localRemote({ baseDir: './.wvb/local' })` to keep the store inside your project instead. + +### 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` above is optional. `--deploy` is off by default; pass it (or run `wvb deploy app --version 1.1.0` separately) to make the version the one clients receive. See the [CLI reference](/docs/guide/cli) for every flag. + +### Serve it + +```sh +wvb remote local # serves ~/.wvb/local on http://localhost:4313 +# options: --base-dir ./.wvb/local --port 4313 --allow-other-versions +``` + +`wvb remote local` listens on port `4313` by default. Pass `--allow-other-versions` to let clients fetch a specific non-deployed version through `GET /bundles/{name}/{version}` instead of getting a `403`. + +### Point your app at it + +Set your 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. + + + 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, which is handy for confirming a server behaves before you point an app at it. + +```sh +wvb remote list --endpoint http://localhost:4313 +wvb remote current app --endpoint http://localhost:4313 +wvb remote download app --endpoint http://localhost:4313 --outfile ./app.wvb +``` + +`wvb remote list` (alias `wvb remote ls`) calls `GET /bundles`, `wvb remote current` calls `HEAD /bundles/{name}`, and `wvb remote download` pulls the current bundle to disk. + +### Preview a single bundle + +To look at what is inside a `.wvb` without a server or a deployment, serve its files directly: + +```sh +wvb serve ./app.wvb # serves the bundle's files at 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` and a server that implements the contract above, plus Pulumi packages to provision it. + + + + + + diff --git a/content/docs/guide/why-webview-bundle.mdx b/content/docs/guide/why-webview-bundle.mdx new file mode 100644 index 0000000..95f48df --- /dev/null +++ b/content/docs/guide/why-webview-bundle.mdx @@ -0,0 +1,183 @@ +--- +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 a single compressed, integrity-checked archive (`.wvb`) +that your app ships with and serves to its webview over a custom URL scheme. Instead of loading a +remote URL or re-fetching assets on every launch, the webview reads its HTML, JS, CSS, and media +from local storage. A remote can be configured so the app downloads newer bundles over the air, +delivering updated app code without a native app-store release. This page explains the three reasons +teams reach for it: offline-first delivery, over-the-air updates, and a workflow that stays the same +across every webview platform. + +## Offline-first by default + +When a webview loads a remote URL, the first paint waits on the network. A cold cache, a captive +portal, or a flaky connection turns into a blank screen. Even apps that cache aggressively still pay +the cost the first time and risk a stale or partial cache afterward. + +Webview Bundle inverts this. Your assets live inside a `.wvb` file that ships with the app, and the +core serves them through a custom scheme straight from the local archive. The bundle protocol maps a +request such as `bundle://app/index.html` to a file inside the bundle and returns it directly, so the +first paint never depends on the network. + +```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 response = await protocol.handle('get', 'bundle://app/index.html'); +``` + +This matters in the places real users open your app: on a subway between stations, on a plane, or +anywhere the network drops out mid-session. The app paints from the local bundle every time, and the +network becomes an optimization rather than a prerequisite. + + +The bundle protocol supports `GET` and `HEAD`, replays the headers stored for each file, and handles +`Range` requests for media. See [Protocol handling](/docs/guide/protocol-handling) for the full +request mapping. + + +## Over-the-air updates + +A native app-store release is slow and gated. A typo in copy, a broken button, or a small feature can +sit behind a multi-day review. Because your app code lives in a `.wvb` bundle, you can ship a fix by +deploying a new bundle version and letting installed apps download it over the air (OTA). The native +binary stays the same; only the web bundle changes. + +The update model is built around two sources. The `builtin` source is the bundle you shipped inside +the app, and it is read-only. The `remote` source holds bundles downloaded from your server, and it +takes priority when present. If a download or activation ever fails, the app still has the builtin +bundle to fall back to, so an OTA update can never leave a device without a working app. + +Updates run in two explicit steps so a half-downloaded bundle is never served. The updater downloads +a bundle into the remote source first, then activates it in a separate install step. + +```ts title="Checking for and applying an update" +import { Updater } from '@wvb/node'; + +const updater = new Updater(source, remote); + +const info = await updater.getUpdate('app'); +if (info.isAvailable) { + await updater.download('app'); // stages into the remote source, verifies + await updater.install('app', info.version); // activates the staged version +} +``` + +Two more pieces keep OTA safe and controlled: + +- **Staged rollouts with channels.** A channel is an optional string passed to the remote, sent as a + `?channel=` query parameter on list and download requests. Point a subset of devices at a `beta` + channel, watch it, then promote to the default. There is no hardcoded channel name; when you set + none, the parameter is simply omitted. +- **Authenticity with integrity and signatures.** Every bundle download is verified before it is + activated. Integrity uses SHA-2 (`sha256` by default, with `sha384` and `sha512` available) and is + serialized as `":"`. An optional signature proves who published the bundle; it signs + the integrity string bytes and supports ECDSA, Ed25519, and RSA verification. The default integrity + policy is `optional` (verify if present); set it to `strict` to require it. + + +Integrity is SHA-2, not SHA-3. The signature covers the integrity string, not the raw archive bytes. +See [Remote bundles](/docs/guide/remote-bundles) for the verification flow and +[Remote, integrity & signature config](/docs/config/remote) for the configuration schema. + + +## For web developers + +You keep writing the app you already know. React, Vue, Svelte, or plain HTML all work, because +Webview Bundle operates on your bundler's output, not your source. There is no custom framework and no +special build step to learn. Point the `wvb pack` command at your build directory and it produces one +`.wvb` artifact. + +```sh title="Pack your build output into one .wvb" +npm install --save-dev @wvb/cli +npx wvb pack ./dist +``` + +By default the packed file is written to `.wvb/` and the `.wvb` extension is appended for +you. In `wvb.config.ts` the output path is 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 web code talks to the native host through the `@wvb/bridge` package. One +`invoke()` API exposes the source, remote, and updater commands, and the bridge picks the right +transport for Electron, Tauri, Android, or iOS underneath. The mental model is identical everywhere, +so the same update code runs on every platform. + +```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); +} +``` + +### How this compares + +Two common alternatives solve part of the same problem: + +- **Per-platform native asset bundling.** You can embed your web build in each native shell and write + the serving and update logic once per platform. It works, but the toolchain, the asset format, and + the update code differ on each platform, and you maintain all of them. Webview Bundle gives you one + `.wvb` format 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 an app-store release, which is + the same payoff as the remote source here. The tradeoffs are different: Webview Bundle is built + around a verifiable bundle format you host yourself, with a builtin fallback, channel-based + rollouts, and integrity plus optional signatures, rather than a managed third-party service. You own + the server and the artifact. + +The honest tradeoff is that you adopt a bundle format and a pack step, and you run the remote that +serves updates. 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 but pre-release: install from source for now, since the +mobile artifacts are not yet published to Maven Central or tagged for Swift Package Manager. 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..a97bed3 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"] } diff --git a/content/docs/references/deno.mdx b/content/docs/references/deno.mdx new file mode 100644 index 0000000..bdc2210 --- /dev/null +++ b/content/docs/references/deno.mdx @@ -0,0 +1,204 @@ +--- +title: Deno API +description: Reference for the experimental @wvb/deno package, the Deno FFI peer of @wvb/node. +--- + +`@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 and to look up the +classes, options, and limitations of the Deno binding. + + + **Experimental.** `@wvb/deno` is published on JSR at version `0.0.0`, its source lives on an + unmerged branch, and `main` ships only a prebuilt cdylib. The API surface described here may change + before a stable release. Do not depend on it for production. + + +## 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) | + +## API surface + +The Deno binding mirrors `@wvb/node`. The classes below all take a loaded `lib` and serve the same +roles described in the [Node API reference](/docs/references/node). + +### Protocols + +`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)`: + +```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); +}); +``` + +`HttpMethod` is the union of accepted request methods (for example `'get'` and `'head'`). + +### BundleSource + +`BundleSource` resolves bundles from a builtin directory and a remote directory, with remote taking +priority. It exposes the same data API as `@wvb/node`'s `BundleSource` — listing bundles, loading a +version, fetching a bundle or descriptor, and pruning retained remote versions. + +### Remote + +`Remote` talks to a remote bundle server. Construct it with an 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) +``` + +`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. Related +types: `RemoteOptions`, `HttpOptions`, and `RemoteBundleInfo`. + +### Updater + +`Updater` checks a remote for newer versions and installs them. It accepts a `BundleSource`, a +`Remote`, and optional `UpdaterOptions`. Configure verification declaratively with a +`SignatureVerifierOptions` value: + +```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 }, + }, +}); + +const update = await updater.getUpdate('app'); +if (update.isAvailable) { + await updater.download('app'); + await updater.install('app', update.version); +} +``` + +Related types: `UpdaterOptions`, `IntegrityPolicy` (`'strict' | 'optional' | 'none'`), +`SignatureAlgorithm`, `SignatureVerifierOptions`, and `BundleUpdateInfo`. + +### 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 not available: + +- **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 yet supported** on the Deno `Remote`. + +## Deno Desktop integration + +`@wvb/deno-desktop` (JSR, `0.0.0`, experimental) 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. + + + + + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx new file mode 100644 index 0000000..4354a0d --- /dev/null +++ b/content/docs/references/index.mdx @@ -0,0 +1,61 @@ +--- +title: References +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. + +```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). + + + The mobile bindings are pre-release and not yet published to Maven Central or tagged for Swift Package Manager. Install from source for now. + diff --git a/content/docs/references/meta.json b/content/docs/references/meta.json new file mode 100644 index 0000000..cab1a3e --- /dev/null +++ b/content/docs/references/meta.json @@ -0,0 +1,10 @@ +{ + "root": true, + "title": "References", + "pages": [ + "index", + "[Rust (docs.rs)](https://docs.rs/wvb)", + "node", + "deno" + ] +} diff --git a/content/docs/references/node.mdx b/content/docs/references/node.mdx new file mode 100644 index 0000000..2a3b0b0 --- /dev/null +++ b/content/docs/references/node.mdx @@ -0,0 +1,261 @@ +--- +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/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`, 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. | + +## Classes + +### Bundle and builder + +| 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. It is distinct from the bundle's integrity hash, which uses SHA-2. + +### Descriptor, header, and index + +A descriptor exposes a bundle's structure without loading every file into memory. + +| 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` | + +`LoadedDescriptor` holds a reference-counted handle that is released on garbage collection, so reads stay lazy against the file on disk. + +### BundleSource + +`new BundleSource(config: BundleSourceConfig)` manages bundles across a builtin directory (the bundles shipped with the app) and a remote directory (bundles downloaded over the air). When both contain a bundle, the remote directory takes priority. + +| 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` | + +### Protocol handlers + +| Class | Constructor | Method | +|---|---|---| +| `BundleProtocol` | `new BundleProtocol(source: BundleSource)` | `handle(method: HttpMethod, uri: string, headers?): Promise` | +| `LocalProtocol` | `new LocalProtocol(hosts: Record)` | `handle(method, uri, headers?): Promise` | + +`BundleProtocol` serves `scheme://bundle_name/path` requests from a `BundleSource`. It answers `GET` and `HEAD`, and turns a `Range` request into a `206` response. `LocalProtocol` proxies `app://host/...` requests to a localhost URL (useful in development), caching responses and returning `304` when unchanged. See [Protocol handling](/docs/guide/protocol-handling). + +### Remote + +`new Remote(endpoint: string, options?: RemoteOptions)` is the HTTP client for a [bundle remote](/docs/guide/remote-bundles). It speaks the remote's HTTP contract over the configured `endpoint`. + +| 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]>` | + +`download` and `downloadVersion` resolve to a tuple of the bundle info, the parsed `Bundle`, and the raw bytes. `RemoteOptions` accepts an `http` config and an `onDownload` progress callback: + +```ts +type RemoteOptions = { + http?: HttpOptions; + onDownload?: (data: RemoteOnDownloadData) => void; +}; + +type RemoteOnDownloadData = { + downloadedBytes: number; + totalBytes?: number; + endpoint: string; +}; +``` + + +The default request timeout is 120 seconds. Override it through `HttpOptions.timeout` (milliseconds). `HttpOptions` also exposes `userAgent`, `defaultHeaders`, connection-pool tuning, and transport flags. + + +### Updater + +`new Updater(source: BundleSource, remote: Remote, options?: UpdaterOptions)` checks a remote for newer bundles, downloads them, and activates them. Downloads and installs are serialized per bundle. + +| Method | Signature | Behavior | +|---|---|---| +| `listRemotes` | `(): Promise` | Lists bundles on the remote. | +| `getUpdate` | `(bundleName): Promise` | Checks the remote's current version against the local one. | +| `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, swaps the current version, drops the cached descriptor, and prunes old versions. | + +`getUpdate` is the check step — there is no method named `check`. A separate `download` then `install` keeps download and activation as distinct, restartable phases. + +## Enums and unions + +| Type | Values | +|---|---| +| `Version` | `'v1'` | +| `HttpMethod` | `'get' \| 'head' \| 'options' \| 'post' \| 'put' \| 'patch' \| 'delete' \| 'trace' \| 'connect'` | +| `BundleSourceKind` | `'builtin' \| 'remote'` | +| `IntegrityAlgorithm` | `'sha256' \| 'sha384' \| 'sha512'` (SHA-2; `sha384` recommended) | +| `IntegrityPolicy` | `'strict' \| 'optional' \| 'none'` (`optional` is the default) | +| `SignatureAlgorithm` | `'ecdsaSecp256R1' \| 'ecdsaSecp384R1' \| 'ed25519' \| 'rsaPkcs1V15' \| 'rsaPss'` | +| `VerifyingKeyFormat` | `'spkiDer' \| 'spkiPem' \| 'pkcs1Der' \| 'pkcs1Pem' \| 'sec1' \| 'raw'` | + +Key-format constraints: `pkcs1Der`/`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, so signature verification requires an integrity value to be present. See [Remote, integrity & signature config](/docs/config/remote). + +## Option interfaces + +```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 + key) and a custom callback. The same applies to `integrityChecker`, which can replace the built-in SHA-2 check with your own function. + +## Examples + +### Read a bundle and get a file + +```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')); +} +``` + +### 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 +``` + +### 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. + +```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/src/layouts/home/data.ts b/src/layouts/home/data.ts index 66efc5d..3c71c92 100644 --- a/src/layouts/home/data.ts +++ b/src/layouts/home/data.ts @@ -11,7 +11,7 @@ export const NAV_ITEMS: NavItem[] = [ { label: 'Demo', href: '#demo' }, { label: 'How it works', href: '#how-it-works' }, { label: 'Platforms', href: '#platforms' }, - { label: 'Reference', href: '/docs/cli' }, + { label: 'Reference', href: '/docs/references' }, ]; /** Files shown on the left ("your source") of the architecture diagram. */ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index cf652ae..f5c897f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as DocsIndexRouteImport } from './routes/docs/index' import { Route as DocsSplatRouteImport } from './routes/docs/$' import { Route as ApiSearchRouteImport } from './routes/api/search' @@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const DocsIndexRoute = DocsIndexRouteImport.update({ + id: '/docs/', + path: '/docs/', + getParentRoute: () => rootRouteImport, +} as any) const DocsSplatRoute = DocsSplatRouteImport.update({ id: '/docs/$', path: '/docs/$', @@ -33,30 +39,34 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute + '/docs/': typeof DocsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute + '/docs': typeof DocsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute + '/docs/': typeof DocsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/api/search' | '/docs/$' + fullPaths: '/' | '/api/search' | '/docs/$' | '/docs/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/search' | '/docs/$' - id: '__root__' | '/' | '/api/search' | '/docs/$' + to: '/' | '/api/search' | '/docs/$' | '/docs' + id: '__root__' | '/' | '/api/search' | '/docs/$' | '/docs/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ApiSearchRoute: typeof ApiSearchRoute DocsSplatRoute: typeof DocsSplatRoute + DocsIndexRoute: typeof DocsIndexRoute } declare module '@tanstack/react-router' { @@ -68,6 +78,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/docs/': { + id: '/docs/' + path: '/docs' + fullPath: '/docs/' + preLoaderRoute: typeof DocsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/docs/$': { id: '/docs/$' path: '/docs/$' @@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ApiSearchRoute: ApiSearchRoute, DocsSplatRoute: DocsSplatRoute, + DocsIndexRoute: DocsIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/docs/$.tsx b/src/routes/docs/$.tsx index 2b2bdb4..0ccfe92 100644 --- a/src/routes/docs/$.tsx +++ b/src/routes/docs/$.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, notFound } from '@tanstack/react-router'; +import { createFileRoute, notFound, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import { useFumadocsLoader } from 'fumadocs-core/source/client'; import { DocsLayout } from 'fumadocs-ui/layouts/docs'; @@ -11,7 +11,11 @@ import { useMDXComponents } from '../../mdx'; export const Route = createFileRoute('/docs/$')({ component: Page, loader: async ({ params }) => { - const slugs = params._splat?.split('/') ?? []; + const slugs = params._splat?.split('/').filter(Boolean) ?? []; + // `/docs` has no tab context; send readers to the Guide tab. + if (slugs.length === 0) { + throw redirect({ href: '/docs/guide' }); + } const data = await serverLoader({ data: slugs }); await clientLoader.preload(data.path); return data; @@ -59,6 +63,7 @@ function Page() { nav={{ title: 'Webview Bundle', }} + tabMode="top" tree={data.pageTree} > {clientLoader.useContent(data.path)} diff --git a/src/routes/docs/index.tsx b/src/routes/docs/index.tsx new file mode 100644 index 0000000..0875e6f --- /dev/null +++ b/src/routes/docs/index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +// `/docs` itself has no tab context — land readers on the Guide tab. +export const Route = createFileRoute('/docs/')({ + beforeLoad: () => { + throw redirect({ href: '/docs/guide' }); + }, +}); From c000e6c178422cf0cfe89ade441290dc54d3efb6 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Tue, 30 Jun 2026 14:17:22 +0900 Subject: [PATCH 02/15] docs: visualize diagrams, tighten prose, add code examples - Rename the tagline to "web application deployment system" - Replace the three ASCII schematic diagrams (pack/serve/update flow, platform architecture, update lifecycle) with theme-aware SVGs under public/diagrams/, matching the site's zinc + brand-blue tone and angular corners (light/dark via prefers-color-scheme). Directory-tree and short code listings stay as copyable code blocks. - Tighten 17 prose-heavy pages: cut filler, turn explanatory paragraphs into short lead-ins, and add runnable code examples (install, config, CLI, and per-platform snippets), keeping every fact, table, callout, link, and image. Verified: yarn build and yarn lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- content/docs/guide/bundle-format.mdx | 166 +++++++++---- content/docs/guide/index.mdx | 104 ++++++-- content/docs/guide/platform-integration.mdx | 188 +++++++++++--- content/docs/guide/platform-support.mdx | 126 ++++++++-- content/docs/guide/platforms/android.mdx | 150 ++++++++--- content/docs/guide/platforms/deno.mdx | 157 ++++++++---- content/docs/guide/platforms/electron.mdx | 144 +++++++---- content/docs/guide/platforms/ios.mdx | 207 ++++++++++------ content/docs/guide/platforms/tauri.mdx | 262 +++++++++++++------- content/docs/guide/protocol-handling.mdx | 120 +++++++-- content/docs/guide/providers/aws.mdx | 160 +++++++----- content/docs/guide/providers/cloudflare.mdx | 144 +++++++---- content/docs/guide/providers/local.mdx | 163 ++++++------ content/docs/guide/remote-bundles.mdx | 214 +++++++++------- content/docs/guide/remote.mdx | 159 +++++++++--- content/docs/guide/why-webview-bundle.mdx | 151 +++++------ public/diagrams/architecture.svg | 63 +++++ public/diagrams/pack-serve-update.svg | 69 ++++++ public/diagrams/update-lifecycle.svg | 69 ++++++ 19 files changed, 1966 insertions(+), 850 deletions(-) create mode 100644 public/diagrams/architecture.svg create mode 100644 public/diagrams/pack-serve-update.svg create mode 100644 public/diagrams/update-lifecycle.svg diff --git a/content/docs/guide/bundle-format.mdx b/content/docs/guide/bundle-format.mdx index fcfa537..30d0e3d 100644 --- a/content/docs/guide/bundle-format.mdx +++ b/content/docs/guide/bundle-format.mdx @@ -3,31 +3,41 @@ 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 file with the `.wvb` extension that packs your built web assets into one -compressed, integrity-checked archive. This page explains how that file is laid out on disk: its three -sections, the fields in each, and the checksums that catch corruption before any content is served. If -you just want to produce one, see the [CLI](/docs/guide/cli); read on to understand what it contains. +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: -## Overview +```text +[ Header (17 bytes) ][ Index (variable) ][ Data (variable) ] +``` + +| Section | Holds | +| ---------------- | ------------------------------------------------------- | +| Header | magic number, format version, index size, checksum | +| Index | path → offset/length/headers entry map | +| Data | LZ4-compressed file contents, sorted by path | -A `.wvb` file is three sections written back to back: +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): -| Header (17 bytes) | Index (variable) | Data (variable) | -| -------------------------------------------------- | -------------------------------------- | ----------------------------- | -| magic number, format version, index size, checksum | path → offset/length/headers entry map | LZ4-compressed file contents | +```ts title="build.ts" +import { BundleBuilder, writeBundle } from '@wvb/node'; -The **header** is a fixed 17-byte preamble. The **index** maps each file path to where its bytes live -and how to serve them. The **data** section holds the compressed file contents. Every section carries -its own checksum, so a truncated or corrupted archive is detected before its contents are trusted. +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'); -By convention a packed file is named `_.wvb`, for example `app_1.0.0.wvb`. The bundle -name and version are source-level concepts that live in the filename and the manifest, not inside the -archive itself. See [Bundle sources](/docs/guide/bundle-sources) for how names and versions are tracked. +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. It is written by hand rather than through a -serializer, so every field sits at a known offset. +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 | | ------------ | ------ | ------ | ----------------------------------------- | @@ -36,31 +46,38 @@ serializer, so every field sits at a known offset. | Index size | 9 | 4 | `u32`, big-endian | | Checksum | 13 | 4 | `u32`, big-endian xxHash-32 | -The magic number `0xF09F8C90F09F8E81` is the UTF-8 encoding of the two emoji 🌐🎁 (globe and wrapped -gift). A reader that does not find these bytes rejects the file as not a bundle. +For a `V1` bundle with an index size of `1234`, the 17 bytes are: -The version byte holds the bundle **format version**. The format 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. +```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 index size is the byte length of the index section that follows, not counting the index's own -trailing checksum. A reader uses it to know where the index ends and the data section begins. +- 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. -The checksum is an xxHash-32 over the first 13 bytes of the header (everything before the checksum -itself), stored big-endian. +Read header fields back through the descriptor: -## Index +```ts +import { readBundle } from '@wvb/node'; -The index sits immediately after the header. It is a map from each file path, such as `/index.html`, to -an entry describing where that file's bytes are and how to serve them. +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 +``` -The index is serialized with [bincode](https://github.com/bincode-org/bincode) using a big-endian, -variable-length integer configuration. Numeric fields are therefore varint-encoded, not fixed width. -Before serialization the map is sorted by path, 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. +## Index -Each entry holds the following fields: +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 | | ---------------- | -------- | --------------------------------------------------------------- | @@ -70,44 +87,83 @@ Each entry holds the following fields: | `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 one of these entries. +[Protocol handling](/docs/guide/protocol-handling) for how a URL maps to an entry. ## Data -The data section holds the file contents, laid out sorted by path in the same order as the index. Each -file is stored as its compressed bytes followed by a 4-byte big-endian xxHash-32 checksum of those -compressed bytes: +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. The `offset` in each index entry points at the -start of a file's compressed bytes, and its `len` is the length of those bytes; the per-file checksum -sits at `offset + len`. +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. Keeping them straight avoids a -common confusion. +The format uses two different hashing schemes for two different jobs. -The **internal checksums** in the header, index, and per-file data are all **xxHash-32**. They guard -**storage integrity**: they catch a file that was truncated, partially written, or corrupted on disk. -xxHash is fast and non-cryptographic, which is exactly what you want for a corruption check. +**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 entirely. It is a **SHA-2** hash computed over the -serialized bytes of a downloaded bundle, so a client can confirm it received exactly what the server -published. The algorithms are `sha256` (the default), `sha384`, and `sha512`, serialized as -`:`, for example `sha384:Ws2q…`. +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 internal 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. +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 diff --git a/content/docs/guide/index.mdx b/content/docs/guide/index.mdx index fa44f31..0d3158b 100644 --- a/content/docs/guide/index.mdx +++ b/content/docs/guide/index.mdx @@ -1,28 +1,63 @@ --- title: Introduction -description: An offline-first web resource delivery system for webview-based frameworks and platforms. +description: An offline-first web application deployment system for webview-based frameworks and platforms. --- -Webview Bundle (`wvb`) is an offline-first web resource delivery system for webview-based -frameworks and platforms. Instead of fetching your web app over the network, it packs your built -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. Configure a remote, and your -app downloads newer bundles over the air (OTA = over-the-air) — delivering updated app code without -a native app-store release. One `.wvb` format runs on every webview platform via a shared Rust core. - -```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 │ ──────────────┘ - └───────────────┘ +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. + +![Build output is packed into a .wvb archive, served to the webview, and updated over the air from a remote server.](/diagrams/pack-serve-update.svg) + +## 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? @@ -35,6 +70,25 @@ a native app-store release. One `.wvb` format runs on every webview platform via - **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 @@ -124,6 +178,16 @@ The core is a Rust crate. Each platform consumes it through a thin integration p | `@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. The Android (Kotlin) and iOS (Swift) bindings are built from the core via UniFFI and diff --git a/content/docs/guide/platform-integration.mdx b/content/docs/guide/platform-integration.mdx index e34310b..4754f67 100644 --- a/content/docs/guide/platform-integration.mdx +++ b/content/docs/guide/platform-integration.mdx @@ -3,11 +3,11 @@ 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, the bundle source, the protocol handlers, the remote client, the updater, and the integrity and signature checks all live in a single Rust crate. Every platform consumes that same crate through a thin binding, so a `.wvb` produced for Electron behaves identically on Tauri, Android, iOS, and Deno. This page explains how the core reaches each host and how your web app talks back to the native side through the bridge. Read it before the individual platform guides — those guides assume you know which package belongs where. +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` (published on [crates.io](https://crates.io/crates/wvb), version 0.2.0) is the single source of truth. It implements: +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), @@ -16,15 +16,15 @@ The Rust crate `wvb` (published on [crates.io](https://crates.io/crates/wvb), ve - the updater (check, download, verify, install), - integrity (SHA-2) and signature verification. -Each platform binding is intentionally thin. It does not reimplement the format or the update logic. It exposes the core to the host's language and runtime, registers a URL scheme with that host, and forwards requests into the core. When the core gains a feature or a fix, every platform inherits it on the next binding release. You can browse the core API on [docs.rs/wvb](https://docs.rs/wvb). +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 a URL scheme or validates one. Picking a scheme such as `app://` or `bundle://` and registering it with the operating system is the binding's job. See [Protocol handling](/docs/guide/protocol-handling) for how a request maps to a file. +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. The binding language and the package you install depend on the host. +Each platform reaches the core through a different distribution channel. | Platform | Binding | How it is shipped | |---|---|---| @@ -36,34 +36,134 @@ Each platform reaches the core through a different distribution channel. The bin ### Electron and Node.js -`@wvb/node` (version 0.1.0 on npm) wraps the core as an N-API native addon built with NAPI-RS. It ships prebuilt binaries for the common targets, so you install it like any npm dependency and get the native `.node` addon for your platform. +`@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 ``` -For Electron specifically, use `@wvb/electron` (version 0.1.0), which builds on `@wvb/node` and handles scheme privileges, protocol registration, and the IPC channel for you. The two 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). +```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` (version 0.1.0 on crates.io). There is no `@wvb/tauri` npm package — you add the crate to your `src-tauri` project and register it as a Tauri v2 plugin. +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" ``` -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`. See the [Tauri guide](/docs/guide/platforms/tauri). +```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 native libraries plus language bindings, packaged as GitHub release assets: +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: `webview-bundle-android` (Kotlin) and `webview-bundle-ios` (Swift). They resolve assets by release tag — stable releases use `ffi/` (for example `ffi/0.1.0`) and prereleases use `prerelease/`. +Two dedicated repositories consume these assets and resolve them by release tag — stable releases use `ffi/`, prereleases use `prerelease/`. + +```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 — install from source (pre-release)" +.binaryTarget( + name: "WebViewBundleFFI", + url: "https://github.com/webview-bundle/webview-bundle/releases/download/ffi/0.1.0/WebViewBundleFFI.xcframework.zip", + checksum: "" +) +``` Android and iOS are implemented and end-to-end tested, but the bindings are pre-release. They are not yet published to Maven Central, and there is no Swift Package Manager tag — install from source for now. The minimum supported iOS version is iOS 16. The currently committed xcframework contains a simulator-only slice; the device slice is pending. See the [Android](/docs/guide/platforms/android) and [iOS](/docs/guide/platforms/ios) guides. @@ -71,11 +171,21 @@ Android and iOS are implemented and end-to-end tested, but the bindings are pre- ### Deno -`@wvb/deno` reaches the core through Deno FFI over a prebuilt dynamic library. 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). +`@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` (version 0.1.0 on npm), is the web side. It lets JavaScript running inside the webview call native source, remote, and updater operations through one uniform API, regardless of which host renders the page. +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()`: @@ -85,11 +195,7 @@ import { invoke } from '@wvb/bridge'; const update = await invoke('updaterGetUpdate', { bundleName: 'app' }); ``` -You rarely call `invoke()` by name. The bridge groups the native commands into three typed objects, each method of which is a typed `invoke()` call: - -- `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. +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'; @@ -102,6 +208,10 @@ if (info.isAvailable) { } ``` +- `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 | @@ -111,37 +221,33 @@ The bridge detects the host at runtime and routes each call over the right trans | Android | `window.wvbAndroid.postMessage(...)` | | iOS | `window.webkit.messageHandlers.wvbIos.postMessage(...)` | -Detection and transport are internal — 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. +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'); +``` -Import `@wvb/bridge/testing` to test webview code without a native host. It provides `mockInvoke`, `mockPlatform`, and `mockBridge` helpers so you can stub command responses in unit tests. +`@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. -```text - ┌─────────────────────────────────────────────┐ - │ web app (HTML / JS in the webview) │ - │ @wvb/bridge invoke() │ - └───────────────────────┬─────────────────────┘ - │ (electron / tauri / android / ios) - ┌───────────────────────▼─────────────────────┐ - │ native host │ - │ @wvb/node | wvb-tauri | UniFFI | @wvb/deno │ - └───────────────────────┬─────────────────────┘ - │ - ┌───────────────────────▼─────────────────────┐ - │ wvb core (Rust) │ - │ source · protocol · remote · updater │ - └──────────┬──────────────────────┬────────────┘ - │ │ - ┌─────────▼─────────┐ ┌────────▼───────────┐ - │ source │ │ remote server │ - │ (builtin/remote) │ │ (OTA bundles) │ - └───────────────────┘ └─────────────────────┘ -``` +![A web app calls the native host through @wvb/bridge; the host embeds the wvb Rust core, which reads from a source and a remote server.](/diagrams/architecture.svg) 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. diff --git a/content/docs/guide/platform-support.mdx b/content/docs/guide/platform-support.mdx index 25bf0f2..1a09670 100644 --- a/content/docs/guide/platform-support.mdx +++ b/content/docs/guide/platform-support.mdx @@ -3,50 +3,124 @@ title: Platform Support description: Which platforms run Webview Bundle, the package to install for each, and where each one sits on the road to 1.0. --- -Webview Bundle runs the same `.wvb` archive on every platform that provides a webview. One shared -Rust core powers each integration, so a bundle you pack once loads identically in 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). +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 to install per platform, its minimum host -version, and how close that integration is to a stable release. +This page is the support matrix: the package or crate per platform, its minimum host version, and how close each integration is to a stable release. ## Support matrix | Platform | Webview host | Package / crate | Min version | Status | | --- | --- | --- | --- | --- | -| [Electron](/docs/guide/platforms/electron) | Chromium | `@wvb/electron` 0.1.0 (npm) | — | Stable (pre-1.0) | +| [Electron](/docs/guide/platforms/electron) | Chromium | `@wvb/electron` 0.1.0 (npm) | `electron >= 15` | Stable (pre-1.0) | | [Tauri desktop](/docs/guide/platforms/tauri) | System WebView | `wvb-tauri` 0.1.0 (crate) | Tauri v2 | Stable (pre-1.0) | | Tauri mobile | System WebView | `wvb-tauri` 0.1.0 (crate) | See [Android](/docs/guide/platforms/android) / [iOS](/docs/guide/platforms/ios) | Pre-release | | [Android](/docs/guide/platforms/android) | System WebView | `webview-bundle-android` (Kotlin) | minSdk 24 / Android 7.0 | Pre-release — not yet on Maven Central | | [iOS](/docs/guide/platforms/ios) | WKWebView | `webview-bundle-ios` (Swift) | iOS 16 / macOS 12 | Pre-release — no SPM tag yet | | [Deno Desktop](/docs/guide/platforms/deno) | Deno webview | `@wvb/deno-desktop` 0.0.0 (JSR) | — | Experimental | -All integrations are still pre-1.0, so APIs may change between minor versions. +All integrations are pre-1.0, so APIs may change between minor versions. ## Desktop -Electron and Tauri desktop are the most mature integrations. Both are published — `@wvb/electron` on -npm and the `wvb-tauri` crate on crates.io — and both serve bundles through a custom URL scheme backed -by the Rust core. +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** ships as the `@wvb/electron` package and runs on `electron >= 15`. Start with the - [Electron guide](/docs/guide/platforms/electron). -- **Tauri** ships as the `wvb-tauri` Rust crate (there is no `@wvb/tauri` npm package) and targets - **Tauri v2**. The same crate also drives Tauri's mobile targets. Start with the - [Tauri guide](/docs/guide/platforms/tauri). +### 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, exercised by end-to-end tests against a live remote. -They are **pre-release**: neither has a published artifact yet, so you install them from source for now. +Android and iOS are real, functional integrations, exercised by end-to-end tests against a live remote. Both are **pre-release**: no published artifact yet, so you install from source. + +### 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 from source, 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")!)) +``` -- **Android** lives in the `webview-bundle-android` Kotlin library, namespace `dev.wvb`. It requires - **minSdk 24 (Android 7.0)** and serves bundles through a `WebViewClient`. See the - [Android guide](/docs/guide/platforms/android). -- **iOS** lives in the `webview-bundle-ios` Swift package. Its minimum deployment targets are - **iOS 16** and **macOS 12**, and it serves bundles through a `WKURLSchemeHandler`. See the - [iOS guide](/docs/guide/platforms/ios). +See the [iOS guide](/docs/guide/platforms/ios). Android and iOS are functional and end-to-end tested, but **pre-release**. The Android library is @@ -57,9 +131,7 @@ They are **pre-release**: neither has a published artifact yet, so you install t ## Deno Desktop -Deno Desktop is the newest integration, distributed as the `@wvb/deno-desktop` package on JSR -(version 0.0.0). It builds a bundle source and exposes a `Deno.serve`-compatible handler, with one -protocol per window. +Deno Desktop is the newest integration, on JSR as `@wvb/deno-desktop` (version 0.0.0). It builds a bundle source and exposes a `Deno.serve`-compatible handler, with one protocol per window. Deno Desktop is **experimental** and offered as a preview. Treat it as not yet production-ready, and diff --git a/content/docs/guide/platforms/android.mdx b/content/docs/guide/platforms/android.mdx index 7206823..e738b35 100644 --- a/content/docs/guide/platforms/android.mdx +++ b/content/docs/guide/platforms/android.mdx @@ -3,16 +3,14 @@ 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`. You give it a `WebView`, it intercepts requests over ordinary `https://.wvb/` URLs and serves files from a bundle you ship in the APK, and it can pull newer bundles over the air (OTA) from a remote without an app-store release. The library lives in its own Kotlin repository and consumes a prebuilt native binding from the core repo; this page covers requirements, a quick start, how serving works, builtin bundles, protocols, and OTA updates. +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. -**Pre-release.** The library is not yet published to Maven Central. There is no release tag yet, so the Maven coordinates and version below describe the intended artifact, not something you can resolve today. For now, build and consume it from the [`webview-bundle-android`](https://github.com/webview-bundle/webview-bundle-android) repository. The native FFI is pinned in a `.ffi-version` file and installed by `scripts/install.mjs`, which downloads the `android.zip` asset from a [core repo](https://github.com/webview-bundle/webview-bundle) release and unpacks the Kotlin bindings and `jniLibs` into the library module. +**Pre-release.** Not yet published to Maven Central. There is no release tag, so the coordinates below describe the intended artifact, not something you can resolve today. For now, build from the [`webview-bundle-android`](https://github.com/webview-bundle/webview-bundle-android) repository. The native FFI is pinned in `.ffi-version` and installed by `scripts/install.mjs`, which downloads the `android.zip` asset from a [core repo](https://github.com/webview-bundle/webview-bundle) release and unpacks the Kotlin bindings and `jniLibs` into the library module. ## Requirements -The library targets a modern Android baseline: - | Item | Value | | --- | --- | | `minSdk` | 24 (Android 7.0) | @@ -21,11 +19,11 @@ The library targets a modern Android baseline: | System WebView | Must support `WEB_MESSAGE_LISTENER` (modern WebView / Chrome 88+) | | Library namespace | `dev.wvb` | -The bridge that connects your web app to the native host attaches through `WebViewCompat.addWebMessageListener`. On a device whose System WebView is too old to support `WEB_MESSAGE_LISTENER`, the bridge is not attached and the library logs a warning. Serving still works; only the JavaScript bridge is unavailable. +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 need to add keep rules for the FFI yourself. +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. -Once a release is cut, the intended Maven coordinates are: +Once a release is cut, the intended coordinates are: ```kotlin title="build.gradle.kts" dependencies { @@ -33,8 +31,26 @@ dependencies { } ``` +Until then, build from source. Clone the repo, install the pinned FFI, and build the `:lib` module: + +```sh +git clone https://github.com/webview-bundle/webview-bundle-android +cd webview-bundle-android +node scripts/install.mjs # installs the FFI pinned in .ffi-version +./gradlew :lib:assembleRelease +``` + +`install.mjs` fetches the `android.zip` release asset and unpacks the Kotlin bindings into `lib/src/main/kotlin/` and the JNI libs into `lib/src/main/jniLibs/`. Pin a different FFI version per invocation: + +```sh +node scripts/install.mjs # use the version in .ffi-version +node scripts/install.mjs 0.1.0 # resolves to tag ffi/0.1.0 +node scripts/install.mjs latest # highest ffi/* release +node scripts/install.mjs --prerelease a3f693a # prerelease/ +``` + -Until the artifact is on Maven Central, clone the repo and follow its README to install the pinned FFI (`node scripts/install.mjs`) and build the `:lib` module locally. See [Platform support](/docs/guide/platform-support) for the status of every platform. +`install.mjs` requires `unzip` on `PATH` and honors `GITHUB_TOKEN` / `GH_TOKEN`. See [Platform support](/docs/guide/platform-support) for the status of every platform. ## Quick start @@ -86,9 +102,16 @@ class MainActivity : AppCompatActivity() { } ``` -`getInstance` honors `config` only on the first call per process; later calls return the existing instance and ignore the passed config. Call `handle.close()` and `webView.destroy()` when the `WebView` goes away. +`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 +``` -Your manifest needs the internet permission. For local development against a cleartext dev server or remote, also enable `usesCleartextTraffic`. +The manifest needs the internet permission. For local development against a cleartext dev server or remote, also enable `usesCleartextTraffic`. ```xml title="AndroidManifest.xml" @@ -105,13 +128,11 @@ Your manifest needs the internet permission. For local development against a cle ## How serving works -The library does not register a custom URL scheme. Instead, `install()` sets a custom `WebViewClient` whose `shouldInterceptRequest` inspects ordinary `http`/`https` requests and serves matching ones from the bundle source. +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. -For each request, the handler lowercases the host and walks your registered protocols in order; the first protocol whose matcher accepts the host wins. The bundle protocol takes the **bundle name from the first host label** (`app.wvb` resolves to bundle `app`) and returns the file at the request path. When no protocol matches, the handler returns `null` and the `WebView` loads the request from the network as usual. +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`. -If a handler throws while serving, the library synthesizes a `500 text/plain` response and calls the optional `onError` callback you pass in `WebViewBundleConfig`. 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, unless you opt out. Your web app posts a message with a command name and params, and the bridge dispatches to a registered handler. To register extra native handlers or to skip the bridge, use the install options: +`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) { @@ -123,9 +144,15 @@ val handle = wvb.install(webView) { } ``` +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/`. That directory holds a `manifest.json` plus the `.wvb` files it references. +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/ @@ -134,18 +161,50 @@ app/src/main/assets/bundles/ └── app_0.1.0.wvb ``` -On install, the native source reads files rather than asset streams, so the library copies `assets/bundles/` into the app's `filesDir` (the builtin directory). This extraction is controlled by `SourceOptions.builtinAssetsDir`, which defaults to `"bundles"`; set it to `null` to disable extraction. Re-extraction happens on each APK install or update and is additive — removed assets are not deleted. +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": "..." + } + } + } + } +} +``` -The `manifest.json` declares the bundle entries and the current version, and may carry `integrity` and `signature` values for the bundles it ships. See [Bundle sources](/docs/guide/bundle-sources) for the manifest format. +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. +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 of the bundle source. +`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 { @@ -158,7 +217,7 @@ WebViewBundleProtocol.bundle { -`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. +`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( @@ -182,7 +241,7 @@ WebViewBundleConfig( ## OTA updates -To enable over-the-air 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 are `null` when no updater config is provided. +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 @@ -218,7 +277,15 @@ val wvb = WebViewBundle.getInstance( ) ``` -`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 — `ED25519` accepts `SPKI_DER`, `SPKI_PEM`, or `RAW` (a 32-byte key supplied via `der`); an unsupported pair throws when the updater is built. +`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. @@ -226,21 +293,44 @@ On the Android emulator, a remote running on the host machine is reachable at `h ### Driving updates from Kotlin -The updater runs a three-step cycle. Each call is a `suspend` function, so call them from a coroutine. +The updater runs a three-step cycle. Each call is a `suspend` function, so call it from a coroutine. ```kotlin -val update = wvb.updater?.getUpdate("app") // check the remote, no download -if (update?.isAvailable == true) { - wvb.updater?.downloadUpdate("app") // download latest, persist to remote dir - wvb.updater?.install("app", update.version) // verify, activate, prune old versions +import kotlinx.coroutines.launch + +lifecycleScope.launch { + val update = wvb.updater?.getUpdate("app") // check the remote, no download + if (update?.isAvailable == true) { + 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 it. `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. +`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`. These throw `updater_not_initialized` when no updater config was provided. Use the bridge when your update UI lives in the web layer. +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). diff --git a/content/docs/guide/platforms/deno.mdx b/content/docs/guide/platforms/deno.mdx index 314b7f7..0fc1794 100644 --- a/content/docs/guide/platforms/deno.mdx +++ b/content/docs/guide/platforms/deno.mdx @@ -3,41 +3,35 @@ title: Deno Desktop description: Serve and update Webview Bundle archives in a Deno desktop webview through a Deno.serve request handler. --- -Deno Desktop renders a native webview with `Deno.BrowserWindow` and serves it over `Deno.serve`. -Webview Bundle plugs in as the request handler, so the window loads your packed `.wvb` assets offline -instead of fetching them over the network. The `@wvb/deno-desktop` integration builds the bundle -source, wires an optional remote and updater, and exposes a `Deno.serve`-compatible `fetch` handler. -Underneath, `@wvb/deno` is the foreign-function-interface (FFI) peer of `@wvb/node` that drives the -shared Rust core. +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 (preview)" +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 / preview** and not production-ready. The integration source currently -lives on an un-merged branch, and `main` ships only a prebuilt dylib artifact. The JSR packages -`@wvb/deno` and `@wvb/deno-desktop` are published at version **`0.0.0`**, and their APIs may change. -Treat the examples below as preview snippets, not a stable contract. +Deno Desktop is **experimental / preview** and not production-ready. The source lives on an un-merged branch; `main` ships only a prebuilt dylib artifact. The JSR packages `@wvb/deno` and `@wvb/deno-desktop` are published at version **`0.0.0`** and their APIs may change. Treat these snippets as preview, not a stable contract. -## What it is +## How it fits together -A Deno desktop app opens a window with `Deno.BrowserWindow` and points it at a local server started by -`Deno.serve`. Webview Bundle becomes that server's handler: every request the window makes is answered -from a `.wvb` bundle on disk. Because the window talks to a single local origin, the integration is -**single-origin** — it allows exactly one protocol. See -[Platform integration](/docs/guide/platform-integration) for how the shared core reaches each platform, -and [Protocol handling](/docs/guide/protocol-handling) for the protocol model. +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 -`@wvb/deno-desktop` (`0.0.0`, experimental) is the desktop integration layer. It ties `@wvb/deno` to -Deno's desktop runtime and to the [bridge](/docs/guide/platform-integration). - -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 `fetch` handler you can hand -straight to `Deno.serve`. Because Deno desktop is single-origin, the config accepts **exactly one** -protocol. +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 (preview)" -// Preview: @wvb/deno-desktop is experimental (0.0.0) and the API may change. import { webviewBundle, bundleProtocol } from "@wvb/deno-desktop"; const wvb = await webviewBundle({ @@ -48,17 +42,13 @@ const wvb = await webviewBundle({ }); 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 the native commands to your web app. It registers a single -`Deno.BrowserWindow` binding named `wvbInvoke`, reachable from the page as `window.bindings.wvbInvoke`. -That one binding dispatches every `@wvb/bridge` command in the `source.*`, `remote.*`, and `updater.*` -groups. +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 (preview)" import { webviewBundle, bundleProtocol, registerBindings } from "@wvb/deno-desktop"; @@ -76,43 +66,71 @@ await win.closed; ``` The binding never throws across the FFI boundary. Each call resolves to an `InvokeResult` envelope: -`{ ok: true, value }` on success, or `{ ok: false, error: { code?, message } }` on failure. The -`@wvb/bridge` client unwraps this envelope for you when it detects the `deno` platform, so web code -keeps calling `invoke()`, `source.*`, `remote.*`, and `updater.*` exactly as on other platforms. + +```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, preview)" +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` (`0.0.0`, experimental) is the FFI peer of [`@wvb/node`](/docs/references/node). It loads a -prebuilt Rust `cdylib` through `Deno.dlopen` and exposes the same core surface: the classes -`BundleProtocol`, `LocalProtocol`, `Remote`, `BundleSource`, and `Updater`. Each class is `Disposable` -— free it with `free()` or a `using` declaration that calls `[Symbol.dispose]`. +`@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 (preview)" +// Explicit cleanup. +const protocol = bundleProtocol({ scheme: "app" }); +const res = await protocol.handle("get", "app://my-app/index.html"); +protocol.free(); + +// Or scope-bound cleanup with `using`. +{ + using scoped = bundleProtocol({ scheme: "app" }); + await scoped.handle("get", "app://my-app/index.html"); +} // [Symbol.dispose]() runs here +``` ### Load the native library -Load the `cdylib` one of three ways: +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 (preview)" import { loadLib, loadLibViaPlug } from "@wvb/deno"; -// 1. Load from an explicit path or URL. +// 1. Load a library already on disk. const lib = loadLib("./vendor/wvb/libwvb_deno.dylib"); -// 2. Download a sha256-verified prebuilt via @denosaurs/plug. +// 2. Download a sha256-verified prebuilt at runtime. const libViaPlug = await loadLibViaPlug(); ``` -`loadLib(libPath)` opens a library you already have on disk. `loadLibViaPlug(options?)` downloads the -prebuilt library and verifies it against its SHA-256 checksum before loading. You can also set the -`WVB_DENO_LIB` environment variable to point at a library file. +Point `loadLib` at a file with the `WVB_DENO_LIB` environment variable instead of hard-coding the path: -The third option vendors the library ahead of time with the installer subcommand: +```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. The supported targets are: +`@wvb/deno/install` downloads the cdylib from GitHub Releases and verifies its SHA-256 checksum by default. Supported targets: | Target triple | |---| @@ -122,20 +140,51 @@ default. The supported targets are: | `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: +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 (preview)" +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: -- **Custom verifier callbacks are not supported.** The `Updater` accepts only the **declarative** - `signatureVerifier` (a `SignatureVerifierOptions` with an `algorithm` and a `key`). 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`. +```ts title="http.ts (preview)" +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). +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.mdx b/content/docs/guide/platforms/electron.mdx index cd14b75..2f8801d 100644 --- a/content/docs/guide/platforms/electron.mdx +++ b/content/docs/guide/platforms/electron.mdx @@ -3,12 +3,12 @@ title: Electron description: Serve your Electron UI from a .wvb bundle through a custom protocol, with dev-server proxying and over-the-air updates. --- -Webview Bundle wires into an Electron app in three moves: serve your UI from a `.wvb` bundle through a custom protocol, proxy to a live dev server while you develop, and (optionally) update bundles over the air without an app-store release. This guide walks through each step with `@wvb/electron`. - -The package and its end-to-end fixtures live in [`packages/electron`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron) — the `e2e/fixtures/app` directory there is a minimal, runnable main-process setup you can read alongside this guide. +`@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. The runnable [`e2e/fixtures/app`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron) is a minimal main-process setup you can read alongside this guide. ## 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 @@ -30,11 +30,9 @@ yarn add -D @wvb/cli -`@wvb/electron` depends on `@wvb/node`, the native N-API binding that ships prebuilt binaries for common platforms. No Rust toolchain is required to consume it. `@wvb/cli` is a dev dependency you use to pack bundles. `@wvb/electron` requires Electron 15 or newer. - ## Register the protocol in the main process -Call `wvb(...)` (an alias of `webviewBundle(...)`) **before** any window loads. It registers your custom schemes as privileged, builds the bundle source, and wires the protocol handlers and IPC. +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'; @@ -42,22 +40,16 @@ import { app, BrowserWindow } from 'electron'; import { bundleProtocol, localProtocol, wvb } from '@wvb/electron'; const instance = wvb({ - // Where bundles live on disk. Defaults are Electron-aware (see "Where bundles live"). source: { builtinDir: path.join(process.resourcesPath, 'bundles'), }, protocols: [ - // In development, proxy `app-local://simple.wvb/...` to the Vite dev server so you - // keep hot reload. `MAIN_WINDOW_VITE_DEV_SERVER_URL` is injected by Forge's Vite plugin. + // Dev: proxy `app-local://simple.wvb/...` to the Vite dev server for hot reload. localProtocol('app-local', { - hosts: { - 'simple.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL, - }, - }), - // In production, serve `app://.wvb/...` straight from the bundle. - bundleProtocol('app', { - onError: e => console.error('[wvb]', e), + hosts: { 'simple.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL }, }), + // Prod: serve `app://.wvb/...` straight from the bundle. + bundleProtocol('app', { onError: e => console.error('[wvb]', e) }), ], }); @@ -72,25 +64,40 @@ async function createWindow() { nodeIntegration: false, }, }); - // `app://.wvb/` — the host label before `.wvb` is the bundle name. await win.loadURL('app://simple.wvb'); } app.whenReady().then(createWindow); ``` -- **`bundleProtocol(scheme, options?)`** serves files directly out of bundles in the source. A URL like `app://simple.wvb/index.html` resolves to bundle `simple`, file `/index.html`. -- **`localProtocol(scheme, { hosts })`** proxies matching hosts to a localhost dev server instead. `hosts` is required: a `Record` (or a function returning one) mapping a host to a dev-server URL. Use it during development so you keep the same scheme while getting your bundler's hot reload. +URL shape is `://.wvb/` — `app://simple.wvb/index.html` resolves to bundle `simple`, 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://simple.wvb' : 'app-local://simple.wvb'); +``` -`whenProtocolRegistered()` resolves after every protocol is registered and `app.whenReady()` has fired, so await it before navigating. A common pattern is to register both a `bundleProtocol` and a `localProtocol` and choose which URL to load based on `app.isPackaged`. +`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 sensible defaults (`standard`, `secure`, `bypassCSP`, `allowServiceWorkers`, `supportFetchAPI`, `corsEnabled`, `codeCache` all on). Override them per protocol with the `privileges` option. See [Protocol handling](/docs/guide/protocol-handling) for the full model. + 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 small, safe transport into the renderer that the bridge uses for source, remote, and updater calls. +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'; @@ -98,11 +105,11 @@ import { preload } from '@wvb/electron/preload'; preload(); ``` -Point your `BrowserWindow`'s `webPreferences.preload` at the compiled preload (as above), and keep `contextIsolation: true` with `nodeIntegration: false`. +Point `webPreferences.preload` at the compiled preload and keep `contextIsolation: true` with `nodeIntegration: false`. ## Call the API from the renderer -To let the UI drive updates — for example a "Check for updates" button — import the bridge in your renderer code. It forwards to the main process over IPC, so it only works when the preload script is loaded. +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'; @@ -113,31 +120,28 @@ const current = await source.loadVersion('app'); // Is a newer version deployed on the remote? const update = await updater.getUpdate('app'); if (update) { - // Download + verify, stage into the remote source, then activate. - const downloaded = await updater.download('app'); - await updater.install('app', downloaded.version); - // Reload the window to pick up the new bundle — that step is your app's responsibility. + 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. } ``` - The renderer-facing `source`, `remote`, and `updater` objects come from `@wvb/bridge`, which auto-detects the Electron transport that `@wvb/electron/preload` installs. The exact shapes of `getUpdate`/`download` results are documented in the [Node API reference](/docs/references/node). + `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 from the instance returned by `wvb(...)`: +The same surfaces are reachable in the main process on the instance returned by `wvb(...)`: ```ts -const instance = wvb({ /* … */ }); - -instance.source; // BundleSource -instance.remote; // Remote | null (null unless `updater` is configured) +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 server. 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. +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({ @@ -145,36 +149,48 @@ wvb({ updater: { remote: { endpoint: 'https://updates.example.com' }, channel: 'stable', - // integrity and signature verification options also live here: + // integrity and signature verification also live here: // integrityPolicy, integrityChecker, signatureVerifier }, protocols: [bundleProtocol('app')], }); ``` -See [Remote bundles](/docs/guide/remote-bundles) for integrity and signature verification, and [Building a remote](/docs/guide/remote) for how to stand a server up — including a local one for testing. +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). The defaults are Electron-aware: +`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 always 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 how builtin and remote bundles resolve. +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 a bundle and place it in the directory you configured as `builtinDir`: +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 ``` -When packaging the app, include the `bundles` directory as an extra resource and unpack the native `@wvb/node` binary from the ASAR archive. With Electron Forge: +When packaging, ship the `bundles` directory as an extra resource and unpack the native `@wvb/node` binary from the ASAR archive. With Electron Forge: ```ts title="forge.config.ts" const config: ForgeConfig = { @@ -190,25 +206,55 @@ const config: ForgeConfig = { ``` - The Forge Vite plugin can exclude `node_modules` from the package, which drops the native module. If you hit a missing `@wvb/node` binary at runtime, override `packagerConfig.ignore` to keep `node_modules` so the native module is bundled. + The Forge Vite plugin can exclude `node_modules` from the package, dropping the native module. If you hit a missing `@wvb/node` binary at runtime, override `packagerConfig.ignore` to keep `node_modules`. ## Forge and electron-builder integrations -Two helper packages automate installing builtin bundles at package time, so you do not stage `.wvb` files by hand: +Two helper packages install builtin bundles at package time, so you do not stage `.wvb` files by hand. + +[`@wvb/electron-forge`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-forge) — an Electron Forge plugin (`WebviewBundlePlugin`, alias `WvbPlugin`) hooking `packageAfterCopy`: + +```ts title="forge.config.ts" +import { WebviewBundlePlugin } from '@wvb/electron-forge'; + +export default { + plugins: [ + new WebviewBundlePlugin({ bundlesDir: 'bundles', channel: 'beta' }), + ], +}; +``` + +[`@wvb/electron-builder`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-builder) — an `afterPack` integration. Wrap your config with `withWebviewBundle(...)` (alias `withWvb`): + +```ts title="electron-builder.config.ts" +import { withWebviewBundle } from '@wvb/electron-builder'; + +export default withWebviewBundle({ + appId: 'com.example.app', + asar: true, + mac: { target: 'dmg' }, +}); +``` + +Or compose the raw `webviewBundleAfterPack(...)` hook (alias `wvbAfterPack`) yourself. Both plugins share these options: -- **[`@wvb/electron-forge`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-forge)** — an Electron Forge plugin (`WebviewBundlePlugin`) that hooks `packageAfterCopy`, installs your configured builtin bundles, and copies them into the packaged app's resources. -- **[`@wvb/electron-builder`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-builder)** — an electron-builder `afterPack` integration. Wrap your config with `withWebviewBundle(...)` (alias `withWvb`), or compose the raw `webviewBundleAfterPack(...)` hook (alias `wvbAfterPack`) yourself. +| Option | Type | Default | Meaning | +| --- | --- | --- | --- | +| `bundlesDir` | `string` | `'bundles'` | dest dir under packaged `Resources` | +| `configFile` | `string \| boolean` | `true` | `true` auto-discovers + merges; a path loads explicitly; `false` is inline only | +| `channel` | `string` | — | release channel to install from | +| `throwWhenBuiltinIsEmpty` | `boolean` | `true` | throw if zero bundles installed | - Both packages are in the repository but not yet published to npm. Until they are, install from source or pin the workspace versions. The manual `extraResource` + `AutoUnpackNativesPlugin` setup above works without them. + Both packages live in the repository but are not yet published to npm. Install from source or pin the workspace versions for now. The manual `extraResource` + `AutoUnpackNativesPlugin` setup above works without them. ## Troubleshooting -- **Blank window or `ERR_FAILED`** — confirm `wvb(...)` runs and `whenProtocolRegistered()` resolves before `loadURL`, and that the bundle name in the URL matches the packed file (`app://simple.wvb` → bundle `simple`). -- **Renderer cannot reach the bridge API** — the preload script is not loaded. Check `webPreferences.preload` points at the compiled preload and that it calls `preload()`. -- **`remote_not_initialized` / `updater_not_initialized`** — the renderer called a `remote.*` or `updater.*` method but no `updater` block was passed to `wvb(...)`. Add the [over-the-air updates](#configure-over-the-air-updates) config. +- **Blank window or `ERR_FAILED`** — confirm `wvb(...)` runs and `whenProtocolRegistered()` resolves before `loadURL`, and the URL's bundle name matches the packed file (`app://simple.wvb` → bundle `simple`). +- **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. Verify `extraResource` and `AutoUnpackNativesPlugin`. ## Next steps diff --git a/content/docs/guide/platforms/ios.mdx b/content/docs/guide/platforms/ios.mdx index ae3d9da..265c777 100644 --- a/content/docs/guide/platforms/ios.mdx +++ b/content/docs/guide/platforms/ios.mdx @@ -4,17 +4,14 @@ description: Serve and update Webview Bundle archives in a WKWebView using the w --- 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. You register a scheme such as `app`, -point a `WKWebView` at `app://app.wvb`, and the package answers every request from the bundle on -disk. Your web app runs offline-first, and the updater downloads newer bundles in the background -without an App Store release. +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. -The iOS package is **pre-release**. There is no published Swift Package Manager version or git tag -yet, so you install the native FFI from source. Run `node scripts/install.mjs` to wire a release into -the package: it extracts the Swift bindings from the release `apple.zip` asset and resolves the -SHA-256 checksum of `WebViewBundleFFI.xcframework.zip`. Releases are tagged `ffi/` (for -example `ffi/0.1.0`); prereleases are tagged `prerelease/`. +The iOS package is **pre-release**: no published Swift Package Manager version or git tag yet, so you +install the native FFI from source (see below). Releases are tagged `ffi/` (for example +`ffi/0.1.0`); prereleases are tagged `prerelease/`. @@ -25,44 +22,59 @@ device-bearing `WebViewBundleFFI.xcframework` is installed. Develop against the ## Requirements -- iOS 16 or newer, macOS 12 or newer. `Package.swift` declares `platforms: [.macOS(.v12), .iOS(.v16)]`. -- Swift tools 6.1 (`swift-tools-version: 6.1`), Swift language mode 6. -- The SwiftPM product to depend on is **`WebViewBundle`**. +| 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. +Because the package is pre-release, clone it and depend on it locally (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 -Clone the package and resolve the native binary from a release before you build: +Resolve the native binary from a release before you build: ```sh -# Install the latest release (resolves the highest ffi/* tag) -node scripts/install.mjs latest - -# Pin a specific release -node scripts/install.mjs 0.1.0 # -> tag ffi/0.1.0 -node scripts/install.mjs ffi/0.1.0 # explicit release tag - -# Install a prerelease by commit sha -node scripts/install.mjs --prerelease a3f693a # -> tag prerelease/a3f693a +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 +node scripts/install.mjs --prerelease a3f693a # -> tag prerelease/a3f693a ``` -The script reads the GitHub release for the resolved tag and does two things: +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 the archive — and writes the checksum - and tag into `Package.swift`. +- 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`. -The script needs `unzip` on your `PATH`. Set `GITHUB_TOKEN` or `GH_TOKEN` for a private source repo, -and pass `--repo ` to override the default `webview-bundle/webview-bundle`. +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 the package once, register its scheme on a `WKWebViewConfiguration`, and load the entry -URL. Use a **custom scheme** — `http` and `https` are reserved and rejected at init. +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 @@ -90,59 +102,95 @@ 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. `install(on:)` registers the -scheme handler and the bridge on the configuration you pass. The entry URL `app://app.wvb` selects the -bundle named `app` (see below). If you prefer to skip the manual configuration, call -`instance.makeWebView()` to get a ready-to-use `WKWebView`. +`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`: - -`WebViewBundle.shared` precondition-fails if you read it before calling `configure(_:)`. Use -`WebViewBundle.safeShared`, which returns `nil` when the package has not been configured yet, for a -non-trapping read. - +```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 -The package registers a `WKURLSchemeHandler` for each scheme via -`WKWebViewConfiguration.setURLSchemeHandler(_:forURLScheme:)`. When the webview requests a URL on that -scheme, the handler maps the request to the Rust core, which answers from the bundle on disk. +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 = the first label of the request host.** `app://app.wvb/index.html` resolves to the - bundle `app` and the path `/index.html`. -- **Reserved schemes are rejected at init.** Registering a handler for a native scheme raises an - uncatchable exception, so `http`, `https`, `file`, `ftp`, `ftps`, `ws`, `wss`, `about`, `blob`, - `data`, and `javascript` throw `WebViewBundleError.reservedScheme` instead. -- **A scheme must match `^[a-z][a-z0-9+.-]*$`** and be unique. Empty, malformed, or duplicate schemes - throw `WebViewBundleError`. +- **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`: -Two protocol kinds exist. `.bundle(scheme:)` serves entries from the bundle source. `.local(scheme:, -hosts:)` proxies requests to a local HTTP server, matching the full request host against the `hosts` -map (for example `["myapp": "http://localhost:8080"]`). See -[Protocol handling](/docs/guide/protocol-handling) for the underlying model. +```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 messages to -`window.webkit.messageHandlers.wvbIos`. The bridge accepts **main-frame messages only** — messages -from subframes and iframes are dropped — and exposes the source, remote, and updater commands to your -web code. +`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** — the read-only bundles shipped inside the app. The default builtin directory is the app - bundle's `/bundles` folder. Ship it as a folder reference so the files land at that path. -- **Remote** — the writable directory for downloaded updates. The default remote directory lives under - Application Support, namespaced by the app's bundle identifier. +- **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`: -You can override either path through `SourceOptions` (`builtinDir`, `remoteDir`, -`builtinManifestFilepath`, `remoteManifestFilepath`) 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, the unqualified name `Bundle` resolves to the FFI bundle class, not -`Foundation.Bundle`. Write `Foundation.Bundle` explicitly when you mean the app bundle — for example -to locate resources. +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 @@ -162,18 +210,21 @@ let updaterConfig = WebViewBundleUpdaterConfig( ) ``` -`integrityPolicy` is one of `.strict`, `.optional`, or `.none`. Integrity values are SHA-2 digests -serialized as `sha256:`. The `signatureVerifier` proves who published a bundle. Pick a -`SignatureAlgorithm` (`.ecdsaSecp256r1`, `.ecdsaSecp384r1`, `.ed25519`, `.rsaPkcs1V15`, `.rsaPss`) and -supply a `SignatureVerifyingKey` whose `format` is one of `.spkiDer`, `.spkiPem`, `.pkcs1Der`, -`.pkcs1Pem`, `.sec1`, or `.raw`. Use the `pem` field for text keys and `der` for binary keys. +`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") +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) @@ -181,9 +232,15 @@ if info.isAvailable { ``` `getUpdate` checks the remote for a newer version without downloading. `downloadUpdate` downloads and -persists a version, defaulting to the latest when `version` is `nil`. `install` activates a staged -version, verifies its integrity and signature when configured, then prunes stale versions. The web app -can call `location.reload()` afterward to render the new bundle. +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). diff --git a/content/docs/guide/platforms/tauri.mdx b/content/docs/guide/platforms/tauri.mdx index 3ea3d7e..3de0aa4 100644 --- a/content/docs/guide/platforms/tauri.mdx +++ b/content/docs/guide/platforms/tauri.mdx @@ -3,32 +3,21 @@ 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. --- -The `wvb-tauri` crate 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). - -This page shows how to install the crate, register the plugin, point a window at the custom scheme, -drive updates from the frontend, and reach the bundle source from Rust. +`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` (published on 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`. +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 -Add the plugin crate to your `src-tauri/Cargo.toml` alongside Tauri v2: - ```toml title="src-tauri/Cargo.toml" [dependencies] wvb-tauri = "0.1" tauri = { version = "2", features = [] } ``` -If you drive updates from the frontend, install Tauri's JS API in your web app so you can call plugin -commands: +To drive updates from the frontend, install Tauri's JS API in your web app: @@ -56,9 +45,7 @@ yarn add @tauri-apps/api ## Register the plugin -In `src-tauri/src/lib.rs`, register the plugin with a `Config` that declares a bundle **source**, one -or more **protocols** to serve, and optionally a **remote** for updates. Build the config with -`Config::new()` and chain `.source(...)`, `.protocol(...)`, and `.remote(...)`: +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; @@ -69,8 +56,6 @@ pub fn run() { tauri::Builder::default() .plugin(wvb_tauri::init( Config::new() - // Where bundles live. `builtin_dir_fn` resolves the path from the - // AppHandle at runtime; `builtin_dir` takes a static path string. .source(Source::new().builtin_dir_fn(|app| { Ok(app.path().resource_dir()?.join("bundles")) })) @@ -84,25 +69,36 @@ pub fn run() { } ``` -- **`Protocol::bundle(scheme)`** serves files directly from a packed bundle. The plugin derives the - bundle name from the request host: `bundle://app.wvb/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. Configure multiple hosts with repeated - `.host(...)` calls or a map via `.hosts(...)`. -- **`Source`** accepts `builtin_dir` / `remote_dir` (static path strings, resolved through Tauri's path - API) or `builtin_dir_fn` / `remote_dir_fn` (closures that compute the path from the `AppHandle`). - When unset, the builtin dir defaults to `bundles` in the app's resource directory and the remote dir - to `bundles` in app local data. - -You can call `.protocol(...)` more than once; each configured protocol is registered as its own -asynchronous URI scheme. See [Protocol handling](/docs/guide/protocol-handling) for how the bundle and -local schemes resolve requests. +`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 -Load the bundle scheme in your main window. The most common approach is to set the window URL in -`tauri.conf.json` so the app boots straight into the bundle: +Boot the main window straight into the bundle scheme: ```json title="src-tauri/tauri.conf.json" { @@ -116,9 +112,7 @@ Load the bundle scheme in your main window. The most common approach is to set t } ``` -If you call plugin commands from the frontend, grant the plugin's permissions in a capability file. The -plugin's permission namespace is `wvb-tauri`, and the `wvb-tauri:default` set allows all source, remote, -and updater commands: +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" { @@ -128,26 +122,45 @@ and updater commands: } ``` -To restrict access, replace `wvb-tauri:default` with the individual `wvb-tauri:allow-*` permissions you -need (for example `wvb-tauri:allow-updater-get-update`). +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 the directory you configured as the -source: +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 ``` -Include the `bundles` directory in your app resources so it ships with the build (see -`tauri.conf.json` → `bundle.resources`). For the full packing workflow and flags, see the -[CLI reference](/docs/guide/cli) and [Bundle sources](/docs/guide/bundle-sources). +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 the config to enable OTA downloads, then call the plugin commands from your web app: +Add a `Remote` to enable OTA downloads: ```rust title="src-tauri/src/lib.rs" use wvb_tauri::{Config, Protocol, Remote, Source}; @@ -158,9 +171,21 @@ Config::new() .remote(Remote::new("https://updates.example.com")); ``` -The plugin exposes its commands through Tauri's command bridge as -`plugin:wvb-tauri|`. Arguments use camelCase on the JS side (for example `bundle_name` -becomes `bundleName`). The core OTA flow uses three updater commands: +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 | | ----------------------------- | ------------------------ | ---------------------------------------- | @@ -171,16 +196,41 @@ becomes `bundleName`). The core OTA flow uses three updater commands: ```ts title="src/update.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 }); +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 inspecting and managing local bundles, and `remote_*` -commands for listing and staging remote bundles: +The plugin also exposes `source_*` commands for managing local bundles and `remote_*` commands for listing and staging remote bundles: | Command | Arguments | Returns | | ---------------------- | ------------------------ | ---------------------------------------- | @@ -191,69 +241,101 @@ commands for listing and staging remote bundles: | `remote_get_info` | `bundleName`, `channel?` | current remote metadata | | `remote_download` | `bundleName`, `channel?` | `RemoteBundleInfo` (stages without activating) | -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. +`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`, so the frontend can branch on it. +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()`), which returns the managed state. From there, `.source()` is always available, and -`.remote()` / `.updater()` return `Option` depending on your config: +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(); +let _source = wvb.source(); -if let Some(updater) = wvb.updater() { +if let Some(_updater) = wvb.updater() { // updater is present only when both `.remote(...)` and `.updater(...)` are configured - // run your own update check / install logic here } ``` -This is the same state the frontend commands operate on, so you can mix Rust-side and frontend-driven -update logic. +This is the same state the frontend commands operate on, so you can mix Rust-side and frontend-driven update logic. ## Tauri mobile -The plugin supports Tauri mobile. On **iOS**, builtin bundles live in a real filesystem resource -directory, so no extra setup is needed beyond the desktop configuration. On **Android**, builtin -bundles ship inside the APK as `asset://` resources that the filesystem cannot read directly, so an app -that ships builtin bundles must also register the Tauri filesystem plugin: +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 +```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. +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 for that request. Check - that the bundle exists in the configured source directory and that 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 configuring `.remote(...)` (and `.updater(...)`) on the `Config`. Branch on - `error.code` in the frontend. -- **Command rejected by the ACL** — add `wvb-tauri:default` (or the specific `wvb-tauri:allow-*` - permission) to a capability that targets the calling window. -- **Bundle not found at runtime** — confirm the `bundles` directory is listed in `tauri.conf.json` - resources and that `Source` resolves to it. On Android, confirm `tauri_plugin_fs::init()` is - registered when shipping builtin bundles. +- **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 index 0dfff6a..273bf68 100644 --- a/content/docs/guide/protocol-handling.mdx +++ b/content/docs/guide/protocol-handling.mdx @@ -3,36 +3,45 @@ title: Protocol handling description: How a webview request maps to a file inside a .wvb bundle through the bundle and local protocols. --- -When your webview asks for a URL, something has to turn that request into bytes from a bundle instead of bytes from the network. That something is a protocol handler. Webview Bundle ships two: the **bundle protocol**, which serves files straight out of a `.wvb` source, and the **local protocol**, a development proxy that forwards to your bundler's dev server so you keep hot reload. This page explains how a request is mapped to a file, what the bundle protocol guarantees, and how each platform wires a scheme to the handler. +A protocol handler turns a webview request into bytes from a bundle instead of bytes from the network. Webview Bundle ships two: -The Rust core does not register or validate the scheme. It does not care whether the URL starts with `app://`, `bundle://`, or `https://`. Only the URI **host** and **path** are read. Picking a scheme and registering it with the OS is the platform integration's job. +- **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 bundle protocol - -The bundle protocol resolves a request against a bundle source and returns the matching file. Two parts of the URI drive resolution. +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. -### Mapping a request to a bundle and file +## The bundle protocol -The **bundle name** is the first label of the host, taken up to the first `.`. The **path** is the percent-decoded URI path. +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` the way a static file server does: +Path resolution fills in `index.html` like a static file server: -- A path that ends with `/` resolves to `index.html` in that directory. `/` becomes `/index.html`. -- A last segment with no `.` in it is treated as a directory. `/about` becomes `/about/index.html`. -- A last segment that contains a `.` is served as-is. `/a.js` stays `/a.js`. +```text +/ -> /index.html # trailing slash +/about -> /about/index.html # last segment has no "." +/a.js -> /a.js # last segment has a "." -> served as-is +``` -If the host has no label, the request fails with a bundle-not-found error. If the resolved path is not present in the bundle index, the handler returns **404 Not Found**. +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 -The bundle protocol serves only `GET` and `HEAD`. Any other method returns **405 Method Not Allowed**. A `HEAD` request returns the response headers with an empty body. +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 first replays the HTTP headers stored for that file in the bundle index, then sets `Content-Type` from the index entry and `Content-Length` from the uncompressed size. These two always reflect the index, overriding anything stored in the replayed headers. +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. @@ -40,24 +49,62 @@ Headers are stored per file when the bundle is built, so caching directives and ### Range requests -The bundle protocol supports HTTP range requests, which webviews rely on for seeking media and resuming large downloads. +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 +``` -- A request with a `Range` header gets **206 Partial Content**, an `Accept-Ranges: bytes` header, and a `Content-Range` describing the slice. -- Each returned range is capped at roughly 1 MB (`1000 * 1024` bytes); a larger requested range is truncated. +- 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. -- A range that cannot be satisfied returns **416 Range Not Satisfiable** with `Content-Range: bytes */`. +- 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 file content, so offsets always match the file your build produced. +Ranges are computed over the uncompressed content, so offsets match the file your build produced. ## The local protocol -The local protocol is a development proxy. Instead of reading from a `.wvb` file, it maps a host to a localhost base URL and forwards the request there. You keep the same custom scheme your app uses in production while your bundler serves live, hot-reloading assets. +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 ``` -You provide a host-to-URL map. 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. +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. @@ -65,22 +112,21 @@ Use the local protocol during development and the bundle protocol for shipped bu ## Per-platform schemes -Each platform decides how a request reaches the handler. Electron, Tauri, and iOS register a real custom scheme with the webview. Android takes a different route: it intercepts ordinary `https://.wvb` requests through `WebViewClient.shouldInterceptRequest`, so there is no custom scheme to register. - -In every case the bundle name still comes from the host label and the path still comes from the URI path. The examples below register a scheme named `app` (Electron) or `bundle` (Tauri); the name is yours to choose. +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 for you. Pass `bundleProtocol(scheme, ...)` to `webviewBundle`, then load `://.wvb`. +`@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')], + protocols: [bundleProtocol('app', { onError: (e) => console.error('[wvb]', e) })], }); app.whenReady().then(async () => { @@ -90,6 +136,16 @@ app.whenReady().then(async () => { }); ``` +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. @@ -110,12 +166,22 @@ tauri::Builder::default() .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` and serves the matching file. The same host-and-path mapping applies, so `https://app.wvb/index.html` resolves to bundle `app`, file `/index.html`. +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() { diff --git a/content/docs/guide/providers/aws.mdx b/content/docs/guide/providers/aws.mdx index 257a99a..f4c135c 100644 --- a/content/docs/guide/providers/aws.mdx +++ b/content/docs/guide/providers/aws.mdx @@ -3,36 +3,29 @@ title: AWS provider description: Host, serve, and provision remote Webview Bundles on AWS using S3, CloudFront, Lambda@Edge, and optional KMS signing. --- -The AWS provider lets you publish bundles to your own AWS account and serve them over a global CDN. -Bundles live in **S3**, **CloudFront** fronts them as a cache, two **Lambda@Edge** functions translate -the remote HTTP contract into S3 reads, and **KMS** can optionally sign each bundle. You wire the -publish side into `wvb.config.ts`, run the serving side as Lambda@Edge, and provision the whole stack -with Pulumi. +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. -All AWS packages are at `0.1.0` and pre-release. Treat them as preview and install from source until a -published release lands. For the contract these pieces implement and how to choose a provider, see -[Building a remote](/docs/guide/remote). + +All AWS packages are at `0.1.0` and pre-release. Treat them as preview and install from source until a published release lands. + -## Backing services +For the contract these pieces implement and how to choose a provider, see [Building a remote](/docs/guide/remote). -The AWS provider maps the remote contract onto four AWS services. +## 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 — an origin-request handler that rewrites the URL to the bundle key, and an origin-response handler that turns S3 object metadata into the contract response headers. | +| 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 -Three packages cover the three roles. The publish side runs in your CI or on your machine, the provider -runs at the edge, and the Pulumi package provisions everything. - | Package | Version | Role | |---|---|---| -| `@wvb/remote-aws` | `0.1.0` | Publish side. Produces the `uploader`, `deployer`, and optional `signature` for `wvb.config.ts`. | -| `@wvb/remote-aws-provider` | `0.1.0` | The Lambda@Edge server (a Hono app) that serves bundles. Exposes `./origin-request` and `./origin-response` subpaths. | +| `@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. | @@ -55,13 +48,11 @@ yarn add @wvb/remote-aws @wvb/remote-aws-provider @wvb/remote-aws-provider-pulum ## Configure the publish side -`@wvb/remote-aws` exports `awsRemote()`. Pass a `bucket`, optional `signature`, and shared `aws` client -defaults; it returns `{ uploader, deployer, signature? }` that you spread into the `remote` block of your -config. See [Remote, integrity and signature config](/docs/config/remote) for the full `remote` schema. +`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, awsKmsSignatureSigner } from '@wvb/remote-aws'; +import { awsRemote } from '@wvb/remote-aws'; export default defineConfig({ remote: { @@ -72,41 +63,78 @@ export default defineConfig({ region: 'us-east-1', profile: 'my-app-deploy', }, - // Optional: sign each bundle's integrity string with KMS. - signature: { - keyId: 'arn:aws:kms:us-east-1:111122223333:key/abcd-1234', - algorithm: 'ECDSA_SHA_256', - }, }), }, }); ``` -The `signature` field is `false` or a KMS signer config. You can also build the signer directly with -`awsKmsSignatureSigner({ keyId, algorithm })`, which returns a signing function. The signer calls KMS -with `MessageType: 'DIGEST'` and returns a base64 signature over the bundle's integrity string. +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: -The uploader streams the bundle to S3 with a multipart upload and stores 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 reads and rewrites -`deployment.json` in S3, then optionally issues a CloudFront invalidation if you pass an `invalidation` -with a `distributionId`. +```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 incoming requests to -`bundles/{name}/{version}/{name}_{version}.wvb` — an extra `{version}` directory segment. For an -end-to-end AWS setup you must reconcile the two: set a custom uploader `key` (a string or a -`(bundleName, version) => string` function) that produces the provider's layout, or otherwise arrange a -matching S3 layout. Verify the intended layout against the package source before relying on the defaults. +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` exports a Hono app that implements the remote contract and runs as -Lambda@Edge. The app is split across two CloudFront event handlers, imported from subpaths: +`@wvb/remote-aws-provider` splits the contract across two CloudFront event handlers. Wire each to its own Lambda: -```ts title="origin-request handler" +```ts title="origin-request.ts" import { originRequest } from '@wvb/remote-aws-provider/origin-request'; export const handler = originRequest({ @@ -116,47 +144,49 @@ export const handler = originRequest({ }); ``` -```ts title="origin-response handler" +```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` for the requested bundle, resolves the version -(falling back from a channel to the default version), rewrites the origin URI to the bundle key, and -tags the request so the origin-response handler can act on it. The origin-response handler converts the -S3 `x-amz-meta-webview-bundle-*` metadata into the `webview-bundle-*` response headers the client -expects. A request for `GET /bundles/{name}/{version}` returns `403` unless you set -`allowOtherVersions: true`. +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 -`@wvb/remote-aws-provider-pulumi` exports `WebViewBundleRemoteProvider`, a Pulumi `ComponentResource` -(token `webview-bundle:aws:RemoteProvider`). It creates the S3 bucket and bucket 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 as origin-request and origin-response associations. +`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'; +import { WebviewBundleRemoteProvider } from '@wvb/remote-aws-provider-pulumi'; -const provider = new WebViewBundleRemoteProvider('webview-bundle', { - // Override defaults here, e.g. lambda names or CloudFront settings. -}); +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 the bucket name, region, and -`allowOtherVersions` flag into each function. The functions default to the `nodejs22.x` runtime. Useful -outputs include `bucketName`, `cloudfrontDistributionDomainName`, `cloudfrontDistributionId`, and the -two Lambda ARNs. +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`: - -Lambda@Edge functions must live in `us-east-1`. Configure that region for the Pulumi AWS provider when -you deploy this component. - +```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 index 83fd9a6..a66c7cd 100644 --- a/content/docs/guide/providers/cloudflare.mdx +++ b/content/docs/guide/providers/cloudflare.mdx @@ -3,13 +3,9 @@ title: Cloudflare provider description: Store, deploy, and serve bundles on Cloudflare using R2, Workers KV, and Cloudflare Workers. --- -The Cloudflare provider runs a Webview Bundle remote entirely on Cloudflare's edge. Bundles live in -an **R2** bucket, deployment and version pointers live in **Workers KV**, and a **Cloudflare Worker** -serves the HTTP contract that the updater talks to. You get global, low-latency delivery without -managing servers, and your published bundles reach devices over the air (OTA) without a native -app-store release. +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. -The provider ships as three packages, each for a different stage of the workflow: +Three packages cover the workflow: | Package | Role | Runs | |---|---|---| @@ -17,7 +13,11 @@ The provider ships as three packages, each for a different stage of the workflow | `@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 at version `0.1.0` and are pre-release. Pin the version and expect breaking changes. +All three are `0.1.0` and pre-release. Pin the version and expect breaking changes. + +```sh +npm i -D @wvb/remote-cloudflare +``` New to remotes? Start with [Building a remote](/docs/guide/remote) for the contract and the local @@ -27,27 +27,21 @@ testing loop, and see [Remote, integrity & signature config](/docs/config/remote ## How the pieces fit -The Cloudflare provider mirrors the shared remote contract, mapping each role onto a Cloudflare -service: +Each role maps onto a Cloudflare service: -- **R2** stores each bundle as an object, accessed through R2's S3-compatible API. The uploader in - `@wvb/remote-cloudflare` reuses the S3 uploader from `@wvb/remote-aws`, pointed at R2's endpoint. -- **Workers KV** holds the deployment pointer — a 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. +- **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 — there is no KMS equivalent here, 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`. +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 -In your `wvb.config.ts`, call `cloudflareRemote()` and spread the result into the `remote` block. It -returns `{ uploader, deployer }`: the uploader writes the bundle to R2, and the deployer writes the -version to KV. +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'; @@ -66,8 +60,6 @@ export default defineConfig({ }); ``` -`cloudflareRemote()` takes the following options: - | Option | Type | Notes | |---|---|---| | `bucket` | `string` | R2 bucket that stores bundle objects. | @@ -76,13 +68,34 @@ export default defineConfig({ | `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`). | -Under the hood the uploader defaults the S3 client to `region: 'auto'` and the endpoint -`https://.r2.cloudflarestorage.com`, then stores the object at the key -`bundles//_.wvb` with `webview-bundle-*` custom metadata. The deployer writes the -version into KV under the key `` (or `/` when you deploy to a channel). +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= +``` -Provide R2 S3 credentials the same way you would for AWS, for example through environment variables -that the S3 client picks up. With the config in place, the usual commands publish a bundle: +With the config in place, publish a bundle: ```sh wvb pack ./dist @@ -90,14 +103,23 @@ 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. See the [CLI reference](/docs/guide/cli) for every flag. +`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` and the `BUCKET` -(R2) binding becomes `r2`. +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'; @@ -116,14 +138,15 @@ export default { }; ``` -`wvbRemote()` accepts a single option, `allowOtherVersions` (default `false`). When `false`, requests -for a specific version path return `403`; set it to `true` to let clients download versions other than -the deployed one. The Worker resolves the version from KV, reads the object from R2 at -`bundles//_.wvb`, 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). +`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 your `wrangler.jsonc` so the names match the `Context`: +Bind `KV` and `BUCKET` in `wrangler.jsonc` so the names match the `Context`: ```json title="wrangler.jsonc" { @@ -135,12 +158,27 @@ Bind `KV` and `BUCKET` in your `wrangler.jsonc` so the names match the `Context` } ``` +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 a single Pulumi component -resource. Construct `WebviewBundleRemoteProvider` with your account ID; it creates an R2 bucket, a -Workers KV namespace, and a Worker (with a version and a deployment) wired together with the right -bindings. +`@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'; @@ -154,16 +192,18 @@ const provider = new WebviewBundleRemoteProvider('webview-bundle', { 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`. By -default it deploys the bundled Worker entry with the bindings `BUCKET` (R2) and `KV`, matching the -`Context` the Worker expects. `workerDeploymentPercentage` controls a gradual rollout — the share of -traffic the new Worker version receives — and defaults to `100`. The component exports `bucketName`, -`kvNamespaceId`, `workerId`, `workerVersionId`, and `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 +``` -After `pulumi up`, take the bucket name and KV namespace ID it exports and feed them into the -`cloudflareRemote()` config above, then publish with the CLI. +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 index 965aeb3..f9f95c1 100644 --- a/content/docs/guide/providers/local.mdx +++ b/content/docs/guide/providers/local.mdx @@ -3,35 +3,26 @@ 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. --- -The local provider is 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 complete bundle lifecycle — pack, upload, -deploy, and over-the-air (OTA) update — without provisioning any infrastructure. Use it to develop and -test your update flow end to end, then switch to a [cloud provider](/docs/guide/remote) for production. +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. -The local provider is for development and testing only. It stores bundles as plain files on a single -machine and the `wvb remote local` server binds to `localhost` by default. Do not use it to serve -production traffic — see the [AWS](/docs/guide/providers/aws) and -[Cloudflare](/docs/guide/providers/cloudflare) providers for that. +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 -The local provider ships as two packages, both at version `0.0.0` (pre-release, not yet published — -install from source for now): +Both at `0.0.0` (pre-release, not yet published — install from source for now): | Package | Role | | --- | --- | -| `@wvb/remote-local` | The publish-side client. Its `localRemote()` produces the `uploader` and `deployer` you wire into `wvb.config`. | +| `@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, and the `-provider` package serves -them. See [Building a remote](/docs/guide/remote) for the shared contract and how the pieces fit together. +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 -Add `localRemote()` to the `remote` block of your `wvb.config`. It returns an object with `uploader` -and `deployer`, which you spread into the config. +Spread `localRemote()` — which returns `{ uploader, deployer }` — into the `remote` block: ```ts title="wvb.config.ts" import { defineConfig } from '@wvb/config'; @@ -46,13 +37,13 @@ export default defineConfig({ }); ``` -`localRemote()` accepts a single optional field: +`localRemote()` accepts one optional field: | Option | Type | Default | Description | | --- | --- | --- | --- | | `baseDir` | `string` | `~/.wvb/local` | Directory where bundles and deployment state are stored on disk. | -Pass `baseDir` to keep a project's bundles isolated from the default store: +Set `baseDir` to isolate a project's bundles from the default store: ```ts title="wvb.config.ts" import os from 'node:os'; @@ -71,26 +62,22 @@ export default defineConfig({ }); ``` -For the full `remote` block — including `endpoint`, `bundleName`, `packBeforeUpload`, integrity, and -signature — see the [remote config reference](/docs/config/remote). +For the full `remote` block — `endpoint`, `bundleName`, `packBeforeUpload`, integrity, and signature — see the [remote config reference](/docs/config/remote). ## On-disk layout -The uploader and deployer write everything under `{baseDir}/bundles`. Each bundle name gets its own -directory holding one `.wvb` file per version, a metadata file per version, and a single deployment -pointer. +Everything is written under `{baseDir}/bundles`, one directory per bundle name: ```text {baseDir}/ └── bundles/ └── {name}/ - ├── {name}_{version}.wvb # the bundle binary (one per uploaded version) + ├── {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 ``` -For example, after uploading and deploying version `1.1.0` of a bundle named `app` to the default -store: +After uploading and deploying `1.1.0` of bundle `app` to the default store: ```text ~/.wvb/local/ @@ -101,102 +88,128 @@ store: └── deployment.json ``` -The `deployment.json` file records the current version. Deploying without a channel sets the default -`version`; deploying with a channel sets `channels[channel]` instead, so multiple channels can point at -different versions of the same bundle. +`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 -The `@wvb/remote-local-provider` package only builds the Hono app — it does not bind a port. The CLI -command `wvb remote local` serves that app with a Node HTTP server. Start it from your project: +`@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 ``` -By default it serves the store at `~/.wvb/local` on `http://localhost:4313`. The command accepts: +Defaults to serving `~/.wvb/local` on `http://localhost:4313`. Flags: | Flag | Default | Description | | --- | --- | --- | -| `--base-dir` | `~/.wvb/local` | Directory to serve. Match the `baseDir` you set in `localRemote()`. | -| `--port`, `-P` | `4313` | Port to listen on (also reads the `PORT` env var). | -| `--hostname`, `-H` | `localhost` | Host to bind (also reads the `HOSTNAME` env var). | -| `--allow-other-versions` | `false` | Allow downloading specific versions other than the deployed one. | +| `--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` | — | Suppress server logging. | -If you set a custom `baseDir` in your config, pass the same path to the server so it serves the bundles -you uploaded: +Pass the same path you set in your config: ```sh -wvb remote local --base-dir ~/.wvb/my-app +wvb remote local --base-dir ~/.wvb/my-app --port 4313 ``` -See the [CLI reference](/docs/guide/cli) for the full `remote` command group, including `remote list`. +See the [CLI reference](/docs/guide/cli) for the full `remote` command group (`remote list`, alias `remote ls`). ### allowOtherVersions -By default the server only serves the currently deployed version of each bundle. A request for a -specific version — `GET /bundles/{name}/{version}` — returns `403 Forbidden`. This matches how the -cloud providers behave and keeps clients pinned to deployed versions. +By default the server serves only the deployed version. `GET /bundles/{name}/{version}` returns `403 Forbidden`, matching the cloud providers and keeping clients pinned: -Set `--allow-other-versions` (or `allowOtherVersions: true` when you build the provider app directly) -to let those requests succeed. This is useful for testing rollbacks or inspecting an older bundle: +```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 through -`GET /bundles/{name}`. The version-specific route stays locked behind `403` until you opt in. +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 -Put the pieces together to test an OTA update against a local server. The first three steps run on your -machine; the last points your app's updater at the server. +Test an OTA update against a local server end to end. -`wvb upload` does not deploy by default — `--deploy` is `false` unless you pass it. Use -`wvb upload --deploy` to upload and mark the version current in one step, or run `wvb deploy` -separately. +`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. -1. Wire up `localRemote()` in `wvb.config`, as shown above. +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: -2. Pack, upload, and deploy a version. `--deploy` makes it the current version immediately: +```sh +wvb pack ./dist +wvb upload --version 1.1.0 --deploy +``` - ```sh - wvb pack ./dist - wvb upload --version 1.1.0 --deploy - ``` +Start the server against the same store and confirm the bundle is listed: - With `packBeforeUpload` left at its default, `wvb upload` packs for you, so you can skip the explicit - `wvb pack` step. Pass the version with the `--version`, `-V` flag. +```sh +wvb remote local +``` -3. Start the server pointed at the same store: +```sh +curl http://localhost:4313/bundles +# [{"name":"app","version":"1.1.0"}] +``` - ```sh - wvb remote local - ``` +```sh +curl -I http://localhost:4313/bundles/app +# Webview-Bundle-Name: app +# Webview-Bundle-Version: 1.1.0 +# Content-Type: application/webview-bundle +``` - Confirm the deployed bundle is listed: +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. - ```sh - curl http://localhost:4313/bundles - # [{"name":"app","version":"1.1.0"}] - ``` +Publish an update by bumping the version and deploying again; the next updater check picks it up: -4. Point your app's updater at `http://localhost:4313`. The updater calls - `GET /bundles/{name}` to download the current bundle, verifies it, 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. +```sh +wvb upload --version 1.2.0 --deploy +``` -To publish an update, bump the version, upload and deploy again, and the next updater check picks it up: +Deploy to a channel instead of the default by passing `--channel`: ```sh -wvb upload --version 1.2.0 --deploy +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/remote-bundles.mdx b/content/docs/guide/remote-bundles.mdx index 6c1a945..946f488 100644 --- a/content/docs/guide/remote-bundles.mdx +++ b/content/docs/guide/remote-bundles.mdx @@ -3,58 +3,39 @@ 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 can download newer bundles over the air (OTA), so you ship updated app code without a -native app-store release. This page is the conceptual hub for that capability: it explains 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 and test the loop -locally, see [Building a remote](/docs/guide/remote); for the exact per-language updater APIs, see the -[Node](/docs/references/node) and [Deno](/docs/references/deno) references. +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 your build output to a device in five stages. The first three run on your -machine or CI; the last two run inside the app, driven by the updater. +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. -```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 ◀┘ │ - └──────────────────────┘ -``` +![The developer machine uploads and deploys a bundle to the remote server; the device checks, downloads, verifies, and installs the update.](/diagrams/update-lifecycle.svg) 1. **Pack** your build into a `.wvb` archive. -2. **Upload** that archive to the server, optionally attaching an integrity hash and a signature. -3. **Deploy** the uploaded version so clients on a given channel start seeing it. -4. On the device, the **updater checks** for a newer deployed version, then **downloads** it. -5. The updater **verifies** the download and **installs** it into the `remote` source, after which the - app serves it instead of the builtin bundle. +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 +``` -Stages 1 through 3 are CLI commands — see the [CLI reference](/docs/guide/cli) for `pack`, `upload`, -`deploy`, and `download`. Stages 4 and 5 are the updater, covered next. +`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. They are deliberately separate so you control exactly when a device fetches bytes and when -it switches versions. The names below are conceptual; each platform exposes the same three operations -under its own API. +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`** is a check only. It asks the server for the current deployed version and compares it - to the local version. It reports `isAvailable` as `true` when there is no local version, or when the - local version differs from the deployed version. It downloads nothing. -- **`download`** fetches the bundle, verifies it against your integrity and signature policy, and - stages it into the `remote` source. It does **not** activate the new version — the app keeps serving - the current one. -- **`install`** re-verifies the staged bundle from disk, activates it as the current version, unloads - the old descriptor, and prunes versions that are no longer retained. +- **`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, for example, download in the background and install on the next app launch. +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` @@ -63,9 +44,14 @@ This split lets you, for example, download in the background and install on the [Bundle sources](/docs/guide/bundle-sources). -A typical flow in the Rust core looks like this; platform wrappers mirror it one-to-one: +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 @@ -76,6 +62,30 @@ if update.is_available { } ``` +`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 @@ -83,8 +93,7 @@ The platform guides show the idiomatic call for each target: ## The remote HTTP contract -A Webview Bundle server is any HTTP server that implements four endpoints. The provider packages -(local, AWS, Cloudflare) implement this spec, and you can build your own to match it. +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 | | ------------------------------- | -------------------------------------------------- | @@ -93,61 +102,84 @@ A Webview Bundle server is any HTTP server that implements four endpoints. The p | `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 -to scope the response to a channel. Metadata travels in response headers: +`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: -- `Webview-Bundle-Name` — bundle name **(required)** -- `Webview-Bundle-Version` — version **(required)** -- `Webview-Bundle-Integrity` — integrity string (optional) -- `Webview-Bundle-Signature` — signature (optional) -- `ETag` and `Last-Modified` — standard validators (optional) +```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" +``` -Downloads use `Content-Type: application/webview-bundle`; the list endpoint returns -`application/json`. A missing bundle or version returns `404`; requesting a specific non-deployed -version returns `403` when that server disallows it. Both missing required headers and any other -non-2xx response cause the client to reject the download. For a worked server implementation and local -testing, see [Building a remote](/docs/guide/remote). +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 that the bytes it downloaded are exactly the bytes you -published. Webview Bundle uses **SHA-2**: `sha256` (the default), `sha384`, or `sha512`. The hash is -serialized as `":"`, for example: +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 (configured in -[Remote config](/docs/config/remote)), the server returns it in the `Webview-Bundle-Integrity` header, -and the updater enforces it on download according to an **integrity policy**: +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**: -- **`Strict`** — an integrity value must be present and must match, or the download is rejected. -- **`Optional`** — the **default**. Verify the integrity value if one is present; allow the download - if none is attached. -- **`None`** — skip integrity checking entirely. +| 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. Configure the updater with a public key, and it -rejects any download whose signature does not verify. +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. -The core verifies these algorithms: +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 keys can be supplied in these formats: +Public-key formats: -- **SPKI** as PEM or DER (all algorithms) -- **PKCS#1** as PEM or DER (RSA only) -- **SEC1** point bytes (ECDSA only) -- **Raw** 32-byte key (Ed25519 only) +| 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 | -A Rust updater that requires both a matching integrity hash and a valid Ed25519 signature: +An updater requiring both a matching integrity hash and a valid Ed25519 signature: ```rust use wvb::integrity::IntegrityPolicy; @@ -164,23 +196,37 @@ let config = UpdaterConfig::new() let updater = Updater::new(source, remote, Some(config)); ``` -The publish-side counterpart — which algorithm signs, and the private-key format — is set in -[Remote config](/docs/config/remote). +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, which makes staged rollouts possible. Deploy -a version to a channel, and only clients that request that channel receive it; clients with no channel -get the default deployment. +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 configuration and forwards it as the `?channel=` -query parameter when it checks for and downloads updates. A channel is a free-form string; there is no -hardcoded default name — when no channel is set, the query parameter is simply omitted. +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 index 7332cef..88643bb 100644 --- a/content/docs/guide/remote.mdx +++ b/content/docs/guide/remote.mdx @@ -3,13 +3,13 @@ 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 page is the practical, server-side companion to [Remote bundles](/docs/guide/remote-bundles): it shows the packages that build a remote, how to run the full publish-and-update loop on your machine with no cloud account, and how to pick a provider when you go to production. +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 a remote for three backends — `local`, `aws`, and `cloudflare` — and each backend is split into three packages with clear roles. You only need the ones for the job in front of you. +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 -For every backend there are up to three packages, each playing a distinct role. +Every backend has up to three packages: | Role | Package | What it does | | --- | --- | --- | @@ -25,30 +25,72 @@ Concretely, per backend: | `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. The publish-side integrity and signature options live alongside them — see [Remote, integrity & signature config](/docs/config/remote). +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 `local` packages and `@wvb/remote-local-provider` are pre-release (`0.0.0`); the AWS and Cloudflare packages are at `0.1.0`. Treat all of them as not-yet-published and install from source for now. + `@wvb/remote-local` and `@wvb/remote-local-provider` are pre-release (`0.0.0`); the AWS and Cloudflare packages are at `0.1.0`. Treat all of them as not-yet-published and install from source for now. ## The HTTP contract -Every provider implements the same four-endpoint contract, so the client never cares which backend is behind it. +Every provider implements the same four endpoints, so the client never cares which backend is behind it. -| Method and path | Purpose | -| --- | --- | -| `GET /bundles` | List deployed bundles as `[{ "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 | +| 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 | -Metadata travels in response headers (`Webview-Bundle-Name` and `Webview-Bundle-Version` are required; `Webview-Bundle-Integrity`, `Webview-Bundle-Signature`, `ETag`, and `Last-Modified` are optional), and downloads use `Content-Type: application/webview-bundle`. The full endpoint, header, status-code, and channel-query spec is in [Remote bundles](/docs/guide/remote-bundles). +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). -Because the contract is small and transport-only, you can also implement your own server to it instead of using a provider — anything that answers these four routes with the required headers works. +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 -You can exercise the entire update loop on your machine. `@wvb/remote-local` gives you an uploader and 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. +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 @@ -68,29 +110,60 @@ export default defineConfig({ }); ``` -`localRemote()` defaults its `baseDir` to `~/.wvb/local`; pass `localRemote({ baseDir: './.wvb/local' })` to keep the store inside your project instead. +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 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` above is optional. `--deploy` is off by default; pass it (or run `wvb deploy app --version 1.1.0` separately) to make the version the one clients receive. See the [CLI reference](/docs/guide/cli) for every flag. +`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 -# options: --base-dir ./.wvb/local --port 4313 --allow-other-versions +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. Pass `--allow-other-versions` to let clients fetch a specific non-deployed version through `GET /bundles/{name}/{version}` instead of getting a `403`. +`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 your 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. +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. @@ -98,22 +171,20 @@ Set your app's updater endpoint to `http://localhost:4313`. Now `getUpdate`, `do ### Inspect with the client commands -The CLI can act as a client against any remote, which is handy for confirming a server behaves before you point an app at it. +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 -wvb remote current app --endpoint http://localhost:4313 -wvb remote download app --endpoint http://localhost:4313 --outfile ./app.wvb +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 ``` -`wvb remote list` (alias `wvb remote ls`) calls `GET /bundles`, `wvb remote current` calls `HEAD /bundles/{name}`, and `wvb remote download` pulls the current bundle to disk. - ### Preview a single bundle -To look at what is inside a `.wvb` without a server or a deployment, serve its files directly: +To inspect a `.wvb` without a server or deployment, serve its files directly: ```sh -wvb serve ./app.wvb # serves the bundle's files at http://localhost:4312 +wvb serve ./app.wvb # serves the bundle's files on http://localhost:4312 wvb serve ./app.wvb --port 8080 ``` @@ -121,7 +192,33 @@ wvb serve ./app.wvb --port 8080 ## 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` and a server that implements the contract above, plus Pulumi packages to provision it. +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/why-webview-bundle.mdx b/content/docs/guide/why-webview-bundle.mdx index 95f48df..dd6a827 100644 --- a/content/docs/guide/why-webview-bundle.mdx +++ b/content/docs/guide/why-webview-bundle.mdx @@ -3,24 +3,20 @@ 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 a single compressed, integrity-checked archive (`.wvb`) -that your app ships with and serves to its webview over a custom URL scheme. Instead of loading a -remote URL or re-fetching assets on every launch, the webview reads its HTML, JS, CSS, and media -from local storage. A remote can be configured so the app downloads newer bundles over the air, -delivering updated app code without a native app-store release. This page explains the three reasons -teams reach for it: offline-first delivery, over-the-air updates, and a workflow that stays the same -across every webview platform. +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 -When a webview loads a remote URL, the first paint waits on the network. A cold cache, a captive -portal, or a flaky connection turns into a blank screen. Even apps that cache aggressively still pay -the cost the first time and risk a stale or partial cache afterward. +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: -Webview Bundle inverts this. Your assets live inside a `.wvb` file that ships with the app, and the -core serves them through a custom scheme straight from the local archive. The bundle protocol maps a -request such as `bundle://app/index.html` to a file inside the bundle and returns it directly, so the -first paint never depends on the network. +```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'; @@ -33,78 +29,91 @@ const source = new BundleSource({ const protocol = new BundleProtocol(source); // Your platform integration routes scheme requests here. -const response = await protocol.handle('get', 'bundle://app/index.html'); +const res = await protocol.handle('get', 'bundle://app/index.html'); +// res: { status, headers, body } ``` -This matters in the places real users open your app: on a subway between stations, on a plane, or -anywhere the network drops out mid-session. The app paints from the local bundle every time, and the -network becomes an optimization rather than a prerequisite. +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 handles -`Range` requests for media. See [Protocol handling](/docs/guide/protocol-handling) for the full -request mapping. +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 -A native app-store release is slow and gated. A typo in copy, a broken button, or a small feature can -sit behind a multi-day review. Because your app code lives in a `.wvb` bundle, you can ship a fix by -deploying a new bundle version and letting installed apps download it over the air (OTA). The native -binary stays the same; only the web bundle changes. +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. -The update model is built around two sources. The `builtin` source is the bundle you shipped inside -the app, and it is read-only. The `remote` source holds bundles downloaded from your server, and it -takes priority when present. If a download or activation ever fails, the app still has the builtin -bundle to fall back to, so an OTA update can never leave a device without a working app. +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. -Updates run in two explicit steps so a half-downloaded bundle is never served. The updater downloads -a bundle into the remote source first, then activates it in a separate install step. +```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="Checking for and applying an update" +```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'); // stages into the remote source, verifies - await updater.install('app', info.version); // activates the staged version + await updater.download('app'); // stage + verify + await updater.install('app', info.version); // activate } ``` -Two more pieces keep OTA safe and controlled: +### Staged rollouts with channels -- **Staged rollouts with channels.** A channel is an optional string passed to the remote, sent as a - `?channel=` query parameter on list and download requests. Point a subset of devices at a `beta` - channel, watch it, then promote to the default. There is no hardcoded channel name; when you set - none, the parameter is simply omitted. -- **Authenticity with integrity and signatures.** Every bundle download is verified before it is - activated. Integrity uses SHA-2 (`sha256` by default, with `sha384` and `sha512` available) and is - serialized as `":"`. An optional signature proves who published the bundle; it signs - the integrity string bytes and supports ECDSA, Ed25519, and RSA verification. The default integrity - policy is `optional` (verify if present); set it to `strict` to require it. +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. -See [Remote bundles](/docs/guide/remote-bundles) for the verification flow and -[Remote, integrity & signature config](/docs/config/remote) for the configuration schema. +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 -You keep writing the app you already know. React, Vue, Svelte, or plain HTML all work, because -Webview Bundle operates on your bundler's output, not your source. There is no custom framework and no -special build step to learn. Point the `wvb pack` command at your build directory and it produces one -`.wvb` artifact. +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 ``` -By default the packed file is written to `.wvb/` and the `.wvb` extension is appended for -you. In `wvb.config.ts` the output path is the `outFile` field, a single path. +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'; @@ -116,10 +125,7 @@ export default defineConfig({ }); ``` -Inside the webview, your web code talks to the native host through the `@wvb/bridge` package. One -`invoke()` API exposes the source, remote, and updater commands, and the bridge picks the right -transport for Electron, Tauri, Android, or iOS underneath. The mental model is identical everywhere, -so the same update code runs on every platform. +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'; @@ -131,30 +137,25 @@ if (update.isAvailable) { } ``` +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.** You can embed your web build in each native shell and write - the serving and update logic once per platform. It works, but the toolchain, the asset format, and - the update code differ on each platform, and you maintain all of them. Webview Bundle gives you one - `.wvb` format 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 an app-store release, which is - the same payoff as the remote source here. The tradeoffs are different: Webview Bundle is built - around a verifiable bundle format you host yourself, with a builtin fallback, channel-based - rollouts, and integrity plus optional signatures, rather than a managed third-party service. You own - the server and the artifact. - -The honest tradeoff is that you adopt a bundle format and a pack step, and you run the remote that -serves updates. In return you get offline-first delivery, OTA without store review, and one workflow -that does not change as you add platforms. +- **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 but pre-release: install from source for now, since the -mobile artifacts are not yet published to Maven Central or tagged for Swift Package Manager. The iOS -minimum is iOS 16. Deno Desktop is experimental. See -[Platform support](/docs/guide/platform-support) for current status. +Android and iOS are implemented and shipping but pre-release: install from source for now, since the mobile artifacts are not yet published to Maven Central or tagged for Swift Package Manager. 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/public/diagrams/architecture.svg b/public/diagrams/architecture.svg new file mode 100644 index 0000000..03d929c --- /dev/null +++ b/public/diagrams/architecture.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + Web app — HTML / JS in the webview + @wvb/bridge invoke() + + + electron · tauri · android · ios + + + + Native host + @wvb/node · wvb-tauri · UniFFI · @wvb/deno + + + + + + wvb core (Rust) + source · protocol · remote · updater + + + + + + + Source + builtin · remote + + + Remote server + OTA bundles + diff --git a/public/diagrams/pack-serve-update.svg b/public/diagrams/pack-serve-update.svg new file mode 100644 index 0000000..d8f2411 --- /dev/null +++ b/public/diagrams/pack-serve-update.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + BUILD OUTPUT + .WVB ARCHIVE + WEBVIEW HOST + + + + dist/ + index.html + app.js … + + + + app_1.0.0.wvb + compressed · verified + + + + app://app/index.html + offline · instant + + + + pack + + serve + + + + remote server + + + + upload + deploy + + download + verify + updater + diff --git a/public/diagrams/update-lifecycle.svg b/public/diagrams/update-lifecycle.svg new file mode 100644 index 0000000..3e6722c --- /dev/null +++ b/public/diagrams/update-lifecycle.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + DEVELOPER · CI + REMOTE SERVER + END-USER DEVICE + + + + wvb pack ./dist + → app_1.1.0.wvb + wvb upload + +integrity +signature + wvb deploy --channel + + + + store version + mark deployed + (per channel) + + + + updater.getUpdate() + isAvailable? + HEAD / GET + /bundles/app + verify + install + + + + upload + + deploy + + + + check + + bundle + From 8be80c7d2b28e707151d8b22808b949a2da512d7 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Tue, 30 Jun 2026 15:52:02 +0900 Subject: [PATCH 03/15] fix: format CI + address cubic review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: - Run oxfmt on all docs/source so `yarn format --check` passes (the failing `format` job). cubic review fixes: - electron: bundle name was inconsistent (served `simple`, packed/updated `app`); use `app` everywhere so the example actually serves a bundle (P1). - android: `update.version` after `update?.isAvailable == true` doesn't smart-cast; guard with `update != null && update.isAvailable`. - bundle-format: import `readFile` from `node:fs/promises` in the build example. - config: fix the array-style `headers` example to a valid `HeadersInit` (`[['cache-control','max-age=0']]`). - deno: the `@wvb/deno` section now uses the `BundleProtocol` class, not the `bundleProtocol` factory (that belongs to `@wvb/deno-desktop`). - references/node: add a Result types section (BundleManifestMetadata, BundleSourceVersion, ListBundleItem, BundleUpdateInfo, BuildOptions). - providers/local: `--silent` default is `false`, not `—`. - routes: redirect with `to: '/docs/$'` (type-safe) instead of `href`. - AGENTS.md / INDEX.md: grammar/term/heading consistency. Not changed: cubic flagged `import { platform } from '@wvb/bridge'` as invalid, but `platform` is a documented export of the package, so the example is correct. Verified: yarn build, yarn lint, and yarn format --check all pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- AGENTS.md | 6 +- INDEX.md | 2 +- content/docs/config/index.mdx | 124 +++++++----- content/docs/config/remote.mdx | 84 ++++---- content/docs/guide/bundle-format.mdx | 52 ++--- content/docs/guide/bundle-sources.mdx | 46 ++--- content/docs/guide/cli-programmatic.mdx | 52 +++-- content/docs/guide/cli.mdx | 212 ++++++++++---------- content/docs/guide/index.mdx | 54 ++--- content/docs/guide/platform-integration.mdx | 87 +++++--- content/docs/guide/platform-support.mdx | 22 +- content/docs/guide/platforms/android.mdx | 76 ++++--- content/docs/guide/platforms/deno.mdx | 88 ++++---- content/docs/guide/platforms/electron.mdx | 105 +++++----- content/docs/guide/platforms/ios.mdx | 55 +++-- content/docs/guide/platforms/tauri.mdx | 77 ++++--- content/docs/guide/protocol-handling.mdx | 35 +++- content/docs/guide/providers/aws.mdx | 65 +++--- content/docs/guide/providers/cloudflare.mdx | 60 ++++-- content/docs/guide/providers/local.mdx | 34 ++-- content/docs/guide/remote-bundles.mdx | 78 ++++--- content/docs/guide/remote.mdx | 66 +++--- content/docs/guide/why-webview-bundle.mdx | 18 +- content/docs/references/deno.mdx | 20 +- content/docs/references/index.mdx | 8 +- content/docs/references/meta.json | 7 +- content/docs/references/node.mdx | 207 +++++++++++-------- src/routes/docs/$.tsx | 2 +- src/routes/docs/index.tsx | 2 +- 29 files changed, 1018 insertions(+), 726 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index baa2193..0ad68a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,9 @@ ### 역할 이 봇의 역할은 기술 문서를 작성하기 전에, 문서 유형과 각 유형에 맞는 작성법을 안내하는 것입니다. -아래 정보를 참고하여, 내 상황에 가장 적합한 문서 유형과 그 문서 유형에 맞는 작성 가이드를 추천해 주고. 필요하다면 복수의 문서 유형을 제안해도 괜찮지만, 최대한 하나로 정해주세요. +아래 정보를 참고하여, 내 상황에 가장 적합한 문서 유형과 그 문서 유형에 맞는 작성 가이드를 추천해 주세요. 필요하다면 복수의 문서 유형을 제안해도 괜찮지만, 최대한 하나로 정해주세요. -아래 정보를 바탕으로, 가장 적합한 문서 유형(학습 중심 / 문제 해결 / 참조 / 설명)과 작성 시 유의해야 할 점을 제안해 주세요. +아래 정보를 바탕으로, 가장 적합한 문서 유형(학습 중심 / 문제 해결 / 참조 / 깊은 이해)과 작성 시 유의해야 할 점을 제안해 주세요. 이모지는 사용하지 마세요. - 문서 목표 (예: “React의 Hook 개념을 자세히 알리고 싶다” / “Webpack 설정을 잡아주고 싶다” / “에러 발생 시 해결 방법을 제공하고 싶다” 등) @@ -54,7 +54,7 @@ 문서는 독자가 바로 적용할 수 있는 실용적인 해결책을 제공해야 합니다. -#### 참조 문서 작성 프롬프트를 작성할 때 주의해야 할 사항 +#### 참조 문서를 작성할 때 주의해야 할 사항 문서에 포함해야 할 사항: diff --git a/INDEX.md b/INDEX.md index b64846a..783d1e7 100644 --- a/INDEX.md +++ b/INDEX.md @@ -46,6 +46,6 @@ - Node API - Deno API -## Config +# Config - Config file types diff --git a/content/docs/config/index.mdx b/content/docs/config/index.mdx index ade94f3..2985ebc 100644 --- a/content/docs/config/index.mdx +++ b/content/docs/config/index.mdx @@ -13,9 +13,21 @@ This page covers the config file itself and the top-level `pack`, `serve`, and dedicated page. - - - + + + ## The config file @@ -89,20 +101,20 @@ export default defineConfig(async () => { -Install the package as a dev dependency: `npm install -D @wvb/config`. + 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` | — | +| 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 @@ -130,13 +142,13 @@ export default defineConfig({ }); ``` -| 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)` | — | +| 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 @@ -144,8 +156,8 @@ default `.wvb/` derives `` from your `package.json` name with any sc 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`. + 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 @@ -161,7 +173,7 @@ headers: { // 2. Array of [glob, HeadersInit] tuples headers: [ ['*.html', { 'cache-control': 'max-age=3600' }], - ['*.png', ['cache-control', 'max-age=0']], + ['*.png', [['cache-control', 'max-age=0']]], // HeadersInit also accepts [name, value] pairs ] // 3. Function returning headers per file @@ -185,18 +197,18 @@ export default defineConfig({ }); ``` -| Field | Type | Default | -| -------- | --------- | ------------------ | +| Field | Type | Default | +| -------- | --------- | ----------------------- | | `file` | `string` | the `pack.outFile` path | -| `port` | `number` | `4312` | -| `silent` | `boolean` | — | +| `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. + `serve` has no `hostname` field. To bind a different host, pass `--hostname` (alias `-H`) to `wvb + serve` on the command line. ## builtin @@ -219,13 +231,13 @@ export default defineConfig({ }); ``` -| 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` | +| 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 @@ -262,29 +274,41 @@ For the `remote` target, `endpoint` and `download` are optional. The only concur 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` | — | +| `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` | +| `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/remote.mdx b/content/docs/config/remote.mdx index 9db6a2f..9be3b96 100644 --- a/content/docs/config/remote.mdx +++ b/content/docs/config/remote.mdx @@ -18,16 +18,16 @@ activates these bundles over the air — see [Remote bundles](/docs/guide/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). | +| 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 @@ -49,9 +49,9 @@ remote: { } ``` -| Resolver | Accepted forms | -| ------------ | ----------------------------------------------------------------------------------------- | -| `bundleName` | `{ from: 'package.json' }` \| `string` \| `(params) => string \| Promise` | +| 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. @@ -64,9 +64,21 @@ Each provider exports a factory that returns `{ uploader, deployer }` (AWS also `signature` signer) ready to spread into `remote`. - - - + + + ## integrity @@ -88,18 +100,18 @@ integrity: { algorithm: 'sha384' }, // 'sha256' | 'sha384' | 'sha512' 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. | +| 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 +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. @@ -107,19 +119,19 @@ 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` | +| `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` | +| `format` | `data` type | +| -------------------------------- | ------------ | +| `'jwk'` | `JsonWebKey` | +| `'raw'` \| `'pkcs8'` \| `'spki'` | `Buffer` | ```ts title="wvb.config.ts" // ECDSA @@ -149,10 +161,10 @@ 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. + 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 diff --git a/content/docs/guide/bundle-format.mdx b/content/docs/guide/bundle-format.mdx index 30d0e3d..7832b91 100644 --- a/content/docs/guide/bundle-format.mdx +++ b/content/docs/guide/bundle-format.mdx @@ -10,16 +10,17 @@ integrity-checked archive. It is three sections written back to back: [ Header (17 bytes) ][ Index (variable) ][ Data (variable) ] ``` -| Section | Holds | -| ---------------- | ------------------------------------------------------- | -| Header | magic number, format version, index size, checksum | -| Index | path → offset/length/headers entry map | -| Data | LZ4-compressed file contents, sorted by path | +| Section | Holds | +| ------- | -------------------------------------------------- | +| Header | magic number, format version, index size, checksum | +| Index | path → offset/length/headers entry map | +| Data | 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(); @@ -39,12 +40,12 @@ See [Bundle sources](/docs/guide/bundle-sources). 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 | +| 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: @@ -69,8 +70,8 @@ Read header fields back through the descriptor: 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.version(); // 'v1' +header.indexSize(); // byte length of the index header.indexEndOffset(); // 17 + indexSize + 4 -> where the data section starts ``` @@ -79,13 +80,13 @@ header.indexEndOffset(); // 17 + indexSize + 4 -> where the data section starts 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 | +| 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(); @@ -133,8 +134,8 @@ 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 +bundle.getData('/index.html'); // Buffer (decompressed) | null +bundle.getDataChecksum('/index.html'); // number (xxHash-32) | null ``` ## Checksums versus integrity @@ -161,9 +162,10 @@ import type { IntegrityAlgorithm, IntegrityPolicy } from '@wvb/node'; ``` -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. + 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 diff --git a/content/docs/guide/bundle-sources.mdx b/content/docs/guide/bundle-sources.mdx index 8f73e1d..4b5fb36 100644 --- a/content/docs/guide/bundle-sources.mdx +++ b/content/docs/guide/bundle-sources.mdx @@ -14,7 +14,7 @@ A **bundle** is one logical web app, identified by a **bundle name** (for exampl 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 +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. @@ -23,7 +23,7 @@ the byte layout. 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 +- **`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. @@ -32,9 +32,9 @@ When a bundle exists in both sources, **remote wins**. The source resolves a ver 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. + 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, @@ -67,10 +67,10 @@ sources are separate directories, the same `app_1.1.0.wvb` filename can exist in 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. + 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 @@ -95,23 +95,23 @@ present, their metadata, and which version is current: 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. | +| 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. | +| 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 @@ -129,8 +129,8 @@ becomes current. Only the current and previous versions are retained; older vers 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 +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 diff --git a/content/docs/guide/cli-programmatic.mdx b/content/docs/guide/cli-programmatic.mdx index fd3d3f2..ee15600 100644 --- a/content/docs/guide/cli-programmatic.mdx +++ b/content/docs/guide/cli-programmatic.mdx @@ -47,14 +47,14 @@ yarn add -D @wvb/cli 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` | +| 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 @@ -73,12 +73,12 @@ 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 + 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 +console.log(result.bundle); // the in-memory Bundle ``` `PackParams` accepts `srcDir`, `outFile`, optional `ignores` and `headers` (the same shapes as the @@ -96,7 +96,7 @@ 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 + clean: true, // default false; remove outDir first }); ``` @@ -112,8 +112,8 @@ 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 + port: 4312, // default 4312 + silent: false, // default false; true disables request logging }); // ... later @@ -188,7 +188,7 @@ import { localRemote } from '@wvb/cli/api'; const instance = await localRemote({ baseDir: '~/.wvb/local', - port: 4313, // default 4313 + port: 4313, // default 4313 allowOtherVersions: false, }); @@ -265,14 +265,26 @@ without a separate build step. Bare and `npm:` specifiers and Node builtins stay 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). + 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 index 10a60e1..a81b6b3 100644 --- a/content/docs/guide/cli.mdx +++ b/content/docs/guide/cli.mdx @@ -8,24 +8,9 @@ The `wvb` command-line tool packs your built web assets into `.wvb` bundles and 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 -``` - + ```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. The sections below document each command and its flags; for calling the same logic from JavaScript, see the [programmatic API](/docs/guide/cli-programmatic). @@ -34,11 +19,11 @@ Most commands read their defaults from a [`wvb.config`](/docs/config) file disco 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. | +| 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. @@ -59,14 +44,14 @@ wvb pack ./dist --ignore '*.map' --ignore 'node_modules/**' wvb pack ./dist --header '*.html' 'cache-control' 'max-age=3600' ``` -| Option | Default | Description | -| --------------- | ----------------------------- | ---------------------------------------------------------------------------- | -| `SRC_DIR` | `pack.srcDir` ?? `./dist` | Source directory to pack. | -| `--outfile, -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 file (dry run). | -| `--overwrite` | `true` | Overwrite an existing output file. | +| Option | Default | Description | +| --------------- | ------------------------- | --------------------------------------------------------------------------- | +| `SRC_DIR` | `pack.srcDir` ?? `./dist` | Source directory to pack. | +| `--outfile, -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 file (dry run). | +| `--overwrite` | `true` | Overwrite an existing output file. | `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. @@ -81,12 +66,12 @@ wvb extract ./build/app.wvb --outdir ./unpacked wvb extract ./build/app.wvb --outdir ./unpacked --clean ``` -| Option | Default | Description | -| -------------- | ------------------------------- | ------------------------------------------------- | -| `FILE` | required | Bundle file to extract. | -| `--outdir, -O` | `.wvb/` | Destination directory. | -| `--clean` | `false` | Remove the output directory first if it exists. | -| `--no-write` | writes by default | Run the extract without writing files (dry run). | +| Option | Default | Description | +| -------------- | ------------------------------ | ------------------------------------------------ | +| `FILE` | required | Bundle file to extract. | +| `--outdir, -O` | `.wvb/` | Destination directory. | +| `--clean` | `false` | Remove the output directory first if it exists. | +| `--no-write` | writes by default | Run the extract without writing files (dry run). | ### wvb serve [FILE] @@ -97,12 +82,12 @@ wvb serve ./build/app.wvb # http://localhost:4312 wvb serve ./build/app.wvb --port 8080 --hostname 0.0.0.0 ``` -| Option | Default | Env | Description | -| ---------------- | ------------- | ---------- | ---------------------------------------- | -| `FILE` | from config | — | Bundle to serve. Falls back to `serve.file`. | -| `--hostname, -H` | `localhost` | `HOSTNAME` | Bind hostname. | -| `--port, -P` | `4312` | `PORT` | Port to listen on. | -| `--silent` | `false` | — | Disable request logging. | +| Option | Default | Env | Description | +| ---------------- | ----------- | ---------- | -------------------------------------------- | +| `FILE` | from config | — | Bundle to serve. Falls back to `serve.file`. | +| `--hostname, -H` | `localhost` | `HOSTNAME` | Bind hostname. | +| `--port, -P` | `4312` | `PORT` | Port to listen on. | +| `--silent` | `false` | — | Disable request logging. | ## Publishing and updating @@ -119,20 +104,21 @@ wvb upload app --version 1.2.0 --deploy --channel beta wvb upload --no-pack --file ./build/app.wvb --force ``` -| Option | Default | Description | -| ------------------ | -------------------------- | ---------------------------------------------------------------------- | -| `BUNDLE` | from config / `--file` name | Bundle name. | -| `--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. | +| Option | Default | Description | +| ------------------ | -------------------------------- | ------------------------------------------------------------------- | +| `BUNDLE` | from config / `--file` name | Bundle name. | +| `--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. | -The version is the `--version` (`-V`) flag, not a positional argument. `--deploy` defaults to `false`, so an upload publishes the version without making it current until you deploy it. + The version is the `--version` (`-V`) flag, not a positional argument. `--deploy` defaults to + `false`, so an upload publishes the version without making it current until you deploy it. `upload` requires `remote.uploader` in your config; with `--deploy` it also requires `remote.deployer`. On success it prints the bundle endpoint. @@ -146,11 +132,11 @@ wvb deploy app --version 1.2.0 wvb deploy app --version 1.2.0 --channel beta ``` -| Option | Default | Description | -| --------------- | -------------------------------- | --------------------- | -| `BUNDLE` | from config | Bundle name. | -| `--version, -V` | config or `package.json` version | Version to deploy. | -| `--channel` | — | Release channel. | +| Option | Default | Description | +| --------------- | -------------------------------- | ------------------ | +| `BUNDLE` | from config | Bundle name. | +| `--version, -V` | config or `package.json` version | Version to deploy. | +| `--channel` | — | Release channel. | The version is the `--version` (`-V`) flag — there is no positional version argument. `deploy` requires `remote.deployer` in your config. @@ -164,16 +150,16 @@ wvb download app 1.2.0 --out ./bundles/app.wvb --overwrite wvb download app --no-write # fetch and print info only ``` -| Option | Default | Description | -| ---------------- | ------------------ | -------------------------------------------- | -| `BUNDLE` | from config | Bundle name. | -| `VERSION` | current deployed | Specific version to download. | -| `--out, -O` | `.wvb` | Output file path. | -| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | -| `--channel` | — | Release channel. | -| `--no-write` | writes by default | Fetch and print info without saving. | -| `--overwrite` | `false` | Overwrite an existing file. | -| `--progress` | `true` | Show a download progress bar. | +| Option | Default | Description | +| ---------------- | ------------------- | ------------------------------------ | +| `BUNDLE` | from config | Bundle name. | +| `VERSION` | current deployed | Specific version to download. | +| `--out, -O` | `.wvb` | Output file path. | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | +| `--channel` | — | Release channel. | +| `--no-write` | writes by default | Fetch and print info without saving. | +| `--overwrite` | `false` | Overwrite an existing file. | +| `--progress` | `true` | Show a download progress bar. | ### wvb builtin @@ -185,21 +171,22 @@ wvb builtin --include 'app*' --exclude 'internal*' wvb builtin --android # install into the detected Android module ``` -| Option | Default | Description | -| ------------------------- | ------------------------ | ------------------------------------------------------------------ | -| `--out, -O` | `.wvb/builtin/bundles` | Output directory. | -| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. Remote target only. | -| `--channel` | — | Release channel. Remote target only. | -| `--include` / `--exclude` | — | Glob filters over the target bundles. Repeatable. | -| `--clean` | `true` | Clear the output directory before installing. | -| `--concurrency` | CPU count, capped at 8 | Parallel downloads. Remote target only. | -| `--android` | — | Install into an Android module. Bare auto-detects; `=` sets it. | -| `--ios` | — | Install into an iOS project. Bare auto-detects; `=` sets it. | -| `--no-write` | writes by default | Run without writing files (dry run). | -| `--progress` | `true` | Show a progress bar. Remote target only. | +| Option | Default | Description | +| ------------------------- | ---------------------- | -------------------------------------------------------------------- | +| `--out, -O` | `.wvb/builtin/bundles` | Output directory. | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. Remote target only. | +| `--channel` | — | Release channel. Remote target only. | +| `--include` / `--exclude` | — | Glob filters over the target bundles. Repeatable. | +| `--clean` | `true` | Clear the output directory before installing. | +| `--concurrency` | CPU count, capped at 8 | Parallel downloads. Remote target only. | +| `--android` | — | Install into an Android module. Bare auto-detects; `=` sets it. | +| `--ios` | — | Install into an iOS project. Bare auto-detects; `=` sets it. | +| `--no-write` | writes by default | Run without writing files (dry run). | +| `--progress` | `true` | Show a progress bar. Remote target only. | -You cannot pass both `--android` and `--ios` in the same run. The `--ios` folder-reference step targets Tuist projects. + You cannot pass both `--android` and `--ios` in the same run. The `--ios` folder-reference step + targets Tuist projects. ## Inspecting and testing the remote @@ -213,11 +200,11 @@ wvb remote current app --endpoint https://updates.example.com wvb remote current app --channel beta ``` -| Option | Default | Description | -| ---------------- | ----------------- | ----------------- | -| `BUNDLE` | from config | Bundle name. | -| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | -| `--channel` | — | Release channel. | +| Option | Default | Description | +| ---------------- | ----------------- | ---------------- | +| `BUNDLE` | from config | Bundle name. | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | +| `--channel` | — | Release channel. | ### wvb remote list @@ -228,10 +215,10 @@ wvb remote list --endpoint https://updates.example.com wvb remote ls --channel beta ``` -| Option | Default | Description | -| ---------------- | ----------------- | ----------------- | -| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | -| `--channel` | — | Release channel. | +| Option | Default | Description | +| ---------------- | ----------------- | ---------------- | +| `--endpoint, -E` | `remote.endpoint` | Remote endpoint. | +| `--channel` | — | Release channel. | ### wvb remote local @@ -242,23 +229,40 @@ wvb remote local # http://localhost:4313, servi wvb remote local --base-dir ./.wvb/local --port 4313 --allow-other-versions ``` -| Option | 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. | +| Option | 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 commands, it does not read `--config` or `--cwd`. + `remote local` requires the optional `@wvb/remote-local-provider` package, which the command + imports on demand. Unlike the other commands, it does not read `--config` or `--cwd`. ## Next steps - - - - + + + + diff --git a/content/docs/guide/index.mdx b/content/docs/guide/index.mdx index 0d3158b..7ff8c18 100644 --- a/content/docs/guide/index.mdx +++ b/content/docs/guide/index.mdx @@ -16,21 +16,9 @@ native app-store release. One `.wvb` format runs on every webview platform via a 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 npm install --save-dev @wvb/cli ``` + ```sh pnpm add -D @wvb/cli ``` + ```sh yarn add -D @wvb/cli ``` ```sh @@ -163,20 +151,20 @@ See [Remote, integrity & signature config](/docs/config/remote) for the full sch 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` | unpublished | electron-builder integration (in-repo, not yet on npm) | -| `@wvb/electron-forge` | unpublished | Electron Forge plugin (in-repo, not yet on npm) | -| `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 | +| 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` | unpublished | electron-builder integration (in-repo, not yet on npm) | +| `@wvb/electron-forge` | unpublished | Electron Forge plugin (in-repo, not yet on npm) | +| `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: @@ -190,10 +178,10 @@ cargo add wvb-tauri The Tauri integration is the **`wvb-tauri` crate** on crates.io — there is no `@wvb/tauri` npm - package. The Android (Kotlin) and iOS (Swift) bindings are built from the core via UniFFI and - are **pre-release**: they are not yet published to Maven Central or tagged for Swift Package - Manager, so install from source for now. See [Platform Support](/docs/guide/platform-support) - for the full status. + package. The Android (Kotlin) and iOS (Swift) bindings are built from the core via UniFFI and are + **pre-release**: they are not yet published to Maven Central or tagged for Swift Package Manager, + so install from source for now. See [Platform Support](/docs/guide/platform-support) for the full + status. ## Next steps diff --git a/content/docs/guide/platform-integration.mdx b/content/docs/guide/platform-integration.mdx index 4754f67..0260ca0 100644 --- a/content/docs/guide/platform-integration.mdx +++ b/content/docs/guide/platform-integration.mdx @@ -19,20 +19,22 @@ The Rust crate `wvb` ([crates.io](https://crates.io/crates/wvb), version 0.2.0) 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. + 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) | +| 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 @@ -166,7 +168,11 @@ let bundle = try await source.fetchBundle(bundleName: "app") ``` -Android and iOS are implemented and end-to-end tested, but the bindings are pre-release. They are not yet published to Maven Central, and there is no Swift Package Manager tag — install from source for now. The minimum supported iOS version is iOS 16. The currently committed xcframework contains a simulator-only slice; the device slice is pending. See the [Android](/docs/guide/platforms/android) and [iOS](/docs/guide/platforms/ios) guides. + Android and iOS are implemented and end-to-end tested, but the bindings are pre-release. They are + not yet published to Maven Central, and there is no Swift Package Manager tag — install from + source for now. The minimum supported iOS version is iOS 16. The currently committed xcframework + contains a simulator-only slice; the device slice is pending. See the + [Android](/docs/guide/platforms/android) and [iOS](/docs/guide/platforms/ios) guides. ### Deno @@ -214,12 +220,12 @@ if (info.isAvailable) { 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(...)` | +| 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. @@ -240,7 +246,8 @@ const info = await updater.getUpdate('app'); ``` -`@wvb/bridge/testing` provides `mockInvoke`, `mockPlatform`, and `mockBridge` so you can stub command responses in unit tests. + `@wvb/bridge/testing` provides `mockInvoke`, `mockPlatform`, and `mockBridge` so you can stub + command responses in unit tests. ## Architecture at a glance @@ -254,12 +261,44 @@ The webview also loads its assets through the same core: the bundle protocol ser ## Where to go next - - - - - - - - + + + + + + + + diff --git a/content/docs/guide/platform-support.mdx b/content/docs/guide/platform-support.mdx index 1a09670..5a8096c 100644 --- a/content/docs/guide/platform-support.mdx +++ b/content/docs/guide/platform-support.mdx @@ -9,14 +9,14 @@ This page is the support matrix: the package or crate per platform, its minimum ## 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 (pre-1.0) | -| [Tauri desktop](/docs/guide/platforms/tauri) | System WebView | `wvb-tauri` 0.1.0 (crate) | Tauri v2 | Stable (pre-1.0) | -| Tauri mobile | System WebView | `wvb-tauri` 0.1.0 (crate) | See [Android](/docs/guide/platforms/android) / [iOS](/docs/guide/platforms/ios) | Pre-release | -| [Android](/docs/guide/platforms/android) | System WebView | `webview-bundle-android` (Kotlin) | minSdk 24 / Android 7.0 | Pre-release — not yet on Maven Central | -| [iOS](/docs/guide/platforms/ios) | WKWebView | `webview-bundle-ios` (Swift) | iOS 16 / macOS 12 | Pre-release — no SPM tag yet | -| [Deno Desktop](/docs/guide/platforms/deno) | Deno webview | `@wvb/deno-desktop` 0.0.0 (JSR) | — | Experimental | +| Platform | Webview host | Package / crate | Min version | Status | +| -------------------------------------------- | -------------- | --------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | +| [Electron](/docs/guide/platforms/electron) | Chromium | `@wvb/electron` 0.1.0 (npm) | `electron >= 15` | Stable (pre-1.0) | +| [Tauri desktop](/docs/guide/platforms/tauri) | System WebView | `wvb-tauri` 0.1.0 (crate) | Tauri v2 | Stable (pre-1.0) | +| Tauri mobile | System WebView | `wvb-tauri` 0.1.0 (crate) | See [Android](/docs/guide/platforms/android) / [iOS](/docs/guide/platforms/ios) | Pre-release | +| [Android](/docs/guide/platforms/android) | System WebView | `webview-bundle-android` (Kotlin) | minSdk 24 / Android 7.0 | Pre-release — not yet on Maven Central | +| [iOS](/docs/guide/platforms/ios) | WKWebView | `webview-bundle-ios` (Swift) | iOS 16 / macOS 12 | Pre-release — no SPM tag yet | +| [Deno Desktop](/docs/guide/platforms/deno) | Deno webview | `@wvb/deno-desktop` 0.0.0 (JSR) | — | Experimental | All integrations are pre-1.0, so APIs may change between minor versions. @@ -39,7 +39,7 @@ import { app, BrowserWindow } from 'electron'; import { bundleProtocol, wvb } from '@wvb/electron'; const instance = wvb({ - protocols: [bundleProtocol('app', { onError: (e) => console.error('[wvb]', e) })], + protocols: [bundleProtocol('app', { onError: e => console.error('[wvb]', e) })], }); app.whenReady().then(async () => { @@ -134,8 +134,8 @@ See the [iOS guide](/docs/guide/platforms/ios). Deno Desktop is the newest integration, on JSR as `@wvb/deno-desktop` (version 0.0.0). It builds a bundle source and exposes a `Deno.serve`-compatible handler, with one protocol per window. - Deno Desktop is **experimental** and offered as a preview. Treat it as not yet production-ready, and - expect breaking changes. See the [Deno Desktop guide](/docs/guide/platforms/deno) and the + Deno Desktop is **experimental** and offered as a preview. Treat it as not yet production-ready, + and expect breaking changes. See the [Deno Desktop guide](/docs/guide/platforms/deno) and the [Deno API reference](/docs/references/deno). diff --git a/content/docs/guide/platforms/android.mdx b/content/docs/guide/platforms/android.mdx index e738b35..1b485ff 100644 --- a/content/docs/guide/platforms/android.mdx +++ b/content/docs/guide/platforms/android.mdx @@ -6,18 +6,23 @@ description: Serve and update Webview Bundles inside an Android WebView with the 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. -**Pre-release.** Not yet published to Maven Central. There is no release tag, so the coordinates below describe the intended artifact, not something you can resolve today. For now, build from the [`webview-bundle-android`](https://github.com/webview-bundle/webview-bundle-android) repository. The native FFI is pinned in `.ffi-version` and installed by `scripts/install.mjs`, which downloads the `android.zip` asset from a [core repo](https://github.com/webview-bundle/webview-bundle) release and unpacks the Kotlin bindings and `jniLibs` into the library module. + **Pre-release.** Not yet published to Maven Central. There is no release tag, so the coordinates + below describe the intended artifact, not something you can resolve today. For now, build from the + [`webview-bundle-android`](https://github.com/webview-bundle/webview-bundle-android) repository. + The native FFI is pinned in `.ffi-version` and installed by `scripts/install.mjs`, which downloads + the `android.zip` asset from a [core repo](https://github.com/webview-bundle/webview-bundle) + release and unpacks the Kotlin bindings and `jniLibs` into the library module. ## 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` | +| 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. @@ -50,7 +55,8 @@ node scripts/install.mjs --prerelease a3f693a # prerelease/ ``` -`install.mjs` requires `unzip` on `PATH` and honors `GITHUB_TOKEN` / `GH_TOKEN`. See [Platform support](/docs/guide/platform-support) for the status of every platform. + `install.mjs` requires `unzip` on `PATH` and honors `GITHUB_TOKEN` / `GH_TOKEN`. See [Platform + support](/docs/guide/platform-support) for the status of every platform. ## Quick start @@ -123,7 +129,9 @@ The manifest needs the internet permission. For local development against a clea ``` -`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. + `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 @@ -279,16 +287,18 @@ val wvb = WebViewBundle.getInstance( `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` | +| `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. + 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 @@ -300,7 +310,7 @@ import kotlinx.coroutines.launch lifecycleScope.launch { val update = wvb.updater?.getUpdate("app") // check the remote, no download - if (update?.isAvailable == true) { + 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 } @@ -315,21 +325,21 @@ The same cycle is available to your web app through the `window.wvbAndroid` brid ```ts declare const wvbAndroid: { - postMessage(message: string): void -} + 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 })) - }) + 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" }) +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). @@ -337,7 +347,19 @@ For the remote HTTP contract, integrity, and signatures across platforms, see [R ## Next steps - - - + + + diff --git a/content/docs/guide/platforms/deno.mdx b/content/docs/guide/platforms/deno.mdx index 0fc1794..64e554a 100644 --- a/content/docs/guide/platforms/deno.mdx +++ b/content/docs/guide/platforms/deno.mdx @@ -6,10 +6,10 @@ description: Serve and update Webview Bundle archives in a Deno desktop webview 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 (preview)" -import { webviewBundle, bundleProtocol } from "@wvb/deno-desktop"; +import { webviewBundle, bundleProtocol } from '@wvb/deno-desktop'; const wvb = await webviewBundle({ - protocols: [bundleProtocol({ scheme: "app" })], + protocols: [bundleProtocol({ scheme: 'app' })], }); const server = Deno.serve(wvb.fetch); @@ -20,7 +20,10 @@ 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 / preview** and not production-ready. The source lives on an un-merged branch; `main` ships only a prebuilt dylib artifact. The JSR packages `@wvb/deno` and `@wvb/deno-desktop` are published at version **`0.0.0`** and their APIs may change. Treat these snippets as preview, not a stable contract. + Deno Desktop is **experimental / preview** and not production-ready. The source lives on an + un-merged branch; `main` ships only a prebuilt dylib artifact. The JSR packages `@wvb/deno` and + `@wvb/deno-desktop` are published at version **`0.0.0`** and their APIs may change. Treat these + snippets as preview, not a stable contract. ## How it fits together @@ -32,12 +35,12 @@ The window points at a single local origin served by `Deno.serve`, so the integr 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 (preview)" -import { webviewBundle, bundleProtocol } from "@wvb/deno-desktop"; +import { webviewBundle, bundleProtocol } from '@wvb/deno-desktop'; const wvb = await webviewBundle({ - protocols: [bundleProtocol({ scheme: "app" })], + protocols: [bundleProtocol({ scheme: 'app' })], updater: { - remote: { endpoint: "https://bundles.example.com" }, + remote: { endpoint: 'https://bundles.example.com' }, }, }); @@ -51,10 +54,10 @@ await win.closed; 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 (preview)" -import { webviewBundle, bundleProtocol, registerBindings } from "@wvb/deno-desktop"; +import { webviewBundle, bundleProtocol, registerBindings } from '@wvb/deno-desktop'; const wvb = await webviewBundle({ - protocols: [bundleProtocol({ scheme: "app" })], + protocols: [bundleProtocol({ scheme: 'app' })], }); const server = Deno.serve(wvb.fetch); @@ -76,12 +79,12 @@ type InvokeResult = 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, preview)" -import { updater } from "@wvb/bridge"; +import { updater } from '@wvb/bridge'; -const update = await updater.getUpdate("my-app"); +const update = await updater.getUpdate('my-app'); if (update.isAvailable) { - await updater.download("my-app"); - await updater.install("my-app", update.version); + await updater.download('my-app'); + await updater.install('my-app', update.version); } ``` @@ -92,15 +95,18 @@ if (update.isAvailable) { Each class is `Disposable` — free it explicitly with `free()`, or let a `using` declaration call `[Symbol.dispose]` at scope exit: ```ts title="dispose.ts (preview)" +import { BundleProtocol } from '@wvb/deno'; + +// `lib` and `source` come from the load + BundleSource steps below. // Explicit cleanup. -const protocol = bundleProtocol({ scheme: "app" }); -const res = await protocol.handle("get", "app://my-app/index.html"); +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 = bundleProtocol({ scheme: "app" }); - await scoped.handle("get", "app://my-app/index.html"); + using scoped = new BundleProtocol(lib, source); + await scoped.handle('get', 'app://my-app/index.html'); } // [Symbol.dispose]() runs here ``` @@ -109,10 +115,10 @@ protocol.free(); 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 (preview)" -import { loadLib, loadLibViaPlug } from "@wvb/deno"; +import { loadLib, loadLibViaPlug } from '@wvb/deno'; // 1. Load a library already on disk. -const lib = loadLib("./vendor/wvb/libwvb_deno.dylib"); +const lib = loadLib('./vendor/wvb/libwvb_deno.dylib'); // 2. Download a sha256-verified prebuilt at runtime. const libViaPlug = await loadLibViaPlug(); @@ -132,13 +138,13 @@ 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` | +| Target triple | +| --------------------------- | +| `aarch64-apple-darwin` | +| `x86_64-apple-darwin` | | `aarch64-unknown-linux-gnu` | -| `x86_64-unknown-linux-gnu` | -| `x86_64-pc-windows-msvc` | +| `x86_64-unknown-linux-gnu` | +| `x86_64-pc-windows-msvc` | Vendor a specific target with `--target`: @@ -156,13 +162,13 @@ Beyond the experimental status, two gaps apply to the Deno bindings today. ```ts title="updater.ts (preview)" const wvb = await webviewBundle({ - protocols: [bundleProtocol({ scheme: "app" })], + protocols: [bundleProtocol({ scheme: 'app' })], updater: { - remote: { endpoint: "https://bundles.example.com" }, - integrityPolicy: "strict", + remote: { endpoint: 'https://bundles.example.com' }, + integrityPolicy: 'strict', signatureVerifier: { - algorithm: "ed25519", - key: { format: "raw", data: publicKeyBytes }, + algorithm: 'ed25519', + key: { format: 'raw', data: publicKeyBytes }, }, }, }); @@ -174,11 +180,11 @@ The custom `integrityChecker` / `signatureVerifier` function callbacks available ```ts title="http.ts (preview)" const wvb = await webviewBundle({ - protocols: [bundleProtocol({ scheme: "app" })], + protocols: [bundleProtocol({ scheme: 'app' })], updater: { remote: { - endpoint: "https://bundles.example.com", - http: { userAgent: "my-app/1.0", timeout: 120_000 }, + endpoint: 'https://bundles.example.com', + http: { userAgent: 'my-app/1.0', timeout: 120_000 }, }, }, }); @@ -189,7 +195,19 @@ For how integrity and signatures work across platforms, see [Remote bundles](/do ## Next steps - - - + + + diff --git a/content/docs/guide/platforms/electron.mdx b/content/docs/guide/platforms/electron.mdx index 2f8801d..9e8bb63 100644 --- a/content/docs/guide/platforms/electron.mdx +++ b/content/docs/guide/platforms/electron.mdx @@ -10,24 +10,9 @@ description: Serve your Electron UI from a .wvb bundle through a custom protocol `@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 -``` - + ```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 @@ -44,9 +29,9 @@ const instance = wvb({ builtinDir: path.join(process.resourcesPath, 'bundles'), }, protocols: [ - // Dev: proxy `app-local://simple.wvb/...` to the Vite dev server for hot reload. + // Dev: proxy `app-local://app.wvb/...` to the Vite dev server for hot reload. localProtocol('app-local', { - hosts: { 'simple.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL }, + 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) }), @@ -64,13 +49,13 @@ async function createWindow() { nodeIntegration: false, }, }); - await win.loadURL('app://simple.wvb'); + await win.loadURL('app://app.wvb'); } app.whenReady().then(createWindow); ``` -URL shape is `://.wvb/` — `app://simple.wvb/index.html` resolves to bundle `simple`, file `/index.html`. +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. @@ -78,7 +63,7 @@ URL shape is `://.wvb/` — `app://simple.wvb/index.h Choose which URL to load based on `app.isPackaged`: ```ts -await win.loadURL(app.isPackaged ? 'app://simple.wvb' : 'app-local://simple.wvb'); +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. @@ -92,7 +77,9 @@ bundleProtocol('app', { ``` - 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. + 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 @@ -120,21 +107,23 @@ 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 + 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). + `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.source; // BundleSource +instance.remote; // Remote | null (null unless `updater` is configured) instance.updater; // Updater | null (null unless `updater` is configured) await instance.whenProtocolRegistered(); ``` @@ -156,7 +145,7 @@ wvb({ }); ``` -Pin a signing key to verify *who* published an update: +Pin a signing key to verify _who_ published an update: ```ts updater: { @@ -174,10 +163,10 @@ updater: { `source` accepts `builtinDir` (shipped, read-only) and `remoteDir` (downloaded updates), both with Electron-aware defaults: -| Option | Default | -| --- | --- | +| Option | Default | +| ------------ | --------------------------------------------------------------------------- | | `builtinDir` | `process.resourcesPath/bundles` when packaged, else `process.cwd()/bundles` | -| `remoteDir` | `app.getPath('userData')/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. @@ -206,7 +195,9 @@ const config: ForgeConfig = { ``` - The Forge Vite plugin can exclude `node_modules` from the package, dropping the native module. If you hit a missing `@wvb/node` binary at runtime, override `packagerConfig.ignore` to keep `node_modules`. + The Forge Vite plugin can exclude `node_modules` from the package, dropping the native module. If + you hit a missing `@wvb/node` binary at runtime, override `packagerConfig.ignore` to keep + `node_modules`. ## Forge and electron-builder integrations @@ -219,9 +210,7 @@ Two helper packages install builtin bundles at package time, so you do not stage import { WebviewBundlePlugin } from '@wvb/electron-forge'; export default { - plugins: [ - new WebviewBundlePlugin({ bundlesDir: 'bundles', channel: 'beta' }), - ], + plugins: [new WebviewBundlePlugin({ bundlesDir: 'bundles', channel: 'beta' })], }; ``` @@ -239,20 +228,22 @@ export default withWebviewBundle({ Or compose the raw `webviewBundleAfterPack(...)` hook (alias `wvbAfterPack`) yourself. Both plugins share these options: -| Option | Type | Default | Meaning | -| --- | --- | --- | --- | -| `bundlesDir` | `string` | `'bundles'` | dest dir under packaged `Resources` | -| `configFile` | `string \| boolean` | `true` | `true` auto-discovers + merges; a path loads explicitly; `false` is inline only | -| `channel` | `string` | — | release channel to install from | -| `throwWhenBuiltinIsEmpty` | `boolean` | `true` | throw if zero bundles installed | +| Option | Type | Default | Meaning | +| ------------------------- | ------------------- | ----------- | ------------------------------------------------------------------------------- | +| `bundlesDir` | `string` | `'bundles'` | dest dir under packaged `Resources` | +| `configFile` | `string \| boolean` | `true` | `true` auto-discovers + merges; a path loads explicitly; `false` is inline only | +| `channel` | `string` | — | release channel to install from | +| `throwWhenBuiltinIsEmpty` | `boolean` | `true` | throw if zero bundles installed | - Both packages live in the repository but are not yet published to npm. Install from source or pin the workspace versions for now. The manual `extraResource` + `AutoUnpackNativesPlugin` setup above works without them. + Both packages live in the repository but are not yet published to npm. Install from source or pin + the workspace versions for now. The manual `extraResource` + `AutoUnpackNativesPlugin` setup above + works without them. ## 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://simple.wvb` → bundle `simple`). +- **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. Verify `extraResource` and `AutoUnpackNativesPlugin`. @@ -260,8 +251,24 @@ Or compose the raw `webviewBundleAfterPack(...)` hook (alias `wvbAfterPack`) you ## Next steps - - - - + + + + diff --git a/content/docs/guide/platforms/ios.mdx b/content/docs/guide/platforms/ios.mdx index 265c777..4b2b66f 100644 --- a/content/docs/guide/platforms/ios.mdx +++ b/content/docs/guide/platforms/ios.mdx @@ -15,18 +15,19 @@ install the native FFI from source (see below). Releases are tagged `ffi/ -The xcframework committed to the package today is **simulator-only** — it ships the -`ios-arm64_x86_64-simulator` slice with no device slice. On-device builds will not link until a -device-bearing `WebViewBundleFFI.xcframework` is installed. Develop against the iOS Simulator for now. + The xcframework committed to the package today is **simulator-only** — it ships the + `ios-arm64_x86_64-simulator` slice with no device slice. On-device builds will not link until a + device-bearing `WebViewBundleFFI.xcframework` is installed. Develop against the iOS Simulator for + now. ## Requirements -| Requirement | Value | -|---|---| -| Minimum OS | iOS 16, macOS 12 (`platforms: [.macOS(.v12), .iOS(.v16)]`) | -| Swift tools | 6.1, language mode 6 | -| SwiftPM product | `WebViewBundle` | +| 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) @@ -160,9 +161,9 @@ iframe messages are dropped) and exposes the source, remote, and updater command ```ts title="web app" window.webkit.messageHandlers.wvbIos.postMessage({ - name: "updaterGetUpdate", - params: { bundleName: "app" }, -}) + name: 'updaterGetUpdate', + params: { bundleName: 'app' }, +}); ``` ## Sources @@ -189,8 +190,8 @@ WebViewBundleConfig( ``` -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. + 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 @@ -213,11 +214,11 @@ let updaterConfig = WebViewBundleUpdaterConfig( `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 | +| 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: @@ -248,7 +249,19 @@ For the remote HTTP contract, integrity, and signing details, see ## Next steps - - - + + + diff --git a/content/docs/guide/platforms/tauri.mdx b/content/docs/guide/platforms/tauri.mdx index 3de0aa4..240e359 100644 --- a/content/docs/guide/platforms/tauri.mdx +++ b/content/docs/guide/platforms/tauri.mdx @@ -6,7 +6,9 @@ description: Add the wvb-tauri plugin to a Tauri v2 app so the webview is served `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`. + 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 @@ -187,11 +189,11 @@ Remote::new("https://updates.example.com") 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)| +| 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'; @@ -211,16 +213,12 @@ interface RemoteBundleInfo { } export async function checkAndUpdate(bundleName: string) { - const update = await invoke( - 'plugin:wvb-tauri|updater_get_update', - { bundleName }, - ); + 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 }, - ); + const info = await invoke('plugin:wvb-tauri|updater_download', { bundleName }); await invoke('plugin:wvb-tauri|updater_install', { bundleName, version: info.version, @@ -232,14 +230,14 @@ export async function checkAndUpdate(bundleName: string) { 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) | +| 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`: @@ -247,10 +245,9 @@ The plugin also exposes `source_*` commands for managing local bundles and `remo 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' }, -); +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, @@ -260,7 +257,9 @@ await invoke('plugin:wvb-tauri|source_update_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`. + 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: @@ -340,8 +339,24 @@ The plugin then extracts each bundle from the APK on first request and caches it ## Learn more - - - - + + + + diff --git a/content/docs/guide/protocol-handling.mdx b/content/docs/guide/protocol-handling.mdx index 273bf68..b604856 100644 --- a/content/docs/guide/protocol-handling.mdx +++ b/content/docs/guide/protocol-handling.mdx @@ -44,7 +44,8 @@ GET app://app.wvb/assets/app.js ``` -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. + 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 @@ -107,7 +108,8 @@ Config::new().protocol( 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. + 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 @@ -126,7 +128,7 @@ const { bundleProtocol, wvb } = require('@wvb/electron'); const instance = wvb({ source: { builtinDir: path.join(__dirname, 'bundles') }, - protocols: [bundleProtocol('app', { onError: (e) => console.error('[wvb]', e) })], + protocols: [bundleProtocol('app', { onError: e => console.error('[wvb]', e) })], }); app.whenReady().then(async () => { @@ -198,7 +200,8 @@ class BundleWebViewClient : WebViewClient() { See the [Android guide](/docs/guide/platforms/android) for the working setup. -Android and iOS are pre-release and not yet published to Maven Central or tagged for SPM. Install from source for now. + Android and iOS are pre-release and not yet published to Maven Central or tagged for SPM. Install + from source for now. @@ -222,8 +225,24 @@ 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 index f4c135c..4f6dacb 100644 --- a/content/docs/guide/providers/aws.mdx +++ b/content/docs/guide/providers/aws.mdx @@ -6,44 +6,39 @@ description: Host, serve, and provision remote Webview Bundles on AWS using S3, 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. -All AWS packages are at `0.1.0` and pre-release. Treat them as preview and install from source until a published release lands. + All AWS packages are at `0.1.0` and pre-release. Treat them as preview and install from source + until a published release lands. 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. | +| 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. | +| 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. | +| 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 -``` - + + ```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 @@ -127,7 +122,11 @@ import { awsRemote } from '@wvb/remote-aws'; 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. + 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 @@ -191,6 +190,14 @@ 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 index a66c7cd..2e0229c 100644 --- a/content/docs/guide/providers/cloudflare.mdx +++ b/content/docs/guide/providers/cloudflare.mdx @@ -7,11 +7,11 @@ Run a Webview Bundle remote entirely on Cloudflare's edge: bundles live in **R2* 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` | +| 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` and pre-release. Pin the version and expect breaking changes. @@ -20,9 +20,9 @@ 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. + 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 @@ -34,9 +34,9 @@ Each role maps onto a Cloudflare service: - 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`. + 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 @@ -60,13 +60,13 @@ export default defineConfig({ }); ``` -| 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`). | +| 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). @@ -208,8 +208,24 @@ Feed the exported `bucketName` and `kvNamespaceId` into `cloudflareRemote()` abo ## Next steps - - - - + + + + diff --git a/content/docs/guide/providers/local.mdx b/content/docs/guide/providers/local.mdx index f9f95c1..440d7a2 100644 --- a/content/docs/guide/providers/local.mdx +++ b/content/docs/guide/providers/local.mdx @@ -6,16 +6,18 @@ description: Run a filesystem-backed remote on your own machine to test the full 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. + 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` (pre-release, not yet published — install from source for now): -| Package | Role | -| --- | --- | -| `@wvb/remote-local` | Publish-side client. `localRemote()` produces the `uploader` and `deployer` you wire into `wvb.config`. | +| 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. @@ -39,8 +41,8 @@ export default defineConfig({ `localRemote()` accepts one optional field: -| Option | Type | Default | Description | -| --- | --- | --- | --- | +| 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: @@ -110,13 +112,13 @@ 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` | — | Suppress server logging. | +| 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: @@ -160,7 +162,8 @@ const app = wvbRemote({ ``` -With `allowOtherVersions` disabled, only the deployed version is reachable via `GET /bundles/{name}`. The version-specific route stays behind `403` until you opt in. + 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 @@ -168,7 +171,8 @@ With `allowOtherVersions` disabled, only the deployed version is reachable via ` 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. + `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: diff --git a/content/docs/guide/remote-bundles.mdx b/content/docs/guide/remote-bundles.mdx index 946f488..ef9aa6b 100644 --- a/content/docs/guide/remote-bundles.mdx +++ b/content/docs/guide/remote-bundles.mdx @@ -38,10 +38,10 @@ The updater ties a [bundle source](/docs/guide/bundle-sources) to a remote endpo 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 `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: @@ -104,13 +104,13 @@ A Webview Bundle server implements four endpoints. The provider packages (local, `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 | +| 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: @@ -145,11 +145,11 @@ 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 | +| 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; @@ -161,7 +161,7 @@ let config = UpdaterConfig::new() ## 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. +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: @@ -171,13 +171,13 @@ Verify algorithms: 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 | +| 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: @@ -231,10 +231,30 @@ A channel is a free-form string; there is no hardcoded default name — when no ## Where to go next - - - - + + + + - + diff --git a/content/docs/guide/remote.mdx b/content/docs/guide/remote.mdx index 88643bb..f1e1e16 100644 --- a/content/docs/guide/remote.mdx +++ b/content/docs/guide/remote.mdx @@ -11,18 +11,18 @@ Webview Bundle ships remotes for three backends — `local`, `aws`, and `cloudfl 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. | +| 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` | +| 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`: @@ -43,19 +43,21 @@ export default defineConfig({ Publish-side integrity and signature options live alongside `uploader`/`deployer` — see [Remote, integrity & signature config](/docs/config/remote). - `@wvb/remote-local` and `@wvb/remote-local-provider` are pre-release (`0.0.0`); the AWS and Cloudflare packages are at `0.1.0`. Treat all of them as not-yet-published and install from source for now. + `@wvb/remote-local` and `@wvb/remote-local-provider` are pre-release (`0.0.0`); the AWS and + Cloudflare packages are at `0.1.0`. Treat all of them as not-yet-published and install from source + for now. ## 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 | +| 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). @@ -66,11 +68,9 @@ import { Hono } from 'hono'; const app = new Hono(); -app.get('/bundles', (c) => - c.json([{ name: 'app', version: '1.1.0' }]), -); +app.get('/bundles', c => c.json([{ name: 'app', version: '1.1.0' }])); -app.get('/bundles/:name', (c) => { +app.get('/bundles/:name', c => { const body = readCurrentBundle(c.req.param('name')); // your storage return new Response(body, { headers: { @@ -81,8 +81,9 @@ app.get('/bundles/:name', (c) => { }); }); -app.get('/bundles/:name/:version', (c) => - c.body(null, 403), // forbid specific versions by default +app.get( + '/bundles/:name/:version', + c => c.body(null, 403) // forbid specific versions by default ); export default app; @@ -166,7 +167,8 @@ if info.is_available { ``` - On the Android emulator, the host machine is not `localhost`. Point the updater at `http://10.0.2.2:4313` instead. + 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 @@ -221,7 +223,19 @@ export const remote = new WebviewBundleRemoteProvider('app', { ``` - - - + + + diff --git a/content/docs/guide/why-webview-bundle.mdx b/content/docs/guide/why-webview-bundle.mdx index dd6a827..383fb77 100644 --- a/content/docs/guide/why-webview-bundle.mdx +++ b/content/docs/guide/why-webview-bundle.mdx @@ -36,7 +36,9 @@ const res = await protocol.handle('get', 'bundle://app/index.html'); 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. + 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 @@ -48,7 +50,7 @@ Two sources back the update model. The `builtin` source is the read-only bundle ```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 + remoteDir: '/path/to/remote', // OTA downloads, takes priority }); ``` @@ -61,7 +63,7 @@ const updater = new Updater(source, remote); const info = await updater.getUpdate('app'); if (info.isAvailable) { - await updater.download('app'); // stage + verify + await updater.download('app'); // stage + verify await updater.install('app', info.version); // activate } ``` @@ -101,7 +103,10 @@ const updater = new Updater(source, remote, { 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. + 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 @@ -155,7 +160,10 @@ Two common alternatives solve part of the same problem: 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 but pre-release: install from source for now, since the mobile artifacts are not yet published to Maven Central or tagged for Swift Package Manager. The iOS minimum is iOS 16. Deno Desktop is experimental. See [Platform support](/docs/guide/platform-support) for current status. + Android and iOS are implemented and shipping but pre-release: install from source for now, since + the mobile artifacts are not yet published to Maven Central or tagged for Swift Package Manager. + 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/references/deno.mdx b/content/docs/references/deno.mdx index bdc2210..3ec2a79 100644 --- a/content/docs/references/deno.mdx +++ b/content/docs/references/deno.mdx @@ -10,8 +10,8 @@ classes, options, and limitations of the Deno binding. **Experimental.** `@wvb/deno` is published on JSR at version `0.0.0`, its source lives on an - unmerged branch, and `main` ships only a prebuilt cdylib. The API surface described here may change - before a stable release. Do not depend on it for production. + unmerged branch, and `main` ships only a prebuilt cdylib. The API surface described here may + change before a stable release. Do not depend on it for production. ## Loading the native library @@ -56,13 +56,13 @@ deno run -A jsr:@wvb/deno/install --out vendor/wvb --target aarch64-unknown-linu 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) | +| 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) | ## API surface @@ -87,7 +87,7 @@ const source = new BundleSource(lib, { using protocol = new BundleProtocol(lib, source); -Deno.serve(async (req) => { +Deno.serve(async req => { const res = await protocol.handle('get', req.url, Object.fromEntries(req.headers)); return toResponse(res); }); diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 4354a0d..4c406e0 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -6,7 +6,10 @@ description: API references for the Rust core, the Node and Deno bindings, and t 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). + 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 @@ -57,5 +60,6 @@ The Tauri and mobile integrations expose their own APIs, documented alongside th - Android (Kotlin) and iOS (Swift) bindings are covered in the [Android guide](/docs/guide/platforms/android) and the [iOS guide](/docs/guide/platforms/ios). - The mobile bindings are pre-release and not yet published to Maven Central or tagged for Swift Package Manager. Install from source for now. + The mobile bindings are pre-release and not yet published to Maven Central or tagged for Swift + Package Manager. Install from source for now. diff --git a/content/docs/references/meta.json b/content/docs/references/meta.json index cab1a3e..22eb027 100644 --- a/content/docs/references/meta.json +++ b/content/docs/references/meta.json @@ -1,10 +1,5 @@ { "root": true, "title": "References", - "pages": [ - "index", - "[Rust (docs.rs)](https://docs.rs/wvb)", - "node", - "deno" - ] + "pages": ["index", "[Rust (docs.rs)](https://docs.rs/wvb)", "node", "deno"] } diff --git a/content/docs/references/node.mdx b/content/docs/references/node.mdx index 2a3b0b0..90152c4 100644 --- a/content/docs/references/node.mdx +++ b/content/docs/references/node.mdx @@ -14,42 +14,30 @@ The addon resolves a prebuilt binary from one of the `@wvb/node-` opti ## Install - -```sh -npm install @wvb/node -``` - - -```sh -pnpm add @wvb/node -``` - - -```sh -yarn add @wvb/node -``` - + ```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. | +| 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. | ## Classes ### Bundle and builder -| 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` | +| 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. It is distinct from the bundle's integrity hash, which uses SHA-2. @@ -57,12 +45,12 @@ These functions read and write `.wvb` archives. A descriptor exposes a bundle's structure without loading every file into memory. -| Class | Key methods | -|---|---| +| 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` | `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` | `LoadedDescriptor` holds a reference-counted handle that is released on garbage collection, so reads stay lazy against the file on disk. @@ -70,33 +58,33 @@ A descriptor exposes a bundle's structure without loading every file into memory `new BundleSource(config: BundleSourceConfig)` manages bundles across a builtin directory (the bundles shipped with the app) and a remote directory (bundles downloaded over the air). When both contain a bundle, the remote directory takes priority. -| 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` | +| 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` | ### Protocol handlers -| Class | Constructor | Method | -|---|---|---| -| `BundleProtocol` | `new BundleProtocol(source: BundleSource)` | `handle(method: HttpMethod, uri: string, headers?): Promise` | -| `LocalProtocol` | `new LocalProtocol(hosts: Record)` | `handle(method, uri, headers?): Promise` | +| Class | Constructor | Method | +| ---------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | +| `BundleProtocol` | `new BundleProtocol(source: BundleSource)` | `handle(method: HttpMethod, uri: string, headers?): Promise` | +| `LocalProtocol` | `new LocalProtocol(hosts: Record)` | `handle(method, uri, headers?): Promise` | `BundleProtocol` serves `scheme://bundle_name/path` requests from a `BundleSource`. It answers `GET` and `HEAD`, and turns a `Range` request into a `206` response. `LocalProtocol` proxies `app://host/...` requests to a localhost URL (useful in development), caching responses and returning `304` when unchanged. See [Protocol handling](/docs/guide/protocol-handling). @@ -104,12 +92,12 @@ A descriptor exposes a bundle's structure without loading every file into memory `new Remote(endpoint: string, options?: RemoteOptions)` is the HTTP client for a [bundle remote](/docs/guide/remote-bundles). It speaks the remote's HTTP contract over the configured `endpoint`. -| 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]>` | +| 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]>` | `download` and `downloadVersion` resolve to a tuple of the bundle info, the parsed `Bundle`, and the raw bytes. `RemoteOptions` accepts an `http` config and an `onDownload` progress callback: @@ -127,33 +115,35 @@ type RemoteOnDownloadData = { ``` -The default request timeout is 120 seconds. Override it through `HttpOptions.timeout` (milliseconds). `HttpOptions` also exposes `userAgent`, `defaultHeaders`, connection-pool tuning, and transport flags. + The default request timeout is 120 seconds. Override it through `HttpOptions.timeout` + (milliseconds). `HttpOptions` also exposes `userAgent`, `defaultHeaders`, connection-pool tuning, + and transport flags. ### Updater `new Updater(source: BundleSource, remote: Remote, options?: UpdaterOptions)` checks a remote for newer bundles, downloads them, and activates them. Downloads and installs are serialized per bundle. -| Method | Signature | Behavior | -|---|---|---| -| `listRemotes` | `(): Promise` | Lists bundles on the remote. | -| `getUpdate` | `(bundleName): Promise` | Checks the remote's current version against the local one. | -| `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, swaps the current version, drops the cached descriptor, and prunes old versions. | +| Method | Signature | Behavior | +| ------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `listRemotes` | `(): Promise` | Lists bundles on the remote. | +| `getUpdate` | `(bundleName): Promise` | Checks the remote's current version against the local one. | +| `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, swaps the current version, drops the cached descriptor, and prunes old versions. | `getUpdate` is the check step — there is no method named `check`. A separate `download` then `install` keeps download and activation as distinct, restartable phases. ## Enums and unions -| Type | Values | -|---|---| -| `Version` | `'v1'` | -| `HttpMethod` | `'get' \| 'head' \| 'options' \| 'post' \| 'put' \| 'patch' \| 'delete' \| 'trace' \| 'connect'` | -| `BundleSourceKind` | `'builtin' \| 'remote'` | -| `IntegrityAlgorithm` | `'sha256' \| 'sha384' \| 'sha512'` (SHA-2; `sha384` recommended) | -| `IntegrityPolicy` | `'strict' \| 'optional' \| 'none'` (`optional` is the default) | -| `SignatureAlgorithm` | `'ecdsaSecp256R1' \| 'ecdsaSecp384R1' \| 'ed25519' \| 'rsaPkcs1V15' \| 'rsaPss'` | -| `VerifyingKeyFormat` | `'spkiDer' \| 'spkiPem' \| 'pkcs1Der' \| 'pkcs1Pem' \| 'sec1' \| 'raw'` | +| Type | Values | +| -------------------- | ------------------------------------------------------------------------------------------------ | +| `Version` | `'v1'` | +| `HttpMethod` | `'get' \| 'head' \| 'options' \| 'post' \| 'put' \| 'patch' \| 'delete' \| 'trace' \| 'connect'` | +| `BundleSourceKind` | `'builtin' \| 'remote'` | +| `IntegrityAlgorithm` | `'sha256' \| 'sha384' \| 'sha512'` (SHA-2; `sha384` recommended) | +| `IntegrityPolicy` | `'strict' \| 'optional' \| 'none'` (`optional` is the default) | +| `SignatureAlgorithm` | `'ecdsaSecp256R1' \| 'ecdsaSecp384R1' \| 'ed25519' \| 'rsaPkcs1V15' \| 'rsaPss'` | +| `VerifyingKeyFormat` | `'spkiDer' \| 'spkiPem' \| 'pkcs1Der' \| 'pkcs1Pem' \| 'sec1' \| 'raw'` | Key-format constraints: `pkcs1Der`/`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, so signature verification requires an integrity value to be present. See [Remote, integrity & signature config](/docs/config/remote). @@ -184,6 +174,53 @@ type SignatureVerifierOptions = { `UpdaterOptions` accepts both a declarative `signatureVerifier` (algorithm + 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 methods above. + +```ts +type BundleManifestMetadata = { + etag?: string; + integrity?: string; // ":", e.g. "sha256:n4bQ…" + signature?: string; // base64 + lastModified?: string; +}; + +// loadVersion() +type BundleSourceVersion = { + type: BundleSourceKind; // 'builtin' | 'remote' + version: string; +}; + +// 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; +}; +``` + ## Examples ### Read a bundle and get a file @@ -255,7 +292,19 @@ if (update.isAvailable) { ## Related - - - + + + diff --git a/src/routes/docs/$.tsx b/src/routes/docs/$.tsx index 0ccfe92..c51b094 100644 --- a/src/routes/docs/$.tsx +++ b/src/routes/docs/$.tsx @@ -14,7 +14,7 @@ export const Route = createFileRoute('/docs/$')({ const slugs = params._splat?.split('/').filter(Boolean) ?? []; // `/docs` has no tab context; send readers to the Guide tab. if (slugs.length === 0) { - throw redirect({ href: '/docs/guide' }); + throw redirect({ to: '/docs/$', params: { _splat: 'guide' } }); } const data = await serverLoader({ data: slugs }); await clientLoader.preload(data.path); diff --git a/src/routes/docs/index.tsx b/src/routes/docs/index.tsx index 0875e6f..1afc964 100644 --- a/src/routes/docs/index.tsx +++ b/src/routes/docs/index.tsx @@ -3,6 +3,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; // `/docs` itself has no tab context — land readers on the Guide tab. export const Route = createFileRoute('/docs/')({ beforeLoad: () => { - throw redirect({ href: '/docs/guide' }); + throw redirect({ to: '/docs/$', params: { _splat: 'guide' } }); }, }); From 65b3808452ec1a06ae07038995cddc241f1957b1 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 00:30:02 +0900 Subject: [PATCH 04/15] feat(docs): share the landing top header and unify the sidebar tone Switch the docs to fumadocs' notebook layout (full-width top navbar with the sidebar below, vite.dev-style) so the docs header reads as the same bar as the landing page: - Feed the navbar the landing's logo, `webview-bundle` wordmark (links to /), the same nav items, and the GitHub link; keep Guide/References/Config as the navbar tab row (tabMode: navbar). - Match the landing header's translucency/blur and mono nav-link styling. - Blend the left sidebar with the page base across themes (transparent + a hairline divider) instead of the previous gray tint, so the docs and landing share one color tone. Verified: yarn build, yarn lint, yarn format --check pass; /docs still redirects to /docs/guide. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- src/routes/docs/$.tsx | 26 +++++++++++++++++++++----- src/styles.css | 34 +++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/routes/docs/$.tsx b/src/routes/docs/$.tsx index c51b094..424f387 100644 --- a/src/routes/docs/$.tsx +++ b/src/routes/docs/$.tsx @@ -1,11 +1,13 @@ import { createFileRoute, notFound, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import { useFumadocsLoader } from 'fumadocs-core/source/client'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; +import { DocsLayout } from 'fumadocs-ui/layouts/notebook'; +import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/notebook/page'; import { Suspense } from 'react'; import browserCollections from '~source/browser'; import { docSource } from '../../doc'; +import { Logo } from '../../layouts/home/components/Logo'; +import { GITHUB_URL, NAV_ITEMS } from '../../layouts/home/data'; import { useMDXComponents } from '../../mdx'; export const Route = createFileRoute('/docs/$')({ @@ -60,11 +62,25 @@ function Page() { return ( + + + webview-bundle + + + ), }} - tabMode="top" - tree={data.pageTree} + links={NAV_ITEMS.map(item => ({ + text: item.label, + url: item.href.startsWith('#') ? `/${item.href}` : item.href, + }))} + githubUrl={GITHUB_URL} > {clientLoader.useContent(data.path)} diff --git a/src/styles.css b/src/styles.css index f451a4b..3964461 100644 --- a/src/styles.css +++ b/src/styles.css @@ -68,12 +68,32 @@ --color-fd-ring: var(--color-brand); } -/* fumadocs re-tints the sidebar with a higher-specificity rule of its own; - * keep it in step with the dark palette above. */ -:root.dark #nd-sidebar { - --color-fd-muted: #18181b; /* zinc-900 */ - --color-fd-secondary: #27272a; /* zinc-800 */ - --color-fd-muted-foreground: #a1a1aa; /* zinc-400 */ +/* --------------------------------------------------------------------------- + * Docs use the notebook layout — a full-width top navbar with the sidebar + * below it — so the docs header reads as the same bar as the landing page. + * The rules below align that header and the left sidebar with the landing's + * color tone (white / #09090a base, zinc surfaces, translucent blurred nav). + * ------------------------------------------------------------------------- */ + +/* Top navbar: match the landing header's translucency and blur. */ +#nd-subnav[data-transparent='false'] { + background-color: color-mix(in oklab, var(--color-fd-background) 85%, transparent); + backdrop-filter: blur(12px); +} + +/* Match the landing nav's mono link styling (the tab row keeps the sans font). */ +#nd-subnav [data-header-body] a { + font-family: var(--font-mono); + font-size: 13.5px; +} + +/* Left sidebar: blend with the page base so it shares the landing's tone, with + * a single hairline divider against the content (vite.dev-style clean sidebar). + * In `nav.mode: top` fumadocs already leaves the sidebar transparent; this keeps + * it that way across themes and adds the divider. */ +#nd-notebook-layout #nd-sidebar { + background-color: transparent; + border-inline-end: 1px solid var(--color-fd-border); } /* --------------------------------------------------------------------------- @@ -82,7 +102,7 @@ * utility inside it — the home page keeps its softer radii, and `rounded-full` * (dots, pills, avatars) ignores these tokens and stays circular. * ------------------------------------------------------------------------- */ -#nd-docs-layout { +#nd-notebook-layout { --radius-xs: 1px; --radius-sm: 2px; --radius-md: 3px; From d5b60f33129dd1b616f31eb255fced37b892f68c Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 00:54:58 +0900 Subject: [PATCH 05/15] feat(docs): custom shared header + left-drawer mobile sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the docs navbar with a custom header (DocsNavbar) passed as the notebook layout's `nav.component`, so every /docs page shows one consistent bar: | Guide References Config Changelog | (spacer) | search | language ▾ | GitHub | theme | - Section links (Guide/References/Config/Changelog) sit on the left with active state; search bar, a language dropdown (English; Korean marked "soon"), the GitHub link, and the theme toggle sit on the right. - Add a Changelog section (content/docs/changelog) listing published versions and linking to the GitHub/crates.io/npm release pages. - Set `--fd-header-height` on the grid so the sidebar/ToC/content offset correctly below the custom 56px header. - Mobile: move the sidebar hamburger to the left of the ToC ("On this page") bar (MobileTocBar) and flip the sidebar drawer to slide in from the LEFT. Verified: yarn build, yarn lint, yarn format --check pass; the header, sections, search/language/GitHub/theme controls, the changelog page, and the left-drawer CSS all render. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- content/docs/changelog/index.mdx | 50 ++++++++++++++ content/docs/changelog/meta.json | 5 ++ content/docs/meta.json | 2 +- src/layouts/docs/DocsNavbar.tsx | 65 ++++++++++++++++++ src/layouts/docs/LanguageDropdown.tsx | 95 +++++++++++++++++++++++++++ src/layouts/docs/MobileTocBar.tsx | 21 ++++++ src/routes/docs/$.tsx | 24 ++----- src/styles.css | 23 ++++--- 8 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 content/docs/changelog/index.mdx create mode 100644 content/docs/changelog/meta.json create mode 100644 src/layouts/docs/DocsNavbar.tsx create mode 100644 src/layouts/docs/LanguageDropdown.tsx create mode 100644 src/layouts/docs/MobileTocBar.tsx diff --git a/content/docs/changelog/index.mdx b/content/docs/changelog/index.mdx new file mode 100644 index 0000000..9718a57 --- /dev/null +++ b/content/docs/changelog/index.mdx @@ -0,0 +1,50 @@ +--- +title: Changelog +description: Where Webview Bundle releases are tracked, and the versions published so far. +--- + +Webview Bundle is pre-1.0. 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 | + + + Some packages are not published yet: `@wvb/electron-builder` and `@wvb/electron-forge` exist in + the repository but are not on npm, and the Android (`webview-bundle-android`) and iOS + (`webview-bundle-ios`) bindings are pre-release — not yet on Maven Central or tagged for Swift + Package Manager. See [Platform support](/docs/guide/platform-support) for current status. + + +## Versioning + +Packages follow [semantic versioning](https://semver.org). While the project is pre-1.0, minor +versions may introduce breaking changes; 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/meta.json b/content/docs/meta.json index a97bed3..5c003f3 100644 --- a/content/docs/meta.json +++ b/content/docs/meta.json @@ -1,3 +1,3 @@ { - "pages": ["guide", "references", "config"] + "pages": ["guide", "references", "config", "changelog"] } diff --git a/src/layouts/docs/DocsNavbar.tsx b/src/layouts/docs/DocsNavbar.tsx new file mode 100644 index 0000000..10aa956 --- /dev/null +++ b/src/layouts/docs/DocsNavbar.tsx @@ -0,0 +1,65 @@ +import { usePathname } from 'fumadocs-core/framework'; +import { FullSearchTrigger, SearchTrigger } from 'fumadocs-ui/layouts/shared/slots/search-trigger'; +import { GitHubIcon } from '../home/components/icons'; +import { Logo } from '../home/components/Logo'; +import { ThemeToggle } from '../home/components/ThemeToggle'; +import { GITHUB_URL } from '../home/data'; +import { LanguageDropdown } from './LanguageDropdown'; + +// The docs share the landing page's header. Section links sit on the left; the +// search bar, language dropdown, GitHub link, and theme toggle sit on the right. +const SECTIONS = [ + { label: 'Guide', href: '/docs/guide' }, + { label: 'References', href: '/docs/references' }, + { label: 'Config', href: '/docs/config' }, + { label: 'Changelog', href: '/docs/changelog' }, +]; + +export function DocsNavbar() { + const pathname = usePathname(); + + return ( +
+
+ + + + webview-bundle + + + + + +
+ + + + + + + +
+
+
+ ); +} diff --git a/src/layouts/docs/LanguageDropdown.tsx b/src/layouts/docs/LanguageDropdown.tsx new file mode 100644 index 0000000..e652bf9 --- /dev/null +++ b/src/layouts/docs/LanguageDropdown.tsx @@ -0,0 +1,95 @@ +import { type ComponentProps, useState } from 'react'; +import { cn } from '../../lib/cn'; + +interface Language { + code: string; + label: string; + available: boolean; +} + +// English ships today; Korean docs are planned (the project already maintains a +// Korean README). This is a placeholder switcher until translated docs land. +const LANGUAGES: Language[] = [ + { code: 'en', label: 'English', available: true }, + { code: 'ko', label: '한국어', available: false }, +]; + +function GlobeIcon(props: ComponentProps<'svg'>) { + return ( + + ); +} + +function ChevronDownIcon(props: ComponentProps<'svg'>) { + return ( + + ); +} + +export function LanguageDropdown() { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( + <> + + ); +} diff --git a/src/layouts/docs/MobileTocBar.tsx b/src/layouts/docs/MobileTocBar.tsx new file mode 100644 index 0000000..c313095 --- /dev/null +++ b/src/layouts/docs/MobileTocBar.tsx @@ -0,0 +1,21 @@ +import { TOCPopover } from 'fumadocs-ui/layouts/notebook/page/slots/toc'; +import { SidebarTrigger } from 'fumadocs-ui/layouts/notebook/slots/sidebar'; +import { MenuIcon } from '../home/components/icons'; + +// On mobile, the table-of-contents bar gets a hamburger on its left that toggles +// the left navigation sidebar drawer. The default Fumadocs "On this page" popover +// is kept; its trigger is padded left to leave room for the hamburger, which is +// pinned over the bar's start edge. +export function MobileTocBar() { + return ( + <> + + + + + + ); +} diff --git a/src/routes/docs/$.tsx b/src/routes/docs/$.tsx index 424f387..4e3f6f4 100644 --- a/src/routes/docs/$.tsx +++ b/src/routes/docs/$.tsx @@ -6,8 +6,9 @@ import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layo import { Suspense } from 'react'; import browserCollections from '~source/browser'; import { docSource } from '../../doc'; -import { Logo } from '../../layouts/home/components/Logo'; -import { GITHUB_URL, NAV_ITEMS } from '../../layouts/home/data'; +import { DocsNavbar } from '../../layouts/docs/DocsNavbar'; +import { MobileTocBar } from '../../layouts/docs/MobileTocBar'; +import { GITHUB_URL } from '../../layouts/home/data'; import { useMDXComponents } from '../../mdx'; export const Route = createFileRoute('/docs/$')({ @@ -46,7 +47,7 @@ const clientLoader = browserCollections.docs.createClientLoader({ _props: undefined ) { return ( - + }}> {frontmatter.title} {frontmatter.description} @@ -64,22 +65,7 @@ function Page() { - - - webview-bundle - - - ), - }} - links={NAV_ITEMS.map(item => ({ - text: item.label, - url: item.href.startsWith('#') ? `/${item.href}` : item.href, - }))} + nav={{ mode: 'top', component: }} githubUrl={GITHUB_URL} > {clientLoader.useContent(data.path)} diff --git a/src/styles.css b/src/styles.css index 3964461..c8dba85 100644 --- a/src/styles.css +++ b/src/styles.css @@ -75,16 +75,23 @@ * color tone (white / #09090a base, zinc surfaces, translucent blurred nav). * ------------------------------------------------------------------------- */ -/* Top navbar: match the landing header's translucency and blur. */ -#nd-subnav[data-transparent='false'] { - background-color: color-mix(in oklab, var(--color-fd-background) 85%, transparent); - backdrop-filter: blur(12px); +/* The docs header is a custom component (DocsNavbar) passed as the layout's + * `nav.component`. Fumadocs' built-in header sets `--fd-header-height` on the + * grid via the shipped `layout:` utility; a custom header doesn't, so declare + * the header row height here (DocsNavbar is `h-14` = 3.5rem). This drives the + * sidebar / ToC / content offsets below the sticky header. */ +#nd-notebook-layout { + --fd-header-height: 3.5rem; } -/* Match the landing nav's mono link styling (the tab row keeps the sans font). */ -#nd-subnav [data-header-body] a { - font-family: var(--font-mono); - font-size: 13.5px; +/* Mobile: the sidebar drawer slides in from the LEFT (the hamburger lives at the + * start of the ToC bar). Fumadocs defaults the drawer to the end (right) edge. */ +#nd-sidebar-mobile { + inset-inline-start: 0; + inset-inline-end: auto; + border-inline-start-width: 0; + border-inline-end-width: 1px; + --fd-sidebar-drawer-offset: -100%; } /* Left sidebar: blend with the page base so it shares the landing's tone, with From e5ac2910c8ae5ceace53249a4c674798ca1e54ad Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 01:14:29 +0900 Subject: [PATCH 06/15] feat(header): share one header across landing + docs; mobile/search fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract a shared SiteHeader used by both the landing page and the docs, so they share one design (logo, wordmark, nav links, search, language dropdown, GitHub, theme). The landing keeps its marketing nav items + mobile menu; the docs keep their section links. - Always show the `webview-bundle` wordmark, including on mobile. - Show the sidebar hamburger (in the ToC bar) on mobile only — it was appearing on tablet too, where the static sidebar is already visible. (`xl:hidden` -> `md:hidden`, and only pad the ToC trigger below `md`.) - Make every right-side control 32px tall so the search box lines up with the language / GitHub / theme buttons next to it. The full search bar collapses to an icon below `lg`. Verified in Chrome (desktop, tablet, mobile; light + dark): unified headers, mobile wordmark, mobile-only hamburger toggling the left drawer, and aligned control heights. yarn build / lint / format --check all pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- src/layouts/SiteHeader.tsx | 80 +++++++++++++++++++++ src/layouts/docs/DocsNavbar.tsx | 58 ++------------- src/layouts/docs/LanguageDropdown.tsx | 2 +- src/layouts/docs/MobileTocBar.tsx | 4 +- src/layouts/home/components/MobileMenu.tsx | 2 +- src/layouts/home/components/Navbar.tsx | 45 ++---------- src/layouts/home/components/ThemeToggle.tsx | 2 +- 7 files changed, 95 insertions(+), 98 deletions(-) create mode 100644 src/layouts/SiteHeader.tsx diff --git a/src/layouts/SiteHeader.tsx b/src/layouts/SiteHeader.tsx new file mode 100644 index 0000000..71d1f58 --- /dev/null +++ b/src/layouts/SiteHeader.tsx @@ -0,0 +1,80 @@ +import { usePathname } from 'fumadocs-core/framework'; +import { FullSearchTrigger, SearchTrigger } from 'fumadocs-ui/layouts/shared/slots/search-trigger'; +import type { ReactNode } from 'react'; +import { cn } from '../lib/cn'; +import { LanguageDropdown } from './docs/LanguageDropdown'; +import { GitHubIcon } from './home/components/icons'; +import { Logo } from './home/components/Logo'; +import { ThemeToggle } from './home/components/ThemeToggle'; +import { GITHUB_URL } from './home/data'; + +export interface HeaderLink { + label: string; + href: string; +} + +// Shared site header for both the landing page and the docs, so the two share +// one design (logo, wordmark, nav links, search, language, GitHub, theme). The +// `className` positions it per context (landing: `sticky top-0`; docs: the +// notebook grid header area). All right-side controls are 32px tall so the +// search box lines up with the buttons next to it. +const CONTROL_BUTTON = + 'flex size-8 items-center justify-center rounded-md border border-zinc-300 text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900 dark:border-zinc-800 dark:text-zinc-400 dark:hover:border-zinc-700 dark:hover:text-zinc-100'; + +export function SiteHeader({ + links, + className, + mobileMenu, +}: { + links: HeaderLink[]; + className?: string; + mobileMenu?: ReactNode; +}) { + const pathname = usePathname(); + + return ( +
+
+ + + webview-bundle + + + + +
+ + + + + + + + {mobileMenu} +
+
+
+ ); +} diff --git a/src/layouts/docs/DocsNavbar.tsx b/src/layouts/docs/DocsNavbar.tsx index 10aa956..5e9f323 100644 --- a/src/layouts/docs/DocsNavbar.tsx +++ b/src/layouts/docs/DocsNavbar.tsx @@ -1,13 +1,8 @@ -import { usePathname } from 'fumadocs-core/framework'; -import { FullSearchTrigger, SearchTrigger } from 'fumadocs-ui/layouts/shared/slots/search-trigger'; -import { GitHubIcon } from '../home/components/icons'; -import { Logo } from '../home/components/Logo'; -import { ThemeToggle } from '../home/components/ThemeToggle'; -import { GITHUB_URL } from '../home/data'; -import { LanguageDropdown } from './LanguageDropdown'; +import { SiteHeader } from '../SiteHeader'; -// The docs share the landing page's header. Section links sit on the left; the -// search bar, language dropdown, GitHub link, and theme toggle sit on the right. +// Section links for the docs; the rest of the header is shared with the landing +// page through {@link SiteHeader}. Positioned in the notebook layout's header +// grid area and sticky below any banner. const SECTIONS = [ { label: 'Guide', href: '/docs/guide' }, { label: 'References', href: '/docs/references' }, @@ -16,50 +11,7 @@ const SECTIONS = [ ]; export function DocsNavbar() { - const pathname = usePathname(); - return ( -
-
- - - - webview-bundle - - - - - -
- - - - - - - -
-
-
+ ); } diff --git a/src/layouts/docs/LanguageDropdown.tsx b/src/layouts/docs/LanguageDropdown.tsx index e652bf9..a3b8a19 100644 --- a/src/layouts/docs/LanguageDropdown.tsx +++ b/src/layouts/docs/LanguageDropdown.tsx @@ -55,7 +55,7 @@ export function LanguageDropdown() { onClick={() => setOpen(value => !value)} aria-label="Select language" aria-expanded={open} - className="flex items-center gap-1 rounded-md border border-zinc-300 px-2 py-1.5 text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900 dark:border-zinc-800 dark:text-zinc-400 dark:hover:border-zinc-700 dark:hover:text-zinc-100" + className="flex h-8 items-center gap-1 rounded-md border border-zinc-300 px-2 text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900 dark:border-zinc-800 dark:text-zinc-400 dark:hover:border-zinc-700 dark:hover:text-zinc-100" > diff --git a/src/layouts/docs/MobileTocBar.tsx b/src/layouts/docs/MobileTocBar.tsx index c313095..745cbd7 100644 --- a/src/layouts/docs/MobileTocBar.tsx +++ b/src/layouts/docs/MobileTocBar.tsx @@ -9,10 +9,10 @@ import { MenuIcon } from '../home/components/icons'; export function MobileTocBar() { return ( <> - + diff --git a/src/layouts/home/components/MobileMenu.tsx b/src/layouts/home/components/MobileMenu.tsx index 5ecce77..c3ba2be 100644 --- a/src/layouts/home/components/MobileMenu.tsx +++ b/src/layouts/home/components/MobileMenu.tsx @@ -12,7 +12,7 @@ export function MobileMenu() { diff --git a/src/layouts/home/components/Navbar.tsx b/src/layouts/home/components/Navbar.tsx index e0a9f08..6039827 100644 --- a/src/layouts/home/components/Navbar.tsx +++ b/src/layouts/home/components/Navbar.tsx @@ -1,44 +1,9 @@ -import { GITHUB_URL, NAV_ITEMS } from '../data'; -import { GitHubIcon } from './icons'; -import { Logo } from './Logo'; +import { SiteHeader } from '../../SiteHeader'; +import { NAV_ITEMS } from '../data'; import { MobileMenu } from './MobileMenu'; -import { ThemeToggle } from './ThemeToggle'; +// The landing page shares the docs header (see {@link SiteHeader}); it keeps the +// marketing nav items and the mobile menu, and is sticky to the viewport top. export function Navbar() { - return ( -
-
- - - webview-bundle - - - - -
- - - - - -
-
-
- ); + return } />; } diff --git a/src/layouts/home/components/ThemeToggle.tsx b/src/layouts/home/components/ThemeToggle.tsx index 059bfd1..2835c1e 100644 --- a/src/layouts/home/components/ThemeToggle.tsx +++ b/src/layouts/home/components/ThemeToggle.tsx @@ -24,7 +24,7 @@ export function ThemeToggle({ className }: ThemeToggleProps) { onClick={() => setTheme(isDark ? 'light' : 'dark')} aria-label="Toggle theme" className={cn( - 'rounded-md border border-zinc-300 p-1.5 text-zinc-600 transition-colors', + 'flex size-8 items-center justify-center rounded-md border border-zinc-300 text-zinc-600 transition-colors', 'hover:border-zinc-400 hover:text-zinc-900', 'dark:border-zinc-800 dark:text-zinc-400 dark:hover:border-zinc-700 dark:hover:text-zinc-100', className From 3f871a71aafa91bb5ea7540cd0c0329575ac2d89 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 01:27:07 +0900 Subject: [PATCH 07/15] feat(header): unify landing nav with docs; full-screen mobile menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The landing page now uses the same nav as the docs (Guide / References / Config / Changelog) via the shared SiteHeader — one header everywhere. - Mobile header is now: logo | (spacer) | search icon | hamburger. The language / GitHub / theme controls move off the bar into the menu. - The hamburger opens a full-screen overlay (MobileNav) listing Guide, References, Config, Changelog, with a footer row of language select (opens upward), theme toggle, and GitHub link. - Replace the landing's right-drawer MobileMenu with this shared overlay; add a `menuPlacement` option to the language dropdown so it opens upward at the bottom of the overlay. Verified in Chrome (landing + docs, mobile + desktop, light + dark): unified nav, the mobile logo|search|hamburger layout, and the full-screen overlay with its footer controls. yarn build / lint / format --check pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- src/layouts/MobileNav.tsx | 70 ++++++++++++++++++++++ src/layouts/SiteHeader.tsx | 68 +++++++++------------ src/layouts/docs/DocsNavbar.tsx | 16 +---- src/layouts/docs/LanguageDropdown.tsx | 11 +++- src/layouts/header-shared.ts | 20 +++++++ src/layouts/home/components/MobileMenu.tsx | 65 -------------------- src/layouts/home/components/Navbar.tsx | 8 +-- 7 files changed, 133 insertions(+), 125 deletions(-) create mode 100644 src/layouts/MobileNav.tsx create mode 100644 src/layouts/header-shared.ts delete mode 100644 src/layouts/home/components/MobileMenu.tsx diff --git a/src/layouts/MobileNav.tsx b/src/layouts/MobileNav.tsx new file mode 100644 index 0000000..c97b45f --- /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 ( + + + + + + + + +
+ + + + webview-bundle + + + + + +
+ + + +
+ +
+ + + + +
+
+
+
+
+ ); +} diff --git a/src/layouts/SiteHeader.tsx b/src/layouts/SiteHeader.tsx index 71d1f58..bb8e73a 100644 --- a/src/layouts/SiteHeader.tsx +++ b/src/layouts/SiteHeader.tsx @@ -1,35 +1,20 @@ import { usePathname } from 'fumadocs-core/framework'; import { FullSearchTrigger, SearchTrigger } from 'fumadocs-ui/layouts/shared/slots/search-trigger'; -import type { ReactNode } from 'react'; import { cn } from '../lib/cn'; import { LanguageDropdown } from './docs/LanguageDropdown'; +import { CONTROL_BUTTON, SECTIONS } from './header-shared'; import { GitHubIcon } from './home/components/icons'; import { Logo } from './home/components/Logo'; import { ThemeToggle } from './home/components/ThemeToggle'; import { GITHUB_URL } from './home/data'; +import { MobileNav } from './MobileNav'; -export interface HeaderLink { - label: string; - href: string; -} - -// Shared site header for both the landing page and the docs, so the two share -// one design (logo, wordmark, nav links, search, language, GitHub, theme). The +// Shared site header for both the landing page and the docs. Desktop (`md`+): +// logo, section links, search, language, GitHub, theme. Mobile (`< md`): +// logo, search icon, and a hamburger that opens the full-screen {@link MobileNav}. // `className` positions it per context (landing: `sticky top-0`; docs: the -// notebook grid header area). All right-side controls are 32px tall so the -// search box lines up with the buttons next to it. -const CONTROL_BUTTON = - 'flex size-8 items-center justify-center rounded-md border border-zinc-300 text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900 dark:border-zinc-800 dark:text-zinc-400 dark:hover:border-zinc-700 dark:hover:text-zinc-100'; - -export function SiteHeader({ - links, - className, - mobileMenu, -}: { - links: HeaderLink[]; - className?: string; - mobileMenu?: ReactNode; -}) { +// notebook layout's header grid area). +export function SiteHeader({ className }: { className?: string }) { const pathname = usePathname(); return ( @@ -46,14 +31,14 @@ export function SiteHeader({ @@ -61,18 +46,23 @@ export function SiteHeader({
- - - - - - {mobileMenu} + + {/* Desktop controls; on mobile these live inside the full-screen menu. */} +
+ + + + + +
+ +
diff --git a/src/layouts/docs/DocsNavbar.tsx b/src/layouts/docs/DocsNavbar.tsx index 5e9f323..9c5b839 100644 --- a/src/layouts/docs/DocsNavbar.tsx +++ b/src/layouts/docs/DocsNavbar.tsx @@ -1,17 +1,7 @@ import { SiteHeader } from '../SiteHeader'; -// Section links for the docs; the rest of the header is shared with the landing -// page through {@link SiteHeader}. Positioned in the notebook layout's header -// grid area and sticky below any banner. -const SECTIONS = [ - { label: 'Guide', href: '/docs/guide' }, - { label: 'References', href: '/docs/references' }, - { label: 'Config', href: '/docs/config' }, - { label: 'Changelog', href: '/docs/changelog' }, -]; - +// The docs header is the shared site header, positioned in the notebook layout's +// header grid area and sticky below any banner. export function DocsNavbar() { - return ( - - ); + return ; } diff --git a/src/layouts/docs/LanguageDropdown.tsx b/src/layouts/docs/LanguageDropdown.tsx index a3b8a19..18229ce 100644 --- a/src/layouts/docs/LanguageDropdown.tsx +++ b/src/layouts/docs/LanguageDropdown.tsx @@ -45,11 +45,11 @@ function ChevronDownIcon(props: ComponentProps<'svg'>) { ); } -export function LanguageDropdown() { +export function LanguageDropdown({ menuPlacement = 'down' }: { menuPlacement?: 'down' | 'up' }) { const [open, setOpen] = useState(false); return ( -
+
-
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 5e8b3ea..b3a227a 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -18,6 +18,10 @@ export const Route = createRootRoute({ }, ], links: [ + { + rel: 'preconnect', + href: 'https://static.wvb.dev', + }, { rel: 'stylesheet', href: styles, diff --git a/upload.ts b/upload.ts new file mode 100644 index 0000000..41d2345 --- /dev/null +++ b/upload.ts @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { ListObjectsV2Command, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import dotenv from 'dotenv'; +import mime from 'mime'; +import { glob } from 'tinyglobby'; + +dotenv.config(); + +const UPLOAD_CONCURRENCY = 8; + +const filesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public'); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return value; +} + +const bucket = 'wvb-static'; +const cacheControl = 'public, max-age=31536000, immutable'; +const accountId = requireEnv('CLOUDFLARE_ACCOUNT_ID'); +const accessKeyId = requireEnv('R2_ACCESS_KEY_ID'); +const secretAccessKey = requireEnv('R2_SECRET_ACCESS_KEY'); + +const client = new S3Client({ + region: 'auto', + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId, secretAccessKey }, +}); + +/** Every object key already present in the bucket (paginated). */ +async function listExistingKeys(): Promise> { + const keys = new Set(); + let continuationToken: string | undefined; + do { + const res = await client.send( + new ListObjectsV2Command({ Bucket: bucket, ContinuationToken: continuationToken }) + ); + for (const obj of res.Contents ?? []) { + if (obj.Key) keys.add(obj.Key); + } + continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined; + } while (continuationToken); + return keys; +} + +async function uploadFile(key: string): Promise { + const body = await readFile(path.join(filesDir, key)); + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: mime.getType(key) ?? 'application/octet-stream', + CacheControl: cacheControl, + }) + ); + console.log(` ↑ ${key}`); +} + +/** Runs `task` over `items` with a fixed-size worker pool. */ +async function runPool(items: T[], task: (item: T) => Promise): Promise { + let cursor = 0; + const workers = Array.from({ length: Math.min(UPLOAD_CONCURRENCY, items.length) }, async () => { + while (cursor < items.length) { + const item = items[cursor++]!; + await task(item); + } + }); + await Promise.all(workers); +} + +async function main(): Promise { + // tinyglobby returns paths relative to `cwd` with posix separators, which is + // exactly the object-key shape we want. + const localKeys = await glob('**/*', { cwd: filesDir, onlyFiles: true, dot: false }); + if (localKeys.length === 0) { + console.log(`No files found under ${filesDir}; nothing to upload.`); + return; + } + + const existing = await listExistingKeys(); + const toUpload = localKeys.filter(key => !existing.has(key)).sort(); + + console.log( + `${bucket}: ${localKeys.length} local file(s), ${existing.size} already uploaded, ` + + `${toUpload.length} new.` + ); + + if (toUpload.length === 0) { + console.log('Everything is already up to date.'); + return; + } + + await runPool(toUpload, uploadFile); + console.log(`Uploaded ${toUpload.length} file(s) to ${bucket}.`); +} + +await main(); diff --git a/yarn.lock b/yarn.lock index 1607949..b97ac89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,370 @@ __metadata: version: 10 cacheKey: 10c0 +"@aws-crypto/crc32@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/eab9581d3363af5ea498ae0e72de792f54d8890360e14a9d8261b7b5c55ebe080279fb2556e07994d785341cdaa99ab0b1ccf137832b53b5904cd6928f2b094b + languageName: node + linkType: hard + +"@aws-crypto/crc32c@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32c@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/223efac396cdebaf5645568fa9a38cd0c322c960ae1f4276bedfe2e1031d0112e49d7d39225d386354680ecefae29f39af469a84b2ddfa77cb6692036188af77 + languageName: node + linkType: hard + +"@aws-crypto/sha1-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha1-browser@npm:5.2.0" + dependencies: + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/51fed0bf078c10322d910af179871b7d299dde5b5897873ffbeeb036f427e5d11d23db9794439226544b73901920fd19f4d86bbc103ed73cc0cfdea47a83c6ac + languageName: node + linkType: hard + +"@aws-crypto/sha256-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-browser@npm:5.2.0" + dependencies: + "@aws-crypto/sha256-js": "npm:^5.2.0" + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/05f6d256794df800fe9aef5f52f2ac7415f7f3117d461f85a6aecaa4e29e91527b6fd503681a17136fa89e9dd3d916e9c7e4cfb5eba222875cb6c077bdc1d00d + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-js@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/6c48701f8336341bb104dfde3d0050c89c288051f6b5e9bdfeb8091cf3ffc86efcd5c9e6ff2a4a134406b019c07aca9db608128f8d9267c952578a3108db9fd1 + languageName: node + linkType: hard + +"@aws-crypto/supports-web-crypto@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/4d2118e29d68ca3f5947f1e37ce1fbb3239a0c569cc938cdc8ab8390d595609b5caf51a07c9e0535105b17bf5c52ea256fed705a07e9681118120ab64ee73af2 + languageName: node + linkType: hard + +"@aws-crypto/util@npm:5.2.0, @aws-crypto/util@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/util@npm:5.2.0" + dependencies: + "@aws-sdk/types": "npm:^3.222.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/0362d4c197b1fd64b423966945130207d1fe23e1bb2878a18e361f7743c8d339dad3f8729895a29aa34fff6a86c65f281cf5167c4bf253f21627ae80b6dd2951 + languageName: node + linkType: hard + +"@aws-sdk/checksums@npm:^3.1000.9": + version: 3.1000.9 + resolution: "@aws-sdk/checksums@npm:3.1000.9" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@aws-crypto/crc32c": "npm:5.2.0" + "@aws-crypto/util": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/f50daea883f56561e86b6e7180bdc505b929a3bf96ef3981d220e6ae2bd011ecd12424f60d2900c6ff406197b1b3db4b291aed2ef5bb1fb19ca2e4be06eb4468 + languageName: node + linkType: hard + +"@aws-sdk/client-s3@npm:^3.1076.0": + version: 3.1076.0 + resolution: "@aws-sdk/client-s3@npm:3.1076.0" + dependencies: + "@aws-crypto/sha1-browser": "npm:5.2.0" + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/credential-provider-node": "npm:^3.972.59" + "@aws-sdk/middleware-flexible-checksums": "npm:^3.974.34" + "@aws-sdk/middleware-sdk-s3": "npm:^3.972.55" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.36" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/fetch-http-handler": "npm:^5.6.0" + "@smithy/node-http-handler": "npm:^4.9.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/4e8c5468fcb0b98583d8c0fdc5cabbf66f3cc64c84c33fae0b8ef7482438215e96155d1c69ab97c9d5de1a31f4dfb69c1e63c13c1366e3a0c26409f849d185a7 + languageName: node + linkType: hard + +"@aws-sdk/core@npm:^3.974.24": + version: 3.974.24 + resolution: "@aws-sdk/core@npm:3.974.24" + dependencies: + "@aws-sdk/types": "npm:^3.973.14" + "@aws-sdk/xml-builder": "npm:^3.972.32" + "@aws/lambda-invoke-store": "npm:^0.2.2" + "@smithy/core": "npm:^3.27.0" + "@smithy/signature-v4": "npm:^5.5.3" + "@smithy/types": "npm:^4.15.0" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/a91d9cbf346ff1891ece999102bcf7f7b316e8ddd6aa22fe3538ea29074af209d922b3dbc5d7f48eeeafa567f5ddbc253175b124dbf5bf04ba0e98d7ac173155 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:^3.972.50": + version: 3.972.50 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.50" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/5d5a07849b7c55f41911a84549578f276010645e2185e598bd58a8a34f57d51b975afd51891e828e5639ce7fde14a3a1f4b754c96a33a6957ba6b2fa4ab79b38 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-http@npm:^3.972.52": + version: 3.972.52 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.52" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/fetch-http-handler": "npm:^5.6.0" + "@smithy/node-http-handler": "npm:^4.9.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/2b0be4c332a608535ddda0b4c689ec911ced3ebdde1cdd64dec1e4c56e83a71981984293559bd2fb2071a5fcd513bcd73756904b1be1cc8a9ce3ea2459272a81 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:^3.972.57": + version: 3.972.57 + resolution: "@aws-sdk/credential-provider-ini@npm:3.972.57" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/credential-provider-env": "npm:^3.972.50" + "@aws-sdk/credential-provider-http": "npm:^3.972.52" + "@aws-sdk/credential-provider-login": "npm:^3.972.56" + "@aws-sdk/credential-provider-process": "npm:^3.972.50" + "@aws-sdk/credential-provider-sso": "npm:^3.972.56" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.56" + "@aws-sdk/nested-clients": "npm:^3.997.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/credential-provider-imds": "npm:^4.4.3" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/7146b0fb59ed4344a165e97c4891b131edc8ddbf923abe3f77b8b47e996a784896a05e599d6a7bc9e32841b5cfc69c9af338963cd86e68471fcbe04c63d26a3b + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-login@npm:^3.972.56": + version: 3.972.56 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.56" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/nested-clients": "npm:^3.997.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/898993601bde88ae400c60e1c69152cb4efef7c2fb8ce745d29cafdc662e310b16f16e50f34d16f11ce196f05464e8c022761777af4e85c5832c0152841ab3c5 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:^3.972.59": + version: 3.972.59 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.59" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.50" + "@aws-sdk/credential-provider-http": "npm:^3.972.52" + "@aws-sdk/credential-provider-ini": "npm:^3.972.57" + "@aws-sdk/credential-provider-process": "npm:^3.972.50" + "@aws-sdk/credential-provider-sso": "npm:^3.972.56" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.56" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/credential-provider-imds": "npm:^4.4.3" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/472127e89416c1edffb6ebf12bcc13bbbbcaaef278ce6d2d2ff229cfc0b855bedd9b7ef2a0d9d575f74d6d5d21afbe04cb4d67f5a7909d96e5157e12f9b26544 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:^3.972.50": + version: 3.972.50 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.50" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/decf91e63b879c3bba4bbdc6b1cbada53d8d7e5367bdee9abf322cfd0ea35fbaf6cbf6f8aaa3beea55dc18ccd98e0992f9d887d8ca8f28b1d902b932eca73934 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:^3.972.56": + version: 3.972.56 + resolution: "@aws-sdk/credential-provider-sso@npm:3.972.56" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/nested-clients": "npm:^3.997.24" + "@aws-sdk/token-providers": "npm:3.1076.0" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/c7e0bc28a9de53a10152cb9ec4cb6bba651a38fa993f4c0130d01feb4ca33f24e3bf102fbd1fdc9aec9667e0847b3123f4d2b64493ceecd62a259113f94afda5 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:^3.972.56": + version: 3.972.56 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.56" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/nested-clients": "npm:^3.997.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/a4dd228843bf94a0b99a406b50991d800493043982471033cbe0d05a04dbafae5ab917c6208aa731c1e18a0f85a4ba0fabdb32b1b161d74fe44bf868a81ce2bb + languageName: node + linkType: hard + +"@aws-sdk/middleware-flexible-checksums@npm:^3.974.34": + version: 3.974.34 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.974.34" + dependencies: + "@aws-sdk/checksums": "npm:^3.1000.9" + tslib: "npm:^2.6.2" + checksum: 10c0/1ac25e8609fe28dc2e0821d2cc1fb2c37fcfa412f1b265b274994ea2706f0c32fbc1bfea2e9cbb88eca2563bf33fe6d30698ecc74eb17f3eeac921daa0919776 + languageName: node + linkType: hard + +"@aws-sdk/middleware-sdk-s3@npm:^3.972.55": + version: 3.972.55 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.972.55" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.36" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/cbb6c8ca1c3cb3baab874603f132eb24eb02073bbb060f56db561226cb5d817b5e344c8b33892db4e88fc2dbf1af255c3e87794b4afc0522ad45883387bf97d3 + languageName: node + linkType: hard + +"@aws-sdk/nested-clients@npm:^3.997.24": + version: 3.997.24 + resolution: "@aws-sdk/nested-clients@npm:3.997.24" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.36" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/fetch-http-handler": "npm:^5.6.0" + "@smithy/node-http-handler": "npm:^4.9.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/5f3b9182bbed2c21c0d41eeaeb05936ee9ab2207c9ce6df18041b94669b17cc06c05d18e397c4dc7ad132894922ea6bced2c59f345e10694411c74c2162cefea + languageName: node + linkType: hard + +"@aws-sdk/signature-v4-multi-region@npm:^3.996.36": + version: 3.996.36 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.36" + dependencies: + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/signature-v4": "npm:^5.5.3" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/37a5210d2e9d29f8f60d19149f2b6c5d91c7eaa2cb8e0414ea13253049aa3f40fc0a86cd5a07fdb2361333bf0fccd98140dabc07e4c76a38cc5c96f76c0ae7bd + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.1076.0": + version: 3.1076.0 + resolution: "@aws-sdk/token-providers@npm:3.1076.0" + dependencies: + "@aws-sdk/core": "npm:^3.974.24" + "@aws-sdk/nested-clients": "npm:^3.997.24" + "@aws-sdk/types": "npm:^3.973.14" + "@smithy/core": "npm:^3.27.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/b04eb8488693097f7eccc945106f19c01c2a1d6933026a5918be358189a480b4a516e36b711103764009c1fc05848d681379acb980cf30e66b0e95c6b2eded96 + languageName: node + linkType: hard + +"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.14": + version: 3.973.14 + resolution: "@aws-sdk/types@npm:3.973.14" + dependencies: + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/7261ba4b64259562e0813c07b4d6f56518c8f9df85c3f4658e999867e8813b5cd209cc5769f8e52b62601afd6a4e613df180ffdfd70972627038c149d0e25150 + languageName: node + linkType: hard + +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.965.8 + resolution: "@aws-sdk/util-locate-window@npm:3.965.8" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/fc3fd154cf560d815a4e9a58aadd1aed860a9dd8cc82ce9824faf9255c688626339077940d35ae91798b753dd7ad4e735c08d4f015b573e681130712a8b52865 + languageName: node + linkType: hard + +"@aws-sdk/xml-builder@npm:^3.972.32": + version: 3.972.32 + resolution: "@aws-sdk/xml-builder@npm:3.972.32" + dependencies: + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/a19744136a49ade2da50f6964468e3d9e321794771732afff87df22ad0d5ccdf5b8474b6267527e3ab295d6525f7fde2d210c4e453507f28bc3542bd1e486abd + languageName: node + linkType: hard + +"@aws/lambda-invoke-store@npm:^0.2.2": + version: 0.2.4 + resolution: "@aws/lambda-invoke-store@npm:0.2.4" + checksum: 10c0/29d874d7c1a2d971e0c02980594204f89cda718f215f2fc52b6c56eacbdad1fa5f6ce1b358e5811f5cd35d04c76299a67a8aff95318446af2bdfb4910f213e13 + languageName: node + linkType: hard + "@babel/code-frame@npm:7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -3157,6 +3521,98 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.27.0, @smithy/core@npm:^3.28.0": + version: 3.28.0 + resolution: "@smithy/core@npm:3.28.0" + dependencies: + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/1f3cf0be56ac588b9271a308c3330d942d3e61904ee2674250d3f8a1d2262e58cb7f1fe0f16797c8b5df988d3a1b5a5df516bab24e0c678df140a49e325843af + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^4.4.3": + version: 4.4.4 + resolution: "@smithy/credential-provider-imds@npm:4.4.4" + dependencies: + "@smithy/core": "npm:^3.28.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/57a897f061c3f8af2a1e4b8d2bb737f726a1f6f5f28b8b79ad23b0a27adb3c4ec4d85ffada817ddd7cc49d0ce2c2dc349314ed9a7cbe12c55e761ddd0620f954 + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^5.6.0": + version: 5.6.1 + resolution: "@smithy/fetch-http-handler@npm:5.6.1" + dependencies: + "@smithy/core": "npm:^3.28.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/77c6d2f76a9971d683f499df44053ab9bc51ab1be1cb76687285f53c5348b5f3b5d35e04d00460f8fbadbb3cc4a0d576f7f1c6951b93e1583c0df72af87f53ec + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/is-array-buffer@npm:2.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/2f2523cd8cc4538131e408eb31664983fecb0c8724956788b015aaf3ab85a0c976b50f4f09b176f1ed7bbe79f3edf80743be7a80a11f22cd9ce1285d77161aaf + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^4.9.0": + version: 4.9.1 + resolution: "@smithy/node-http-handler@npm:4.9.1" + dependencies: + "@smithy/core": "npm:^3.28.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/3a4b752b6616889d29d6df3ea29b2d35f266ae7daa5b8a05802e3b9e724ed3f1340e4bf3a02b903a87887e79ce1ee3e4b697d6b6cb3b39f496245eb387268d21 + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^5.5.3": + version: 5.6.0 + resolution: "@smithy/signature-v4@npm:5.6.0" + dependencies: + "@smithy/core": "npm:^3.28.0" + "@smithy/types": "npm:^4.15.0" + tslib: "npm:^2.6.2" + checksum: 10c0/092f6f5e31d6bdce7f9b217934d919b94b85fd4e74a33cd2d4f0b37923587836a20f64d83672094820a754df3e354ffd8eff1365e3214f9280399748e75f63ab + languageName: node + linkType: hard + +"@smithy/types@npm:^4.15.0": + version: 4.15.0 + resolution: "@smithy/types@npm:4.15.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/18b7f64544c7450dbc5602817d6f1a6bc337fcb19bc56d6df977bfcf7a25e233640df1f7f1791cc50a291dfedf30b99f5942ea517e0611b37f4c4a79327637cf + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-buffer-from@npm:2.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/223d6a508b52ff236eea01cddc062b7652d859dd01d457a4e50365af3de1e24a05f756e19433f6ccf1538544076b4215469e21a4ea83dc1d58d829725b0dbc5a + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.0": + version: 2.3.0 + resolution: "@smithy/util-utf8@npm:2.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/e18840c58cc507ca57fdd624302aefd13337ee982754c9aa688463ffcae598c08461e8620e9852a424d662ffa948fc64919e852508028d09e89ced459bd506ab + languageName: node + linkType: hard + "@speed-highlight/core@npm:^1.2.7": version: 1.2.15 resolution: "@speed-highlight/core@npm:1.2.15" @@ -3969,6 +4425,13 @@ __metadata: languageName: node linkType: hard +"bowser@npm:^2.11.0": + version: 2.14.1 + resolution: "bowser@npm:2.14.1" + checksum: 10c0/bb69b55ba7f0456e3dc07d0cfd9467f985581f640ba8fd426b08754a6737ee0d6cf3b50607941e5255f04c83075b952ece0599f978dd4d20f1e95461104c5ffd + languageName: node + linkType: hard + "brace-expansion@npm:^5.0.5": version: 5.0.5 resolution: "brace-expansion@npm:5.0.5" @@ -4304,6 +4767,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^17.4.2": + version: 17.4.2 + resolution: "dotenv@npm:17.4.2" + checksum: 10c0/164f8e77a646c8446867d5b588d26ea6005c8ea7c5eb41cf926f6113d23f2191355f6e0cfd95ea9bab98394a5b0a3f1e51a8399711b666fe55cc7b0bd745f942 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.328": version: 1.5.340 resolution: "electron-to-chromium@npm:1.5.340" @@ -6252,6 +6722,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^4.1.0": + version: 4.1.0 + resolution: "mime@npm:4.1.0" + bin: + mime: bin/cli.js + checksum: 10c0/3b8602e50dff1049aea8bb2d4c65afc55bf7f3eb5c17fd2bcb315b8c8ae225a7553297d424d3621757c24cdba99e930ecdc4108467009cdc7ed55614cd55031d + languageName: node + linkType: hard + "miniflare@npm:4.20260521.0": version: 4.20260521.0 resolution: "miniflare@npm:4.20260521.0" @@ -7581,6 +8060,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + "tinypool@npm:2.1.0": version: 2.1.0 resolution: "tinypool@npm:2.1.0" @@ -7609,7 +8098,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -7951,6 +8440,7 @@ __metadata: version: 0.0.0-use.local resolution: "webview-bundle-website@workspace:." dependencies: + "@aws-sdk/client-s3": "npm:^3.1076.0" "@base-ui/react": "npm:1.5.0" "@cloudflare/vite-plugin": "npm:1.38.0" "@date-fns/tz": "npm:1.5.0" @@ -7969,10 +8459,12 @@ __metadata: "@vitejs/plugin-react": "npm:6.0.2" clsx: "npm:2.1.1" date-fns: "npm:4.3.0" + dotenv: "npm:^17.4.2" fumadocs-core: "npm:16.7.16" fumadocs-mdx: "npm:14.3.0" fumadocs-ui: "npm:16.7.16" jose: "npm:6.2.3" + mime: "npm:^4.1.0" oxfmt: "npm:0.51.0" oxlint: "npm:1.66.0" oxlint-tsgolint: "npm:0.23.0" @@ -7980,6 +8472,7 @@ __metadata: react-dom: "npm:19.2.6" tailwind-merge: "npm:3.5.0" tailwindcss: "npm:4.3.0" + tinyglobby: "npm:^0.2.17" typescript: "npm:6.0.3" vite: "npm:8.0.14" wrangler: "npm:4.94.0" From ebde485cce4ddb43c1e75693de88759eb141dbfc Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 02:18:15 +0900 Subject: [PATCH 11/15] fix --- package.json | 4 - upload.ts | 105 ----------- yarn.lock | 495 +-------------------------------------------------- 3 files changed, 1 insertion(+), 603 deletions(-) delete mode 100644 upload.ts diff --git a/package.json b/package.json index fe82c32..0ad55d0 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "tailwind-merge": "3.5.0" }, "devDependencies": { - "@aws-sdk/client-s3": "^3.1076.0", "@cloudflare/vite-plugin": "1.38.0", "@sentry/vite-plugin": "5.3.0", "@tailwindcss/vite": "4.3.0", @@ -43,13 +42,10 @@ "@types/react": "19.2.15", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.2", - "dotenv": "^17.4.2", - "mime": "^4.1.0", "oxfmt": "0.51.0", "oxlint": "1.66.0", "oxlint-tsgolint": "0.23.0", "tailwindcss": "4.3.0", - "tinyglobby": "^0.2.17", "typescript": "6.0.3", "vite": "8.0.14", "wrangler": "4.94.0" diff --git a/upload.ts b/upload.ts deleted file mode 100644 index 41d2345..0000000 --- a/upload.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import process from 'node:process'; -import { fileURLToPath } from 'node:url'; -import { ListObjectsV2Command, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import dotenv from 'dotenv'; -import mime from 'mime'; -import { glob } from 'tinyglobby'; - -dotenv.config(); - -const UPLOAD_CONCURRENCY = 8; - -const filesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public'); - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return value; -} - -const bucket = 'wvb-static'; -const cacheControl = 'public, max-age=31536000, immutable'; -const accountId = requireEnv('CLOUDFLARE_ACCOUNT_ID'); -const accessKeyId = requireEnv('R2_ACCESS_KEY_ID'); -const secretAccessKey = requireEnv('R2_SECRET_ACCESS_KEY'); - -const client = new S3Client({ - region: 'auto', - endpoint: `https://${accountId}.r2.cloudflarestorage.com`, - credentials: { accessKeyId, secretAccessKey }, -}); - -/** Every object key already present in the bucket (paginated). */ -async function listExistingKeys(): Promise> { - const keys = new Set(); - let continuationToken: string | undefined; - do { - const res = await client.send( - new ListObjectsV2Command({ Bucket: bucket, ContinuationToken: continuationToken }) - ); - for (const obj of res.Contents ?? []) { - if (obj.Key) keys.add(obj.Key); - } - continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined; - } while (continuationToken); - return keys; -} - -async function uploadFile(key: string): Promise { - const body = await readFile(path.join(filesDir, key)); - await client.send( - new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: body, - ContentType: mime.getType(key) ?? 'application/octet-stream', - CacheControl: cacheControl, - }) - ); - console.log(` ↑ ${key}`); -} - -/** Runs `task` over `items` with a fixed-size worker pool. */ -async function runPool(items: T[], task: (item: T) => Promise): Promise { - let cursor = 0; - const workers = Array.from({ length: Math.min(UPLOAD_CONCURRENCY, items.length) }, async () => { - while (cursor < items.length) { - const item = items[cursor++]!; - await task(item); - } - }); - await Promise.all(workers); -} - -async function main(): Promise { - // tinyglobby returns paths relative to `cwd` with posix separators, which is - // exactly the object-key shape we want. - const localKeys = await glob('**/*', { cwd: filesDir, onlyFiles: true, dot: false }); - if (localKeys.length === 0) { - console.log(`No files found under ${filesDir}; nothing to upload.`); - return; - } - - const existing = await listExistingKeys(); - const toUpload = localKeys.filter(key => !existing.has(key)).sort(); - - console.log( - `${bucket}: ${localKeys.length} local file(s), ${existing.size} already uploaded, ` + - `${toUpload.length} new.` - ); - - if (toUpload.length === 0) { - console.log('Everything is already up to date.'); - return; - } - - await runPool(toUpload, uploadFile); - console.log(`Uploaded ${toUpload.length} file(s) to ${bucket}.`); -} - -await main(); diff --git a/yarn.lock b/yarn.lock index b97ac89..1607949 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,370 +5,6 @@ __metadata: version: 10 cacheKey: 10c0 -"@aws-crypto/crc32@npm:5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/crc32@npm:5.2.0" - dependencies: - "@aws-crypto/util": "npm:^5.2.0" - "@aws-sdk/types": "npm:^3.222.0" - tslib: "npm:^2.6.2" - checksum: 10c0/eab9581d3363af5ea498ae0e72de792f54d8890360e14a9d8261b7b5c55ebe080279fb2556e07994d785341cdaa99ab0b1ccf137832b53b5904cd6928f2b094b - languageName: node - linkType: hard - -"@aws-crypto/crc32c@npm:5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/crc32c@npm:5.2.0" - dependencies: - "@aws-crypto/util": "npm:^5.2.0" - "@aws-sdk/types": "npm:^3.222.0" - tslib: "npm:^2.6.2" - checksum: 10c0/223efac396cdebaf5645568fa9a38cd0c322c960ae1f4276bedfe2e1031d0112e49d7d39225d386354680ecefae29f39af469a84b2ddfa77cb6692036188af77 - languageName: node - linkType: hard - -"@aws-crypto/sha1-browser@npm:5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/sha1-browser@npm:5.2.0" - dependencies: - "@aws-crypto/supports-web-crypto": "npm:^5.2.0" - "@aws-crypto/util": "npm:^5.2.0" - "@aws-sdk/types": "npm:^3.222.0" - "@aws-sdk/util-locate-window": "npm:^3.0.0" - "@smithy/util-utf8": "npm:^2.0.0" - tslib: "npm:^2.6.2" - checksum: 10c0/51fed0bf078c10322d910af179871b7d299dde5b5897873ffbeeb036f427e5d11d23db9794439226544b73901920fd19f4d86bbc103ed73cc0cfdea47a83c6ac - languageName: node - linkType: hard - -"@aws-crypto/sha256-browser@npm:5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/sha256-browser@npm:5.2.0" - dependencies: - "@aws-crypto/sha256-js": "npm:^5.2.0" - "@aws-crypto/supports-web-crypto": "npm:^5.2.0" - "@aws-crypto/util": "npm:^5.2.0" - "@aws-sdk/types": "npm:^3.222.0" - "@aws-sdk/util-locate-window": "npm:^3.0.0" - "@smithy/util-utf8": "npm:^2.0.0" - tslib: "npm:^2.6.2" - checksum: 10c0/05f6d256794df800fe9aef5f52f2ac7415f7f3117d461f85a6aecaa4e29e91527b6fd503681a17136fa89e9dd3d916e9c7e4cfb5eba222875cb6c077bdc1d00d - languageName: node - linkType: hard - -"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/sha256-js@npm:5.2.0" - dependencies: - "@aws-crypto/util": "npm:^5.2.0" - "@aws-sdk/types": "npm:^3.222.0" - tslib: "npm:^2.6.2" - checksum: 10c0/6c48701f8336341bb104dfde3d0050c89c288051f6b5e9bdfeb8091cf3ffc86efcd5c9e6ff2a4a134406b019c07aca9db608128f8d9267c952578a3108db9fd1 - languageName: node - linkType: hard - -"@aws-crypto/supports-web-crypto@npm:^5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/4d2118e29d68ca3f5947f1e37ce1fbb3239a0c569cc938cdc8ab8390d595609b5caf51a07c9e0535105b17bf5c52ea256fed705a07e9681118120ab64ee73af2 - languageName: node - linkType: hard - -"@aws-crypto/util@npm:5.2.0, @aws-crypto/util@npm:^5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/util@npm:5.2.0" - dependencies: - "@aws-sdk/types": "npm:^3.222.0" - "@smithy/util-utf8": "npm:^2.0.0" - tslib: "npm:^2.6.2" - checksum: 10c0/0362d4c197b1fd64b423966945130207d1fe23e1bb2878a18e361f7743c8d339dad3f8729895a29aa34fff6a86c65f281cf5167c4bf253f21627ae80b6dd2951 - languageName: node - linkType: hard - -"@aws-sdk/checksums@npm:^3.1000.9": - version: 3.1000.9 - resolution: "@aws-sdk/checksums@npm:3.1000.9" - dependencies: - "@aws-crypto/crc32": "npm:5.2.0" - "@aws-crypto/crc32c": "npm:5.2.0" - "@aws-crypto/util": "npm:5.2.0" - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/f50daea883f56561e86b6e7180bdc505b929a3bf96ef3981d220e6ae2bd011ecd12424f60d2900c6ff406197b1b3db4b291aed2ef5bb1fb19ca2e4be06eb4468 - languageName: node - linkType: hard - -"@aws-sdk/client-s3@npm:^3.1076.0": - version: 3.1076.0 - resolution: "@aws-sdk/client-s3@npm:3.1076.0" - dependencies: - "@aws-crypto/sha1-browser": "npm:5.2.0" - "@aws-crypto/sha256-browser": "npm:5.2.0" - "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/credential-provider-node": "npm:^3.972.59" - "@aws-sdk/middleware-flexible-checksums": "npm:^3.974.34" - "@aws-sdk/middleware-sdk-s3": "npm:^3.972.55" - "@aws-sdk/signature-v4-multi-region": "npm:^3.996.36" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/fetch-http-handler": "npm:^5.6.0" - "@smithy/node-http-handler": "npm:^4.9.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/4e8c5468fcb0b98583d8c0fdc5cabbf66f3cc64c84c33fae0b8ef7482438215e96155d1c69ab97c9d5de1a31f4dfb69c1e63c13c1366e3a0c26409f849d185a7 - languageName: node - linkType: hard - -"@aws-sdk/core@npm:^3.974.24": - version: 3.974.24 - resolution: "@aws-sdk/core@npm:3.974.24" - dependencies: - "@aws-sdk/types": "npm:^3.973.14" - "@aws-sdk/xml-builder": "npm:^3.972.32" - "@aws/lambda-invoke-store": "npm:^0.2.2" - "@smithy/core": "npm:^3.27.0" - "@smithy/signature-v4": "npm:^5.5.3" - "@smithy/types": "npm:^4.15.0" - bowser: "npm:^2.11.0" - tslib: "npm:^2.6.2" - checksum: 10c0/a91d9cbf346ff1891ece999102bcf7f7b316e8ddd6aa22fe3538ea29074af209d922b3dbc5d7f48eeeafa567f5ddbc253175b124dbf5bf04ba0e98d7ac173155 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-env@npm:^3.972.50": - version: 3.972.50 - resolution: "@aws-sdk/credential-provider-env@npm:3.972.50" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/5d5a07849b7c55f41911a84549578f276010645e2185e598bd58a8a34f57d51b975afd51891e828e5639ce7fde14a3a1f4b754c96a33a6957ba6b2fa4ab79b38 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-http@npm:^3.972.52": - version: 3.972.52 - resolution: "@aws-sdk/credential-provider-http@npm:3.972.52" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/fetch-http-handler": "npm:^5.6.0" - "@smithy/node-http-handler": "npm:^4.9.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/2b0be4c332a608535ddda0b4c689ec911ced3ebdde1cdd64dec1e4c56e83a71981984293559bd2fb2071a5fcd513bcd73756904b1be1cc8a9ce3ea2459272a81 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-ini@npm:^3.972.57": - version: 3.972.57 - resolution: "@aws-sdk/credential-provider-ini@npm:3.972.57" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/credential-provider-env": "npm:^3.972.50" - "@aws-sdk/credential-provider-http": "npm:^3.972.52" - "@aws-sdk/credential-provider-login": "npm:^3.972.56" - "@aws-sdk/credential-provider-process": "npm:^3.972.50" - "@aws-sdk/credential-provider-sso": "npm:^3.972.56" - "@aws-sdk/credential-provider-web-identity": "npm:^3.972.56" - "@aws-sdk/nested-clients": "npm:^3.997.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/credential-provider-imds": "npm:^4.4.3" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/7146b0fb59ed4344a165e97c4891b131edc8ddbf923abe3f77b8b47e996a784896a05e599d6a7bc9e32841b5cfc69c9af338963cd86e68471fcbe04c63d26a3b - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-login@npm:^3.972.56": - version: 3.972.56 - resolution: "@aws-sdk/credential-provider-login@npm:3.972.56" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/nested-clients": "npm:^3.997.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/898993601bde88ae400c60e1c69152cb4efef7c2fb8ce745d29cafdc662e310b16f16e50f34d16f11ce196f05464e8c022761777af4e85c5832c0152841ab3c5 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-node@npm:^3.972.59": - version: 3.972.59 - resolution: "@aws-sdk/credential-provider-node@npm:3.972.59" - dependencies: - "@aws-sdk/credential-provider-env": "npm:^3.972.50" - "@aws-sdk/credential-provider-http": "npm:^3.972.52" - "@aws-sdk/credential-provider-ini": "npm:^3.972.57" - "@aws-sdk/credential-provider-process": "npm:^3.972.50" - "@aws-sdk/credential-provider-sso": "npm:^3.972.56" - "@aws-sdk/credential-provider-web-identity": "npm:^3.972.56" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/credential-provider-imds": "npm:^4.4.3" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/472127e89416c1edffb6ebf12bcc13bbbbcaaef278ce6d2d2ff229cfc0b855bedd9b7ef2a0d9d575f74d6d5d21afbe04cb4d67f5a7909d96e5157e12f9b26544 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-process@npm:^3.972.50": - version: 3.972.50 - resolution: "@aws-sdk/credential-provider-process@npm:3.972.50" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/decf91e63b879c3bba4bbdc6b1cbada53d8d7e5367bdee9abf322cfd0ea35fbaf6cbf6f8aaa3beea55dc18ccd98e0992f9d887d8ca8f28b1d902b932eca73934 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-sso@npm:^3.972.56": - version: 3.972.56 - resolution: "@aws-sdk/credential-provider-sso@npm:3.972.56" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/nested-clients": "npm:^3.997.24" - "@aws-sdk/token-providers": "npm:3.1076.0" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/c7e0bc28a9de53a10152cb9ec4cb6bba651a38fa993f4c0130d01feb4ca33f24e3bf102fbd1fdc9aec9667e0847b3123f4d2b64493ceecd62a259113f94afda5 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-web-identity@npm:^3.972.56": - version: 3.972.56 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.56" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/nested-clients": "npm:^3.997.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/a4dd228843bf94a0b99a406b50991d800493043982471033cbe0d05a04dbafae5ab917c6208aa731c1e18a0f85a4ba0fabdb32b1b161d74fe44bf868a81ce2bb - languageName: node - linkType: hard - -"@aws-sdk/middleware-flexible-checksums@npm:^3.974.34": - version: 3.974.34 - resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.974.34" - dependencies: - "@aws-sdk/checksums": "npm:^3.1000.9" - tslib: "npm:^2.6.2" - checksum: 10c0/1ac25e8609fe28dc2e0821d2cc1fb2c37fcfa412f1b265b274994ea2706f0c32fbc1bfea2e9cbb88eca2563bf33fe6d30698ecc74eb17f3eeac921daa0919776 - languageName: node - linkType: hard - -"@aws-sdk/middleware-sdk-s3@npm:^3.972.55": - version: 3.972.55 - resolution: "@aws-sdk/middleware-sdk-s3@npm:3.972.55" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/signature-v4-multi-region": "npm:^3.996.36" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/cbb6c8ca1c3cb3baab874603f132eb24eb02073bbb060f56db561226cb5d817b5e344c8b33892db4e88fc2dbf1af255c3e87794b4afc0522ad45883387bf97d3 - languageName: node - linkType: hard - -"@aws-sdk/nested-clients@npm:^3.997.24": - version: 3.997.24 - resolution: "@aws-sdk/nested-clients@npm:3.997.24" - dependencies: - "@aws-crypto/sha256-browser": "npm:5.2.0" - "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/signature-v4-multi-region": "npm:^3.996.36" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/fetch-http-handler": "npm:^5.6.0" - "@smithy/node-http-handler": "npm:^4.9.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/5f3b9182bbed2c21c0d41eeaeb05936ee9ab2207c9ce6df18041b94669b17cc06c05d18e397c4dc7ad132894922ea6bced2c59f345e10694411c74c2162cefea - languageName: node - linkType: hard - -"@aws-sdk/signature-v4-multi-region@npm:^3.996.36": - version: 3.996.36 - resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.36" - dependencies: - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/signature-v4": "npm:^5.5.3" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/37a5210d2e9d29f8f60d19149f2b6c5d91c7eaa2cb8e0414ea13253049aa3f40fc0a86cd5a07fdb2361333bf0fccd98140dabc07e4c76a38cc5c96f76c0ae7bd - languageName: node - linkType: hard - -"@aws-sdk/token-providers@npm:3.1076.0": - version: 3.1076.0 - resolution: "@aws-sdk/token-providers@npm:3.1076.0" - dependencies: - "@aws-sdk/core": "npm:^3.974.24" - "@aws-sdk/nested-clients": "npm:^3.997.24" - "@aws-sdk/types": "npm:^3.973.14" - "@smithy/core": "npm:^3.27.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/b04eb8488693097f7eccc945106f19c01c2a1d6933026a5918be358189a480b4a516e36b711103764009c1fc05848d681379acb980cf30e66b0e95c6b2eded96 - languageName: node - linkType: hard - -"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.14": - version: 3.973.14 - resolution: "@aws-sdk/types@npm:3.973.14" - dependencies: - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/7261ba4b64259562e0813c07b4d6f56518c8f9df85c3f4658e999867e8813b5cd209cc5769f8e52b62601afd6a4e613df180ffdfd70972627038c149d0e25150 - languageName: node - linkType: hard - -"@aws-sdk/util-locate-window@npm:^3.0.0": - version: 3.965.8 - resolution: "@aws-sdk/util-locate-window@npm:3.965.8" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/fc3fd154cf560d815a4e9a58aadd1aed860a9dd8cc82ce9824faf9255c688626339077940d35ae91798b753dd7ad4e735c08d4f015b573e681130712a8b52865 - languageName: node - linkType: hard - -"@aws-sdk/xml-builder@npm:^3.972.32": - version: 3.972.32 - resolution: "@aws-sdk/xml-builder@npm:3.972.32" - dependencies: - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/a19744136a49ade2da50f6964468e3d9e321794771732afff87df22ad0d5ccdf5b8474b6267527e3ab295d6525f7fde2d210c4e453507f28bc3542bd1e486abd - languageName: node - linkType: hard - -"@aws/lambda-invoke-store@npm:^0.2.2": - version: 0.2.4 - resolution: "@aws/lambda-invoke-store@npm:0.2.4" - checksum: 10c0/29d874d7c1a2d971e0c02980594204f89cda718f215f2fc52b6c56eacbdad1fa5f6ce1b358e5811f5cd35d04c76299a67a8aff95318446af2bdfb4910f213e13 - languageName: node - linkType: hard - "@babel/code-frame@npm:7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -3521,98 +3157,6 @@ __metadata: languageName: node linkType: hard -"@smithy/core@npm:^3.27.0, @smithy/core@npm:^3.28.0": - version: 3.28.0 - resolution: "@smithy/core@npm:3.28.0" - dependencies: - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/1f3cf0be56ac588b9271a308c3330d942d3e61904ee2674250d3f8a1d2262e58cb7f1fe0f16797c8b5df988d3a1b5a5df516bab24e0c678df140a49e325843af - languageName: node - linkType: hard - -"@smithy/credential-provider-imds@npm:^4.4.3": - version: 4.4.4 - resolution: "@smithy/credential-provider-imds@npm:4.4.4" - dependencies: - "@smithy/core": "npm:^3.28.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/57a897f061c3f8af2a1e4b8d2bb737f726a1f6f5f28b8b79ad23b0a27adb3c4ec4d85ffada817ddd7cc49d0ce2c2dc349314ed9a7cbe12c55e761ddd0620f954 - languageName: node - linkType: hard - -"@smithy/fetch-http-handler@npm:^5.6.0": - version: 5.6.1 - resolution: "@smithy/fetch-http-handler@npm:5.6.1" - dependencies: - "@smithy/core": "npm:^3.28.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/77c6d2f76a9971d683f499df44053ab9bc51ab1be1cb76687285f53c5348b5f3b5d35e04d00460f8fbadbb3cc4a0d576f7f1c6951b93e1583c0df72af87f53ec - languageName: node - linkType: hard - -"@smithy/is-array-buffer@npm:^2.2.0": - version: 2.2.0 - resolution: "@smithy/is-array-buffer@npm:2.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/2f2523cd8cc4538131e408eb31664983fecb0c8724956788b015aaf3ab85a0c976b50f4f09b176f1ed7bbe79f3edf80743be7a80a11f22cd9ce1285d77161aaf - languageName: node - linkType: hard - -"@smithy/node-http-handler@npm:^4.9.0": - version: 4.9.1 - resolution: "@smithy/node-http-handler@npm:4.9.1" - dependencies: - "@smithy/core": "npm:^3.28.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/3a4b752b6616889d29d6df3ea29b2d35f266ae7daa5b8a05802e3b9e724ed3f1340e4bf3a02b903a87887e79ce1ee3e4b697d6b6cb3b39f496245eb387268d21 - languageName: node - linkType: hard - -"@smithy/signature-v4@npm:^5.5.3": - version: 5.6.0 - resolution: "@smithy/signature-v4@npm:5.6.0" - dependencies: - "@smithy/core": "npm:^3.28.0" - "@smithy/types": "npm:^4.15.0" - tslib: "npm:^2.6.2" - checksum: 10c0/092f6f5e31d6bdce7f9b217934d919b94b85fd4e74a33cd2d4f0b37923587836a20f64d83672094820a754df3e354ffd8eff1365e3214f9280399748e75f63ab - languageName: node - linkType: hard - -"@smithy/types@npm:^4.15.0": - version: 4.15.0 - resolution: "@smithy/types@npm:4.15.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/18b7f64544c7450dbc5602817d6f1a6bc337fcb19bc56d6df977bfcf7a25e233640df1f7f1791cc50a291dfedf30b99f5942ea517e0611b37f4c4a79327637cf - languageName: node - linkType: hard - -"@smithy/util-buffer-from@npm:^2.2.0": - version: 2.2.0 - resolution: "@smithy/util-buffer-from@npm:2.2.0" - dependencies: - "@smithy/is-array-buffer": "npm:^2.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/223d6a508b52ff236eea01cddc062b7652d859dd01d457a4e50365af3de1e24a05f756e19433f6ccf1538544076b4215469e21a4ea83dc1d58d829725b0dbc5a - languageName: node - linkType: hard - -"@smithy/util-utf8@npm:^2.0.0": - version: 2.3.0 - resolution: "@smithy/util-utf8@npm:2.3.0" - dependencies: - "@smithy/util-buffer-from": "npm:^2.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/e18840c58cc507ca57fdd624302aefd13337ee982754c9aa688463ffcae598c08461e8620e9852a424d662ffa948fc64919e852508028d09e89ced459bd506ab - languageName: node - linkType: hard - "@speed-highlight/core@npm:^1.2.7": version: 1.2.15 resolution: "@speed-highlight/core@npm:1.2.15" @@ -4425,13 +3969,6 @@ __metadata: languageName: node linkType: hard -"bowser@npm:^2.11.0": - version: 2.14.1 - resolution: "bowser@npm:2.14.1" - checksum: 10c0/bb69b55ba7f0456e3dc07d0cfd9467f985581f640ba8fd426b08754a6737ee0d6cf3b50607941e5255f04c83075b952ece0599f978dd4d20f1e95461104c5ffd - languageName: node - linkType: hard - "brace-expansion@npm:^5.0.5": version: 5.0.5 resolution: "brace-expansion@npm:5.0.5" @@ -4767,13 +4304,6 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^17.4.2": - version: 17.4.2 - resolution: "dotenv@npm:17.4.2" - checksum: 10c0/164f8e77a646c8446867d5b588d26ea6005c8ea7c5eb41cf926f6113d23f2191355f6e0cfd95ea9bab98394a5b0a3f1e51a8399711b666fe55cc7b0bd745f942 - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.5.328": version: 1.5.340 resolution: "electron-to-chromium@npm:1.5.340" @@ -6722,15 +6252,6 @@ __metadata: languageName: node linkType: hard -"mime@npm:^4.1.0": - version: 4.1.0 - resolution: "mime@npm:4.1.0" - bin: - mime: bin/cli.js - checksum: 10c0/3b8602e50dff1049aea8bb2d4c65afc55bf7f3eb5c17fd2bcb315b8c8ae225a7553297d424d3621757c24cdba99e930ecdc4108467009cdc7ed55614cd55031d - languageName: node - linkType: hard - "miniflare@npm:4.20260521.0": version: 4.20260521.0 resolution: "miniflare@npm:4.20260521.0" @@ -8060,16 +7581,6 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.17": - version: 0.2.17 - resolution: "tinyglobby@npm:0.2.17" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.4" - checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c - languageName: node - linkType: hard - "tinypool@npm:2.1.0": version: 2.1.0 resolution: "tinypool@npm:2.1.0" @@ -8098,7 +7609,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -8440,7 +7951,6 @@ __metadata: version: 0.0.0-use.local resolution: "webview-bundle-website@workspace:." dependencies: - "@aws-sdk/client-s3": "npm:^3.1076.0" "@base-ui/react": "npm:1.5.0" "@cloudflare/vite-plugin": "npm:1.38.0" "@date-fns/tz": "npm:1.5.0" @@ -8459,12 +7969,10 @@ __metadata: "@vitejs/plugin-react": "npm:6.0.2" clsx: "npm:2.1.1" date-fns: "npm:4.3.0" - dotenv: "npm:^17.4.2" fumadocs-core: "npm:16.7.16" fumadocs-mdx: "npm:14.3.0" fumadocs-ui: "npm:16.7.16" jose: "npm:6.2.3" - mime: "npm:^4.1.0" oxfmt: "npm:0.51.0" oxlint: "npm:1.66.0" oxlint-tsgolint: "npm:0.23.0" @@ -8472,7 +7980,6 @@ __metadata: react-dom: "npm:19.2.6" tailwind-merge: "npm:3.5.0" tailwindcss: "npm:4.3.0" - tinyglobby: "npm:^0.2.17" typescript: "npm:6.0.3" vite: "npm:8.0.14" wrangler: "npm:4.94.0" From dda4d67e23decb24f2f6783ac4bb53675e24333c Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 02:32:07 +0900 Subject: [PATCH 12/15] fix(docs): tablet sidebar dropdown, code-block formatting, drop e2e notes - Hide the redundant section dropdown at the top of the docs sidebar from `md` up (it now only appears in the mobile drawer; the header carries the sections). - Repair Tabs install blocks that had collapsed onto one line (rendering a stray "sh" and breaking the code-block styling). - Remove the runnable/e2e-test descriptions from the platform docs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- content/docs/guide/cli.mdx | 27 +++++++++++++++-- content/docs/guide/index.mdx | 24 +++++++++++++-- content/docs/guide/platform-support.mdx | 2 +- .../docs/guide/platforms/electron/index.mdx | 29 ++++++++++++++++--- content/docs/guide/platforms/tauri.mdx | 2 +- content/docs/guide/providers/aws.mdx | 18 ++++++++++-- content/docs/references/node.mdx | 24 +++++++++++++-- src/styles.css | 9 +++--- 8 files changed, 113 insertions(+), 22 deletions(-) diff --git a/content/docs/guide/cli.mdx b/content/docs/guide/cli.mdx index bb5c1a9..083df92 100644 --- a/content/docs/guide/cli.mdx +++ b/content/docs/guide/cli.mdx @@ -8,9 +8,30 @@ The `wvb` command-line tool packs your built web assets into `.wvb` bundles and 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 ``` + + +```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). diff --git a/content/docs/guide/index.mdx b/content/docs/guide/index.mdx index a405cab..58fb2c7 100644 --- a/content/docs/guide/index.mdx +++ b/content/docs/guide/index.mdx @@ -16,9 +16,27 @@ native app-store release. One `.wvb` format runs on every webview platform via a 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 +npm install --save-dev @wvb/cli +``` + + + + +```sh +pnpm add -D @wvb/cli +``` + + + + +```sh +yarn add -D @wvb/cli +``` + + ```sh diff --git a/content/docs/guide/platform-support.mdx b/content/docs/guide/platform-support.mdx index 5a8096c..417a17b 100644 --- a/content/docs/guide/platform-support.mdx +++ b/content/docs/guide/platform-support.mdx @@ -79,7 +79,7 @@ The window then loads `bundle://hacker-news.wvb`. Start with the [Tauri guide](/ ## Mobile -Android and iOS are real, functional integrations, exercised by end-to-end tests against a live remote. Both are **pre-release**: no published artifact yet, so you install from source. +Android and iOS are real, functional integrations. Both are **pre-release**: no published artifact yet, so you install from source. ### Android diff --git a/content/docs/guide/platforms/electron/index.mdx b/content/docs/guide/platforms/electron/index.mdx index a61ad85..4c60aef 100644 --- a/content/docs/guide/platforms/electron/index.mdx +++ b/content/docs/guide/platforms/electron/index.mdx @@ -3,16 +3,37 @@ 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. The runnable [`e2e/fixtures/app`](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron) is a minimal main-process setup you can read alongside this guide. +`@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 ``` + + +```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 diff --git a/content/docs/guide/platforms/tauri.mdx b/content/docs/guide/platforms/tauri.mdx index 240e359..f95474e 100644 --- a/content/docs/guide/platforms/tauri.mdx +++ b/content/docs/guide/platforms/tauri.mdx @@ -357,6 +357,6 @@ The plugin then extracts each bundle from the APK on first request and caches it diff --git a/content/docs/guide/providers/aws.mdx b/content/docs/guide/providers/aws.mdx index 4f6dacb..fbf5822 100644 --- a/content/docs/guide/providers/aws.mdx +++ b/content/docs/guide/providers/aws.mdx @@ -31,13 +31,25 @@ For the contract these pieces implement and how to choose a provider, see [Build - ```sh npm install @wvb/remote-aws @wvb/remote-aws-provider @wvb/remote-aws-provider-pulumi ``` + +```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 +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 ``` + +```sh +yarn add @wvb/remote-aws @wvb/remote-aws-provider @wvb/remote-aws-provider-pulumi +``` + diff --git a/content/docs/references/node.mdx b/content/docs/references/node.mdx index 90152c4..d9809ef 100644 --- a/content/docs/references/node.mdx +++ b/content/docs/references/node.mdx @@ -14,9 +14,27 @@ The addon resolves a prebuilt binary from one of the `@wvb/node-` opti ## Install - ```sh npm install @wvb/node ``` - ```sh pnpm add @wvb/node ``` - ```sh yarn add @wvb/node ``` + + +```sh +npm install @wvb/node +``` + + + + +```sh +pnpm add @wvb/node +``` + + + + +```sh +yarn add @wvb/node +``` + + ## Top-level functions diff --git a/src/styles.css b/src/styles.css index d28c632..a195e39 100644 --- a/src/styles.css +++ b/src/styles.css @@ -103,10 +103,11 @@ border-inline-end: 1px solid var(--color-fd-border); } -/* From `lg` up the section switcher lives in the header, so fumadocs hides the - * sidebar's dropdown but its padded container still reserves space at the top. - * Collapse that container on desktop so the page tree starts flush with the top. */ -@media (width >= 64rem) { +/* From `md` up the section switcher lives in the header, so the sidebar's own + * section dropdown is redundant. Hide its container (the static sidebar shows at + * `md`+) so the page tree starts flush with the top and no dropdown appears on + * tablet or desktop. The mobile drawer keeps its own switcher. */ +@media (width >= 48rem) { #nd-notebook-layout #nd-sidebar > :first-child { display: none; } From da7175e3a9541dad05d086335d760ac14de6b96e Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 02:40:29 +0900 Subject: [PATCH 13/15] docs: OpenAPI-style remote spec, category Node API, @wvb/bridge reference - Split the Remote HTTP Spec into one OpenAPI-style page per endpoint (GET /bundles, HEAD /bundles/{name}, GET /bundles/{name}, GET /bundles/{name}/{version}) plus a dedicated Errors page, each with an operation line, Parameters and Responses tables, response headers, and curl examples. The Spec landing links them. - Split the Node API reference into a "Node API" group with per-category pages (Bundle, Source, Protocol, Remote, Updater, Types); the overview keeps install and top-level functions. - Add a @wvb/bridge reference under References (invoke, platform, source/remote/ updater command groups, errors, testing), and link it from the References overview. Verified: build, links, and all new pages (200); sidebar shows the Node API and Spec groups. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- content/docs/guide/spec/errors.mdx | 93 ++++++ content/docs/guide/spec/get-current.mdx | 99 +++++++ content/docs/guide/spec/get-version.mdx | 106 +++++++ content/docs/guide/spec/head-current.mdx | 90 ++++++ content/docs/guide/spec/http.mdx | 171 ----------- content/docs/guide/spec/index.mdx | 76 +++++ content/docs/guide/spec/list-bundles.mdx | 97 +++++++ content/docs/guide/spec/meta.json | 2 +- content/docs/references/bridge.mdx | 244 ++++++++++++++++ content/docs/references/index.mdx | 7 +- content/docs/references/meta.json | 2 +- content/docs/references/node.mdx | 328 ---------------------- content/docs/references/node/bundle.mdx | 84 ++++++ content/docs/references/node/index.mdx | 121 ++++++++ content/docs/references/node/meta.json | 4 + content/docs/references/node/protocol.mdx | 86 ++++++ content/docs/references/node/remote.mdx | 141 ++++++++++ content/docs/references/node/source.mdx | 92 ++++++ content/docs/references/node/types.mdx | 169 +++++++++++ content/docs/references/node/updater.mdx | 163 +++++++++++ 20 files changed, 1673 insertions(+), 502 deletions(-) create mode 100644 content/docs/guide/spec/errors.mdx create mode 100644 content/docs/guide/spec/get-current.mdx create mode 100644 content/docs/guide/spec/get-version.mdx create mode 100644 content/docs/guide/spec/head-current.mdx delete mode 100644 content/docs/guide/spec/http.mdx create mode 100644 content/docs/guide/spec/index.mdx create mode 100644 content/docs/guide/spec/list-bundles.mdx create mode 100644 content/docs/references/bridge.mdx delete mode 100644 content/docs/references/node.mdx create mode 100644 content/docs/references/node/bundle.mdx create mode 100644 content/docs/references/node/index.mdx create mode 100644 content/docs/references/node/meta.json create mode 100644 content/docs/references/node/protocol.mdx create mode 100644 content/docs/references/node/remote.mdx create mode 100644 content/docs/references/node/source.mdx create mode 100644 content/docs/references/node/types.mdx create mode 100644 content/docs/references/node/updater.mdx 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/http.mdx b/content/docs/guide/spec/http.mdx deleted file mode 100644 index 9a74b59..0000000 --- a/content/docs/guide/spec/http.mdx +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: Remote HTTP Spec -description: The precise HTTP contract a server must implement so the Webview Bundle client and updater can list, inspect, and download bundles. ---- - -Any HTTP server that implements the four endpoints below 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: [Local](/docs/guide/providers/local), [AWS](/docs/guide/providers/aws), and [Cloudflare](/docs/guide/providers/cloudflare) all serve this exact contract. For the conceptual update flow that drives these calls, see [Remote bundles & updates](/docs/guide/remote-bundles); to stand up and test a server, see [Building a remote](/docs/guide/remote). - -## Endpoints - -A remote exposes four routes under its configured `endpoint` base URL. - -| Method | Path | Query | Purpose | -| ------ | --------------------------- | ----------- | ----------------------------------- | -| `GET` | `/bundles` | `?channel=` | List deployed bundles | -| `HEAD` | `/bundles/{name}` | `?channel=` | Current version's metadata, no body | -| `GET` | `/bundles/{name}` | `?channel=` | Download the current version | -| `GET` | `/bundles/{name}/{version}` | none | Download a specific version | - -The `?channel=` query is optional on the first three routes and selects a channel's deployment. The version-specific `GET /bundles/{name}/{version}` does not accept a channel. - -## Response headers - -Download responses (`GET /bundles/{name}` and `GET /bundles/{name}/{version}`) and metadata responses (`HEAD /bundles/{name}`) carry bundle metadata in headers. 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 a response that omits `Webview-Bundle-Name` or `Webview-Bundle-Version`. - -Set `Content-Type: application/webview-bundle` on download responses, and `Content-Type: application/json` on the list response. - -## Status codes - -| Code | When | -| ----- | ------------------------------------------------------------------------------- | -| `200` | List, and downloads with a body | -| `204` | `HEAD /bundles/{name}` returning metadata headers only | -| `404` | The named bundle or version is not deployed | -| `403` | A specific version is requested but the server disables other-version downloads | - -The client treats any 2xx response as success. It maps `404` to a "bundle not found" error and `403` to a "forbidden" error. Any other non-2xx response is parsed as a JSON error body of the shape `{ "message": string }`. - -`403` is the deliberate signal that a version-specific download is disabled. Providers expose this as an `allowOtherVersions` option (default `false`); when disabled, `GET /bundles/{name}/{version}` returns `403` even though the version exists. - - - The client applies a default total request timeout of 120s to every call. It can be overridden in - the client's HTTP configuration, but a server should respond well within it. - - -## List response shape - -`GET /bundles` returns a JSON array of the currently deployed bundles. Each item has a `name` and a `version`; undeployed bundles are excluded. - -```json -[ - { "name": "app", "version": "1.2.0" }, - { "name": "admin", "version": "0.4.1" } -] -``` - -## Examples - -### List deployed bundles - -```sh -$ curl -s http://localhost:4313/bundles -HTTP/1.1 200 OK -Content-Type: application/json - -[ - { "name": "app", "version": "1.2.0" }, - { "name": "admin", "version": "0.4.1" } -] -``` - -Filter by channel: - -```sh -$ curl -s 'http://localhost:4313/bundles?channel=beta' -``` - -### Read current metadata - -`HEAD` returns headers only, with no body. - -```sh -$ curl -sI http://localhost:4313/bundles/app -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 -``` - -### Download the current version - -```sh -$ curl -s http://localhost:4313/bundles/app -o app.wvb -D - -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= -``` - -Request a channel's current version with `?channel=`: - -```sh -$ curl -s 'http://localhost:4313/bundles/app?channel=beta' -o app-beta.wvb -``` - -### Download a specific version - -```sh -$ curl -s http://localhost:4313/bundles/app/1.1.0 -o app-1.1.0.wvb -D - -HTTP/1.1 200 OK -Content-Type: application/webview-bundle -Webview-Bundle-Name: app -Webview-Bundle-Version: 1.1.0 -``` - -When the server disables other-version downloads, the same request returns `403`: - -```sh -$ curl -sI http://localhost:4313/bundles/app/1.1.0 -HTTP/1.1 403 Forbidden -``` - -A bundle or version that is not deployed returns `404`: - -```sh -$ curl -sI http://localhost:4313/bundles/unknown -HTTP/1.1 404 Not Found -``` - -## Where to go next - - - - - - - - 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 index ae8c5ad..f8023a5 100644 --- a/content/docs/guide/spec/meta.json +++ b/content/docs/guide/spec/meta.json @@ -1,4 +1,4 @@ { "title": "Spec", - "pages": ["http"] + "pages": ["index", "list-bundles", "head-current", "get-current", "get-version", "errors"] } diff --git a/content/docs/references/bridge.mdx b/content/docs/references/bridge.mdx new file mode 100644 index 0000000..c7c9f0e --- /dev/null +++ b/content/docs/references/bridge.mdx @@ -0,0 +1,244 @@ +--- +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. + +### source + +`source` maps to the native bundle source — the store of builtin and remote bundles on the host. Its 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. + + +### remote + +`remote` talks to the configured remote server through the native host: + +| 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. | + +`download` and `downloadVersion` resolve to `RemoteBundleInfo` only — bundle bytes are not transferred across the bridge. + +### updater + +`updater` drives the over-the-air (OTA) update flow: + +| 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. | + +`getUpdate()` returns a `BundleUpdateInfo`: + +```ts +type BundleUpdateInfo = { + name: string; + version: string; + localVersion?: string; + isAvailable: boolean; + etag?: string; + integrity?: string; + signature?: string; + lastModified?: string; +}; +``` + +## Updater flow + +A typical 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(); +} +``` + +## Errors + +Every bridge failure surfaces as a `BridgeError`, an `Error` subclass with `name` set to `'BridgeError'` and an optional `code`. + +```ts +import { BridgeError, BridgeErrorCode, isBridgeError } from '@wvb/bridge'; + +try { + await updater.getUpdate('app'); +} catch (err) { + if (isBridgeError(err) && err.code === BridgeErrorCode.UpdaterNotInitialized) { + // The native host has no updater configured. + } +} +``` + +`BridgeErrorCode` values: + +| Code | Value | Meaning | +| ----------------------- | ------------------------- | ------------------------------------------- | +| `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. | + +The package also exports the guards `isBridgeError` and `isBridgeErrorData`, plus the static helpers `BridgeError.of(code, message?)` and `BridgeError.from(value)`. + +## Testing + +The `@wvb/bridge/testing` subpath provides helpers to drive the bridge in tests without a native host. Mock command keys are dotted and typed, such as `'source.loadVersion'`. + +```ts +import { mockBridge } from '@wvb/bridge/testing'; + +const bridge = mockBridge({ platform: 'electron' }).mockInvoke('updater.getUpdate', () => ({ + name: 'app', + version: '1.2.0', + isAvailable: true, +})); + +// Run code under test, then: +bridge.clear(); +``` + +The testing module exports: + +- `mockInvoke(command, handler)` — mock a single command; returns a `Disposable`. +- `mockPlatform(type)` — force `platform.type`; returns a `Disposable`. +- `mockBridge(options?)` — a chainable mock with `.mockInvoke()` and `.clear()`. +- `clearInvokeMocks()` — remove all registered mocks. + +## See also + + + + + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 4c406e0..3dcd165 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -30,11 +30,16 @@ Most app developers never touch these APIs directly. To ship Webview Bundle in a href="/docs/references/deno" description="@wvb/deno — the Deno peer of @wvb/node. Experimental." /> + ## 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. +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'; diff --git a/content/docs/references/meta.json b/content/docs/references/meta.json index 22eb027..6fa009c 100644 --- a/content/docs/references/meta.json +++ b/content/docs/references/meta.json @@ -1,5 +1,5 @@ { "root": true, "title": "References", - "pages": ["index", "[Rust (docs.rs)](https://docs.rs/wvb)", "node", "deno"] + "pages": ["index", "[Rust (docs.rs)](https://docs.rs/wvb)", "node", "deno", "bridge"] } diff --git a/content/docs/references/node.mdx b/content/docs/references/node.mdx deleted file mode 100644 index d9809ef..0000000 --- a/content/docs/references/node.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -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/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`, 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. | - -## Classes - -### Bundle and builder - -| 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. It is distinct from the bundle's integrity hash, which uses SHA-2. - -### Descriptor, header, and index - -A descriptor exposes a bundle's structure without loading every file into memory. - -| 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` | - -`LoadedDescriptor` holds a reference-counted handle that is released on garbage collection, so reads stay lazy against the file on disk. - -### BundleSource - -`new BundleSource(config: BundleSourceConfig)` manages bundles across a builtin directory (the bundles shipped with the app) and a remote directory (bundles downloaded over the air). When both contain a bundle, the remote directory takes priority. - -| 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` | - -### Protocol handlers - -| Class | Constructor | Method | -| ---------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | -| `BundleProtocol` | `new BundleProtocol(source: BundleSource)` | `handle(method: HttpMethod, uri: string, headers?): Promise` | -| `LocalProtocol` | `new LocalProtocol(hosts: Record)` | `handle(method, uri, headers?): Promise` | - -`BundleProtocol` serves `scheme://bundle_name/path` requests from a `BundleSource`. It answers `GET` and `HEAD`, and turns a `Range` request into a `206` response. `LocalProtocol` proxies `app://host/...` requests to a localhost URL (useful in development), caching responses and returning `304` when unchanged. See [Protocol handling](/docs/guide/protocol-handling). - -### Remote - -`new Remote(endpoint: string, options?: RemoteOptions)` is the HTTP client for a [bundle remote](/docs/guide/remote-bundles). It speaks the remote's HTTP contract over the configured `endpoint`. - -| 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]>` | - -`download` and `downloadVersion` resolve to a tuple of the bundle info, the parsed `Bundle`, and the raw bytes. `RemoteOptions` accepts an `http` config and an `onDownload` progress callback: - -```ts -type RemoteOptions = { - http?: HttpOptions; - onDownload?: (data: RemoteOnDownloadData) => void; -}; - -type RemoteOnDownloadData = { - downloadedBytes: number; - totalBytes?: number; - endpoint: string; -}; -``` - - - The default request timeout is 120 seconds. Override it through `HttpOptions.timeout` - (milliseconds). `HttpOptions` also exposes `userAgent`, `defaultHeaders`, connection-pool tuning, - and transport flags. - - -### Updater - -`new Updater(source: BundleSource, remote: Remote, options?: UpdaterOptions)` checks a remote for newer bundles, downloads them, and activates them. Downloads and installs are serialized per bundle. - -| Method | Signature | Behavior | -| ------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `listRemotes` | `(): Promise` | Lists bundles on the remote. | -| `getUpdate` | `(bundleName): Promise` | Checks the remote's current version against the local one. | -| `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, swaps the current version, drops the cached descriptor, and prunes old versions. | - -`getUpdate` is the check step — there is no method named `check`. A separate `download` then `install` keeps download and activation as distinct, restartable phases. - -## Enums and unions - -| Type | Values | -| -------------------- | ------------------------------------------------------------------------------------------------ | -| `Version` | `'v1'` | -| `HttpMethod` | `'get' \| 'head' \| 'options' \| 'post' \| 'put' \| 'patch' \| 'delete' \| 'trace' \| 'connect'` | -| `BundleSourceKind` | `'builtin' \| 'remote'` | -| `IntegrityAlgorithm` | `'sha256' \| 'sha384' \| 'sha512'` (SHA-2; `sha384` recommended) | -| `IntegrityPolicy` | `'strict' \| 'optional' \| 'none'` (`optional` is the default) | -| `SignatureAlgorithm` | `'ecdsaSecp256R1' \| 'ecdsaSecp384R1' \| 'ed25519' \| 'rsaPkcs1V15' \| 'rsaPss'` | -| `VerifyingKeyFormat` | `'spkiDer' \| 'spkiPem' \| 'pkcs1Der' \| 'pkcs1Pem' \| 'sec1' \| 'raw'` | - -Key-format constraints: `pkcs1Der`/`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, so signature verification requires an integrity value to be present. See [Remote, integrity & signature config](/docs/config/remote). - -## Option interfaces - -```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 + 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 methods above. - -```ts -type BundleManifestMetadata = { - etag?: string; - integrity?: string; // ":", e.g. "sha256:n4bQ…" - signature?: string; // base64 - lastModified?: string; -}; - -// loadVersion() -type BundleSourceVersion = { - type: BundleSourceKind; // 'builtin' | 'remote' - version: string; -}; - -// 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; -}; -``` - -## Examples - -### Read a bundle and get a file - -```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')); -} -``` - -### 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 -``` - -### 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. - -```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/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 + + + + + + + From e72d403a8d51e2266ba771b85080945f7faa08d6 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 03:05:09 +0900 Subject: [PATCH 14/15] docs: 2-space code, format table, split Deno/Bridge, drop pre-release notes - Re-indent all rust/kotlin/swift/xml code blocks to 2-space (TS/JSON already 2). - Render the .wvb layout as an offset/size/contents table instead of ASCII. - Split the Deno API and @wvb/bridge references into per-category groups (Deno: source/protocol/remote/updater; Bridge: source/remote/updater/errors/ testing), mirroring the Node API. - Rename the References landing title to "Overview". - Remove all pre-release / unpublished / install-from-source / pre-1.0 warnings and present every package as a published, stable release. - Add a Badge MDX component and mark Deno Desktop (guide + API) Experimental. Verified: build, format, internal links, and all affected pages (200). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- content/docs/changelog/index.mdx | 15 +- content/docs/guide/bundle-format.mdx | 19 +- content/docs/guide/index.mdx | 41 ++- content/docs/guide/platform-integration.mdx | 64 ++--- content/docs/guide/platform-support.mdx | 60 ++--- content/docs/guide/platforms/android.mdx | 176 ++++++------- content/docs/guide/platforms/deno.mdx | 23 +- .../docs/guide/platforms/electron/builder.mdx | 7 - .../docs/guide/platforms/electron/forge.mdx | 6 - content/docs/guide/platforms/ios.mdx | 17 +- content/docs/guide/platforms/tauri.mdx | 72 +++--- content/docs/guide/protocol-handling.mdx | 21 +- content/docs/guide/providers/aws.mdx | 6 - content/docs/guide/providers/cloudflare.mdx | 2 +- content/docs/guide/providers/local.mdx | 2 +- content/docs/guide/remote-bundles.mdx | 36 +-- content/docs/guide/remote.mdx | 14 +- content/docs/guide/why-webview-bundle.mdx | 6 +- content/docs/references/bridge.mdx | 244 ------------------ content/docs/references/bridge/errors.mdx | 61 +++++ content/docs/references/bridge/index.mdx | 160 ++++++++++++ content/docs/references/bridge/meta.json | 4 + content/docs/references/bridge/remote.mdx | 58 +++++ content/docs/references/bridge/source.mdx | 68 +++++ content/docs/references/bridge/testing.mdx | 147 +++++++++++ content/docs/references/bridge/updater.mdx | 87 +++++++ content/docs/references/deno.mdx | 204 --------------- content/docs/references/deno/index.mdx | 139 ++++++++++ content/docs/references/deno/meta.json | 4 + content/docs/references/deno/protocol.mdx | 74 ++++++ content/docs/references/deno/remote.mdx | 93 +++++++ content/docs/references/deno/source.mdx | 87 +++++++ content/docs/references/deno/updater.mdx | 111 ++++++++ content/docs/references/index.mdx | 7 +- src/mdx.tsx | 30 +++ 35 files changed, 1364 insertions(+), 801 deletions(-) delete mode 100644 content/docs/references/bridge.mdx create mode 100644 content/docs/references/bridge/errors.mdx create mode 100644 content/docs/references/bridge/index.mdx create mode 100644 content/docs/references/bridge/meta.json create mode 100644 content/docs/references/bridge/remote.mdx create mode 100644 content/docs/references/bridge/source.mdx create mode 100644 content/docs/references/bridge/testing.mdx create mode 100644 content/docs/references/bridge/updater.mdx delete mode 100644 content/docs/references/deno.mdx create mode 100644 content/docs/references/deno/index.mdx create mode 100644 content/docs/references/deno/meta.json create mode 100644 content/docs/references/deno/protocol.mdx create mode 100644 content/docs/references/deno/remote.mdx create mode 100644 content/docs/references/deno/source.mdx create mode 100644 content/docs/references/deno/updater.mdx diff --git a/content/docs/changelog/index.mdx b/content/docs/changelog/index.mdx index 9718a57..f4e6ab1 100644 --- a/content/docs/changelog/index.mdx +++ b/content/docs/changelog/index.mdx @@ -3,8 +3,8 @@ title: Changelog description: Where Webview Bundle releases are tracked, and the versions published so far. --- -Webview Bundle is pre-1.0. Releases are cut per package, so the authoritative release -notes live with each registry and the GitHub release pages. +Releases are cut per package, so the authoritative release notes live with each registry and the +GitHub release pages. - Some packages are not published yet: `@wvb/electron-builder` and `@wvb/electron-forge` exist in - the repository but are not on npm, and the Android (`webview-bundle-android`) and iOS - (`webview-bundle-ios`) bindings are pre-release — not yet on Maven Central or tagged for Swift - Package Manager. See [Platform support](/docs/guide/platform-support) for current status. - - ## Versioning -Packages follow [semantic versioning](https://semver.org). While the project is pre-1.0, minor -versions may introduce breaking changes; pin exact versions for reproducible builds. The `.wvb` +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/guide/bundle-format.mdx b/content/docs/guide/bundle-format.mdx index 7832b91..4d6ebf0 100644 --- a/content/docs/guide/bundle-format.mdx +++ b/content/docs/guide/bundle-format.mdx @@ -4,17 +4,14 @@ description: How a .wvb archive is laid out on disk, section by section, and how --- 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: - -```text -[ Header (17 bytes) ][ Index (variable) ][ Data (variable) ] -``` - -| Section | Holds | -| ------- | -------------------------------------------------- | -| Header | magic number, format version, index size, checksum | -| Index | path → offset/length/headers entry map | -| Data | LZ4-compressed file contents, sorted by path | +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): diff --git a/content/docs/guide/index.mdx b/content/docs/guide/index.mdx index 58fb2c7..765b30e 100644 --- a/content/docs/guide/index.mdx +++ b/content/docs/guide/index.mdx @@ -106,7 +106,7 @@ See [Remote, integrity & signature config](/docs/config/remote) for the full sch @@ -169,20 +169,20 @@ See [Remote, integrity & signature config](/docs/config/remote) for the full sch 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` | unpublished | electron-builder integration (in-repo, not yet on npm) | -| `@wvb/electron-forge` | unpublished | Electron Forge plugin (in-repo, not yet on npm) | -| `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 | +| 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: @@ -196,10 +196,7 @@ cargo add wvb-tauri The Tauri integration is the **`wvb-tauri` crate** on crates.io — there is no `@wvb/tauri` npm - package. The Android (Kotlin) and iOS (Swift) bindings are built from the core via UniFFI and are - **pre-release**: they are not yet published to Maven Central or tagged for Swift Package Manager, - so install from source for now. See [Platform Support](/docs/guide/platform-support) for the full - status. + package. See [Platform Support](/docs/guide/platform-support) for the full status. ## Next steps diff --git a/content/docs/guide/platform-integration.mdx b/content/docs/guide/platform-integration.mdx index 9b9f0e6..845b204 100644 --- a/content/docs/guide/platform-integration.mdx +++ b/content/docs/guide/platform-integration.mdx @@ -93,13 +93,13 @@ wvb-tauri = "0.1.0" 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(); + .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. @@ -114,8 +114,8 @@ The same crate handles desktop and mobile. On Android, builtin bundles ship insi ```rust tauri::Builder::default() - .plugin(tauri_plugin_fs::init()) // required on Android for builtin bundles - .plugin(wvb_tauri::init(config)) + .plugin(tauri_plugin_fs::init()) // required on Android for builtin bundles + .plugin(wvb_tauri::init(config)) ``` See the [Tauri guide](/docs/guide/platforms/tauri). @@ -128,19 +128,19 @@ Android and iOS share one binding generator: `packages/ffi`, a Rust crate that u - `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 — stable releases use `ffi/`, prereleases use `prerelease/`. +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, - ), + BundleSourceConfig( + builtinDir = "$filesDir/bundles", + remoteDir = "$cacheDir/remote", + builtinManifestFilepath = null, + remoteManifestFilepath = null, + ), ) val bundle = source.fetchBundle("app") ``` @@ -149,32 +149,24 @@ val bundle = source.fetchBundle("app") import WebViewBundleLibrary let source = BundleSource( - config: BundleSourceConfig( - builtinDir: "\(docs)/bundles", - remoteDir: "\(caches)/remote", - builtinManifestFilepath: nil, - remoteManifestFilepath: nil - ) + config: BundleSourceConfig( + builtinDir: "\(docs)/bundles", + remoteDir: "\(caches)/remote", + builtinManifestFilepath: nil, + remoteManifestFilepath: nil + ) ) let bundle = try await source.fetchBundle(bundleName: "app") ``` -```swift title="Package.swift — install from source (pre-release)" +```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: "" + name: "WebViewBundleFFI", + url: "https://github.com/webview-bundle/webview-bundle/releases/download/ffi/0.1.0/WebViewBundleFFI.xcframework.zip", + checksum: "" ) ``` - - Android and iOS are implemented and end-to-end tested, but the bindings are pre-release. They are - not yet published to Maven Central, and there is no Swift Package Manager tag — install from - source for now. The minimum supported iOS version is iOS 16. The currently committed xcframework - contains a simulator-only slice; the device slice is pending. See the - [Android](/docs/guide/platforms/android) and [iOS](/docs/guide/platforms/ios) guides. - - ### Deno `@wvb/deno` reaches the core through Deno FFI over a prebuilt dynamic library. Install the dylib from GitHub Releases, then load it. @@ -279,12 +271,12 @@ The webview also loads its assets through the same core: the bundle protocol ser = 15` | Stable (pre-1.0) | -| [Tauri desktop](/docs/guide/platforms/tauri) | System WebView | `wvb-tauri` 0.1.0 (crate) | Tauri v2 | Stable (pre-1.0) | -| Tauri mobile | System WebView | `wvb-tauri` 0.1.0 (crate) | See [Android](/docs/guide/platforms/android) / [iOS](/docs/guide/platforms/ios) | Pre-release | -| [Android](/docs/guide/platforms/android) | System WebView | `webview-bundle-android` (Kotlin) | minSdk 24 / Android 7.0 | Pre-release — not yet on Maven Central | -| [iOS](/docs/guide/platforms/ios) | WKWebView | `webview-bundle-ios` (Swift) | iOS 16 / macOS 12 | Pre-release — no SPM tag yet | -| [Deno Desktop](/docs/guide/platforms/deno) | Deno webview | `@wvb/deno-desktop` 0.0.0 (JSR) | — | Experimental | - -All integrations are pre-1.0, so APIs may change between minor versions. +| 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 @@ -66,24 +64,24 @@ wvb-tauri = "0.1.0" 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"); + .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. Both are **pre-release**: no published artifact yet, so you install from source. +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 from source, then install on a `WebView`: +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 @@ -91,8 +89,8 @@ node scripts/install.mjs ```kotlin title="MainActivity.kt" val wvb = WebViewBundle.getInstance( - this, - WebViewBundleConfig(protocols = listOf(WebViewBundleProtocol.bundle())), + this, + WebViewBundleConfig(protocols = listOf(WebViewBundleProtocol.bundle())), ) val webView = WebView(this) webView.settings.javaScriptEnabled = true @@ -112,7 +110,7 @@ node scripts/install.mjs ffi/0.1.0 ```swift title="ContentView.swift" let instance = try WebViewBundle.configure( - WebViewBundleConfig(protocols: [.bundle(scheme: "app")]) + WebViewBundleConfig(protocols: [.bundle(scheme: "app")]) ) let config = WKWebViewConfiguration() instance.install(on: config) @@ -122,21 +120,13 @@ webView.load(URLRequest(url: URL(string: "app://app.wvb")!)) See the [iOS guide](/docs/guide/platforms/ios). - - Android and iOS are functional and end-to-end tested, but **pre-release**. The Android library is - not yet published to Maven Central, and the iOS package has no Swift Package Manager tag — install - both from source for now, following their guides. The committed iOS `xcframework` is currently - **simulator-only**: on-device builds will not link until a device-bearing binary is published. - - ## Deno Desktop -Deno Desktop is the newest integration, on JSR as `@wvb/deno-desktop` (version 0.0.0). It builds a bundle source and exposes a `Deno.serve`-compatible handler, with one protocol per window. +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 offered as a preview. Treat it as not yet production-ready, - and expect breaking changes. See the [Deno Desktop guide](/docs/guide/platforms/deno) and the - [Deno API reference](/docs/references/deno). + 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 index 1b485ff..b977b65 100644 --- a/content/docs/guide/platforms/android.mdx +++ b/content/docs/guide/platforms/android.mdx @@ -5,15 +5,6 @@ description: Serve and update Webview Bundles inside an Android WebView with the 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. - - **Pre-release.** Not yet published to Maven Central. There is no release tag, so the coordinates - below describe the intended artifact, not something you can resolve today. For now, build from the - [`webview-bundle-android`](https://github.com/webview-bundle/webview-bundle-android) repository. - The native FFI is pinned in `.ffi-version` and installed by `scripts/install.mjs`, which downloads - the `android.zip` asset from a [core repo](https://github.com/webview-bundle/webview-bundle) - release and unpacks the Kotlin bindings and `jniLibs` into the library module. - - ## Requirements | Item | Value | @@ -28,35 +19,16 @@ The bridge attaches through `WebViewCompat.addWebMessageListener`. On a device w 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. -Once a release is cut, the intended coordinates are: +Add the dependency: ```kotlin title="build.gradle.kts" dependencies { - implementation("dev.wvb:webview-bundle-android:") + implementation("dev.wvb:webview-bundle-android:") } ``` -Until then, build from source. Clone the repo, install the pinned FFI, and build the `:lib` module: - -```sh -git clone https://github.com/webview-bundle/webview-bundle-android -cd webview-bundle-android -node scripts/install.mjs # installs the FFI pinned in .ffi-version -./gradlew :lib:assembleRelease -``` - -`install.mjs` fetches the `android.zip` release asset and unpacks the Kotlin bindings into `lib/src/main/kotlin/` and the JNI libs into `lib/src/main/jniLibs/`. Pin a different FFI version per invocation: - -```sh -node scripts/install.mjs # use the version in .ffi-version -node scripts/install.mjs 0.1.0 # resolves to tag ffi/0.1.0 -node scripts/install.mjs latest # highest ffi/* release -node scripts/install.mjs --prerelease a3f693a # prerelease/ -``` - - `install.mjs` requires `unzip` on `PATH` and honors `GITHUB_TOKEN` / `GH_TOKEN`. See [Platform - support](/docs/guide/platform-support) for the status of every platform. + See [Platform support](/docs/guide/platform-support) for the status of every platform. ## Quick start @@ -73,38 +45,38 @@ 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 - } + private lateinit var webView: WebView + private lateinit var handle: AutoCloseable - handle = wvb.install(webView) { } - webView.loadUrl("https://app.wvb/") - setContentView(webView) - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - override fun onDestroy() { - handle.close() - webView.destroy() - super.onDestroy() + 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() + } } ``` @@ -123,8 +95,8 @@ The manifest needs the internet permission. For local development against a clea - + android:usesCleartextTraffic="true"> + ``` @@ -144,11 +116,11 @@ If a handler throws, the library synthesizes a `500 text/plain` response and cal ```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" } - } + delegate = myWebViewClient // your callbacks are preserved + disableBridge = false // set true to skip window.wvbAndroid + bridge = { + handler("greet") { params -> "hello" } + } } ``` @@ -175,12 +147,12 @@ The native source reads files, not asset streams, so the library copies `assets/ 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) - ), + 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) + ), ) ``` @@ -216,9 +188,9 @@ A protocol decides which hosts a `WebView` request is served from. Register prot ```kotlin WebViewBundleProtocol.bundle { - passthrough("api.example.com") - passthroughDomain("analytics.example.com") - passthrough { host -> host.endsWith(".cdn.example.com") } + passthrough("api.example.com") + passthroughDomain("analytics.example.com") + passthrough { host -> host.endsWith(".cdn.example.com") } } ``` @@ -229,7 +201,7 @@ WebViewBundleProtocol.bundle { ```kotlin WebViewBundleProtocol.local( - mapOf("app.wvb" to "http://10.0.2.2:3000"), + mapOf("app.wvb" to "http://10.0.2.2:3000"), ) ``` @@ -240,10 +212,10 @@ A typical dev configuration registers `local()` for the hosts you proxy and `bun ```kotlin WebViewBundleConfig( - protocols = listOf( - WebViewBundleProtocol.local(mapOf("app.wvb" to "http://10.0.2.2:3000")), - WebViewBundleProtocol.bundle(), - ), + protocols = listOf( + WebViewBundleProtocol.local(mapOf("app.wvb" to "http://10.0.2.2:3000")), + WebViewBundleProtocol.bundle(), + ), ) ``` @@ -264,24 +236,24 @@ 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), - ), - ), + 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 */ }, + ), ), + onError = { error -> /* log */ }, + ), ) ``` @@ -309,11 +281,11 @@ The updater runs a three-step cycle. Each call is a `suspend` function, so call 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 - } + 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 + } } ``` diff --git a/content/docs/guide/platforms/deno.mdx b/content/docs/guide/platforms/deno.mdx index 64e554a..32091bd 100644 --- a/content/docs/guide/platforms/deno.mdx +++ b/content/docs/guide/platforms/deno.mdx @@ -3,9 +3,11 @@ 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 (preview)" +```ts title="main.ts" import { webviewBundle, bundleProtocol } from '@wvb/deno-desktop'; const wvb = await webviewBundle({ @@ -20,10 +22,7 @@ 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 / preview** and not production-ready. The source lives on an - un-merged branch; `main` ships only a prebuilt dylib artifact. The JSR packages `@wvb/deno` and - `@wvb/deno-desktop` are published at version **`0.0.0`** and their APIs may change. Treat these - snippets as preview, not a stable contract. + Deno Desktop is experimental and its API may change before a stable release. ## How it fits together @@ -34,7 +33,7 @@ The window points at a single local origin served by `Deno.serve`, so the integr 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 (preview)" +```ts title="main.ts" import { webviewBundle, bundleProtocol } from '@wvb/deno-desktop'; const wvb = await webviewBundle({ @@ -53,7 +52,7 @@ await win.closed; 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 (preview)" +```ts title="main.ts" import { webviewBundle, bundleProtocol, registerBindings } from '@wvb/deno-desktop'; const wvb = await webviewBundle({ @@ -78,7 +77,7 @@ type InvokeResult = 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, preview)" +```ts title="app.ts (in the webview)" import { updater } from '@wvb/bridge'; const update = await updater.getUpdate('my-app'); @@ -94,7 +93,7 @@ if (update.isAvailable) { Each class is `Disposable` — free it explicitly with `free()`, or let a `using` declaration call `[Symbol.dispose]` at scope exit: -```ts title="dispose.ts (preview)" +```ts title="dispose.ts" import { BundleProtocol } from '@wvb/deno'; // `lib` and `source` come from the load + BundleSource steps below. @@ -114,7 +113,7 @@ protocol.free(); 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 (preview)" +```ts title="load.ts" import { loadLib, loadLibViaPlug } from '@wvb/deno'; // 1. Load a library already on disk. @@ -160,7 +159,7 @@ 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 (preview)" +```ts title="updater.ts" const wvb = await webviewBundle({ protocols: [bundleProtocol({ scheme: 'app' })], updater: { @@ -178,7 +177,7 @@ The custom `integrityChecker` / `signatureVerifier` function callbacks available **`HttpOptions.defaultHeaders` is not yet supported** on the Deno `Remote`. Other `HttpOptions` fields still apply: -```ts title="http.ts (preview)" +```ts title="http.ts" const wvb = await webviewBundle({ protocols: [bundleProtocol({ scheme: 'app' })], updater: { diff --git a/content/docs/guide/platforms/electron/builder.mdx b/content/docs/guide/platforms/electron/builder.mdx index 1d412f2..12e9515 100644 --- a/content/docs/guide/platforms/electron/builder.mdx +++ b/content/docs/guide/platforms/electron/builder.mdx @@ -5,13 +5,6 @@ description: Install builtin .wvb bundles at package time and keep the native @w `@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. - - `@wvb/electron-builder` lives in the - [repository](https://github.com/webview-bundle/webview-bundle/tree/main/packages/electron-builder) - but is **not yet published to npm** (version `0.0.0`). Install it from source or pin the workspace - version for now. The manual `extraResources` setup below works without it. - - ## 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. diff --git a/content/docs/guide/platforms/electron/forge.mdx b/content/docs/guide/platforms/electron/forge.mdx index 16f692d..3cecc07 100644 --- a/content/docs/guide/platforms/electron/forge.mdx +++ b/content/docs/guide/platforms/electron/forge.mdx @@ -5,12 +5,6 @@ description: Ship packed .wvb builtins as an extra resource and unpack the nativ `@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. - - `@wvb/electron-forge` lives in the repository but is not yet published to npm (version `0.0.0`). - Install it from source or pin the workspace version for now. The manual `extraResource` plus - `AutoUnpackNativesPlugin` setup below works without it. - - ## 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. diff --git a/content/docs/guide/platforms/ios.mdx b/content/docs/guide/platforms/ios.mdx index 4b2b66f..1794691 100644 --- a/content/docs/guide/platforms/ios.mdx +++ b/content/docs/guide/platforms/ios.mdx @@ -8,19 +8,6 @@ scheme and keeps them current with over-the-air (OTA) updates. Register a scheme 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. - -The iOS package is **pre-release**: no published Swift Package Manager version or git tag yet, so you -install the native FFI from source (see below). Releases are tagged `ffi/` (for example -`ffi/0.1.0`); prereleases are tagged `prerelease/`. - - - - The xcframework committed to the package today is **simulator-only** — it ships the - `ios-arm64_x86_64-simulator` slice with no device slice. On-device builds will not link until a - device-bearing `WebViewBundleFFI.xcframework` is installed. Develop against the iOS Simulator for - now. - - ## Requirements | Requirement | Value | @@ -33,8 +20,7 @@ The package binds the Rust core through a UniFFI-generated module named `WebView exposed under the `WebViewBundle` module. See [Platform integration](/docs/guide/platform-integration) for how the shared core reaches each platform. -Because the package is pre-release, clone it and depend on it locally (the native FFI is wired in by -the install script below): +Add the package as a dependency (the native FFI is wired in by the install script below): ```swift title="Package.swift" dependencies: [ @@ -55,7 +41,6 @@ Resolve the native binary from a release before you build: 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 -node scripts/install.mjs --prerelease a3f693a # -> tag prerelease/a3f693a ``` The script reads the GitHub release for the resolved tag and: diff --git a/content/docs/guide/platforms/tauri.mdx b/content/docs/guide/platforms/tauri.mdx index f95474e..3a7d0d1 100644 --- a/content/docs/guide/platforms/tauri.mdx +++ b/content/docs/guide/platforms/tauri.mdx @@ -55,19 +55,19 @@ 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"); + 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"); } ``` @@ -79,8 +79,8 @@ pub fn run() { 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()), + ("example.com".to_string(), "http://localhost:1420".to_string()), + ("api.example.com".to_string(), "http://localhost:8080".to_string()), ])); ``` @@ -168,9 +168,9 @@ Add a `Remote` to enable OTA downloads: 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")); + .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`: @@ -179,12 +179,12 @@ Tune the HTTP client with `Http` (defaults: request timeout `120_000` ms), and w 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"); - } - }); + .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: @@ -287,15 +287,15 @@ use wvb_tauri::{Config, Ed25519Verifier, IntegrityPolicy, Protocol, Remote, Sour 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())), - ); + .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. @@ -311,7 +311,7 @@ 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 + // updater is present only when both `.remote(...)` and `.updater(...)` are configured } ``` @@ -323,8 +323,8 @@ On **iOS**, builtin bundles live in a real filesystem resource directory — no ```rust title="src-tauri/src/lib.rs" tauri::Builder::default() - .plugin(tauri_plugin_fs::init()) - .plugin(wvb_tauri::init(/* ... */)); + .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. diff --git a/content/docs/guide/protocol-handling.mdx b/content/docs/guide/protocol-handling.mdx index b604856..a0d92ca 100644 --- a/content/docs/guide/protocol-handling.mdx +++ b/content/docs/guide/protocol-handling.mdx @@ -101,7 +101,7 @@ In Tauri, configure the host map on `Protocol::local`: use wvb_tauri::{Config, Protocol}; Config::new().protocol( - Protocol::local("app").host("myapp.wvb", "http://localhost:3000"), + Protocol::local("app").host("myapp.wvb", "http://localhost:3000"), ); ``` @@ -159,13 +159,13 @@ The `wvb-tauri` crate registers an async URI-scheme protocol per configured `Pro 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(); + .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`: @@ -199,11 +199,6 @@ class BundleWebViewClient : WebViewClient() { See the [Android guide](/docs/guide/platforms/android) for the working setup. - - Android and iOS are pre-release and not yet published to Maven Central or tagged for SPM. Install - from source for now. - - diff --git a/content/docs/guide/providers/aws.mdx b/content/docs/guide/providers/aws.mdx index fbf5822..dcf3bc3 100644 --- a/content/docs/guide/providers/aws.mdx +++ b/content/docs/guide/providers/aws.mdx @@ -4,12 +4,6 @@ description: Host, serve, and provision remote Webview Bundles on AWS using S3, --- 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. - - - All AWS packages are at `0.1.0` and pre-release. Treat them as preview and install from source - until a published release lands. - - For the contract these pieces implement and how to choose a provider, see [Building a remote](/docs/guide/remote). ## Backing services diff --git a/content/docs/guide/providers/cloudflare.mdx b/content/docs/guide/providers/cloudflare.mdx index 2e0229c..fc53b3b 100644 --- a/content/docs/guide/providers/cloudflare.mdx +++ b/content/docs/guide/providers/cloudflare.mdx @@ -13,7 +13,7 @@ Three packages cover the workflow: | `@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` and pre-release. Pin the version and expect breaking changes. +All three are `0.1.0`. Pin the version in your `wvb.config`. ```sh npm i -D @wvb/remote-cloudflare diff --git a/content/docs/guide/providers/local.mdx b/content/docs/guide/providers/local.mdx index 440d7a2..855f22c 100644 --- a/content/docs/guide/providers/local.mdx +++ b/content/docs/guide/providers/local.mdx @@ -13,7 +13,7 @@ A filesystem-backed remote that runs on your own machine. It implements the same ## Two packages -Both at `0.0.0` (pre-release, not yet published — install from source for now): +Both at `0.0.0`: | Package | Role | | ---------------------------- | --------------------------------------------------------------------------------------------------------- | diff --git a/content/docs/guide/remote-bundles.mdx b/content/docs/guide/remote-bundles.mdx index b7e63c8..be7b4bb 100644 --- a/content/docs/guide/remote-bundles.mdx +++ b/content/docs/guide/remote-bundles.mdx @@ -54,11 +54,11 @@ let updater = Updater::new(source, remote, None); // None -> UpdaterConfig::defa 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 + // 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 } ``` @@ -66,14 +66,14 @@ if update.is_available { ```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, + 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, } ``` @@ -156,7 +156,7 @@ use wvb::integrity::IntegrityPolicy; use wvb::updater::UpdaterConfig; let config = UpdaterConfig::new() - .integrity_policy(IntegrityPolicy::Strict); + .integrity_policy(IntegrityPolicy::Strict); ``` ## Signatures @@ -188,11 +188,11 @@ use wvb::updater::{Updater, UpdaterConfig}; use std::sync::Arc; let verifier = SignatureVerifier::Ed25519(Arc::new( - Ed25519Verifier::from_public_key_pem(PUBLIC_KEY_PEM)?, + Ed25519Verifier::from_public_key_pem(PUBLIC_KEY_PEM)?, )); let config = UpdaterConfig::new() - .integrity_policy(IntegrityPolicy::Strict) - .signature_verifier(verifier); + .integrity_policy(IntegrityPolicy::Strict) + .signature_verifier(verifier); let updater = Updater::new(source, remote, Some(config)); ``` @@ -203,7 +203,7 @@ use wvb::signature::{EcdsaSecp256r1Verifier, SignatureVerifier}; use std::sync::Arc; let verifier = SignatureVerifier::EcdsaSecp256r1(Arc::new( - EcdsaSecp256r1Verifier::from_public_key_pem(PUBLIC_KEY_PEM)?, + EcdsaSecp256r1Verifier::from_public_key_pem(PUBLIC_KEY_PEM)?, )); ``` diff --git a/content/docs/guide/remote.mdx b/content/docs/guide/remote.mdx index f1e1e16..64cbcc5 100644 --- a/content/docs/guide/remote.mdx +++ b/content/docs/guide/remote.mdx @@ -42,12 +42,6 @@ export default defineConfig({ Publish-side integrity and signature options live alongside `uploader`/`deployer` — see [Remote, integrity & signature config](/docs/config/remote). - - `@wvb/remote-local` and `@wvb/remote-local-provider` are pre-release (`0.0.0`); the AWS and - Cloudflare packages are at `0.1.0`. Treat all of them as not-yet-published and install from source - for now. - - ## The HTTP contract Every provider implements the same four endpoints, so the client never cares which backend is behind it. @@ -155,14 +149,14 @@ Set the app's updater endpoint to `http://localhost:4313`. Now `getUpdate`, `dow ```rust let remote = Remote::builder() - .endpoint("http://localhost:4313") - .build()?; + .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 + updater.download("app", None).await?; // GET /bundles/app + updater.install("app", &info.version).await?; // activate the downloaded version } ``` diff --git a/content/docs/guide/why-webview-bundle.mdx b/content/docs/guide/why-webview-bundle.mdx index 383fb77..332774d 100644 --- a/content/docs/guide/why-webview-bundle.mdx +++ b/content/docs/guide/why-webview-bundle.mdx @@ -160,10 +160,8 @@ Two common alternatives solve part of the same problem: 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 but pre-release: install from source for now, since - the mobile artifacts are not yet published to Maven Central or tagged for Swift Package Manager. - The iOS minimum is iOS 16. Deno Desktop is experimental. See [Platform - support](/docs/guide/platform-support) for current status. + 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/references/bridge.mdx b/content/docs/references/bridge.mdx deleted file mode 100644 index c7c9f0e..0000000 --- a/content/docs/references/bridge.mdx +++ /dev/null @@ -1,244 +0,0 @@ ---- -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. - -### source - -`source` maps to the native bundle source — the store of builtin and remote bundles on the host. Its 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. - - -### remote - -`remote` talks to the configured remote server through the native host: - -| 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. | - -`download` and `downloadVersion` resolve to `RemoteBundleInfo` only — bundle bytes are not transferred across the bridge. - -### updater - -`updater` drives the over-the-air (OTA) update flow: - -| 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. | - -`getUpdate()` returns a `BundleUpdateInfo`: - -```ts -type BundleUpdateInfo = { - name: string; - version: string; - localVersion?: string; - isAvailable: boolean; - etag?: string; - integrity?: string; - signature?: string; - lastModified?: string; -}; -``` - -## Updater flow - -A typical 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(); -} -``` - -## Errors - -Every bridge failure surfaces as a `BridgeError`, an `Error` subclass with `name` set to `'BridgeError'` and an optional `code`. - -```ts -import { BridgeError, BridgeErrorCode, isBridgeError } from '@wvb/bridge'; - -try { - await updater.getUpdate('app'); -} catch (err) { - if (isBridgeError(err) && err.code === BridgeErrorCode.UpdaterNotInitialized) { - // The native host has no updater configured. - } -} -``` - -`BridgeErrorCode` values: - -| Code | Value | Meaning | -| ----------------------- | ------------------------- | ------------------------------------------- | -| `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. | - -The package also exports the guards `isBridgeError` and `isBridgeErrorData`, plus the static helpers `BridgeError.of(code, message?)` and `BridgeError.from(value)`. - -## Testing - -The `@wvb/bridge/testing` subpath provides helpers to drive the bridge in tests without a native host. Mock command keys are dotted and typed, such as `'source.loadVersion'`. - -```ts -import { mockBridge } from '@wvb/bridge/testing'; - -const bridge = mockBridge({ platform: 'electron' }).mockInvoke('updater.getUpdate', () => ({ - name: 'app', - version: '1.2.0', - isAvailable: true, -})); - -// Run code under test, then: -bridge.clear(); -``` - -The testing module exports: - -- `mockInvoke(command, handler)` — mock a single command; returns a `Disposable`. -- `mockPlatform(type)` — force `platform.type`; returns a `Disposable`. -- `mockBridge(options?)` — a chainable mock with `.mockInvoke()` and `.clear()`. -- `clearInvokeMocks()` — remove all registered mocks. - -## See also - - - - - 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.mdx b/content/docs/references/deno.mdx deleted file mode 100644 index 3ec2a79..0000000 --- a/content/docs/references/deno.mdx +++ /dev/null @@ -1,204 +0,0 @@ ---- -title: Deno API -description: Reference for the experimental @wvb/deno package, the Deno FFI peer of @wvb/node. ---- - -`@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 and to look up the -classes, options, and limitations of the Deno binding. - - - **Experimental.** `@wvb/deno` is published on JSR at version `0.0.0`, its source lives on an - unmerged branch, and `main` ships only a prebuilt cdylib. The API surface described here may - change before a stable release. Do not depend on it for production. - - -## 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) | - -## API surface - -The Deno binding mirrors `@wvb/node`. The classes below all take a loaded `lib` and serve the same -roles described in the [Node API reference](/docs/references/node). - -### Protocols - -`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)`: - -```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); -}); -``` - -`HttpMethod` is the union of accepted request methods (for example `'get'` and `'head'`). - -### BundleSource - -`BundleSource` resolves bundles from a builtin directory and a remote directory, with remote taking -priority. It exposes the same data API as `@wvb/node`'s `BundleSource` — listing bundles, loading a -version, fetching a bundle or descriptor, and pruning retained remote versions. - -### Remote - -`Remote` talks to a remote bundle server. Construct it with an 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) -``` - -`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. Related -types: `RemoteOptions`, `HttpOptions`, and `RemoteBundleInfo`. - -### Updater - -`Updater` checks a remote for newer versions and installs them. It accepts a `BundleSource`, a -`Remote`, and optional `UpdaterOptions`. Configure verification declaratively with a -`SignatureVerifierOptions` value: - -```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 }, - }, -}); - -const update = await updater.getUpdate('app'); -if (update.isAvailable) { - await updater.download('app'); - await updater.install('app', update.version); -} -``` - -Related types: `UpdaterOptions`, `IntegrityPolicy` (`'strict' | 'optional' | 'none'`), -`SignatureAlgorithm`, `SignatureVerifierOptions`, and `BundleUpdateInfo`. - -### 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 not available: - -- **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 yet supported** on the Deno `Remote`. - -## Deno Desktop integration - -`@wvb/deno-desktop` (JSR, `0.0.0`, experimental) 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. - - - - - 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 index 3dcd165..255634f 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,5 +1,5 @@ --- -title: References +title: Overview description: API references for the Rust core, the Node and Deno bindings, and the web-side bridge. --- @@ -63,8 +63,3 @@ The Tauri and mobile integrations expose their own APIs, documented alongside th - 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). - - - The mobile bindings are pre-release and not yet published to Maven Central or tagged for Swift - Package Manager. Install from source for now. - diff --git a/src/mdx.tsx b/src/mdx.tsx index bffcfe7..d038715 100644 --- a/src/mdx.tsx +++ b/src/mdx.tsx @@ -4,6 +4,35 @@ import { ImageZoom } from 'fumadocs-ui/components/image-zoom'; import * as TabsComponents from 'fumadocs-ui/components/tabs'; import defaultMdxComponents from 'fumadocs-ui/mdx'; import type { MDXComponents } from 'mdx/types'; +import type { ReactNode } from 'react'; +import { cn } from './lib/cn'; + +const BADGE_TONES = { + amber: + 'border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400', + brand: 'border-brand/30 bg-brand/10 text-brand', + zinc: 'border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300', +}; + +// Small inline status badge, e.g. `Experimental` under a page title. +function Badge({ + children, + tone = 'amber', +}: { + children: ReactNode; + tone?: keyof typeof BADGE_TONES; +}) { + return ( + + {children} + + ); +} export function getMDXComponents(components?: MDXComponents) { return { @@ -12,6 +41,7 @@ export function getMDXComponents(components?: MDXComponents) { Callout, Card, Cards, + Badge, ...TabsComponents, ...components, } satisfies MDXComponents; From 61da95f7e94b3dfd5cefaa52eb51c912acfb4765 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Wed, 1 Jul 2026 03:14:50 +0900 Subject: [PATCH 15/15] docs: small Experimental badge on the Deno sidebar entries Transform the docs page tree client-side to wrap the two Deno entries (Guide > Deno Desktop and References > Deno API) with a small amber "Experimental" badge in the sidebar. The over-the-wire tree still carries plain-string names; only the rendered name gets the badge. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HhaZ2zL7bAqT3c8mRWTSs9 --- src/layouts/docs/sidebar-badges.tsx | 43 +++++++++++++++++++++++++++++ src/routes/docs/$.tsx | 6 ++-- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/layouts/docs/sidebar-badges.tsx diff --git a/src/layouts/docs/sidebar-badges.tsx b/src/layouts/docs/sidebar-badges.tsx new file mode 100644 index 0000000..1902993 --- /dev/null +++ b/src/layouts/docs/sidebar-badges.tsx @@ -0,0 +1,43 @@ +import type * as PageTree from 'fumadocs-core/page-tree'; +import type { ReactNode } from 'react'; + +// Sidebar entries (by URL) that should carry a small "Experimental" badge. +const EXPERIMENTAL_URLS = new Set(['/docs/guide/platforms/deno', '/docs/references/deno']); + +function ExperimentalBadge() { + return ( + + Experimental + + ); +} + +function withBadge(name: ReactNode): ReactNode { + return ( + <> + {name} + + + ); +} + +function visit(node: PageTree.Node): PageTree.Node { + if (node.type === 'folder') { + const folder = { ...node, children: node.children.map(visit) }; + const url = node.index?.url; + return url != null && EXPERIMENTAL_URLS.has(url) + ? { ...folder, name: withBadge(folder.name) } + : folder; + } + if (node.type === 'page' && EXPERIMENTAL_URLS.has(node.url)) { + return { ...node, name: withBadge(node.name) }; + } + return node; +} + +// Tag the Deno (experimental) entries in the sidebar tree with a small badge. +// Applied client-side after the serialized tree is loaded, so the ReactNode name +// is fine — the over-the-wire payload still carries plain-string names. +export function withExperimentalBadges(root: PageTree.Root): PageTree.Root { + return { ...root, children: root.children.map(visit) }; +} diff --git a/src/routes/docs/$.tsx b/src/routes/docs/$.tsx index 4e3f6f4..d816683 100644 --- a/src/routes/docs/$.tsx +++ b/src/routes/docs/$.tsx @@ -3,11 +3,12 @@ import { createServerFn } from '@tanstack/react-start'; import { useFumadocsLoader } from 'fumadocs-core/source/client'; import { DocsLayout } from 'fumadocs-ui/layouts/notebook'; import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/notebook/page'; -import { Suspense } from 'react'; +import { Suspense, useMemo } from 'react'; import browserCollections from '~source/browser'; import { docSource } from '../../doc'; import { DocsNavbar } from '../../layouts/docs/DocsNavbar'; import { MobileTocBar } from '../../layouts/docs/MobileTocBar'; +import { withExperimentalBadges } from '../../layouts/docs/sidebar-badges'; import { GITHUB_URL } from '../../layouts/home/data'; import { useMDXComponents } from '../../mdx'; @@ -60,10 +61,11 @@ const clientLoader = browserCollections.docs.createClientLoader({ function Page() { const data = useFumadocsLoader(Route.useLoaderData()); + const tree = useMemo(() => withExperimentalBadges(data.pageTree), [data.pageTree]); return ( }} githubUrl={GITHUB_URL}