diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index c943bc06..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Keep the served companion APK in sync with main on every push. -# -# When a push to main includes Android changes, rebuild the APK, refresh -# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask -# you to push again (so the refreshed APK rides along in the same push). -# -# Enable once per clone: git config core.hooksPath .githooks -set -euo pipefail - -ROOT="$(git rev-parse --show-toplevel)" -cd "$ROOT" - -# ship-companion.sh already (re)published the APK for this push — don't redo it. -[ -n "${SHIP_COMPANION:-}" ] && exit 0 - -PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW="" -while read -r _local_ref local_sha remote_ref remote_sha; do - if [ "${remote_ref##*/}" = "main" ]; then - PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha" - fi -done -[ "$PUSH_MAIN" = "1" ] || exit 0 - -# Loop-break: if the tip is already the auto APK commit, let the push proceed. -case "$(git log -1 --pretty=%s)" in - *"companion APK"*) exit 0 ;; -esac - -# Only rebuild when this push actually touches the Android app. -ZEROS="0000000000000000000000000000000000000000" -if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then - ANDROID_CHANGED=1 -elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then - ANDROID_CHANGED=0 -else - ANDROID_CHANGED=1 -fi -[ "$ANDROID_CHANGED" = "1" ] || exit 0 - -bash scripts/publish-companion-apk.sh || exit 0 - -DEST="neode-ui/public/packages/archipelago-companion.apk" -if git diff --cached --quiet -- "$DEST"; then - exit 0 # APK unchanged — nothing to do -fi - -git commit -q -m "chore(android): update companion APK download [skip ci]" -echo "" >&2 -echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2 -exit 1 diff --git a/.gitignore b/.gitignore index 3e1610e0..02574536 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,6 @@ uploads/ /image-recipe/INTEGRATION-GUIDE.md /docs/multinode-testing-plan.md /docs/bitcoin-version-bulletproof-rollout.md + +# Generated PWA dev output (vite-plugin-pwa) — never a source artifact +neode-ui/dev-dist/ diff --git a/Android/archipelago-0.3.0-debug.apk.zip b/Android/archipelago-0.3.0-debug.apk.zip deleted file mode 100644 index be620f3c..00000000 Binary files a/Android/archipelago-0.3.0-debug.apk.zip and /dev/null differ diff --git a/RELEASE-NOTES-v1.0.0.md b/RELEASE-NOTES-v1.0.0.md deleted file mode 100644 index ba8f7f91..00000000 --- a/RELEASE-NOTES-v1.0.0.md +++ /dev/null @@ -1,112 +0,0 @@ -# Archipelago v1.0.0 Release Notes - -**Release Date**: March 2026 -**Target Platform**: Debian 13 (Trixie) — x86_64 and ARM64 - -## What is Archipelago? - -Archipelago is a self-sovereign Bitcoin Node OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface. Run Bitcoin infrastructure, self-hosted apps, and Web5 identity — all from hardware you control. - -## Key Features - -### Bitcoin Infrastructure -- **Bitcoin Knots** full node with pruning support -- **LND** Lightning Network daemon with channel management UI -- **Electrs** Electrum server for wallet connectivity -- **BTCPay Server** for accepting Bitcoin payments -- **Mempool** block explorer and fee estimator -- **Fedimint** federation guardian and gateway - -### Self-Hosted Apps (20+) -- **Storage**: File Browser, Immich, PhotoPrism, Nextcloud -- **Productivity**: Penpot, OnlyOffice, Vaultwarden -- **Media**: Jellyfin -- **Search**: SearXNG (private search) -- **AI**: Ollama (local LLMs with Claude, GPT, and open models) -- **Network**: Tailscale VPN, Nginx Proxy Manager, Uptime Kuma -- **Home**: Home Assistant -- **Platform**: IndeedHub, Grafana monitoring - -### Web5 Identity -- DID-based digital identity (Ed25519 + secp256k1 dual key) -- Verifiable Credentials issuance and verification -- Decentralized Web Node (DWN) for data sync -- Nostr relay integration for node discovery - -### Federation -- DID-authenticated peer-to-peer federation -- Remote node monitoring and management -- Bilateral trust with single-use invite codes -- Tor hidden services for private communication - -### Security -- AES-256-GCM encrypted secrets at rest -- Container isolation: read-only root, capability dropping, non-root user -- TOTP two-factor authentication with backup codes -- Session management: HttpOnly cookies, SameSite=Strict, CSRF tokens -- Rate limiting on sensitive endpoints -- AppArmor profiles for container confinement -- Per-endpoint input validation - -### System -- Rust backend with JSON-RPC API (<1ms response time) -- Vue 3 frontend with glassmorphism design -- WebSocket real-time updates -- Automated OTA updates with rollback -- Tor hidden services for all apps -- Goal-based onboarding wizard -- Kiosk mode for dedicated hardware - -## Supported Hardware - -- **x86_64**: Any 64-bit PC, Intel NUC, mini PCs -- **ARM64**: Raspberry Pi 5, other ARM64 SBCs -- **Minimum**: 4GB RAM, 32GB storage (500GB+ recommended for Bitcoin) -- **Recommended**: 8GB+ RAM, 1TB+ NVMe SSD - -## Installation - -1. Download the ISO for your architecture -2. Flash to USB drive (use Balena Etcher or `dd`) -3. Boot from USB on target hardware -4. Follow the automated installer -5. Access the web UI at `http://` -6. Set your password and start the onboarding wizard - -## Known Limitations - -- Bitcoin initial block download takes 3-7 days depending on hardware -- Some apps (BTCPay Server, Home Assistant) open in new tab due to X-Frame-Options -- ARM64 builds may have slower container pulls due to less cached registry content -- Tor hidden service generation takes 1-2 minutes on first boot - -## Upgrade from Beta - -If upgrading from v0.5.0-beta: -1. Back up your data via Settings > Backup -2. The OTA update system will handle the upgrade automatically -3. If OTA fails, reflash with the v1.0.0 ISO (app data is preserved on separate partition) - -## Security Model - -Archipelago follows defense-in-depth: -- **Network**: Nginx reverse proxy, Tor hidden services, VPN support -- **Application**: Container isolation with Podman (rootless) -- **Data**: AES-256-GCM encryption for secrets, 0600 file permissions -- **Auth**: Argon2 password hashing, TOTP 2FA, session rotation -- **Updates**: SHA-256 verified downloads with rollback capability - -See `docs/adr/` for architectural decision records on security choices. - -## Contributing - -Archipelago is open source. To contribute: -1. Fork the repository -2. Create a feature branch (`feature/description`) -3. Follow the coding standards in `CLAUDE.md` -4. Submit a pull request with tests - -## License - -MIT License. See `LICENSE` for details. -# 2026-04-18 ISO build trigger diff --git a/docs/archive/README.md b/docs/archive/README.md index 39c4b098..e2012733 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -16,8 +16,6 @@ For current state, start at: | `demo-deployment-design.md` | Design for the public demo sandbox | Demo shipped; `docs/demo-build-info.md` is the live ops doc | | `app-registry-status-2026-06-21.md` | Per-app migration snapshot from node .228 @ v1.7.99-alpha | Point-in-time snapshot; headline findings (immich legacy, meshtastic present) no longer true | | `security-code-audit-2026-03.md` | March 2026 security audit of v0.1.0 (33 findings) | Historical record; top findings since remediated (Argon2id, persisted sessions, image verification) | -| `architecture-review.html` | Generated interactive architecture guide (2026-03) | Stale generated artifact; describes an early crate/app layout | -| `lora-functionality.html` | Generated LoRa/mesh guide (2026-04) | Predates X3DH/double-ratchet, Reticulum transport, and mesh AI | | `INSTALL-SCREENS-DESIGN.md` | Installer screen design solicitation | Installer implemented in `image-recipe/` | | `three-mode-ui-design.md` | Design for the Pro/Easy/Chat three-mode UI | Fully implemented (`stores/uiMode.ts`, `EasyHome.vue`, `Chat.vue`, goals system) | | `HANDOVER-2026-07-02-iso-feedback.md` | Session handover from the 2026-07-02 ISO feedback bug-bash | Completed session log | diff --git a/docs/archive/architecture-review.html b/docs/archive/architecture-review.html deleted file mode 100644 index 1829a208..00000000 --- a/docs/archive/architecture-review.html +++ /dev/null @@ -1,2523 +0,0 @@ - - - - - -Archipelago — Architecture Review & Learning Guide - - - - - - - - -
- - -
-

Archipelago

-

A complete architecture review and learning guide for the Bitcoin Node OS — explained so anyone can understand it.

-
- Rust + Vue 3 + Podman - ~45,000 lines of Rust (213 files) - ~45,500 lines of TypeScript/Vue (232 files) - ~40 shell scripts - v0.1.0-beta -
-
- - -

What Is Archipelago?

- -

Archipelago (nicknamed "Archy") is a personal server operating system focused on Bitcoin. You download an ISO file, flash it to a USB drive, install it on any computer, and it gives you:

- - - -
-

Think of it like an iPhone for servers. Apple gives you a phone with an App Store where you install apps. Archipelago gives you a server with a Marketplace where you install self-hosted apps. The difference? You own and control everything — your data never leaves your machine.

-
- -

Similar projects exist (Umbrel, Start9, RaspiBlitz), but Archipelago is built from scratch with production-grade security and a custom Rust backend instead of Node.js.

- - -

The Big Picture

- -

Before diving into code, understand the four layers of the system and how they stack:

- -
-┌──────────────────────────────────────────────────────┐ -│ YOUR BROWSER │ -│ (Vue.js Single Page Application) │ -└──────────────────────┬───────────────────────────────┘ - │ HTTP requests (fetch API) -┌──────────────────────┴───────────────────────────────┐ -│ NGINX │ -│ Reverse proxy — routes traffic to the right place │ -│ /rpc/v1 → backend /app/bitcoin/ → container │ -└──────────────────────┬───────────────────────────────┘ - │ Internal HTTP (port 5678) -┌──────────────────────┴───────────────────────────────┐ -│ RUST BACKEND │ -│ The brain — handles auth, app installs, Bitcoin │ -│ RPC, mesh networking, federation, health checks │ -└──────────────────────┬───────────────────────────────┘ - │ Podman REST API (Unix socket) -┌──────────────────────┴───────────────────────────────┐ -│ PODMAN CONTAINERS │ -│ Bitcoin Core, LND, Mempool, Nextcloud, etc. │ -│ Each app runs isolated in its own container │ -└──────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────┐ -│ DEBIAN 12 (Linux OS) │ -│ The foundation — systemd, firewall, filesystem │ -└──────────────────────────────────────────────────────┘ -
- -
- Key Concept: Separation of Concerns - Each layer has ONE job. The browser shows things. Nginx routes traffic. Rust makes decisions. Podman runs apps. This makes the system easier to understand, test, and fix — if the UI breaks, you know the problem is in the Vue code, not the Rust code. -
- - -

How It Runs on a Machine

- -

When you install Archipelago on a computer and power it on, here's what happens in order:

- -
1

Linux boots — Debian 12 starts up, loads drivers, mounts disks

-
2

systemd starts services — A program called systemd reads archipelago.service and launches the Rust backend

-
3

Rust backend initializes — Loads config, creates/loads encryption keys, starts the HTTP server on port 5678

-
4

Health monitor starts — Checks which containers are running, restarts crashed ones, reports readiness

-
5

Nginx starts — Listens on port 80 (HTTP) and routes all incoming traffic

-
6

Containers start — Bitcoin, LND, and other apps start in priority order (Bitcoin first, then things that depend on it)

-
7

Ready! — You open a browser, go to your server's IP address, and see the dashboard

- -
-

It's like starting a restaurant. First the building opens (Linux). Then the manager arrives (Rust backend). They check if all kitchen stations are ready (health monitor). The front door opens (Nginx). The cooks start preparing (containers). Customers can now order (you open the web UI).

-
- - -

The Four Layers — Detailed

- -

Layer 1: The Rust Backend (The Brain)

- -

This is the most important piece. It's written in Rust — a programming language known for speed and safety. The backend is the "brain" that controls everything.

- -
- Why Rust? - Rust prevents entire categories of bugs (memory leaks, crashes, race conditions) at compile time. For a server that manages Bitcoin wallets and runs 24/7, this matters. A crash could mean lost money. Rust makes crashes nearly impossible. -
- -

How the code is organized

-

The Rust code lives in core/ and is split into 5 workspace crates (consolidated from 9 during recent refactoring):

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CrateWhat It DoesLinesAnalogy
archipelagoThe main binary. API endpoints, auth, identity, federation, mesh networking, monitoring, health checks~42,000The restaurant manager — coordinates everything
containerPodmanClient (REST API socket), manifest parser, dependency resolver, health monitor, Bitcoin simulator~2,060The kitchen manager — controls cook stations
securityEncrypted secrets (Argon2 + ChaCha20-Poly1305), AppArmor profiles, Cosign image verification~743The security guard — locks doors, checks IDs
parmanodeCompatibility layer for migrating from an older project~234A translation book — speaks the old language
performanceCPU, memory, and disk resource management~92The meter reader — watches resource gauges
- -

Key modules you should know

- -

The recent refactoring split monolithic files into focused module directories. Each directory has a mod.rs entry point and focused sub-files:

- - - - - - - - - - - - - - - -
ModuleWhat It DoesLinesStructure
main.rsEntry point — starts server, registers signal handlers~180Single file
server.rsWires HTTP server, connects all components~506Single file
api/handler/HTTP request routing, CORS, WebSocket upgrade, auth~896mod.rs + content, dwn, node_message, proxy, websocket
api/rpc/RPC dispatch, 29 endpoint modules + 8 subdirectories~20,000dispatcher.rs routes to focused handlers
api/rpc/package/App lifecycle — install, config, runtime, progress, deps~2,248config.rs, install.rs, lifecycle.rs, runtime.rs, stacks.rs, dependencies.rs, progress.rs
mesh/LoRa mesh networking — protocol, crypto, serial, relay~6,00013 files + listener/ subdirectory (6 files)
federation/Multi-node federation — invites, sync, storage~782invites.rs, storage.rs, sync.rs, types.rs
credentials/W3C Verifiable Credentials — CRUD, presentation~803operations.rs, presentation.rs, store.rs, types.rs
monitoring/Metrics collection, alerts, beta telemetry~1,380collector.rs, store.rs, alerts.rs, telemetry.rs, notifications.rs, types.rs
session.rsSession management, remember-me, cookie handling~622Single file
health_monitor.rsContainer health, auto-restart, system alerts~731Single file
rate_limit.rsPer-IP login + endpoint rate limiting~191Single file (new)
- -

How the backend handles a request

- -
-Browser sends: POST /rpc/v1 -Body: { "method": "package.install", "params": { "id": "bitcoin-knots" } } - -Step 1: Nginx receives it on port 80, forwards to port 5678 -Step 2: Rust HTTP server (Hyper) receives the raw bytes -Step 3: handler/mod.rs parses the JSON, extracts the method name -Step 4: rpc/mod.rs checks the CSRF token (security check) -Step 5: rpc/mod.rs checks the session cookie (are you logged in?) -Step 6: dispatcher.rs routes to package/install.rs based on method name -Step 7: package/install.rs validates the app ID -Step 8: package/dependencies.rs checks dependency chain -Step 9: PodmanClient pulls image + creates container via REST API socket -Step 10: Response sent back: { "result": { "state": "installing" } } -
- -
- -

Rust Backend Deep Dive — Should We Use Custom Code?

- -
- The short answer: Yes, custom Rust is the right call for Archipelago. The backend does things no off-the-shelf tool provides: it orchestrates rootless Podman containers, manages Bitcoin/LND RPC, handles encrypted secrets, runs federation/mesh networking, and serves a real-time WebSocket to the Vue frontend — all as a single binary with zero runtime dependencies. The alternatives (Node.js, Go, Python) would need dozens of third-party packages to match, and none offer Rust's memory safety guarantees for a server handling Bitcoin keys. -
- -

Why not use an existing solution?

-

Projects like Umbrel use a Node.js + Docker Compose backend. Start9 uses Rust (like us). RaspiBlitz uses bash scripts. Here's why custom Rust wins:

- - - - - - - - - - - - - - - - - - - - - - - -
ApproachProsCons
Node.js (Umbrel-style)Fast to develop, large ecosystemMemory-unsafe (crypto bugs), GC pauses, runtime dependency, node_modules supply chain risk
Bash scripts (RaspiBlitz-style)Simple, no compilationUnmaintainable at scale, no type safety, fragile error handling, injection risks
GoSingle binary, good concurrencyNo zero-cost abstractions, GC pauses, weaker type system than Rust
Rust (our choice)Single binary, zero-cost abstractions, memory safety without GC, excellent crypto ecosystem, zeroize for key materialSteeper learning curve, slower compile times
- -

RPC Endpoint Architecture (Refactored)

-

Every action the frontend takes goes through POST /rpc/v1 as a JSON-RPC call. The RPC layer was recently refactored from monolithic files into 29 standalone modules + 8 domain subdirectories, totaling ~20,000 lines. Requests flow through dispatcher.rs (395 LOC) which routes to the appropriate handler:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CategoryModuleLinesKey Methods
App Lifecyclepackage/ (7 files)2,248package.install, package.start, package.stop, package.uninstall, package.stacks
container.rs413container.logs, container.inspect
marketplace.rs225marketplace.list, marketplace.search
Auth & Securityauth.rs102auth.login, auth.login.totp, auth.logout
totp.rs295totp.enable, totp.verify, totp.disable
credentials.rs274credentials.issue, credentials.verify (W3C Verifiable Credentials)
security.rs66AppArmor policy management
Bitcoinbitcoin.rs96bitcoin.getblockchaininfo, bitcoin.getpeerinfo — RPC passthrough
lnd/ (5 files)1,092lnd.getinfo, lnd.walletbalance, lnd.channels, lnd.payments
wallet.rs108wallet.balance, wallet.transactions
Systemsystem/ (2 files)777system.stats, system.reboot, system.factory-reset
monitoring.rs216monitoring.containers, monitoring.resources
update.rs108update.check, update.apply
Identity & Federationidentity/ (2 files)778identity.create, identity.export, identity.did
federation/ (2 files)732federation.list-nodes, federation.pair, federation.sync
mesh/ (6 files)885mesh.status, mesh.send, mesh.peers, mesh.bitcoin-ops
Networktor/ (2 files)769tor.status, tor.create-service, tor.get-address
vpn.rs229vpn.status, vpn.configure
Otheranalytics.rs438Event analytics, usage tracking
interfaces.rs442Network interface management
backup_rpc.rs394backup.create, backup.restore
content.rs352Peer content distribution
transport.rs157Transport layer abstraction
- -
- Refactoring win: The old monolithic package.rs (1,795 lines) was split into 7 focused files under package/. Similarly, federation.rs, identity.rs, mesh.rs, system.rs, and tor.rs were each extracted into their own subdirectories with handlers.rs + mod.rs separation. The API handler layer (handler.rs) was split into 6 focused files under api/handler/. -
- -

Container Orchestration — How Podman Is Controlled

-

The backend talks to rootless Podman via its Unix socket REST API (not CLI). This is faster, more reliable, and avoids shell injection risks.

- -
-PodmanClient connects to: - /run/user/1000/podman/podman.sock (API v4.0.0) - -Install flow: - 1. package.rs validates app ID + checks dependencies - 2. DependencyResolver topological sort → install order - 3. PodmanClient.pull_image() → downloads container image - 4. PodmanClient.create_container() → sets ports, volumes, caps, memory limits - 5. PodmanClient.start_container() - 6. HealthMonitor begins watching (60s intervals) - -Crash recovery: - On startup → check PID marker → if unclean shutdown: - → Restart containers in tier order: - Tier 0: Databases (postgres, redis, mariadb) - Tier 1: Core infra (bitcoin-knots) - Tier 2: Dependent services (lnd, electrs, nbxplorer) - Tier 3: Applications (mempool, btcpay, fedimint) - Tier 4: Frontends (mempool-web, lnd-ui) - → Respect user-stopped.json (don't restart manually stopped apps) - → Max 3 restart attempts with exponential backoff (10s → 30s → 90s) -
- -

Security Architecture

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LayerMechanismImplementation
Secrets at restAES-256-GCM encryptioncore/security/secrets_manager.rs — encrypts to /var/lib/archipelago/secrets/
Node identityEd25519 keypairGenerated on first boot, stored at /var/lib/archipelago/identity/
Image verificationCosign signaturescore/security/image_verifier.rs — verifies container image provenance
Sessions32-byte random tokensOsRng, 24h TTL, persisted to sessions.json, zeroized on drop
2FATOTP (RFC 6238)5 attempt lockout, 5min pending session TTL, token rotation after verification
Rate limitingPer-IP + per-endpointLogin endpoints rate-limited, IP extracted from X-Real-IP (loopback only)
RBACExplicit method allowlistsNo prefix matching — each role lists exact permitted methods
Key materialzeroize::ZeroizingAll crypto keys zeroed from memory after use
- -

WebSocket Real-Time Sync

-

The frontend connects to /ws/db and receives the full DataModel on connect, then incremental updates as state changes. This is how the UI shows live container status, sync progress, and notifications without polling.

- -
-DataModel (broadcast to all WebSocket clients): -{ - server_info: { node_id, name, tor_address, lan_ip, version } - package_data: { - "bitcoin-knots": { state: "running", health: "healthy", ... } - "lnd": { state: "running", health: "healthy", ... } - "mempool": { state: "stopped", health: null, ... } - } - peer_health: { "did:key:z6Mk...": true } - notifications: [ { type: "warning", message: "Disk 85% full" } ] -} -
- -

What's custom vs. what could be replaced?

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ComponentCustom?Could it be replaced?
HTTP serverUses hyper (standard Rust HTTP)Could use axum or actix-web for ergonomics, but hyper is fine
RPC routingCustom — hand-rolled JSON-RPC dispatcherCould use jsonrpsee or generate from OpenAPI, but the current router is simple and works
Container orchestrationCustom — PodmanClient + health monitorNo off-the-shelf alternative for rootless Podman orchestration with Bitcoin-specific dependency ordering
Secrets managementCustom — AES-256-GCM with ZeroizeCould use age or sops, but inline encryption is simpler for container secrets
Federation/MeshCustom — Ed25519 signed messages, Nostr discovery, DWNNo existing solution does Bitcoin node federation + mesh radio. This is novel.
Auth/SessionsCustomCould use a library, but the session model is simple (32-byte tokens + file persistence)
Bitcoin/LND RPCCustom passthroughMust be custom — proxies authenticated calls to local Bitcoin/LND with macaroon management
- -
- Bottom line: The custom code isn't reinventing the wheel — it's glue that connects Podman, Bitcoin, LND, Tor, Nostr, mesh radios, and a Vue frontend into a cohesive OS. No existing framework does this. The individual pieces (hyper, serde, tokio, ed25519-dalek, aes-gcm) are all battle-tested crates. The custom part is the orchestration logic that ties them together. -
- -
- -

Layer 2: The Vue.js Frontend (The Face)

- -

The frontend is what you see in the browser. It's built with Vue 3 — a JavaScript framework for building interactive web pages — and TypeScript — JavaScript with type safety.

- -
- What is a Single Page Application (SPA)? - Instead of loading a new HTML page every time you click something (like old websites), an SPA loads once and then dynamically updates the page content. When you click "Marketplace" in Archipelago, it doesn't load a new page — it swaps out the content area. This makes it feel fast and smooth, like a native app. -
- -

Frontend file structure (refactored)

-

The frontend was heavily refactored — large "god components" were split into focused sub-views, and the god store was decomposed into dedicated stores:

-
neode-ui/src/
-├── api/              ← Backend communication (4 files + 1 service)
-│   ├── rpc-client.ts    ← RPC client (18.8 KB) — ~70 methods, retry, CSRF
-│   ├── websocket.ts     ← WebSocket (16.3 KB) — JSON patch (RFC 6902)
-│   ├── container-client.ts ← Container API helpers
-│   ├── filebrowser-client.ts ← FileBrowser API
-│   └── services/contextBroker.ts ← Context management (21.9 KB)
-├── views/            ← 37 top-level + 47 sub-views in 14 subdirectories
-│   ├── Dashboard.vue    ← Main layout with sidebar
-│   ├── dashboard/     ← Sidebar, MobileNav, ConnectionBanner (6 files)
-│   ├── apps/          ← AppCard, UninstallModal, config (5 files)
-│   ├── appDetails/    ← HeroSection, ContentSection, Sidebar (4 files)
-│   ├── appSession/    ← Frame, Header, NostrBridge, AppIdentity (5 files)
-│   ├── discover/      ← Hero, AppGrid, FeaturedApps, FilterModal (6 files)
-│   ├── federation/    ← Header, NodeList, JoinModal, RotateDid (8 files)
-│   ├── fleet/         ← NodeGrid, ContainerMatrix, Alerts, Overview (6 files)
-│   ├── mesh/          ← BitcoinPanel, DeadmanPanel, styles (3 files)
-│   ├── settings/      ← 13 focused sections (Account, 2FA, Backup, etc.)
-│   ├── web5/          ← 14 sub-views (DID, Wallet, Nostr, DWN, etc.)
-│   ├── marketplace/   ← AppCard, FilterModal, marketplaceData (3 files)
-│   ├── server/        ← QuickActions, Modals, TorServices (3 files)
-│   └── home/          ← SystemCard, WalletCard (2 files)
-├── components/       ← 31 reusable components + 6 in subdirectories
-│   ├── BootScreen.vue, SplashScreen.vue, SpotlightSearch.vue
-│   ├── BaseModal.vue, ToastStack.vue, SkeletonCard.vue, EmptyState.vue
-│   ├── MeshMap.vue, LineChart.vue, AnimatedLogo.vue
-│   ├── cloud/         ← FileCard, FileGrid, ShareModal (5 files)
-│   └── federation/    ← NetworkMap.vue
-├── stores/           ← 18 Pinia stores (decomposed from god store)
-│   ├── app.ts           ← Core app state (slimmed down)
-│   ├── auth.ts          ← Login, logout, TOTP, sessions (NEW)
-│   ├── server.ts        ← Server state, package actions (NEW)
-│   ├── sync.ts          ← WebSocket, real-time data, JSON patch (NEW)
-│   ├── container.ts     ← Container states & lifecycle
-│   ├── mesh.ts          ← Mesh networking (14 KB — largest store)
-│   ├── appLauncher.ts   ← App iframe management (11 KB)
-│   └── ... 11 more focused stores
-├── composables/      ← 11 composables + 10 test files
-│   ├── useToast.ts, useControllerNav.ts (16.9 KB)
-│   ├── useLoginSounds.ts, useNavSounds.ts, useAudioPlayer.ts
-│   └── useOnboarding.ts, useModalKeyboard.ts, useMobileBackButton.ts
-├── types/            ← TypeScript type definitions (3 files)
-│   ├── api.ts           ← RPC methods, responses, DataModel, PatchOperation
-│   └── aiui-protocol.ts ← AIUI communication protocol
-├── router/           ← Route definitions (9.5 KB) — lazy-loaded + nav guards
-└── style.css            ← Global glassmorphism theme + Tailwind utilities
- -

How a Vue component works

-

Every .vue file has three sections:

- -
<!-- 1. THE LOGIC (TypeScript) -->
-<script setup lang="ts">
-import { ref, onMounted } from 'vue'
-import { rpcClient } from '@/api/rpc-client'
-
-// "ref" is a reactive variable — when it changes, the UI updates automatically
-const apps = ref([])
-const loading = ref(true)
-
-// "onMounted" runs when the component first appears on screen
-onMounted(async () => {
-  apps.value = await rpcClient.getMarketplace()
-  loading.value = false
-})
-</script>
-
-<!-- 2. THE TEMPLATE (HTML with Vue directives) -->
-<template>
-  <div v-if="loading">Loading...</div>
-  <div v-else v-for="app in apps" class="glass-card">
-    {{ app.name }}
-  </div>
-</template>
-
-<!-- 3. THE STYLES (CSS, scoped to this component) -->
-<style scoped>
-  /* Styles here only affect THIS component */
-</style>
- -
-

A Vue component is like a LEGO brick. Each brick (component) has its own shape (template), color (styles), and moving parts (script). You snap them together to build the full UI. The <Dashboard> component contains <Sidebar>, which contains <NavItem> components — just like nesting LEGO bricks.

-
- -
- -

Layer 3: The Container System (The Apps)

- -

Containers are how Archipelago runs apps like Bitcoin Core, Lightning, Nextcloud, etc. Each app runs in its own isolated "box" called a container.

- -
- What is a Container? - A container is like a lightweight virtual machine. It has its own filesystem, its own network, and its own processes — but it shares the host's Linux kernel, so it's much faster than a full VM. Think of it as an apartment in a building — each apartment has its own walls and locks, but they all share the same building infrastructure. -
- -

Archipelago uses rootless Podman instead of Docker. Podman runs entirely without root privileges under the archipelago user (UID 1000) — no background daemon, no root access needed. The backend communicates with Podman via its REST API socket, not the CLI.

- -

Container security rules

-

Every container in Archipelago follows strict security rules:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RuleWhat It MeansWhy
--cap-drop=ALLRemove all Linux capabilities (super-powers)A hacked container can't do anything dangerous
--cap-add=CHOWNGive back only the specific powers neededMinimum privilege — only what's necessary
readonly_root: trueContainer can't modify its own program filesPrevents malware from modifying the app
--user 1001:1001Run as non-root userEven if exploited, can't access system files
no-new-privilegesCan't escalate to higher permissionsPrevents privilege escalation attacks
- -

Container startup order (tiers)

-
-Tier 1: Foundation (start first, other apps depend on these) - ├── Bitcoin Core/Knots ← The blockchain - ├── MySQL/PostgreSQL ← Databases - └── Redis ← Cache - -Tier 2: Core Services (need Tier 1 to be running) - ├── LND (Lightning) ← Needs Bitcoin - ├── ElectrumX ← Needs Bitcoin - ├── Mempool ← Needs Bitcoin + ElectrumX - └── BTCPay Server ← Needs Bitcoin + LND - -Tier 3: Applications (independent or need Tier 2) - ├── Nextcloud, Jellyfin ← File storage, media - ├── Vaultwarden ← Password manager - ├── Home Assistant ← Smart home - └── Grafana ← Monitoring dashboards -
- -
- -

Layer 4: Nginx (The Traffic Cop)

- -

Nginx (pronounced "engine-X") is a web server that sits between the internet and everything else. Every single request goes through it first. Archipelago's nginx config is ~1,100 lines — one of the most complex parts of the system.

- -
-

Nginx is like the receptionist at a hospital. You walk in and say what you need. "I need the API" — they send you to the Rust backend. "I need the Bitcoin app" — they send you to the Bitcoin container. "I need the website" — they hand you the static files. Without the receptionist, you'd be wandering the hallways lost.

-
- - -

Why Nginx? Comparing Reverse Proxies

- -

Every node OS needs a reverse proxy to route traffic. Here's how the major projects differ:

- -
-
-

Nginx Archipelago

-

Battle-tested (30+ years)
- Sub-millisecond routing
- Fine-grained rate limiting
- sub_filter HTML rewriting
- Full CSP / HSTS control
- ~ Manual config (1,100 lines)
- No auto-TLS (manual certs)

-
-
-

Caddy Umbrel

-

Automatic HTTPS / Let's Encrypt
- Simple Caddyfile syntax
- Built-in HTTP/3 support
- No sub_filter (needs plugins)
- Higher memory footprint
- Less granular rate limiting
- ~ Newer, smaller ecosystem

-
-
-

Tor-only StartOS

-

Maximum privacy (no clearnet)
- No port forwarding needed
- Built-in NAT traversal
- Slow (500ms–3s latency)
- No LAN access without config
- Requires .onion browser support
- No WebSocket over Tor (flaky)

-
-
-

NixOS Module Nix-Bitcoin

-

Declarative, reproducible
- Atomic rollbacks
- Any proxy (Nginx/Caddy/HAProxy)
- ~ Steep learning curve (Nix lang)
- No web UI (CLI only)
- Not beginner-friendly
- Long rebuild times

-
-
- -
- Archipelago's choice: Nginx gives the most control over security headers, rate limiting, and HTML rewriting (injecting Nostr provider scripts into app iframes). The tradeoff is a 1,100-line config instead of a 50-line Caddyfile — but for a Bitcoin node OS, that control is worth it. -
- - -

Head-to-Head: Architecture Decisions

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FeatureArchipelagoUmbrelStartOSNix-BitcoinRaspiBlitz
Reverse ProxyNginxCaddyTor hidden svcNginx (Nix module)Nginx
BackendRustNode.js + GoRust (startos)Shell/NixShell scripts
ContainersRootless PodmanDocker (root)Docker (root)None (native pkgs)Docker (root)
TLS/HTTPSSelf-signed + HSTSAuto (Let's Encrypt)Tor-only (no TLS)Let's EncryptSelf-signed
Rate LimitingDual-zone (RPC 20r/s + Auth 3r/s)NoneNoneOptional (manual)None
Security HeadersFull CSP + HSTS + PermissionsBasicN/A (Tor)ConfigurableMinimal
App IsolationCap-drop, readonly root, non-root UIDDocker defaultsDocker + sandboxingsystemd sandboxingDocker defaults
LAN + RemoteLAN + Tailscale + TorLAN + Tor + TailscaleTor-only (LAN optional)LAN + WireGuardLAN + Tor
WebSocketNative (24h timeout)Polling + WSSSE over TorN/APolling
App UI Injectionsub_filter (Nostr NIP-07)NoneNoneN/ANone
- - -

How Nginx Routes Traffic

- -

The config defines 30+ location blocks across HTTP (port 80) and HTTPS (port 443). Here are the major routing categories:

- -
-

Backend & API Routes

- - - - - - - - - - -
URL PatternBackendRate LimitTimeoutPurpose
/rpc/:567820r/s (burst 40)600sAll RPC API calls (1MB body limit)
/ws:567886,400s (24h)WebSocket — real-time state updates
/health:5678defaultHealth check (no auth)
/archipelago/:5678defaultSystem endpoints
/content:5678defaultPeer content sharing
/dwn:5678defaultDecentralized Web Node
/electrs-status:5678defaultElectrum sync status (CORS enabled)
/lnd-connect-info:5678defaultLND connection URI (CORS enabled)
-
- -
-

App Proxies — 24 Container Apps

-

Every /app/{id}/ route proxies into a container. All share a common pattern: strip the upstream X-Frame-Options, set SAMEORIGIN, inject the Nostr provider script, and forward real IP headers.

- - - - - - - - - - - - - - - - - - - - - - - - - - -
AppPortSpecial Config
bitcoin-ui8334
mempool4080300s timeouts
lnd8081300s timeouts
electrumx50002
btcpay23000
fedimint8175300s timeouts
fedimint-gateway8176300s timeouts
filebrowser808310GB uploads, path traversal blocking
nextcloud8085300s timeouts
vaultwarden8082
immich2283300s timeouts
jellyfin8096
grafana3000
portainer9000
uptime-kuma3001
searxng8888
ollama11434
indeedhub7777URL rewriting, WS, 30-day asset cache
homeassistant812386,400s timeout (persistent)
penpot9001300s timeouts
photoprism2342
onlyoffice8044
endurain8080
nginx-proxy-manager8181
-
- -
-

AIUI Routes (AI Chat Interface)

-

The AI chat UI has its own set of proxied API backends — all require a valid session cookie or return 401.

- - - - - - - -
URL PatternBackendTimeoutPurpose
/aiui/Static filesChat UI (no-cache for HTML)
/aiui/api/claude/:3142300s readClaude proxy (streaming, no buffering)
/aiui/api/ollama/:11434300s readLocal Ollama model (streaming)
/aiui/api/openrouter/openrouter.ai120sExternal AI API (SSL passthrough)
/aiui/api/web-search:888830sSearXNG search (503 JSON on failure)
-
- - -

Security Headers — How Archipelago Compares

- -

Security headers tell the browser what's allowed and what isn't. Here's what each node OS sends:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
HeaderArchipelagoUmbrelStartOSRaspiBlitz
Content-Security-PolicyFull self + WS + frame-srcBasicNoneNone
HSTS1 year + includeSubDomainsYesN/A (Tor)No
X-Frame-OptionsSAMEORIGINVariesNoneNone
X-Content-Type-OptionsnosniffnosniffNoneNone
Permissions-PolicyAll blockedNoneNoneNone
Referrer-Policystrict-originNoneNoneNone
Rate LimitingDual-zoneNoneNoneNone
- -
- Archipelago leads on security headers. - Most node OS projects ship with minimal or no HTTP security headers. Archipelago sets a full Content-Security-Policy, HSTS with 1-year max-age, Permissions-Policy blocking camera/microphone/geolocation/payment, and dual-zone rate limiting — defense-in-depth at the proxy layer. -
- - -

Unique Nginx Features in Archipelago

- -
-
-

Nostr NIP-07 Injection

-
    -
  • Every app proxy uses sub_filter to inject nostr-provider.js into </head>
  • -
  • Gives all container apps window.nostr for signing
  • -
  • No other node OS does this — unique to Archipelago
  • -
  • Accept-Encoding disabled to enable text rewriting
  • -
-
-
-

Dual Rate Limit Zones

-
    -
  • rpc zone: 20 req/s base, burst of 40 — for API calls
  • -
  • auth zone: 3 req/s — for login/auth endpoints (brute-force protection)
  • -
  • Returns HTTP 429 on violation
  • -
  • Per-IP tracking with 10MB shared memory zone
  • -
-
-
-

External Site Proxying

-
    -
  • /ext/botfights/, /ext/484-kitchen/, etc. proxy external HTTPS sites
  • -
  • Strips CORS/COEP/COOP headers for iframe embedding
  • -
  • Rewrites href/src attributes to rebase paths
  • -
  • Standalone proxy servers on ports 8901–8903
  • -
-
-
-

FileBrowser Security

-
    -
  • Path traversal blocked: /\.\. patterns return 403
  • -
  • 10GB upload limit (client_max_body_size 10G)
  • -
  • proxy_request_buffering off for streaming large uploads
  • -
  • Separate protection for /api/resources/ and /api/raw/ paths
  • -
-
-
-

SSL/TLS Configuration

-
    -
  • TLSv1.2 + TLSv1.3 only (no older protocols)
  • -
  • Modern cipher suite: ECDHE-ECDSA + ECDHE-RSA with AES-GCM
  • -
  • Self-signed certificate at /etc/archipelago/ssl/
  • -
  • Dual-server setup: port 80 (HTTP) + port 443 (HTTPS)
  • -
-
-
-

IndeedhHub Complexity

-
    -
  • Most complex app proxy: URL rewriting, WebSocket, caching
  • -
  • _next/ assets cached 30 days with immutable
  • -
  • WebSocket at /app/indeedhub/ws/ with 24h timeout
  • -
  • Rewrites both single and double quoted href/src
  • -
-
-
- - -

Nginx Config File Map

- -
- - - - - - - - - - - - -
FileLinesPurpose
image-recipe/configs/nginx-archipelago.conf~1,100Production config — HTTP + HTTPS servers, all routing
image-recipe/configs/snippets/archipelago-https-app-proxies.conf~400HTTPS app proxy blocks (included in main config)
image-recipe/configs/snippets/archipelago-pwa.conf~30PWA service worker and manifest caching
image-recipe/configs/external-app-proxies.conf~200External site reverse proxies (BotFights, 484 Kitchen)
neode-ui/docker/nginx.conf~60Dev Docker config (mock backend on :5959)
neode-ui/docker/nginx-demo.conf~80Demo mode config (no security, mock backend)
docker/bitcoin-ui/nginx.conf~50Bitcoin UI container — RPC proxy with CORS
docker/electrs-ui/nginx.conf~30Electrs UI container — status endpoint
docker/lnd-ui/nginx.conf~30LND UI container — connect info
indeedhub/nginx.conf~200IndeedhHub container — MinIO, API, relay, SPA
-
- -
- Why so many nginx configs? - There are three layers of nginx: (1) the main server nginx that routes all traffic, (2) per-app container nginx configs inside some containers (bitcoin-ui, electrs-ui, lnd-ui, indeedhub) that serve their own SPAs and proxy to internal services, and (3) dev/demo nginx configs for local development. Changes to app routing require updating BOTH the main config AND the relevant container config. -
- - -

How Data Flows Through the System

- -

Let's trace what happens when you click "Install Bitcoin" in the UI:

- -
-
1

You click the Install button in Marketplace.vue. Vue calls the Pinia store action installPackage('bitcoin-knots')

-
2

The store calls the RPC client: rpcClient.installPackage('bitcoin-knots', 'docker.io/bitcoin/knots:28')

-
3

RPC client sends HTTP POST to /rpc/v1 with a session cookie and CSRF token for security

-
4

Nginx receives the request on port 80, checks rate limits, forwards to the Rust backend on port 5678

-
5

Rust backend validates — checks your session is valid, CSRF token matches, app ID is safe (no shell injection characters)

-
6

Rust checks dependencies — if you're installing LND, it checks Bitcoin is already running

-
7

Rust tells Podman to pull the imagepodman pull docker.io/bitcoin/knots:28 (downloads the app)

-
8

Rust creates and starts the container with all security flags (cap-drop, readonly root, etc.)

-
9

Backend sends a WebSocket update — the frontend receives a "state changed" event in real time

-
10

Vue reactively updates the UI — the Marketplace card changes from "Install" to "Running" with no page reload

-
- - -

RPC: How Frontend Talks to Backend

- -

RPC stands for Remote Procedure Call. It's a way for the frontend to tell the backend "do something" — like calling a function on a remote computer.

- -
- RPC vs REST - Most web APIs use REST (different URLs for different things: GET /users, POST /users, DELETE /users/5). Archipelago uses RPC instead — every request goes to the same URL (/rpc/v1) and the method name says what to do. It's like having one phone number for a building, and you say who you want to talk to. -
- -

The frontend has a class called RPCClient (in rpc-client.ts) with ~70 methods. Each method maps to a backend function:

- - - - - - - - -
Frontend MethodBackend HandlerWhat It Does
rpcClient.login(password)auth.loginLog in with password
rpcClient.getServerInfo()system.infoGet server name, version, uptime
rpcClient.installPackage(id, image)package.installInstall a container app
rpcClient.getBitcoinInfo()bitcoin.infoGet blockchain sync %, block height
rpcClient.sendMeshMessage(text)mesh.sendSend a message over LoRa radio
- -

Built-in resilience

-

The RPC client has built-in protections:

- - - -

State Management

- -

State is the data your app is currently working with: is the user logged in? What apps are installed? Is Bitcoin synced? This data needs to be shared between components.

- -
- What is Pinia? - Pinia is Vue's state management library. Instead of each component keeping its own data (which leads to chaos), you put shared data in a "store" — a central place that any component can read from and write to. When the store changes, every component that uses it updates automatically. -
- -

Archipelago has 18 Pinia stores (up from 15 — the "god store" was decomposed):

- -
-
-

app.ts slimmed

-

Core app state — slimmed down after extracting auth, server, and sync concerns

-
-
-

auth.ts new

-

Authentication state machine — login, logout, TOTP, session management

-
-
-

server.ts new

-

Server computed state + RPC action proxies (install, restart, update)

-
-
-

sync.ts new

-

WebSocket connection + real-time JSON patch (RFC 6902) data sync

-
-
-

container.ts good

-

Container lifecycle — running, stopped, installing states (9.2 KB)

-
-
-

mesh.ts good

-

LoRa radio state — device, peers, messages, channels (14 KB)

-
-
-

appLauncher.ts good

-

App iframe management, Nostr consent, port mapping (11 KB)

-
-
-

aiPermissions.ts good

-

AI data access permission management (5.2 KB)

-
-
- -
- Store decomposition complete. The old "god store" (app.ts) that handled auth + WebSocket + server data + package management was split into three new focused stores: auth.ts (authentication state machine), server.ts (server state + RPC actions), and sync.ts (WebSocket + data synchronization). The login flow is now: useAuthStore().login()useSyncStore().initializeData() + connectWebSocket() → views consume sync.data reactively. -
- -

WebSocket: real-time updates

-

Instead of the frontend asking "has anything changed?" every second (polling), the backend pushes updates to the frontend through a WebSocket — a persistent, two-way connection.

- -
-Traditional polling (slow, wasteful): - Frontend: "Anything new?" → Backend: "No" (every 1 second) - Frontend: "Anything new?" → Backend: "No" - Frontend: "Anything new?" → Backend: "Yes! Bitcoin synced!" - -WebSocket (fast, efficient): - Frontend ←→ Backend: persistent connection - Backend: "Bitcoin synced!" → Frontend instantly updates - Backend: "New container started!" → Frontend instantly updates -
- - -

Authentication & Sessions

- -

When you log in, the backend creates a session — a temporary "you're allowed in" token. Here's how it works:

- -
1

You enter your password on the login page

-
2

Backend hashes it with bcrypt — a one-way function that makes it impossible to reverse

-
3

Backend compares the hash to the stored hash (never compares raw passwords)

-
4

Backend creates a session — generates a random 256-bit token using a cryptographically secure random number generator

-
5

Session ID sent as a cookie — the browser stores it and sends it with every request

-
6

CSRF token also sent — a second token that prevents cross-site request forgery attacks

- -
- Why two tokens? - The session cookie proves you're logged in. The CSRF token proves the request came from YOUR browser tab, not a malicious website that tricked your browser into sending a request. Both must match for any request to succeed. -
- - -

Security Model

- -

Archipelago is a defense-in-depth system — multiple layers of security so that if one fails, others still protect you.

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LayerProtectionAgainst What
OSUFW firewall, AppArmor profilesNetwork attacks, process escape
NginxRate limiting, security headers, HSTSDDoS, XSS, clickjacking
BackendCSRF validation, session auth, input sanitizationCSRF, injection, unauthorized access
ContainersCapability dropping, readonly root, non-root userContainer escape, privilege escalation
CryptoChaCha20-Poly1305 encryption, Argon2 key derivation, ed25519 signaturesData theft, key compromise, impersonation
NetworkTor routing, onion servicesTraffic analysis, IP exposure
-
- - -

Bitcoin Integration

- -

Bitcoin is the heart of Archipelago. The backend communicates with Bitcoin Core/Knots using JSON-RPC — the same protocol Bitcoin has used since 2009.

- -
- Critical Rule: Never Use Floating Point for Bitcoin - Bitcoin amounts are always in satoshis (1 BTC = 100,000,000 sats) as integers. Using floating point (decimals) causes rounding errors. 0.1 + 0.2 ≠ 0.3 in floating point. When you're dealing with money, that's unacceptable. Archipelago uses u64 in Rust and BigInt in TypeScript for all Bitcoin amounts. -
- -

Bitcoin RPC examples

-
// The backend calls Bitcoin Core like this:
-bitcoin_rpc("getblockchaininfo")   → sync progress, block height
-bitcoin_rpc("getnetworkinfo")      → peer count, version
-bitcoin_rpc("getmempoolinfo")      → unconfirmed transaction count
-bitcoin_rpc("estimatesmartfee", 6) → fee estimate for 6-block confirmation
- - -

Federation & Multi-Node

- -

Multiple Archipelago nodes can form a federation — a trusted network of servers that sync data, share state, and communicate privately.

- -
-Your Node (.228) ←── Tor ──→ Friend's Node - │ │ - └──── Tor ──→ Office Node ←── Tor ──┘ - -Each node has: - • Ed25519 identity key (cryptographic identity) - • DID (Decentralized Identifier — like a username that can't be taken away) - • Onion address (Tor hidden service — no IP address exposed) - • DWN (Decentralized Web Node — stores and syncs data) -
- -

Nodes discover each other through Nostr relays (publish presence, but never onion addresses — those are exchanged privately via encrypted DMs).

- - -

Mesh Networking

- -

Archipelago can communicate over LoRa radio — no internet needed. A small radio device plugs into the server's USB port and sends messages up to 10+ km using the Meshtastic/Meshcore protocol.

- -
-

Imagine walkie-talkies that can send text messages. Each radio can relay messages for others, so even if two radios can't reach each other directly, they can communicate through intermediate radios. That's mesh networking — no cell towers, no ISPs, no internet required.

-
- - -

Deploy System

- -

The deploy script (scripts/deploy-to-target.sh) is how code gets from your development laptop to the live server. It's a ~1,790-line shell script (with shared functions from lib/common.sh) that automates everything:

- -
1

Pre-flight checks — verifies SSH connectivity, checks git state, warns about uncommitted changes

-
2

Frontend build — runs npm run build to compile Vue/TypeScript into static files

-
3

Upload frontend — rsyncs built files to /opt/archipelago/web-ui/ on the server

-
4

Upload Rust source — rsyncs core/ to the server (builds ON the server, not macOS)

-
5

Build on server — runs cargo build --release on the Linux server

-
6

Sync configs — copies nginx config, systemd service from image-recipe/configs/

-
7

Restart services — reloads nginx, restarts the Rust backend via systemd

-
8

Health check — pings /health endpoint to verify everything came back up

-
9

Deploy manifest — writes a JSON file recording the commit, timestamp, and deploy status

- -
- Why build on the server? - Rust compiles to machine code specific to the CPU architecture. If you compile on macOS (ARM/x86) and copy the binary to a Linux server, it won't run — you get an "Exec format error". The deploy script sends the source code and compiles on the target machine. -
- - -

ISO Build Process

- -

The ISO build creates the installer that users flash to USB. It's a ~1,870-line script that:

- -
    -
  1. Downloads a Debian 12 Live ISO as the base
  2. -
  3. Creates a Docker container to build a custom root filesystem
  4. -
  5. Installs Podman, Nginx, and all system dependencies
  6. -
  7. Captures running container images from the live dev server
  8. -
  9. Bundles the frontend files, backend binary, and configs
  10. -
  11. Writes a first-boot script that sets everything up on install
  12. -
  13. Packages everything into a bootable ISO file
  14. -
- - -

First Boot Sequence

- -

When someone installs the ISO and boots for the first time, first-boot-containers.sh runs automatically and:

- -
    -
  1. Generates unique credentials for this installation (Bitcoin RPC password, database passwords)
  2. -
  3. Sets up swap space based on available RAM
  4. -
  5. Creates the archy-net container network for inter-container communication
  6. -
  7. Starts 30+ containers in tiered order (databases first, then Bitcoin, then everything else)
  8. -
  9. Runs health checks on critical containers
  10. -
  11. Configures Tor hidden services
  12. -
- - - - - -

Quality Scores

- -

After reviewing ~45,000 lines of Rust (213 files), ~45,500 lines of TypeScript/Vue (232 files), and ~40 shell scripts, here are the quality scores. Several scores improved since the last review thanks to major refactoring:

- -
-
-
Rust Error Handling
-
A
-

Zero unwrap/panic in prod code

-
-
-
TypeScript Safety
-
A
-

Strict mode, zero any types

-
-
-
Security
-
A
-

33-finding pentest, all remediated

-
-
-
Frontend Architecture
-
A
-

God store split, god views split

-
-
-
Backend Modularity
-
A-
-

Monoliths split into subdirectories

-
-
-
Container Security
-
A
-

Cap-drop, readonly, non-root

-
-
-
Script Modularity
-
B-
-

Shared lib created, still large scripts

-
-
-
Test Coverage
-
B-
-

38 frontend + 36 backend test files

-
-
-
CI/CD
-
C
-

macOS release CI, no test gating

-
-
-
Documentation
-
A
-

This review + MASTER_PLAN + consolidated docs

-
-
-
Dependency Hygiene
-
B-
-

Floating crypto versions

-
-
-
Deploy Safety
-
A
-

Rollback, manifests, health checks, locking

-
-
- -
- Score improvements since last review (2026-03-20): - Security A- → A (rate limiter backend, pentest complete), Frontend Architecture A- → A (god store + god views split), Backend Modularity B+ → A- (monolithic files → subdirectories), Script Modularity C+ → B- (shared library created), Documentation A- → A, Deploy Safety A- → A (deploy locking added). -
- - -

What's Done Well

- -
-

Rust: Exceptional Error Discipline

-

Zero unwrap() or panic!() in production code. Every fallible operation uses the ? operator to propagate errors gracefully. This is rare even in professional Rust codebases.

- -

Backend Module Architecture (Refactored)

-

The backend was comprehensively refactored from monolithic files into domain-focused subdirectories. Previously: package.rs (1,795 lines), federation.rs (810 lines), handler.rs (800+ lines) were all single files. Now: each is a clean directory with focused sub-modules (e.g., package/ has config.rs, install.rs, lifecycle.rs, runtime.rs, stacks.rs, dependencies.rs, progress.rs). The RPC layer uses a dedicated dispatcher.rs for routing. All 8 major domains (package, federation, identity, mesh, system, tor, handler, credentials) follow the same mod.rs + handlers.rs pattern.

- -

Frontend Component Decomposition (Refactored)

-

All "god components" were split into sub-views: Web5.vue (3,940 lines) → 14 focused sub-views under views/web5/. Settings.vue (1,792 lines) → 13 sections. Dashboard.vue, Apps.vue, AppDetails.vue, AppSession.vue, Federation.vue, Fleet.vue, Discover.vue — all extracted into subdirectories with focused components. The Pinia god store was decomposed into auth.ts, server.ts, and sync.ts.

- -

Input Validation is Thorough

-

App IDs validated against a strict character whitelist. Container image names checked for shell injection characters. All external input sanitized at the boundary. Backend rate limiting on login + endpoints via new rate_limit.rs.

- -

TypeScript Strict Mode Actually Used

-

All 5 strictest compiler flags enabled. Zero any types across 45,500+ lines. Every function has proper types. This prevents entire categories of bugs.

- -

Container Security is Production-Grade

-

Every container drops all capabilities and adds back only what's needed. Read-only root filesystems. Non-root users. No-new-privileges. This is better than most commercial container platforms.

- -

WebSocket Resilience

-

Auto-reconnection with exponential backoff, visibility change detection (handles tab switching), network online/offline detection. JSON patch (RFC 6902) for efficient incremental updates. The real-time connection is very robust.

- -

Composables Well-Factored

-

11 Vue composables, each focused on one concern (toasts, audio, keyboard, onboarding, controller nav). Clean, reusable, properly scoped. 10 test files for composables.

- -

Deploy Safety Features

-

Rollback backups before deployment, deploy manifests tracking what was deployed, health checks after deployment, progress bars with ETAs. Deploy locking prevents concurrent deploys. Shared script library (scripts/lib/common.sh) eliminates function duplication.

- -

Monitoring & Telemetry System

-

New monitoring/ module (1,380 LOC) with metrics collection, alert generation, persistent storage, beta telemetry reporting, and notification dispatch. Production-grade observability for the beta phase.

- -

PodmanClient Uses REST API Socket

-

The container management layer communicates with Podman via its async REST API unix socket (/run/user/{UID}/podman/podman.sock), not CLI. This is faster, more reliable, and avoids shell injection risks.

- -

Full Security Audit Completed

-

A comprehensive penetration test (33 findings) was completed in March 2026 and all findings were remediated. Security rules from findings are enforced in CLAUDE.md for all future code.

-
- - -

What Needs Fixing

- -

Production Reliability P0 — blocks beta

- -
-

P0-1. Health RPC endpoint has no handler

-

What: "health" is listed in UNAUTHENTICATED_METHODS but has no match handler — returns "Unknown method" error instead of actual health status.

-

Impact: Frontend, load balancers, and orchestrators can't verify the backend is actually healthy. System appears unhealthy when it's fine.

-

Fix: Add handler that checks crash recovery status, Podman responsiveness, and service readiness.

-
- -
-

P0-2. Zero container health checks across all 30 containers

-

What: first-boot-containers.sh creates 30+ containers with --restart unless-stopped but zero --health-cmd flags. Crashed containers restart endlessly in a hammer loop.

-

Impact: Silent failures — a broken app looks "running" but returns errors. No way for the backend to distinguish healthy from crashed.

-

Fix: Add --health-cmd with appropriate checks (HTTP, TCP, CLI) to every container.

-
- -
-

P0-3. Backup restore has no pre-validation or atomic rollback

-

What: restore_full_backup() extracts directly to the live data directory. If extraction fails halfway, the system is left in a corrupt partial state with no way to recover.

-

Impact: A corrupted backup can brick a fresh install. Data loss on partial restore failure.

-

Fix: Extract to staging directory, validate required files, atomic rename, rollback on failure.

-
- -
-

P0-4. Unauthenticated nginx endpoints missing protections

-

What: /archipelago/, /content, /dwn endpoints (used for Tor P2P federation) have no timeout, body size limit, or rate limiting.

-

Impact: Vulnerable to slow-loris attacks, payload flooding, and connection exhaustion via Tor.

-

Fix: Add proxy_connect_timeout, client_max_body_size 10m, and limit_req to all three locations.

-
- -

Critical Issues recently resolved

- -
-

RESOLVED: package.rs was 1,795 lines — split into 7 files

-

Before: Single monolithic file handling all container operations.

-

After: Split into package/config.rs (692 LOC), package/install.rs (467 LOC), package/lifecycle.rs, package/runtime.rs (417 LOC), package/stacks.rs (356 LOC), package/dependencies.rs (242 LOC), package/progress.rs (140 LOC). Each file has one clear responsibility.

-
- -
-

RESOLVED: Web5.vue was 3,940 lines — split into 14 sub-views

-

Before: One massive component with 17 sections.

-

After: Extracted to views/web5/ with: Web5.vue (main), Web5ConnectedNodes, Web5CredentialsSummary, Web5DWN, Web5Domains, Web5Identities, Web5NodeVisibility, Web5NostrRelays, Web5QuickActions, Web5SendReceiveModals, Web5SharedContent, Web5Wallet, types.ts, utils.ts.

-
- -
-

RESOLVED: useAppStore was a "god store" — split into 3 focused stores

-

Before: One store handling auth, WebSocket, server data, and package management.

-

After: Decomposed into auth.ts (login/logout/TOTP/sessions), server.ts (server state + RPC actions), sync.ts (WebSocket + JSON patch data sync). app.ts is now a thin data store.

-
- -
-

RESOLVED: Shell scripts had no shared library

-

Before: Duplicated functions across deploy, first-boot, and helper scripts.

-

After: scripts/lib/common.sh provides shared functions: colored logging, SSH wrappers (ssh_cmd, scp_cmd), health checks, disk checks, memory limits. Sourced by all deployment scripts.

-
- -

Remaining Critical Issues fix now

- -
-

1. Test coverage exists but has gaps

-

What: 38 frontend test files and 36+ backend test modules exist. However, coverage is uneven — critical paths like session validation, federation sync, and the app install flow lack thorough test suites.

-

Fix: Add integration tests for critical paths (auth flow, container lifecycle, federation handshake). Add CI that runs cargo test + npm test on every push.

-
- -

High Priority fix soon

- -
-

P1-A. Nostr client.connect() hangs indefinitely (no timeout) FIXED

-

What: 6 calls to client.connect().await across identity_manager.rs, nostr_discovery.rs, and marketplace.rs had no timeout wrapper. If a relay is down, peer discovery hangs forever.

-

Fix: All 6 calls wrapped in tokio::time::timeout(Duration::from_secs(10), ...). (v1.3.1, 2026-03-25)

-
- -
-

P1-B. Rate limiter memory grows unbounded

-

What: EndpointRateLimiter::cleanup() and LoginRateLimiter cleanup methods exist but are never spawned. HashMap of (method, IP) entries grows forever.

-

Fix: Spawn cleanup task every 5 minutes in RpcHandler::new().

-
- -
-

P1-C. Systemd service missing resource limits

-

What: No MemoryMax, LimitNOFILE, or TasksMax in archipelago.service. A memory leak in the backend can OOM-kill the entire system.

-

Fix: Add MemoryMax=4G, LimitNOFILE=65535, TasksMax=2048.

-
- -
-

P1-D. Container images using :latest tag (7 instances) FIXED

-

What: Several containers in first-boot-containers.sh and the ISO build pulled floating tags — no exact version pinning.

-

Impact: Two machines installed a week apart may have different Bitcoin node versions. Supply chain risk.

-

Fix: All 15 floating tags in image-versions.sh pinned to exact patch versions (e.g., postgres:15→15.17, redis:7→7.4.8, nginx:alpine→1.29.6-alpine). DWN pinned by SHA256 digest. (v1.3.1, 2026-03-25)

-
- -
-

P1-E. WebSocket reconnect doesn't refresh full state

-

What: After a WebSocket disconnect (5+ minutes), the UI shows stale data. Reconnection applies patches to an outdated base state instead of fetching fresh data.

-

Fix: On reconnect, call server.get-state RPC to refresh full state before accepting patches.

-
- -
-

P1-F. No global Vue error handler

-

What: No app.config.errorHandler in main.ts. Component errors silently log to console — user sees blank screen with no recovery path.

-

Fix: Add error handler that shows user-visible toast and logs structured error.

-
- -
-

5. Cryptographic dependency versions not pinned exactly FIXED

-

What: zeroize = "1.7", chacha20poly1305 = "0.10", ed25519-dalek = "2.1" used floating versions.

-

Why it's bad: A minor version bump in a crypto library could introduce a vulnerability or behavioral change. The project's own rules require exact pinning for crypto deps.

-

Fix: All 12 crypto deps pinned to exact versions from Cargo.lock: ed25519-dalek=2.2.0, zeroize=1.8.2, chacha20poly1305=0.10.1, sha2=0.10.9, hmac=0.12.1, argon2=0.5.3, aes-gcm=0.10.3, etc. (v1.3.1, 2026-03-25)

-
- -
-

6. No frontend-backend type synchronization

-

What: TypeScript types in types/api.ts are manually maintained copies of Rust structs. If the backend changes a field name, the frontend doesn't know until runtime.

-

Why it's bad: Types can drift apart silently. A backend developer renames sync_progress to syncProgress and the frontend breaks in production.

-

Fix: Generate TypeScript types from Rust structs (using ts-rs or a JSON Schema).

-
- -
-

7. Container metadata duplicated in 3 places

-

What: App configuration (ports, volumes, env vars) exists in: package.rs (RPC handler), docker_packages.rs (metadata reader), health_monitor.rs (startup tiers).

-

Why it's bad: Adding a new app means updating 3 files. If you forget one, the app partially works but something is wrong.

-

Fix: Single app config source (manifest YAML or a shared Rust module) that all three consumers read from.

-
- -
-

8. Deploy and ISO build scripts are still 1,700+ lines each

-

What: Two monolithic shell scripts (deploy: ~1,790 lines, ISO build: ~1,870 lines) handle dozens of responsibilities each. Shared functions have been extracted to scripts/lib/common.sh, but the scripts themselves are still large.

-

Improvement: scripts/lib/common.sh now provides shared logging, SSH wrappers, health checks, and memory limits — eliminating most duplication. But the core scripts could still benefit from modular splitting post-beta.

-

Next step: Split deploy into modules: deploy-frontend.sh, deploy-backend.sh, sync-configs.sh. Split ISO build into lib/rootfs.sh, lib/components.sh, lib/installer-env.sh.

-
- -

Medium Priority improve over time

- -
-

9. App integration requires updates in 6+ locations

-

What: Adding a new app to Archipelago requires manual changes in: manifest YAML, package.rs (backend config), docker_packages.rs (metadata), nginx config (routing), Marketplace.vue (frontend listing), appLauncher.ts (port mapping), first-boot-containers.sh (first boot), build-auto-installer-iso.sh (ISO capture).

-

Fix: Move toward a single manifest file per app that drives all of these automatically.

-
- -
-

10. CI/CD pipeline is minimal FIXED

-

What: One GitHub Action builds macOS release binaries on tag push. No tests run in CI. No linting. No Linux build or deploy automation.

-

Fix: Added .github/workflows/ci.yml with two parallel jobs: Rust (fmt check + clippy -D warnings + tests) and Frontend (npm ci + type-check + build). Runs on push to main and all PRs. (v1.3.1, 2026-03-25)

-
- -
-

11. Session persistence uses blocking I/O

-

What: On startup, session.rs reads sessions.json using synchronous (blocking) file I/O in an async context.

-

Fix: Use tokio::fs::read_to_string for non-blocking I/O at startup.

-
- -
-

12. Inconsistent loading state patterns in frontend

-

What: Some components use loading, others isLoading, others loadingApps. No shared composable.

-

Fix: Create a useAsyncState composable that standardizes loading/error/data patterns.

-
- - -

Refactoring Priorities

- -

Ordered by impact. 8 of 12 items completed since the previous review — significant progress:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#TaskImpactEffortStatus
1Split package.rs (1,795 lines) into focused fileshigh2-3 daysDONE
2Split useAppStore into auth/server/synchigh2 daysDONE
3Add CI pipeline (clippy + type-check + basic tests)high1 dayDONE
4Split Web5.vue (3,940 lines) into sub-viewsmedium3 daysDONE
5Pin all crypto dependency versions exactlymedium1 hourDONE
6Extract shared shell library (lib/common.sh)medium1 dayDONE
7Consolidate container metadata to single sourcemedium2 daysTODO
8Generate TypeScript types from Rust structsmedium1 dayTODO
9Split deploy/ISO scripts into moduleslow2 daysPOST-BETA
10Add integration tests for critical pathshigh3 daysTODO
11Split large backend files (federation, identity, handler, system, tor)medium2 daysDONE
12Split large Vue views (Settings 1,792, Mesh, Dashboard, Apps, etc.)low2 daysDONE
- - -

Technical Debt Map

- -

A visual summary of where debt lives in the codebase. Many red items from the previous review are now green after refactoring:

- -
-BACKEND (Rust) — 213 files, ~45K LOC - ████████ package/ (2,248 lines in 7 focused files — was 1,795 god file) - ████████ api/handler/ (896 lines in 6 files — was 800+ god file) - ██████ federation/, identity/, system/, tor/ (all split from monoliths) - ██████ credentials/ (5 files), monitoring/ (7 files) — new modules - ██████ lnd/ (1,092 lines in 5 files — could split further) - ████ mesh/ (6,000 lines — large but domain-appropriate) - ████ session.rs (622), health_monitor.rs (731) — clean single files - ████ rate_limit.rs (191) — new, focused - ██ container, security, performance crates (clean) - -FRONTEND (Vue + TS) — 232 files, ~45.5K LOC - ████████ web5/ (14 sub-views — was 3,940-line god component) - ████████ settings/ (13 sections — was 1,792-line god component) - ██████ apps/, dashboard/, federation/, fleet/, discover/ (all split) - ████ auth.ts + server.ts + sync.ts (was god store) - ██ rpc-client.ts (well-designed), 11 composables (clean) - Type safety (excellent), 38+ test files - -SCRIPTS (Shell) — ~40 scripts - ████████████ deploy-to-target.sh (~1,790 lines — still large) - ████████████ build-auto-installer-iso.sh (~1,870 lines — still large) - ██████ first-boot-containers.sh (~935 lines, version mismatches) - ████ scripts/lib/common.sh — shared library (new) - ████ image-versions.sh — centralized pinning (new) - ██ Test scripts (well-organized) - -ARCHITECTURE - ██████ Tests: 74+ files but gaps in integration coverage - ██████ CI: cargo fmt + clippy + tests, frontend type-check + build - ████ Manual type sync (Rust ↔ TypeScript) - ████ App integration requires 6+ file changes - ████ Crypto deps pinned to exact versions - ████ Security model (pentest completed, rate limiting, CSRF) - ████ Deploy safety (rollback, manifests, locking, health checks) - ████ Module architecture (all god files eliminated) - ██ PodmanClient (REST API socket, not CLI) - ██ Monitoring & telemetry system (production-ready) - -Legend: ██ Critical ██ Needs attention ██ Good - -Progress: ████████████████████████████████████████████████ ~85% green (was ~40%) -
- - -

Recommended Learning Path

- -

If you want to understand this codebase deeply and become proficient in all the technologies, study in this order:

- -
-

Phase 1: Foundations (Weeks 1-4)

-
    -
  1. Linux basics — commands, file permissions, processes, systemd
  2. -
  3. Git — branches, commits, diffs, rebasing
  4. -
  5. HTML/CSS/JavaScript — the building blocks of web UIs
  6. -
  7. TypeScript — JavaScript with type safety (read the official handbook)
  8. -
-
- -
-

Phase 2: Frontend (Weeks 5-8)

-
    -
  1. Vue 3 Composition APIref, computed, watch, onMounted
  2. -
  3. Pinia — state management (read stores/container.ts as a good example)
  4. -
  5. Vue Router — URL-to-component mapping
  6. -
  7. Tailwind CSS — utility-first CSS framework
  8. -
  9. Vite — the build tool that bundles everything
  10. -
-
- -
-

Phase 3: Backend (Weeks 9-14)

-
    -
  1. Rust basics — ownership, borrowing, lifetimes, pattern matching (read "The Rust Book")
  2. -
  3. Async Rust with Tokioasync/await, futures, tokio::spawn
  4. -
  5. Hyper — the HTTP server library (read server.rs)
  6. -
  7. Serde — JSON serialization/deserialization
  8. -
  9. Error handlinganyhow, thiserror, the ? operator
  10. -
-
- -
-

Phase 4: Infrastructure (Weeks 15-18)

-
    -
  1. Containers — Docker/Podman concepts (images, containers, volumes, networks)
  2. -
  3. Nginx — reverse proxy, location blocks, upstream servers
  4. -
  5. Shell scripting — bash/zsh, set -e, functions, trap
  6. -
  7. systemd — service management, unit files, journalctl
  8. -
  9. Networking — TCP/IP, DNS, ports, firewalls (UFW)
  10. -
-
- -
-

Phase 5: Bitcoin & Crypto (Weeks 19-24)

-
    -
  1. Bitcoin protocol — blocks, transactions, UTXOs, mining (read "Mastering Bitcoin")
  2. -
  3. Lightning Network — payment channels, routing, invoices
  4. -
  5. Cryptography — hashing, symmetric/asymmetric encryption, digital signatures
  6. -
  7. Tor — onion routing, hidden services, SOCKS5 proxy
  8. -
  9. Nostr — decentralized messaging protocol, NIPs
  10. -
  11. DIDs — Decentralized Identifiers, Verifiable Credentials
  12. -
-
- -
- Recommended first files to read -
    -
  1. neode-ui/src/stores/auth.ts — Clean authentication state machine (new, focused store)
  2. -
  3. neode-ui/src/stores/sync.ts — WebSocket + JSON patch data sync (new)
  4. -
  5. neode-ui/src/api/rpc-client.ts — Well-designed API client with retry logic
  6. -
  7. core/archipelago/src/api/rpc/dispatcher.rs — How RPC routing works (new)
  8. -
  9. core/archipelago/src/api/rpc/package/install.rs — App install flow (focused)
  10. -
  11. core/archipelago/src/session.rs — Auth flow in Rust with crypto
  12. -
  13. core/container/src/podman_client.rs — How Rust talks to Podman
  14. -
  15. image-recipe/configs/nginx-archipelago.conf — The full routing map
  16. -
-
- - -

Glossary

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TermWhat It Means
APIApplication Programming Interface — a defined way for two programs to talk to each other
Async/AwaitA way to write code that waits for slow things (network, disk) without blocking other work
BackendThe server-side code that runs on the machine (not visible to users)
ContainerAn isolated environment for running an app, like a lightweight virtual machine
ComposableA reusable piece of logic in Vue (similar to React hooks)
CSRFCross-Site Request Forgery — an attack where a malicious site tricks your browser into sending requests
CrateA Rust package (like npm package for JavaScript)
DIDDecentralized Identifier — a self-owned digital identity (no central authority controls it)
DWNDecentralized Web Node — personal data storage that syncs across your devices
FrontendThe browser-side code that users see and interact with
ISOA disk image file — like a digital copy of an installation CD
JWTJSON Web Token — a compact way to pass verified identity between systems
LoRaLong Range radio — low-power wireless communication over several kilometers
NginxA web server that also works as a reverse proxy (routes traffic to the right service)
NostrA decentralized messaging protocol using public/private key pairs
Onion ServiceA Tor hidden service — a server accessible only through the Tor network (no IP address)
PiniaVue's official state management library (successor to Vuex)
PodmanA container runtime like Docker, but rootless (more secure)
RPCRemote Procedure Call — calling a function on another computer over the network
ReactiveData that automatically updates the UI when it changes (core Vue concept)
Reverse ProxyA server that sits between clients and backend servers, forwarding requests
RustA systems programming language focused on safety and performance
SPASingle Page Application — a web app that loads once and dynamically updates content
Satoshi (sat)The smallest unit of Bitcoin. 1 BTC = 100,000,000 sats
systemdLinux's service manager — starts, stops, and monitors background services
TokioRust's async runtime — handles thousands of concurrent operations efficiently
TorThe Onion Router — anonymizes internet traffic by routing through multiple relays
TypeScriptJavaScript with static types — catches bugs at compile time instead of runtime
Vue 3A JavaScript framework for building reactive user interfaces
WebSocketA persistent, two-way connection between browser and server for real-time data
- -
-

- Architecture Review — Archipelago v0.1.0-beta — Updated 2026-03-22
- ~45,000 lines Rust (213 files) · ~45,500 lines TypeScript/Vue (232 files) · ~40 shell scripts -

- -
- - - - - diff --git a/docs/archive/lora-functionality.html b/docs/archive/lora-functionality.html deleted file mode 100644 index 3fceac08..00000000 --- a/docs/archive/lora-functionality.html +++ /dev/null @@ -1,899 +0,0 @@ - - - - - -Archipelago — LoRa & Mesh Functionality Guide - - - - - - -
- -
-

LoRa & Mesh Functionality

-

How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.

-
- Meshcore Companion USB - Double Ratchet E2E - 23 Message Types - 160-byte LoRa Frame -
-
- -

Introduction

-

This document explains Archipelago's mesh subsystem — the code under core/archipelago/src/mesh/ that lets nodes talk to each other over LoRa radio instead of (or alongside) the internet. It covers every message type, the transport layer that carries it, the cryptography that protects it, and the code paths that glue it all together.

-

The goal: give you a mental model that works both ways. If you're an engineer, you can read this and know exactly which bytes get put on the wire for a given RPC call. If you're not, the purple "Layman Analogy" boxes translate each piece into familiar metaphors.

- -

What is LoRa? Layman

-
- Think of LoRa as a whisper that travels 10 kilometers. - Normal Wi-Fi is a shout: loud, fast, lots of data, but only a few rooms away. LoRa is the opposite — a tiny, slow whisper that can cross an entire city because it's so narrow and patient that it slips through walls, trees, and hills. The tradeoff: you can only whisper about 160 bytes at a time, and each whisper takes a second or two to complete. -
-

Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses chirp spread spectrum (CSS). It operates in unlicensed ISM bands (915 MHz in the Americas, 868 MHz in Europe) and trades bandwidth for sensitivity, allowing receivers to decode signals below the noise floor. Typical line-of-sight range is 5–15 km with a simple antenna; data rates are 0.3–50 kbps.

-

Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running Meshcore firmware, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.

- -

Why Archipelago uses it

-
-
-

Off-grid safety

-

Dead-man switch and emergency alerts reach family without cell coverage.

-
-
-

Censorship resistance

-

No ISP, no DNS, no TLS termination — just radio waves between nodes.

-
-
-

Bitcoin when internet is down

-

Relay signed transactions and Lightning payments through on-grid peers.

-
-
-

Truly peer-to-peer chat

-

Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.

-
-
- -
- -

Hardware & Firmware

-

Archipelago expects a Meshcore-compatible radio board plugged into USB. The firmware handles RF, mesh forwarding, and contact management; Archipelago handles encryption, message types, and UI.

- - - - - - - - - - - -
ComponentRoleExamples
MCURuns Meshcore firmware, talks USB serialESP32, nRF52840
RadioSemtech LoRa transceiverSX1262, SX1276
BoardMCU + radio + USB + antennaHeltec V3, T-Beam, RAK WisBlock, Station G2
FirmwareMesh routing + Companion USB protocolMeshcore
ConnectionUSB CDC-ACM serial/dev/mesh-radio (udev symlink), /dev/ttyUSB*, /dev/ttyACM*
Link params115200 baud, 8N1Set in mesh/serial.rs
- -
- It's a modem. Exactly like a 56k modem from the '90s plugged into your serial port, except the other end of the wire is a radio mesh network instead of a phone line. Archipelago tells it "send this to contact X", and it figures out which radios to hop through. -
- -

USB Serial Transport

-

Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with '<' and listens for '>'.

- -
Host → Device: 0x3C '<' │ len_lo len_hiframe_bytes... -Device → Host: 0x3E '>' │ len_lo len_hiframe_bytes... - -Baud: 115200 Framing: 8N1 Source: mesh/serial.rs
- -

The frame body is a Meshcore Companion command or response. Archipelago builds these in mesh/protocol.rs and parses replies in mesh/listener/decode.rs.

- -

Companion commands Archipelago uses

- - - - - - - - - - - - - - - -
CodeNamePurpose
0x01APP_STARTHandshake; device returns its node_id and name
0x02SEND_TXT_MSGSend payload to a contact (targeted by 6-byte pubkey prefix)
0x03SEND_CHANNEL_TXT_MSGBroadcast on a channel (no specific recipient)
0x04GET_CONTACTSPull the device's contact table
0x06SET_DEVICE_TIMESync Unix timestamp for message dating
0x07SEND_SELF_ADVERTBroadcast our identity onto the mesh
0x08SET_ADVERT_NAMESet our display name
0x0ASYNC_NEXT_MESSAGEPop the next queued inbound message
0x0BSET_RADIO_PARAMSFrequency, spreading factor, bandwidth
0x0CSET_RADIO_TX_POWERTransmit power (dBm)
0x38GET_STATSDevice statistics
- -

Responses and push notifications

-

Responses begin with a status byte. Codes < 0x80 are replies to a command we sent; codes >= 0x80 are asynchronous push events from the device.

- - - - - - - - - - - - -
CodeNameMeaning
0x00RESP_OKCommand accepted
0x01RESP_ERRCommand failed + error code
0x03RESP_CONTACTOne contact entry (32-byte pubkey + metadata)
0x05RESP_SELF_INFOOur node_id and name after APP_START
0x10RESP_CONTACT_MSG_V3Direct inbound message (SNR + sender prefix + payload)
0x11RESP_CHANNEL_MSG_V3Channel broadcast inbound
0x83PUSH_MESSAGES_WAITINGAsync: new messages in queue, call SYNC_NEXT_MESSAGE
- -

Wire Format — the payload byte 0

-

Once a frame reaches the message payload, Archipelago looks at the first byte to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.

- -
0x00 Plain text (legacy, unencrypted) -0x01 Identity broadcast (ARCHY:2 / ARCHY:3) -0x02 Typed CBOR envelope (plaintext, used for debug or intra-LAN) -0xEE Encrypted typed — ChaCha20-Poly1305 w/ static shared secret -0xDD Ratcheted typed — Double Ratchet, forward-secure
- -

Markers 0xEE and 0xDD are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.

- -

0xEE — static-key encrypted envelope

-
[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]
- - -

0xDD — Double Ratchet envelope

-
[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
- - -
- Static key vs. ratchet = a safe vs. a self-shredding envelope. - The 0xEE lane is like a locked safe: one key opens everything. The 0xDD lane is like handing your friend a new envelope each time, and burning the old one — so even if someone steals next week's key, they can't read last week's messages. -
- -

Encryption Layers

-

Three cryptographic primitives combine to produce the 0xDD ratchet flow:

- -
-
-

X25519 ECDH

-

Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.

-
-
-

HKDF-SHA256

-

Derives root key, chain key, and message key at each ratchet step.

-
-
-

ChaCha20-Poly1305

-

Symmetric AEAD used for the actual payload encryption + authentication tag.

-
-
- -

Session bootstrap — X3DH-like handshake

-

Before the ratchet can start, peers exchange a PrekeyBundle (type 5) and a SessionInit (type 6). Those two messages are carried by the 0xEE static-key envelope, because the ratchet session doesn't exist yet. Once SessionInit is processed, subsequent traffic switches to 0xDD. See mesh/x3dh.rs.

- -

Fragmentation — how a 500-byte message rides a 160-byte pipe

-

The LoRa frame budget is 160 bytes (protocol::MAX_MESSAGE_LEN). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.

- -
Chunk header ┌──────────┬──────────┬────────────┐ - │ type (1) │ id (1) │ total (1) │ - └──────────┴──────────┴────────────┘ -Chunk body Up to 140 bytes of Base64-encoded payload - -Sender: compress → encrypt → split into 140-char chunks - → send with tiny inter-chunk delay -Receiver: accumulate by (sender, chunk_id) → reassemble - → decrypt → decompress → dispatch
- -

For chat messages shorter than 160 bytes, none of this kicks in — the whole thing fits in one frame. For larger payloads (long messages, forwarded content, PSBTs), the sender splits and the receiver joins.

- -
- Escape hatch: federation fallback. If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago skips LoRa entirely and routes the message over Tor federation instead. See the ContentRef path in rpc/mesh/typed_messages.rs. -
- -

Dual Transport — LoRa + Tor federation

-

Archipelago treats LoRa and Tor federation as two lanes of the same highway. A single chat window may receive some messages over radio and others over onion routing, and the UI doesn't distinguish. The mesh module picks the lane per-message based on the peer type and payload size.

- -
┌──────────────────┐ - │ mesh.send(...) │ - └────────┬─────────┘ - │ - ┌──────────┴──────────┐ - │ Is peer synthetic? │ - └──────────┬──────────┘ - No │ Yes - ┌──────────┘ └──────────┐ - ▼ ▼ - LoRa radio Tor federation - (160-byte frame) (unlimited, slower setup) - │ │ - │ if > 160 B && synth ──────┘ (fallback) - ▼ - Chunked over LoRa - or refused if no fallback
- -

Addressing

- - -

Synthetic federation contacts

-

To let the chat list show federation peers before any message arrives, Archipelago inserts synthetic contacts into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (≥ 0x8000_0000), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.

- -
- -

All 23 Message Types

-

Every typed message is a CBOR envelope identified by a single MeshMessageType byte. The Transport column shows which marker carries it on the wire and which Companion command is used.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDTypePurposeMarkerCmdChunked?
0TextPlain chat message0xDD0x02If >160 B
1AlertEmergency / dead-man heartbeat0xDD0x02/0x03No (short)
2InvoiceLightning / BOLT11 invoice0xDD0x02Usually
3PsbtHashUnsigned tx hash for co-signing0xDD0x02No
4CoordinateGPS location share0xDD0x02No
5PrekeyBundleX3DH bootstrap (pre-session)0xEE0x02No
6SessionInitInitial ratchet message0xEE0x02No
7BlockHeaderBitcoin block height/hash0xDD0x03No
8TxRelaySigned Bitcoin tx for on-grid peer to broadcast0xDD0x02Yes
9TxRelayResponsetxid or error from the relay peer0xDD0x02No
10LightningRelayBOLT11 to pay via on-grid peer0xDD0x02Yes
11LightningRelayResponsepayment_hash or error0xDD0x02No
12TxConfirmationDepth update (1/2/3 confs)0xDD0x02No
13ReplyQuoted reply to a previous message0xDD0x02If long
14ReactionEmoji reaction on MessageKey0xDD0x02No
15ReadReceipt"Seen up to MessageKey X"0xDD0x02No
16ForwardRe-forwarded original w/ provenance0xDD0x02Yes
17EditIn-place text replacement0xDD0x02If long
18DeleteTombstone for earlier message0xDD0x02No
19ContentRefCID of blob held by sender (file/image)0xDD0x02 or TorFederation fallback
20PresenceHeartbeat + last-activity epoch0xDD0x03No
21ChannelInviteGroup membership announcement0xDD0x03No
22ContactCardShareable federation node card0xDD0x02Maybe
- -

The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.

- -

Text, Reply, Edit, Delete, Forward

- -

Text (type 0)

-

Sender path. rpc.mesh.sendtyped_messages::send_text → CBOR-encode the Text{body} variant → ratchet-encrypt → prefix 0xDD → if under 160 B, send in one SEND_TXT_MSG frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.

- -

Reply (type 13)

-

Same as Text, but the CBOR envelope carries a MessageKey pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.

- -

Edit (type 17)

-

Envelope contains the original MessageKey plus the new body. Receiver updates its local store in-place and tags the entry "edited".

- -

Delete (type 18)

-

Tombstone only: MessageKey with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.

- -

Forward (type 16)

-

Wraps original {sender_name, original_timestamp, body} so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are almost always chunked.

- -

Reaction, ReadReceipt, Presence

- -

Reaction (type 14)

-

Envelope: {target: MessageKey, emoji: String}. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see MessageActions in neode-ui).

- -

ReadReceipt (type 15)

-

Envelope: {up_to: MessageKey}. Semantically "I've seen everything up to and including this message." One receipt covers all prior unread, so traffic is O(1) per read burst rather than O(n).

- -

Presence (type 20)

-

Periodic heartbeat carrying {last_activity_epoch}. Broadcast on a channel (SEND_CHANNEL_TXT_MSG, cmd 0x03) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.

- -
- Like a lighthouse beacon. Presence doesn't go to anyone in particular — it's a flash that everyone in radio range can see. "I'm still here, last active two minutes ago." Cheap and unaddressed. -
- -

ContentRef — files and images without bloating the radio

-

LoRa cannot move a 500 KB image. The ContentRef type (19) solves this by sending only a pointer — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.

- -
Sender Receiver -────── ──────── -store blob locally (CID) -┌──────────────────────┐ -│ ContentRef {cid, │ ──ratchet──▶ -│ mime, size, │ 0xDD -│ thumb_hash} │ over LoRa -└──────────────────────┘ - see CID in chat - click to fetch - ┌─────────────────┐ - │ rpc.mesh.fetch- │ - │ content(cid) │ - └────────┬────────┘ - ▼ - federation (Tor) - resolve DID → pull blob
- -
- Resolution bug fix note. An earlier revision of ContentRef routed the fetch via a name-match on the contact list, which broke when two peers had the same display name. The fix (see commit 5f7ebf14) resolves the owning peer by DID and falls back to name-match only if DID lookup fails. -
- -

Bitcoin & Lightning over LoRa

-

Archipelago uses the mesh as a Bitcoin transport of last resort. Signed transactions travel from an offline signer, through the mesh, to a peer with internet, who then rebroadcasts them to the Bitcoin network and reports back.

- -

TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)

-
Offline signer On-grid relay peer Bitcoin p2p -────────────── ────────────────── ─────────── -sign tx -┌─────────────┐ -│ TxRelay │ ─ratchet/LoRa▶ decrypt → validate -│ {raw_tx} │ broadcast via bitcoind ───▶ mempool -└─────────────┘ │ - ▼ - ┌────────────────────────┐ - ◀─ratchet│ TxRelayResponse{txid} │ - └────────────────────────┘ - (or {error}) - -later, as blocks arrive: - ┌────────────────────────┐ - ◀─ratchet│ TxConfirmation │ - │ {txid, depth: 1..3} │ - └────────────────────────┘
- -

The binary framing in mesh/bitcoin_relay.rs is intentionally tight — raw binary, not CBOR — to keep a signed 1-input/1-output tx inside one or two 160-byte frames. Confirmation updates are tiny (txid + depth byte) and ride in a single frame.

- -

LightningRelay (10) → LightningRelayResponse (11)

-

Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns payment_hash or an error. Invoices are often long enough to chunk.

- -

Invoice (2) and PsbtHash (3)

-

These are not relays — they're peer-to-peer handoffs. Invoice delivers a BOLT11 to be paid by the recipient. PsbtHash carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.

- -

BlockHeader (7)

-

Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via SEND_CHANNEL_TXT_MSG) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.

- -

Alerts, Coordinates, Dead-Man

- -

Alert (type 1)

-

Envelope: {kind, message, sender_contact_id}. Kinds include Emergency and Deadman. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).

- -

Dead-man switch

-

A background task in mesh/alerts.rs sends a Deadman alert on a configurable interval (default 6 hours). If the user doesn't touch the UI within that window, the alert fires automatically and asks chosen recipients to check in. Powered off? The next peer to receive your last heartbeat notices the gap.

- -

Coordinate (type 4)

-

Envelope: {lat, lon, accuracy_m} with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.

- -

ChannelInvite (type 21)

-

Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via SEND_CHANNEL_TXT_MSG.

- -

Identity, PrekeyBundle, ContactCard

- -

Identity broadcast (marker 0x01, ARCHY:2/3)

-

The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed 0x01. This is how peers discover each other. The payload encodes protocol version (ARCHY:2 or ARCHY:3) and the raw pubkey. Carried by CMD_SEND_SELF_ADVERT (0x07).

- -

PrekeyBundle (type 5) and SessionInit (type 6)

-

X3DH handshake. PrekeyBundle advertises a signed prekey; SessionInit consumes it to derive the initial ratchet root key. Both ride on 0xEE (static-key encryption), because the ratchet session they're creating doesn't yet exist.

- -

ContactCard (type 22)

-

A shareable card containing {did, onion_address, pubkey, display_name}. When a receiver taps "add" on the card, Archipelago one-click federates with that node over Tor. This is the bridge that lets LoRa-discovered peers become full federation contacts.

- -
- -

RPC API — what callers actually invoke

-

Every user-facing action goes through the RPC dispatcher (api/rpc/dispatcher.rs, lines 287+) and ends in api/rpc/mesh/typed_messages.rs. The tables below show the public surface.

- -

Core commands

- - - - - - - - - - - - -
RPCEffect
mesh.statusDevice info, peer count, enabled state
mesh.peersList all discovered peers with RSSI / SNR / hop count
mesh.messagesRetrieve stored mesh messages
mesh.sendSend plain text to a specific peer
mesh.send-channelBroadcast on a channel
mesh.broadcastMesh-wide announcement
mesh.configureSet device params (name, power, channel)
mesh.debug-dumpRaw state for debugging
- -

Rich message commands

- - - - - - - - - - - - - - - - -
RPCMsg TypeNotes
mesh.send-invoiceInvoice (2)Deliver BOLT11 to peer
mesh.send-coordinateCoordinate (4)Single frame, fixed-point
mesh.send-alertAlert (1)Emergency or deadman
mesh.send-contentContentRef (19)Stores blob, sends CID
mesh.fetch-contentPulls blob via federation
mesh.send-psbtPsbtHash (3)Hash only, full PSBT via fetch
mesh.send-replyReply (13)Quoted response
mesh.send-reactionReaction (14)Emoji
mesh.send-read-receiptReadReceipt (15)Cumulative "seen up to"
mesh.forward-messageForward (16)Wraps original + provenance
mesh.edit-messageEdit (17)In-place text replacement
mesh.delete-messageDelete (18)Tombstone
- -

User Interface

-

The Vue side lives under neode-ui/src/views/mesh/ with state in stores/mesh.ts. Notable panels:

-
-
-

Mesh chat

-

Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.

-
-
-

MeshBitcoinPanel

-

UI for TxRelay / LightningRelay submission and confirmation tracking.

-
-
-

MeshDeadmanPanel

-

Configure dead-man interval, pick recipients, show last heartbeat time.

-
-
-

Unified inbox

-

Federation and mesh chats appear side-by-side; the transport is invisible to the user.

-
-
- -

Listener loop — how inbound traffic is decoded

-

A long-running async task in mesh/listener/mod.rs owns the serial device and feeds events into the rest of the system.

- -
loop { - event = await serial_read() - match event { - PUSH_MESSAGES_WAITING → send SYNC_NEXT_MESSAGE until empty - RESP_CONTACT_MSG_V3 → decode.rs extracts payload - → match first byte: - 0x00 plain text - 0x01 identity → frames::parse_identity - 0x02 typed CBOR plaintext - 0xEE → crypto::decrypt_static - 0xDD → session::load + ratchet::decrypt - → dispatch.rs routes typed msg - to chat store / bitcoin relay / - alerts / presence / ... - RESP_CONTACT → contact list update - RESP_SELF_INFO → record our node_id - } -}
- -

Chunk reassembly happens in listener/session.rs, keyed by (sender_pubkey_prefix, chunk_id). Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.

- -

File Map

- - - - - - - - - - - - - - - - - - -
FileSizeRole
mesh/mod.rs52 KBPublic API, send paths, federation integration
mesh/protocol.rs26 KBFrame encoding/decoding, command builders
mesh/serial.rs15 KBUSB driver, device detection, handshake
mesh/crypto.rs10 KBX25519 ECDH, ChaCha20-Poly1305, HKDF
mesh/ratchet.rs16 KBDouble Ratchet implementation
mesh/message_types.rs23 KB23 typed message discriminators + CBOR schemas
mesh/bitcoin_relay.rs17 KBTxRelay / LightningRelay binary framing
mesh/listener/dispatch.rs29 KBTyped-message routing into chat/relay/alerts
mesh/listener/session.rs14 KBRatchet session persistence + chunk reassembly
mesh/x3dh.rsPrekey / SessionInit bootstrap
mesh/outbox.rsRetry queue for unacked sends
mesh/steganography.rsWeather/sensor framing for deniable traffic
api/rpc/mesh/typed_messages.rsAll mesh.* RPC handlers
neode-ui/src/stores/mesh.ts14 KBPinia store consumed by all mesh Vue views
- -
- -

Summary scoreboard

-
-
23
Message types
-
160
Bytes / frame
-
2
Transports
-
5
Wire markers
-
~6k
LoC in mesh/
-
FS
Forward-secure
-
- -
- Bottom line. Archipelago's mesh isn't a chat toy. It's a complete off-grid transport with forward-secure end-to-end encryption, 23 typed message kinds, Bitcoin and Lightning relay, fragmentation, store-and-forward, and a seamless Tor federation fallback. From the user's perspective it looks like iMessage; from the wire's perspective it's a carefully budgeted 160 bytes of ChaCha20 ciphertext riding on a sub-kbps radio link. -
- -
- - diff --git a/docs/container-architecture.html b/docs/container-architecture.html deleted file mode 100644 index 48cfc0f9..00000000 --- a/docs/container-architecture.html +++ /dev/null @@ -1,4279 +0,0 @@ - - - - - -Archipelago System Architecture - - - -
-
-

Archipelago System Architecture

-

Complete interactive map of every layer, protocol, container, and data path · Click anything to expand

-
- - -
-
- -
- - - - -
- - -
- - - - -
- -
-
34
Containers
-
260+
RPC Methods
-
9
Protocols
-
LUKS2
Encryption
-
Rootless
Podman
-
8 GB+
Recommended RAM
-
- -
-
-
- Kiosk Display - Layer 8 · Physical -
-
X11 + Chromium fullscreen on VT7, showing the web UI directly on connected monitor
-
The TV/monitor screen you see when the box is plugged in. No keyboard needed — it just shows the dashboard.
-
-
-
- Web UI (Vue.js SPA) - Layer 7 · Application -
-
Vue 3 + TypeScript + Pinia frontend served by nginx, communicates via JSON-RPC and WebSocket
-
The dashboard you use in your browser to manage everything — apps, Bitcoin, settings.
-
-
-
- Rust Backend - Layer 6 · Service -
-
Archipelago binary on 127.0.0.1:5678 — RPC server, auth, session management, container orchestration, Tor control
-
The brain of the system. Handles login, manages containers, talks to Bitcoin, and coordinates everything.
-
-
-
- Container Layer (Podman Rootless) - Layer 5 · Isolation -
-
34 containers on archy-net (internal DNS) and bridge networks, managed by rootless Podman
-
Each app runs in its own sandbox. If one app crashes or gets hacked, the others are unaffected.
-
-
-
- Network Layer - Layer 4 · Network -
-
Nginx reverse proxy (80/443), Tailscale mesh VPN, Tor hidden services, UFW firewall
-
Controls what traffic goes where. One front door (nginx) routes requests to the right app. Tor makes you reachable without exposing your IP.
-
-
-
- Encryption Layer - Layer 3 · Security -
-
LUKS2 full-disk encryption on /var/lib/archipelago with auto-detected cipher (AES-XTS or ChaCha20-Adiantum)
-
All your Bitcoin data, passwords, and app data are encrypted. If someone steals the hard drive, they get nothing.
-
-
-
- Operating System - Layer 2 · OS -
-
Debian 12 (Bookworm) minimal — systemd services, x86_64/ARM64, debootstrap custom base
-
The operating system. Debian is rock-solid Linux used by servers worldwide. We strip it down to just what's needed.
-
-
-
- Hardware / Boot - Layer 1 · Physical -
-
UEFI + BIOS dual-boot, GPT partitions, USB flash installer, auto-detect disk + network + CPU features
-
The physical computer. Flash a USB stick, boot from it, and the installer sets everything up automatically.
-
-
- -
-

Container Dependency Chain

-
// Startup order: Databases → Core → Services → Apps -// Health monitor restarts in this order too - -mempool-db ───┐ -btcpay-db ───┤ - - ├──→ bitcoin-knots ──→ electrumx - - ┌────┴────┬──────────┬──────────┐ - - lnd fedimint mempool-api nbxplorer - - fedi-gw mempool-web btcpay - - └──→ lnd-ui - -// IndeedHub stack (independent) -ih-postgres ──→ ih-api ──→ indeedhub -ih-redis ──→ ih-api -ih-minio ──→ ih-api - -// Penpot stack (independent) -penpot-pg ──→ penpot-be ──→ penpot-fe -penpot-vk ──→ penpot-be ──→ penpot-exp - -// Tier 3: All independent — start in any order -filebrowser grafana homeassist jellyfin photoprism -vaultwarden nextcloud searxng uptime-kuma ollama -onlyoffice nginx-pm portainer
-
- -

System Resources

- -
- -
-
Hardware Requirements
- - - - - - - - -
Minimum RAM4 GB
Recommended RAM8 GB+ (core stack uses ~8–10 GB)
Minimum Disk32 GB SSD
Recommended Disk1 TB+ NVMe SSD
CPUx86_64 or ARM64, 4+ cores recommended
NetworkEthernet recommended (WiFi supported)
TargetsHP ProDesk, Intel NUC, any standard PC
-
- -
-
Memory Budget (all containers)
- - - - - - - - - - -
Bitcoin Knots2 GB (1 GB low-memory mode)
ElectrumX1 GB
LND512 MB
BTCPay + DB1.5 GB (1 GB + 512 MB)
Mempool stack1.3 GB (512+256+512 MB)
Fedimint + GW1 GB (512+512 MB)
Ollama (AI)4 GB (1 GB low-memory)
All other apps128–1024 MB each
Total allocated~20 GB (not all run simultaneously)
-
- -
- -
- -
-
Disk Usage by Component
- - - - - - - - - -
Bitcoin blockchain (full)~600 GB
Bitcoin (pruned)~550 MB
ElectrumX index~50 GB
LND channels + wallet~1 GB
Databases (all)~2–10 GB
Container images~15 GB
Ollama models1–50 GB (varies)
Media (Jellyfin/Photos)User-determined
-
- -
-
Network Ports (External)
- - - - - - - -
80 / 443Nginx → Web UI, app proxies
8333Bitcoin P2P (node discovery)
9735Lightning P2P (payment routing)
50001Electrum protocol (wallet queries)
22SSH (admin access)
Internal only8332 (RPC), 10009 (gRPC), 8080 (REST), 8999, 4080, 3000, 3001, 8082–8096, 9000…
-
- -
- -
-
-
Container Security Defaults
- - - - - - - -
Capabilities--cap-drop=ALL then add only needed: CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE. Some get NET_RAW (LND), NET_BIND_SERVICE (Vaultwarden, nginx-pm, LND-UI).
Privileges--security-opt=no-new-privileges on all containers
Health checksAll containers: --health-interval=120s --health-timeout=5s --health-retries=3
Low-memory modeAuto-detected: Bitcoin 2G→1G, PhotoPrism 1G→512M, OnlyOffice 2G→1G, Ollama 4G→1G
Disk modeAuto: if disk <1TB → Bitcoin prune=550, dbcache=512M. If ≥1TB → full txindex, dbcache=4G
RPC methods260+ registered across 20+ namespaces (auth, seed, package, bitcoin, lnd, identity, tor, nostr, mesh, federation, dwn, system, monitoring…)
-
-
- -
- - - - -
- -
- -
-
- 1. Hardware / Boot - Physical -
-
UEFI + BIOS dual-boot installer, GPT partition table, auto-detect hardware
-
You flash a USB drive, plug it in, and the computer installs itself. Works on both old and new machines.
-
-

Partition Layout

-
    -
  • 1 MB — BIOS boot (for older machines without UEFI)
  • -
  • 512 MB — EFI System Partition (UEFI boot files)
  • -
  • 30 GB — Root filesystem (Debian OS, binaries, Podman storage)
  • -
  • Remaining — LUKS2 encrypted → /var/lib/archipelago (all user data)
  • -
-

Installer Features

-
    -
  • Auto-detects target disk (largest available, prefers NVMe)
  • -
  • Auto-detects AES-NI CPU support for encryption cipher selection
  • -
  • Debootstrap minimal Debian 12 (no bloat)
  • -
  • Configures GRUB for both UEFI and legacy BIOS
  • -
  • Creates archipelago user (UID 1000) with Podman subuid/subgid mapping
  • -
-

Hardware Targets

-
    -
  • x86_64: HP ProDesk, Intel NUC, any standard PC
  • -
  • ARM64: Planned but not primary target yet
  • -
  • Minimum: 4 cores, 8GB RAM, 256GB disk
  • -
  • Recommended: 4+ cores, 16GB RAM, 1TB+ disk (for full Bitcoin node)
  • -
-
-
- -
-
- 2. Operating System — Debian 12 - OS -
-
Minimal Debian Bookworm with systemd, custom kernel parameters, hardened services
-
The foundation. Debian is one of the most stable and trusted Linux versions. We remove everything unnecessary.
-
-

Key Packages

-
    -
  • podman — Rootless container runtime (replaces Docker)
  • -
  • nginx — Reverse proxy (front door for all web traffic)
  • -
  • tor — Privacy network daemon
  • -
  • tailscale — Mesh VPN for remote access
  • -
  • chromium — Kiosk browser for local display
  • -
  • cryptsetup — LUKS disk encryption
  • -
  • xorg — Display server for kiosk mode
  • -
-

Kernel Tuning

-
    -
  • net.ipv4.ip_unprivileged_port_start=80 — lets rootless Podman bind ports 80+
  • -
  • vm.overcommit_memory=1 — for Redis/Valkey container requirements
  • -
  • User namespaces enabled for rootless containers
  • -
-

Users

-
    -
  • archipelago (UID 1000) — main user, owns all containers and data
  • -
  • root — only for Tor management, LUKS, and boot services
  • -
-
-
- -
-
- 3. Encryption — LUKS2 - Security -
-
Full-disk encryption on all user data with auto-detected hardware-accelerated ciphers
-
Your Bitcoin wallet, passwords, photos — everything is scrambled. Without the key, the data is just noise.
-
-

Cipher Selection (auto-detected at install)

-
    -
  • With AES-NI: aes-xts-plain64 (AES-256-XTS) — hardware-accelerated, fastest option
  • -
  • Without AES-NI: xchacha20,aes-adiantum-plain64 (ChaCha20-Adiantum) — fast on any CPU
  • -
-

Key Derivation

-
    -
  • PBKDF: Argon2id (memory-hard, GPU-resistant)
  • -
  • Key size: 512 bits
  • -
  • Key file: /root/.luks-archipelago.key (4KB random, auto-generated)
  • -
-

What's Encrypted

-
    -
  • Bitcoin blockchain data
  • -
  • LND wallet & Lightning channels
  • -
  • All database volumes (PostgreSQL, MariaDB)
  • -
  • All app data directories
  • -
  • Secrets (RPC passwords, macaroons, API keys)
  • -
  • Tor hidden service keys
  • -
-

What's NOT Encrypted

-
    -
  • Root filesystem (OS binaries, system config) — no sensitive data here
  • -
  • EFI/boot partitions (must be readable to start)
  • -
-
-
- -
-
- 4. Network Layer - Network -
-
Nginx reverse proxy, Tailscale mesh VPN, Tor hidden services, UFW firewall
-
One front door (nginx) for all traffic. Tor lets people reach you without knowing your real address. Tailscale lets YOU reach the box from anywhere.
-
-

Nginx Reverse Proxy

-
    -
  • Listens on :80 (HTTP) and :443 (HTTPS with self-signed cert)
  • -
  • Serves Vue.js SPA at /
  • -
  • Proxies backend at /rpc/v1, /ws, /health
  • -
  • Proxies each app at /app/{name}/
  • -
  • Rate limits: auth (3/s), RPC (20/s), P2P (10/s)
  • -
  • Security headers: CSP, HSTS, X-Frame-Options, Permissions-Policy
  • -
  • Injects nostr-provider.js into all app iframes
  • -
-

Tor

-
    -
  • System-level Tor daemon (not containerized)
  • -
  • SOCKS5 proxy at 127.0.0.1:9050
  • -
  • Hidden services for: web UI, LND, BTCPay, Mempool, Fedimint
  • -
  • Backend manages services via privileged helper script
  • -
  • Containers connect via host.containers.internal:9050
  • -
-

Tailscale

-
    -
  • Mesh VPN — access your node from anywhere via encrypted tunnel
  • -
  • Runs as system service or container
  • -
  • Provides stable IP (e.g., 100.x.x.x) regardless of network
  • -
-

Firewall (UFW)

-
    -
  • DEFAULT_FORWARD_POLICY=ACCEPT (required for rootless Podman)
  • -
  • Allow: 22 (SSH), 80 (HTTP), 443 (HTTPS), 8333 (Bitcoin P2P), 9735 (Lightning P2P)
  • -
-
-
- -
-
- 5. Rust Backend - Service -
-
Archipelago binary — JSON-RPC server, auth, RBAC, container management, Tor control, DID identity
-
The control center. Every button you click in the dashboard sends a message here, and it makes things happen.
-
-

Bind

-
    -
  • 127.0.0.1:5678 — localhost only, nginx handles external access
  • -
-

Endpoints

-
    -
  • POST /rpc/v1 — JSON-RPC 2.0 (all commands)
  • -
  • WS /ws — WebSocket (live updates, container status, logs)
  • -
  • GET /health — Health check (no auth)
  • -
  • /archipelago/ — P2P node messaging
  • -
  • /content — Content sharing (via Tor)
  • -
  • /dwn — Decentralized Web Node protocol
  • -
-

Key RPC Methods

-
    -
  • auth.* — login, TOTP, password change, onboarding
  • -
  • seed.* — generate, verify, restore wallet seeds
  • -
  • package.* — container CRUD (create, start, stop, remove)
  • -
  • node.* — DID identity, signing, backups
  • -
  • app.* — marketplace, app config
  • -
-

Systemd Service

-
    -
  • Type: notify (signals readiness to systemd)
  • -
  • Watchdog: 300s (must ping every 120s or gets killed)
  • -
  • MemoryMax: 4GB
  • -
  • Crash recovery on startup (detects unclean shutdown, restarts containers)
  • -
  • Periodic container state snapshots for recovery
  • -
-
-
- -
-
- 6. Container Layer — Rootless Podman - Isolation -
-
34 containers, custom bridge network (archy-net), UID mapping, security caps, memory limits
-
Apps run in sealed boxes. They can only see what we let them see, use only the memory we allow, and can't mess with each other.
-
-

Rootless Podman

-
    -
  • All containers run as user archipelago (UID 1000)
  • -
  • No root access required — even if a container is compromised, it can't escalate to root
  • -
  • Subuid/subgid: archipelago:100000:65536
  • -
  • Socket: /run/user/1000/podman/podman.sock
  • -
-

Networks

-
    -
  • archy-net (custom bridge) — Bitcoin stack + services, containers can reach each other by name (DNS)
  • -
  • bridge (default) — standalone apps, port-mapped only
  • -
  • host — Tailscale only (needs full network access)
  • -
-

Security Defaults (per container)

-
    -
  • --cap-drop=ALL then add only what's needed (least privilege)
  • -
  • --security-opt=no-new-privileges
  • -
  • Memory limits (128MB to 4GB depending on app)
  • -
  • Health checks with auto-restart on failure
  • -
  • Read-only root filesystem where possible (--read-only)
  • -
-

UID Mapping (inside container → host)

-
    -
  • root (0) → host UID 100000
  • -
  • postgres (70) → host UID 100070
  • -
  • bitcoin (101) → host UID 100101
  • -
  • grafana (472) → host UID 100472
  • -
  • mariadb (999) → host UID 100999
  • -
-

Registry

-
    -
  • Private registry at source.archipelago-foundation.org/lfg2025/
  • -
  • HTTPS (self-hosted Gitea)
  • -
  • All images pre-pulled into registry; nodes pull on first boot
  • -
-
-
- -
-
- 7. Web UI — Vue.js SPA - Application -
-
Vue 3 + TypeScript + Pinia + Vite, served as static files by nginx
-
The website that runs on the box. Open it in any browser on your network to manage everything.
-
-

Tech Stack

-
    -
  • Framework: Vue 3 with <script setup lang="ts">
  • -
  • State: Pinia stores
  • -
  • Bundler: Vite 7
  • -
  • Styling: Global CSS with Tailwind utility classes in style.css
  • -
-

Communication

-
    -
  • JSON-RPC: All commands go through rpc-client.tsPOST /rpc/v1
  • -
  • WebSocket: Real-time container status, logs, events via /ws
  • -
  • CSRF: Token in cookie + X-CSRF-Token header
  • -
  • Session: HttpOnly cookie, SameSite=Lax
  • -
  • Retry: Auto-retry 3x with exponential backoff on 502/503
  • -
  • Timeout: 15s default (configurable per call)
  • -
-

Key Views

-
    -
  • / — Dashboard (system status, apps)
  • -
  • /kiosk — Kiosk mode (public, no auth)
  • -
  • /kiosk-recovery — Fallback with IP + QR code
  • -
  • /marketplace — App installer
  • -
  • /settings — System configuration
  • -
-

App Embedding

-
    -
  • Apps open as iframes via /app/{name}/ proxy paths
  • -
  • Each iframe gets nostr-provider.js injected for identity
  • -
-
-
- -
-
- 8. Kiosk Display - Physical -
-
X11 + Chromium in kiosk mode on VT7, auto-start, crash recovery
-
Plug in a monitor and the dashboard appears fullscreen. No login, no desktop, just your node. Press Ctrl+Alt+F1 for a terminal.
-
-

How It Works

-
    -
  • X11 server (Xorg) starts on Virtual Terminal 7
  • -
  • Chromium launches in --kiosk --app=http://localhost/kiosk mode
  • -
  • No address bar, no tabs, no right-click — just the dashboard
  • -
  • Cursor hidden after 3 seconds of inactivity
  • -
  • Screen blanking disabled
  • -
-

Resource Limits

-
    -
  • --disable-gpu — software rendering only
  • -
  • --renderer-process-limit=1 — single renderer process
  • -
  • --js-flags="--max-old-space-size=128" — 128MB JS heap max
  • -
  • --disable-metrics-reporting — no telemetry to Google
  • -
  • --enable-low-end-device-mode — reduce animations and compositing
  • -
-

Controls

-
    -
  • Ctrl+Alt+F7 — switch to kiosk
  • -
  • Ctrl+Alt+F1 — switch to terminal
  • -
  • sudo archipelago-kiosk enable|disable|toggle|status
  • -
-
-
- -
-
- - - - -
- -
- - - - - - - - - -
- - -
-
Tier 0 — Databases
-
- -
-
archy-mempool-dbarchy-net
-
MariaDB database storing Bitcoin mempool transaction data for the Mempool block explorer.
-
A database that remembers pending Bitcoin transactions so the block explorer can show them.
-
mariadb:11.4.10
-
No exposed ports (internal only)
-
-
UID100999:100999 (mariadb user)
-
Memory512 MB
-
Healthmariadb -uroot -e 'SELECT 1'
-
Data/var/lib/archipelago/mysql-mempool
-
Databasemempool (user: mempool)
-
DepsNone
-
Needed bymempool-api
-
-
- -
-
archy-btcpay-dbarchy-net
-
PostgreSQL database for BTCPay Server and NBXplorer, storing invoices, transactions, and merchant data.
-
Stores your payment invoices and transaction history for the Bitcoin payment processor.
-
postgres:15.17
-
No exposed ports (internal only)
-
-
UID100070:100070 (postgres user)
-
Memory512 MB
-
Healthpg_isready -U postgres
-
Data/var/lib/archipelago/postgres-btcpay
-
Databasesbtcpay, nbxplorer
-
Needed bynbxplorer btcpay
-
-
- -
-
indeedhub-postgresarchy-net
-
PostgreSQL database for IndeedHub social platform, storing posts, user profiles, and relay data.
-
The database that stores all the social media posts and user data for IndeedHub.
-
postgres:16.13-alpine
-
No exposed ports (internal only)
-
-
Needed byindeedhub-api
-
-
- -
-
indeedhub-redisarchy-net
-
Redis in-memory cache for IndeedHub, handling sessions, job queues, and real-time data.
-
A fast temporary memory store so IndeedHub pages load quickly and background tasks run smoothly.
-
redis:7.4.8-alpine
-
No exposed ports
-
-
Needed byindeedhub-api
-
-
- -
-
penpot-postgresbridge
-
PostgreSQL database for Penpot design tool, storing projects, layers, and design assets.
-
Stores all the design projects and files for the Penpot design tool.
-
postgres:15
-
No exposed ports
-
-
Memory256 MB
-
Needed bypenpot-backend
-
-
- -
-
penpot-valkeybridge
-
Valkey (Redis fork) cache for Penpot, handling sessions and real-time collaboration sync.
-
Fast memory cache that makes Penpot's real-time collaboration work smoothly.
-
valkey:8.1
-
No exposed ports
-
-
Memory128 MB
-
Needed bypenpot-backend
-
-
- -
-
immich_postgresbridge
-
PostgreSQL with vector extensions for Immich photo AI/search. Optional — only if Immich is installed.
-
Database for the photo manager. Has special AI search features for finding photos by what's in them.
-
immich-postgres:14-vectorchord (optional)
-
-
Memory256 MB
-
Needed byimmich
-
-
- -
-
immich_redisbridge
-
Valkey cache for Immich job queue (photo processing, thumbnail generation).
-
Manages the queue of photos waiting to be processed and thumbnailed.
-
valkey:8.1.6 (optional)
-
-
Memory128 MB
-
Needed byimmich
-
-
- -
-
- - -
-
Tier 1 — Core Bitcoin Infrastructure
-
- -
-
bitcoin-knotsarchy-net
-
Full Bitcoin node (Knots variant). Validates every transaction and block independently. The root dependency for the entire Bitcoin stack.
-
Your own copy of the entire Bitcoin network. Nobody can lie to you about your balance because you verify everything yourself.
-
bitcoin-knots:latest
-
Ports: 8332 8333 28332 28333
-
-
Port 8332JSON-RPC API (how other apps talk to Bitcoin)
-
Port 8333P2P network (connects to other Bitcoin nodes worldwide)
-
Port 28332ZMQ block notifications (instant alert when new block arrives)
-
Port 28333ZMQ transaction notifications (instant alert for new transactions)
-
Memory2 GB (1 GB on low-memory systems)
-
Healthbitcoin-cli getblockchaininfo
-
Data/var/lib/archipelago/bitcoin (~500GB full, ~550MB pruned)
-
Disk modeAuto: prune if <1TB, full txindex if ≥1TB
-
RPC AuthHMAC-SHA256 salted hash (no plaintext password in config)
-
TorRoutes P2P through Tor SOCKS5 for privacy
-
CapsCHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE
-
DepsNone — ROOT DEPENDENCY
-
Needed byelectrumx lnd mempool nbxplorer fedimint
-
-
- -
-
electrumxarchy-net
-
Electrum protocol server. Indexes the blockchain by address so wallets can look up balances instantly without scanning every block.
-
An index for Bitcoin. Like a book's table of contents — instead of reading every page to find your info, you jump straight to it.
-
electrumx:v1.18.0
-
Ports: 50001 8000
-
-
Port 50001Electrum protocol (wallet connections)
-
Port 8000Health check / status API
-
Memory1 GB
-
Data/var/lib/archipelago/electrumx
-
ProtocolElectrum JSON-RPC over TCP
-
Depsbitcoin-knots (reads blockchain via RPC)
-
Needed bymempool-api
-
-
- -
-
- - -
-
Tier 2 — Services (depend on Bitcoin core)
-
- -
-
lndarchy-net
-
Lightning Network Daemon. Enables instant, low-fee Bitcoin payments through payment channels.
-
Lets you send and receive Bitcoin instantly (instead of waiting 10+ minutes for a block). Like a tab at a bar — settle up later on-chain.
-
lnd:v0.18.4-beta
-
Ports: 9735 10009 8080
-
-
Port 9735Lightning P2P (connects to other Lightning nodes)
-
Port 10009gRPC API (admin operations, authenticated with macaroons)
-
Port 8080REST API (simpler HTTP interface to LND)
-
Memory512 MB
-
Data/var/lib/archipelago/lnd (wallet, channels, macaroons)
-
AuthMacaroon tokens (read-only for queries, admin for mutations)
-
TorActive with stream isolation (each connection uses different circuit)
-
CapsCHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW
-
Depsbitcoin-knots
-
Needed byfedi-gateway (LND mode) lnd-ui
-
-
- -
-
mempool-apiarchy-net
-
Mempool.space backend API. Provides blockchain analytics, fee estimates, and transaction tracking.
-
The engine behind the block explorer. Shows you what's happening on the Bitcoin network in real time.
-
mempool-backend:v3.0.0
-
Ports: 8999
-
-
Memory512 MB
-
Data/var/lib/archipelago/mempool
-
Depsbitcoin-knots electrumx mempool-db
-
Needed bymempool-web
-
-
- -
-
archy-mempool-webarchy-net
-
Mempool.space frontend. The visual block explorer with real-time mempool visualization and fee graphs.
-
Your personal mempool.space — watch Bitcoin blocks being mined, see fee rates, track your transactions.
-
mempool-frontend:v3.0.0
-
Ports: 4080
-
-
Memory256 MB
-
Depsmempool-api
-
Nginx path/app/mempool/
-
-
- -
-
archy-nbxplorerarchy-net
-
NBXplorer blockchain scanner. Watches Bitcoin addresses for BTCPay and notifies when payments arrive.
-
Watches Bitcoin for incoming payments and tells BTCPay Server when money arrives for your invoices.
-
nbxplorer:2.6.0
-
Ports: 32838
-
-
Memory512 MB
-
Data/var/lib/archipelago/nbxplorer
-
Depsbitcoin-knots btcpay-db
-
Needed bybtcpay
-
-
- -
-
btcpay-serverarchy-net
-
Self-hosted Bitcoin payment processor. Accept Bitcoin payments with invoices, checkout pages, and POS.
-
Your own payment terminal for Bitcoin. Create invoices, get paid, no middleman taking a cut.
-
btcpayserver:2.3.9
-
Ports: 23000
-
-
Memory1 GB
-
Data/var/lib/archipelago/btcpay
-
Depsnbxplorer btcpay-db
-
Nginx path/app/btcpay/
-
TorHas its own .onion address for receiving payments privately
-
-
- -
-
fedimintarchy-net
-
Federated mint daemon. Enables community-run Bitcoin custody with threshold signing and e-cash tokens.
-
A way for a group of trusted people to collectively hold Bitcoin. No single person can steal the funds — you need a majority to approve.
-
fedimintd:v0.10.0
-
Ports: 8173 8174 8175
-
-
Port 8173P2P (federation member communication)
-
Port 8174API / WebSocket (client connections)
-
Port 8175Web UI (guardian dashboard)
-
Memory512 MB
-
Data/var/lib/archipelago/fedimint
-
Depsbitcoin-knots
-
Needed byfedi-gateway
-
-
- -
-
fedimint-gatewayarchy-net
-
Lightning bridge for Fedimint. Connects the federation to the Lightning Network for instant payments.
-
Connects your community mint to Lightning so federation members can send/receive instant payments.
-
gatewayd:v0.10.0
-
Ports: 8176
-
-
Memory512 MB
-
Data/var/lib/archipelago/fedimint-gateway
-
ModeAuto-detect: uses LND if available, otherwise built-in LDK Lightning
-
Depsbitcoin-knots fedimint
-
-
- -
-
immich_serverbridge
-
Self-hosted Google Photos replacement with AI-powered search, face detection, and automatic organization.
-
Like Google Photos but on your own hardware. Your photos never leave your box. AI finds faces and objects locally.
-
immich-server:release (optional)
-
Ports: 2283
-
-
Depsimmich_postgres immich_redis
-
Nginx path/app/immich/
-
-
- -
-
- - -
-
Tier 3 — Applications (independent, no cross-dependencies)
-
- -
-
archy-bitcoin-uiarchy-net
-
Custom Bitcoin node dashboard showing sync status, peer connections, and blockchain info.
-
A pretty dashboard for your Bitcoin node. See how synced you are, how many peers you have, block height.
-
bitcoin-ui:latest
-
Ports: 8334
-
-
Memory128 MB
-
Nginx path/app/bitcoin-ui/
-
-
- -
-
archy-lnd-uiarchy-net
-
Custom Lightning dashboard showing channels, balances, routing stats, and payment history.
-
Dashboard for your Lightning node. See your channels, balance, and recent payments at a glance.
-
lnd-ui:latest
-
Ports: 8081
-
-
Memory128 MB
-
Nginx path/app/lnd/
-
-
- -
-
archy-electrs-uihost
-
ElectrumX status dashboard showing sync progress, connected clients, and index health.
-
Shows whether the Electrum index is synced and healthy. How far behind it is, how many wallets are connected.
-
electrs-ui:latest
-
Ports: 50002
-
-
Memory128 MB
-
Networkhost (needs direct access to localhost:50001)
-
Nginx path/app/electrumx/
-
-
- -
-
homeassistantbridge
-
Open-source home automation platform. Control lights, sensors, cameras, and IoT devices from one dashboard.
-
Smart home control center. Turn lights on, check sensors, automate your house — all locally, no cloud needed.
-
home-assistant:2024.1
-
Ports: 8123
-
-
Memory512 MB
-
Data/var/lib/archipelago/home-assistant
-
Nginx path/app/homeassistant/
-
-
- -
-
grafanabridge
-
Monitoring and visualization platform. Dashboards for system metrics, Bitcoin stats, and container health.
-
Beautiful graphs and charts showing how your system is doing. CPU, memory, Bitcoin sync, everything visualized.
-
grafana:10.2.0
-
Ports: 3000
-
-
UID100472:100472 (grafana user)
-
Memory256 MB
-
Data/var/lib/archipelago/grafana
-
Read-onlyYes (tmpfs for /tmp, /run)
-
Nginx path/app/grafana/
-
-
- -
-
uptime-kumabridge
-
Self-hosted uptime monitor. Pings your services and alerts you when something goes down.
-
Watches all your apps and sends alerts if anything stops working. Like a security guard for your services.
-
uptime-kuma:1
-
Ports: 3001
-
-
Memory256 MB
-
Data/var/lib/archipelago/uptime-kuma
-
-
- -
-
jellyfinbridge
-
Self-hosted media server. Stream your movies, TV shows, and music from your own hardware.
-
Your own Netflix. Put movies on the box, watch them on any device. No subscription, no limits.
-
jellyfin:10.8.13
-
Ports: 8096
-
-
Memory1 GB
-
Data/var/lib/archipelago/jellyfin/{config,cache}
-
Transcodetmpfs /tmp (256MB, rw,exec)
-
Nginx path/app/jellyfin/
-
-
- -
-
photoprismbridge
-
AI-powered photo management. Automatic face recognition, location mapping, and smart search.
-
Photo organizer that uses AI to tag and sort your pictures. Find photos by searching "sunset" or "cat."
-
photoprism:240915
-
Ports: 2342
-
-
Memory1 GB (512 MB on low-memory)
-
Data/var/lib/archipelago/photoprism
-
-
- -
-
vaultwardenbridge
-
Bitwarden-compatible password manager. Store all your passwords encrypted, sync across devices.
-
Your personal password safe. Store every password securely and auto-fill them on your phone and computer.
-
vaultwarden:1.30.0-alpine
-
Ports: 8082
-
-
Memory256 MB
-
Data/var/lib/archipelago/vaultwarden
-
CapsCHOWN, SETUID, SETGID, NET_BIND_SERVICE
-
-
- -
-
nextcloudbridge
-
Self-hosted file sync and collaboration platform. Dropbox/Google Drive replacement with calendar, contacts, and office docs.
-
Your own Dropbox. Sync files, share documents, manage calendar and contacts — all on your own hardware.
-
nextcloud:29
-
Ports: 8085
-
-
Memory1 GB
-
Data/var/lib/archipelago/nextcloud
-
-
- -
-
searxngbridge
-
Privacy-respecting metasearch engine. Searches Google, Bing, DuckDuckGo and others without tracking you.
-
Private search engine. Searches the web without anyone tracking what you look for.
-
searxng:latest
-
Ports: 8888
-
-
Memory512 MB
-
Data/var/lib/archipelago/searxng
-
Read-onlyYes (tmpfs for /tmp, /run)
-
-
- -
-
onlyofficebridge
-
Self-hosted document editor. Edit Word, Excel, and PowerPoint files collaboratively in the browser.
-
Like Google Docs but on your own box. Edit spreadsheets and documents with others in real time.
-
onlyoffice:latest
-
Ports: 9980
-
-
Memory2 GB (1 GB on low-memory)
-
-
- -
-
ollamabridge
-
Local AI model runner. Run LLMs (like Llama, Mistral) entirely on your hardware, no cloud needed.
-
ChatGPT on your own box. Talk to AI privately — nothing you say leaves your machine.
-
ollama:latest (optional)
-
Ports: 11434
-
-
Memory4 GB (1 GB on low-memory)
-
Data/var/lib/archipelago/ollama
-
Read-onlyYes (tmpfs for /tmp, /run)
-
ProtocolREST API at :11434 (OpenAI-compatible)
-
-
- -
-
filebrowserbridge
-
Web-based file manager. Browse, upload, and download files through the browser.
-
A file explorer in your browser. Upload, download, and manage files on the box without SSH.
-
filebrowser:v2.27.0
-
Ports: 8083
-
-
Memory256 MB
-
Data/var/lib/archipelago/filebrowser (served), filebrowser-data (DB)
-
Read-onlyYes
-
Max upload10 GB (nginx limit)
-
-
- -
-
nginx-proxy-managerbridge
-
GUI for managing nginx proxy rules and SSL certificates. Point-and-click reverse proxy configuration.
-
A visual tool for routing web traffic. Point domains to services and manage HTTPS certificates with clicks, not config files.
-
nginx-proxy-manager:latest
-
Ports: 81 8084 8443
-
-
Port 81Admin dashboard
-
Port 8084HTTP proxy
-
Port 8443HTTPS proxy
-
Memory256 MB
-
-
- -
-
portainerbridge
-
Container management UI. Visual dashboard for Podman containers — start, stop, inspect, view logs.
-
Visual control panel for all your containers. See what's running, restart things, read logs — no terminal needed.
-
portainer:latest
-
Ports: 9000
-
-
Memory256 MB
-
SocketPodman socket mounted as Docker socket
-
-
- -
-
- - -
-
IndeedHub Stack — Nostr-based Social Platform
-
- -
-
indeedhub-minioarchy-net
-
S3-compatible object storage for IndeedHub media files (images, videos, attachments).
-
File storage for IndeedHub. When someone posts an image, it lives here.
-
minio:RELEASE.2024-11-07
-
-
Needed byindeedhub-api
-
-
- -
-
indeedhub-apiarchy-net
-
IndeedHub backend API. Handles Nostr events, user profiles, media uploads, and relay communication.
-
The engine behind IndeedHub. Processes posts, handles user accounts, talks to Nostr relays.
-
indeedhub-api (custom build)
-
-
Depspostgres redis minio
-
Needed byindeedhub
-
-
- -
-
indeedhub-ffmpegarchy-net
-
Video transcoding worker for IndeedHub. Converts uploaded videos to web-friendly formats.
-
Converts videos so they play smoothly in the browser. Like a video format translator.
-
indeedhub-ffmpeg (custom build)
-
- -
-
indeedhub-relayarchy-net
-
Nostr relay for IndeedHub. Stores and distributes Nostr events (posts, follows, reactions).
-
A message board that stores Nostr posts. Other Nostr apps can connect here to read and post.
-
indeedhub-relay (custom build)
-
- -
-
indeedhubarchy-net
-
IndeedHub web frontend. Nostr-based social media client with feeds, profiles, messaging, and media.
-
The social media app itself. Post, follow people, send messages, share media — all on the Nostr protocol.
-
indeedhub-frontend (custom build)
-
Ports: 7777
-
-
Nginx path/app/indeedhub/
-
WebSocketYes (for real-time updates)
-
Depsindeedhub-api
-
-
- -
-
- - -
-
Penpot Stack — Design Tool
-
- -
-
penpot-backendbridge
-
Penpot application server. Handles design data, real-time collaboration, and file storage.
-
The engine behind the design tool. Saves your designs and lets multiple people edit at the same time.
-
penpot-backend:2.4
-
-
Memory512 MB
-
Depspenpot-postgres penpot-valkey
-
-
- -
-
penpot-exporterbridge
-
Renders Penpot designs to PDF, SVG, and image formats for export.
-
Turns your designs into downloadable files — PDFs, images, SVGs.
-
penpot-exporter:2.4
-
-
Memory256 MB
-
Depspenpot-backend
-
-
- -
-
penpot-frontendbridge
-
Penpot web UI. Open-source Figma alternative with vector editing, prototyping, and collaboration.
-
Your own Figma. Design interfaces, create prototypes, collaborate — completely self-hosted.
-
penpot-frontend:2.4
-
Ports: 9001
-
-
Memory256 MB
-
Nginx path/app/penpot/
-
Depspenpot-backend
-
-
- -
-
- -
-
archy-net (internal DNS, Bitcoin stack)
-
bridge (standalone, port-mapped)
-
host (direct network access)
-
Dimmed = optional / not always installed
-
-
- - - - -
- -
- -
-
JSON-RPC 2.0
-
Primary protocol between the web UI and the Rust backend. All commands are RPC calls.
-
Like texting the backend: you send a message ("please start this app"), it texts back ("done" or "error").
-
- Endpoint: POST /rpc/v1
- Format: {"jsonrpc":"2.0","method":"package.start","params":{"id":"bitcoin-knots"},"id":1}
- Auth: Session cookie + CSRF token header
- Timeout: 15s default
- Retry: 3 attempts with exponential backoff on 502/503
- Rate limit: 20 req/s (burst 40)
- Used by: Vue.js frontend → Rust backend -
-
- -
-
WebSocket
-
Real-time bidirectional channel for live updates — container status changes, logs, events.
-
An open phone line between your browser and the server. Instead of asking "any updates?" every second, the server just tells you when something changes.
-
- Endpoint: WS /ws (HTTP upgrade)
- Auth: Session cookie
- Read timeout: 86,400s (24 hours)
- Events: Container state changes, log streams, system alerts
- Used by: Vue.js frontend ↔ Rust backend -
-
- -
-
Bitcoin RPC (JSON-RPC 1.0)
-
How apps talk to the Bitcoin node. Authenticated with username + HMAC-hashed password.
-
The language apps use to ask Bitcoin questions: "what's the current block?" or "send this transaction."
-
- Endpoint: bitcoin-knots:8332 (inside archy-net)
- Auth: HTTP Basic with rpcauth hash (HMAC-SHA256, no plaintext)
- Methods: getblockchaininfo, getmempoolinfo, sendrawtransaction, etc.
- Timeout: 10s default, 30s for heavy ops
- Used by: ElectrumX, LND, Mempool, NBXplorer, Fedimint → Bitcoin Knots -
-
- -
-
gRPC
-
High-performance RPC protocol used by LND for admin operations. Binary format, strongly typed.
-
A fast, structured way for apps to control the Lightning node. More efficient than regular HTTP for complex operations.
-
- Endpoint: lnd:10009
- Auth: Macaroon tokens (read-only for queries, admin for mutations)
- TLS: Self-signed certificate (auto-generated)
- Methods: OpenChannel, SendPayment, GetInfo, ListChannels, etc.
- Used by: Fedimint Gateway, LND UI → LND -
-
- -
-
Electrum Protocol
-
Lightweight protocol for wallet address lookups. JSON-RPC over raw TCP sockets.
-
How Bitcoin wallets check their balance without downloading the entire blockchain. Ask "what transactions touched this address?" and get an instant answer.
-
- Endpoint: electrumx:50001 (TCP)
- Format: Newline-delimited JSON-RPC
- Methods: blockchain.scripthash.get_balance, blockchain.transaction.get, etc.
- Used by: Wallets (Sparrow, Electrum), Mempool API → ElectrumX -
-
- -
-
ZMQ (ZeroMQ)
-
Publish-subscribe messaging from Bitcoin node. Instant notifications for new blocks and transactions.
-
A broadcasting system. When a new Bitcoin block is found, Bitcoin instantly shouts it out and everyone listening hears immediately.
-
- Endpoints:
- • tcp://bitcoin-knots:28332 — New block hashes (hashblock)
- • tcp://bitcoin-knots:28333 — New raw transactions (rawtx)
- Pattern: PUB/SUB (publisher/subscriber)
- Subscribers: LND, Mempool, ElectrumX -
-
- -
-
Tor (SOCKS5 + Hidden Services)
-
Privacy layer. Routes Bitcoin P2P through onion routing, exposes services as .onion addresses.
-
Like sending a letter through 3 random post offices so nobody knows where it came from. Also lets people reach your node without knowing your real IP.
-
- SOCKS5 proxy: 127.0.0.1:9050
- Container access: host.containers.internal:9050
- Hidden services: Web UI, LND, BTCPay, Mempool, Fedimint
- Managed by: System Tor daemon + tor-helper.sh (privileged helper)
- Used by: Bitcoin P2P, LND P2P, BTCPay invoices -
-
- -
-
Nostr (NIP-01)
-
Decentralized social protocol. WebSocket-based relay communication for events (posts, follows, messages).
-
A social media protocol where no company controls the network. Your posts live on relays, and you own your identity with a cryptographic key.
-
- Transport: WebSocket (WSS)
- Format: JSON events signed with secp256k1 keys
- Relay: IndeedHub relay (local), configurable external relays
- Integration: nostr-provider.js injected into all app iframes
- Identity: DID-based, linked to node Ed25519 keypair -
-
- -
-
DWN (Decentralized Web Node)
-
W3C protocol for storing encrypted data and messages in a decentralized way. Identity-linked storage.
-
A personal data vault. Apps can store data here that only you control. Like a safety deposit box that follows you across the internet.
-
- Endpoint: /dwn (proxied through nginx)
- Auth: Per-record DID-based permissions
- Reachable via: Tor hidden service
- Used for: Encrypted backups, cross-node messaging, app data sync -
-
- -
-
- - - - -
- -
-
2
DID Methods
-
26+
Identity RPCs
-
W3C 2.0
VC Spec
-
Ed25519
Primary Key
-
DWN
Data Store
-
Dual Key
Ed25519 + secp256k1
-
- -
- -
-
- Applications - Layer 5 · UI -
-
Vue.js views for identity management, credential issuance, DWN dashboard, and quick actions
-
The screens where you manage your digital identity, issue credentials, and control your personal data store.
-
-

Key Views

-
    -
  • Web5Identities.vue — Create/manage identities (Personal, Business, Anonymous purposes)
  • -
  • Web5CredentialsSummary.vue — View issued/held credentials with status badges
  • -
  • Web5DWN.vue — DWN status, protocol registration, message browser
  • -
  • Web5QuickActions.vue — Copy DID, publish to DHT, trigger sync
  • -
-

Data Types (TypeScript)

-
    -
  • ManagedIdentity — id, name, purpose, did, pubkey, nostr_pubkey, profile
  • -
  • VCData — id, issuer, subject, type, claims, status (active/revoked/expired)
  • -
  • DwnStatusData — running, sync_status, message_count, registered_protocols
  • -
-
-
- -
-
- Verifiable Credentials (W3C VC 2.0) - Layer 4 · Trust -
-
Issue, verify, revoke, and present credentials with Ed25519Signature2020 proofs — W3C VC Data Model 2.0
-
Digital certificates that prove things about you — signed by one identity, held by another, verified by anyone. Like a digitally signed diploma.
-
-

Three-Party Model

-
    -
  • Issuer: Creates and signs the credential (any managed identity)
  • -
  • Holder: Stores credentials, creates Verifiable Presentations
  • -
  • Verifier: Checks signature + expiration + revocation status
  • -
-

Credential Structure

-
    -
  • @context: W3C Credentials v2 + Ed25519 signature suite
  • -
  • type: ["VerifiableCredential", "CustomType"]
  • -
  • issuer: did:key or did:dht
  • -
  • credentialSubject: { id: did, claims: {...} }
  • -
  • proof: Ed25519Signature2020 with verification method reference
  • -
  • credentialStatus: CredentialStatusList2021 for revocation
  • -
-

Verifiable Presentations

-
    -
  • Bundle one or more VCs with holder's own signature
  • -
  • Proof purpose: authentication (vs. assertionMethod for VCs)
  • -
  • Selective disclosure — present only relevant credentials
  • -
-

RPC Methods

-
    -
  • identity.issue-credential — Issue from any managed identity
  • -
  • identity.verify-credential — Verify by credential ID
  • -
  • identity.list-credentials — List with optional filtering
  • -
-

Storage

-
    -
  • /var/lib/archipelago/credentials/store.json
  • -
-
-
- -
-
- Decentralized Web Node (DWN) - Layer 3 · Storage -
-
Personal data store with protocol-governed records, peer sync over Tor, and DID-based authorization
-
Your personal database that YOU own. Apps ask permission to read/write data. Syncs with trusted peers automatically over Tor.
-
-

Records Interface

-
    -
  • Records.Write — Store a message (UUID-based record_id)
  • -
  • Records.Read — Retrieve by record_id
  • -
  • Records.Query — Filter by protocol, schema, author, date range
  • -
  • Records.Delete — Remove record
  • -
-

Protocol Definitions

-
    -
  • Declarative rule sets governing data structure and access permissions
  • -
  • types: Define allowed dataFormats and optional schema URIs
  • -
  • structure: Hierarchical — records can have child records (post → comment)
  • -
  • $actions: Who can create/read/update/delete (anyone, author, recipient)
  • -
  • Registered via dwn.register-protocol RPC, enforced automatically
  • -
-

Peer Sync

-
    -
  • Bidirectional sync with trusted peers over Tor SOCKS5 proxy (127.0.0.1:9050)
  • -
  • Deduplication by record_id, batched (200 messages per sync)
  • -
  • 30s per-peer timeout, 90s total timeout
  • -
  • State persisted to /var/lib/archipelago/dwn/sync_state.json
  • -
  • Triggered manually or via background task
  • -
-

HTTP API

-
    -
  • Endpoint: POST /dwn (proxied through nginx)
  • -
  • Reachable remotely via Tor hidden service
  • -
-

RPC Methods (8)

-
    -
  • dwn.status — Running state, sync status, message count
  • -
  • dwn.sync — Trigger background sync with trusted peers
  • -
  • dwn.register-protocol / dwn.list-protocols / dwn.remove-protocol
  • -
  • dwn.write-message / dwn.query-messages / dwn.read-message / dwn.delete-message
  • -
-

Storage

-
    -
  • Messages: /var/lib/archipelago/dwn/messages/{record_id}.json
  • -
  • Protocols: /var/lib/archipelago/dwn/protocols/{protocol_uri}.json
  • -
-
-
- -
-
- Decentralized Identifiers (DIDs) - Layer 2 · Identity -
-
W3C DID Core 1.0 — did:key (primary, offline-capable) and did:dht (discoverability via BitTorrent Mainline DHT)
-
Your self-sovereign digital identity. No company issues it, no platform controls it. You prove who you are with cryptographic keys.
-
-

did:key (Primary Method)

-
    -
  • Self-contained — no external resolution, works fully offline
  • -
  • Format: did:key:z6Mk... (multicodec Ed25519 in base58btc)
  • -
  • Instant, zero-cost, no network dependency
  • -
  • Used for: VCs, federation trust, backup encryption, DWN signing
  • -
  • Cannot be rotated — key is the identifier
  • -
-

did:dht (Discovery Method)

-
    -
  • Publishes DID Document to BitTorrent Mainline DHT via BEP-44 signed mutable items
  • -
  • Format: did:dht:z... (z-base-32 encoded Ed25519 pubkey)
  • -
  • Globally discoverable without any centralized registry
  • -
  • DID Document encoded as DNS Resource Records in DNS packet
  • -
  • Supports key rotation (increment sequence number, republish)
  • -
  • 1-hour TTL cache for performance
  • -
  • Replaced did:ion (Bitcoin-anchored) — simpler, no full node required
  • -
-

DID Document (W3C Core 1.0)

-
    -
  • verificationMethod: Ed25519VerificationKey2020 + derived X25519KeyAgreementKey2020
  • -
  • authentication, assertionMethod, capabilityInvocation, capabilityDelegation
  • -
  • keyAgreement: X25519 (derived from Ed25519 via Curve25519)
  • -
  • Optional EcdsaSecp256k1VerificationKey2019 for Nostr interop
  • -
  • Service endpoints: DWN URL, Nostr relay list
  • -
-

Multi-Identity Manager

-
    -
  • Users create multiple identities with purpose tags: Personal, Business, Anonymous
  • -
  • One default identity (marked with star in UI)
  • -
  • Each identity: Ed25519 key + optional Nostr secp256k1 key + optional NIP-01 profile
  • -
  • Stored as JSON in /var/lib/archipelago/identities/{id}.json
  • -
-

Identity RPC Methods (26+)

-
    -
  • identity.create / .list / .get / .delete / .set-default
  • -
  • identity.sign / .verify — Ed25519 message signing
  • -
  • identity.resolve-did / .verify-did-document
  • -
  • identity.create-dht-did / .resolve-dht-did / .refresh-dht-did
  • -
  • identity.create-nostr-key / .nostr-sign
  • -
  • identity.nostr-encrypt-nip04 / .nostr-decrypt-nip04
  • -
  • identity.nostr-encrypt-nip44 / .nostr-decrypt-nip44
  • -
  • identity.update-profile / .resolve-remote-did
  • -
-
-
- -
-
- Cryptographic Keys - Layer 1 · Foundation -
-
Dual key architecture — Ed25519 for Web5/DIDs + secp256k1 for Bitcoin/Nostr, both derived from BIP-39 master seed
-
Two types of cryptographic keys derived from one master seed. One for identity (Web5), one for money and social (Bitcoin/Nostr).
-
-

Ed25519 (Web5 & Identity)

-
    -
  • W3C DIDs, Verifiable Credentials (Ed25519Signature2020)
  • -
  • DWN message signing and authorization
  • -
  • Federation peer authentication and trust
  • -
  • Backup encryption (via derived X25519 key agreement)
  • -
  • Storage: /var/lib/archipelago/identity/node_key (32 bytes raw)
  • -
-

secp256k1 (Bitcoin & Nostr)

-
    -
  • Nostr event signing (NIP-01), encrypted DMs (NIP-04, NIP-44)
  • -
  • Lightning Network node identity
  • -
  • Social presence and discovery (NIP-05, kind 30078)
  • -
  • Format: hex pubkey + Nostr npub (NIP-19 bech32)
  • -
-

Key Derivation

-
    -
  • Single BIP-39 master seed (12 or 24 word mnemonic)
  • -
  • Deterministic derivation of both Ed25519 and secp256k1 keys
  • -
  • All keys recoverable from seed phrase alone
  • -
  • Seed generated at first boot, stored on LUKS-encrypted partition
  • -
-

Rust Dependencies

-
    -
  • ed25519-dalek 2.2.0 — Ed25519 signatures
  • -
  • curve25519-dalek 4.1.3 — X25519 key agreement (Ed25519 → X25519 conversion)
  • -
  • nostr-sdk 0.44 — secp256k1 signing, NIP-04/44 encryption
  • -
  • mainline 2 — BitTorrent Mainline DHT client (did:dht)
  • -
  • zbase32 0.1 — z-base-32 encoding for DID identifiers
  • -
-
-
- -
- -

Specification Status

-

- Web5 was initiated by TBD (Block/Jack Dorsey) and shut down November 2024. Open-source components were donated to the Decentralized Identity Foundation (DIF). - The W3C specs (DIDs, VCs) are independent standards with broad industry adoption. Archipelago implements these W3C standards directly with a custom DWN — not dependent on TBD's SDK. -

- - - - - - - - - -
ComponentSpecStatusArchipelago
DID Core 1.0W3C RecommendationStabledid:key + did:dht, full DID Document generation
VC Data Model 2.0W3C Recommendation (May 2025)StableIssue/verify/revoke with Ed25519Signature2020
DWNDIF DraftDraftCustom Records interface, protocol management, Tor sync
did:dhtNear v1.0ActiveMainline DHT publishing via mainline crate
did:ion (Sidetree)DIF 1.0AbandonedNot implemented — requires Bitcoin + IPFS full nodes
Presentation Exchange 2.0DIF RatifiedStableVerifiable Presentations with holder proof
- -

Architecture Decision Records

- - - - - -
ADRDecisionRationale
ADR-002did:key as primary DID methodSelf-contained, offline-capable, zero-cost, aligns with sovereignty
ADR-008Dual key architecture (Ed25519 + secp256k1)Ed25519 for W3C/Web5, secp256k1 for Bitcoin/Lightning/Nostr ecosystems
ADR-011Custom DWN, not full W3C spec complianceTBD shut down, DWN spec stalled. Federation + Nostr relays prioritized for peer sync
- -
- - - - -
- -
-
-
1
-
-
BIOS / UEFI → GRUB Bootloader
-
Firmware loads GRUB from EFI partition or BIOS boot sector. GRUB loads the Linux kernel.
-
The computer turns on and finds the operating system to start. Works on both old and new machines.
-
GRUB installed for both UEFI (EFI partition) and legacy BIOS (1MB boot sector) — dual-boot compatible
-
-
- -
-
2
-
-
LUKS Unlock → Mount Encrypted Partition
-
Cryptsetup opens the LUKS2 volume using /root/.luks-archipelago.key and mounts it at /var/lib/archipelago.
-
The encrypted safe is unlocked automatically (no password prompt). All your data becomes accessible.
-
Configured in /etc/crypttab for automatic boot-time unlock
-
-
- -
-
3
-
-
systemd Starts → Network Online
-
systemd boots Debian, starts networking, reaches network-online.target. Tor and Tailscale start.
-
The operating system finishes starting up and connects to the internet.
-
-
- -
-
4
-
-
Archipelago Backend Starts
-
archipelago.service launches the Rust binary. Runs crash recovery, starts container state snapshots, initializes JSON-RPC server on :5678.
-
The brain of the system wakes up. If there was a crash last time, it automatically recovers.
-
Type=notify, WatchdogSec=300s, MemoryMax=4G
-
-
- -
-
5
-
-
First Boot: Container Creation
-
first-boot-containers.sh runs once (guarded by marker file). Creates all containers in tier order: databases → Bitcoin → services → apps.
-
On first startup only: installs all the apps. Databases first, then Bitcoin, then everything else. Takes 5-15 minutes.
-
ConditionPathExists=!/var/lib/archipelago/.first-boot-containers-done · Timeout: 900s
-
-
- -
-
6
-
-
Nginx Ready → Web UI Accessible
-
Nginx serves the Vue.js SPA on :80/:443. Backend health check at /health passes.
-
The website is now live. Open a browser and go to the machine's IP address to see the dashboard.
-
-
- -
-
7
-
-
Kiosk Starts (if enabled)
-
archipelago-kiosk.service waits for /health endpoint (up to 30s), then starts X11 + Chromium on VT7.
-
If a monitor is plugged in, the dashboard appears fullscreen automatically. No login needed for the local screen.
-
Polls /health 15 times at 2s intervals before launching Chromium
-
-
- -
-
8
-
-
Background Services Start
-
Timers activate: container doctor (health repair), reconciler (spec enforcement), self-update. Tor helper watches for service config changes.
-
Maintenance robots start working in the background. They check on apps, fix broken ones, and keep everything updated.
-
archipelago-doctor.timer, archipelago-reconcile.timer, archipelago-update.timer, archipelago-tor-helper.path
-
-
-
-
- - - - -
- -
- -
-
LUKS2 Full-Disk Encryption
-
All user data on an encrypted partition. Auto-detects AES-NI for hardware acceleration, falls back to ChaCha20-Adiantum. Key derived with Argon2id (GPU-resistant).
-
If someone physically steals your hard drive, all they get is encrypted noise. The data is useless without the key.
-
- -
-
Rootless Containers
-
All containers run as unprivileged user archipelago (UID 1000). Even a compromised container cannot escalate to root. UID remapping isolates container users from host users.
-
Apps run in sealed boxes without admin access. A hacked app can't take over the whole machine.
-
- -
-
Capability Dropping
-
--cap-drop=ALL then add only specific capabilities needed. --security-opt=no-new-privileges prevents privilege escalation inside containers.
-
Each app only gets the exact permissions it needs, nothing more. Like giving a valet driver only the car key, not your house keys.
-
- -
-
RBAC (Role-Based Access Control)
-
Backend uses explicit method allowlists per role. No prefix matching — each RPC method must be explicitly permitted. Session cookies are HttpOnly, SameSite=Lax.
-
Different users have different permissions. A viewer can't install apps, and nobody can run commands that aren't on the approved list.
-
- -
-
Rate Limiting
-
Nginx rate limits: auth endpoints (3/s), RPC (20/s), P2P (10/s). Prevents brute-force attacks and API abuse.
-
If someone tries to guess your password by trying thousands of combinations, they get locked out after a few attempts per second.
-
- -
-
Tor Privacy
-
Bitcoin P2P routes through Tor with stream isolation. Hidden services expose node without revealing IP. LND uses Tor for Lightning P2P.
-
Your Bitcoin node connects through the Tor privacy network. Nobody can see your real IP address or location.
-
- -
-
Credential Management
-
Secrets auto-generated at first boot (CSPRNG). Stored in /var/lib/archipelago/secrets/ (mode 700). Bitcoin RPC uses HMAC-SHA256 auth hashes, never plaintext.
-
Passwords are randomly generated and stored securely. They never appear in config files as readable text.
-
- -
-
Memory Limits & Health Checks
-
Every container has a memory limit (128MB–4GB). Health checks auto-restart failed containers. Backend has systemd watchdog (300s).
-
Runaway apps can't eat all the memory. Crashed apps restart automatically. The system self-heals.
-
- -
-
Security Headers
-
CSP (Content Security Policy), HSTS, X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, strict Referrer-Policy, disabled camera/mic/geolocation.
-
The web UI tells browsers to follow strict security rules — no loading scripts from unknown sites, no accessing your camera.
-
- -
-
Systemd Hardening
-
ProtectSystem=strict, MemoryDenyWriteExecute, RestrictRealtime, RestrictAddressFamilies. Backend can only write to approved paths.
-
The operating system restricts what the backend can do. It can only touch the files it needs to, nothing else on the system.
-
- -
-
- - - - -
- -
/var/lib/archipelago/ ← LUKS2 encrypted partition - ├── bitcoin/ Bitcoin blockchain data (~500GB full, ~550MB pruned) - ├── lnd/ Lightning wallet, channels, macaroons, TLS cert - ├── electrumx/ Address index database - ├── postgres-btcpay/ BTCPay PostgreSQL data - ├── mysql-mempool/ Mempool MariaDB data - ├── mempool/ Mempool backend cache - ├── btcpay/ BTCPay server data, plugins - ├── nbxplorer/ NBXplorer blockchain scan state - ├── fedimint/ Federation data, consensus state - ├── fedimint-gateway/ Gateway keys and routing table - ├── home-assistant/ Smart home config, automations, database - ├── grafana/ Dashboards, datasources, alerting rules - ├── uptime-kuma/ Monitor definitions, status history - ├── jellyfin/ Media library metadata, transcoding cache - ├── photoprism/ Photo index, thumbnails, AI models - ├── ollama/ Downloaded LLM models (can be multi-GB) - ├── vaultwarden/ Encrypted password vault database - ├── nextcloud/ Files, calendar, contacts, config - ├── searxng/ Search engine settings - ├── filebrowser/ Served files (user uploads) - ├── filebrowser-data/ FileBrowser internal database - ├── nginx-proxy-manager/ Proxy rules, Let's Encrypt certificates - ├── portainer/ Portainer config and database - ├── tailscale/ VPN state, node identity - ├── secrets/ RPC passwords, DB passwords (mode 700) - ├── identity/ Node Ed25519 keypair (DID identity) - ├── identities/ User DIDs - ├── tor-config/ Tor service definitions (backend-managed) - ├── tor-hostnames/ .onion addresses (synced from /var/lib/tor) - └── .first-boot-containers-done Marker: first boot completed - -/opt/archipelago/ ← Unencrypted (on root partition) - ├── web-ui/ Vue.js SPA (static files served by nginx) - ├── scripts/ Deploy, container, and maintenance scripts - └── image-versions.sh Pinned container image versions - -/usr/local/bin/archipelago Rust backend binary -/etc/nginx/ Nginx config (reverse proxy rules) -/etc/tor/torrc Tor daemon configuration
- -
- - - - -
- -
- -
-
Podman
-
Daemonless container engine (OCI-compatible, Docker alternative)
-
A tool that runs apps in isolated sandboxes. Like Docker but doesn't need a background service running as root.
-
- -
-
Rootless
-
Containers run entirely in user namespace, no root privileges required
-
The sandboxes run without admin access. Even if someone breaks into one, they can't take over the system.
-
- -
-
LUKS2
-
Linux Unified Key Setup v2 — dm-crypt disk encryption with Argon2 KDF
-
Industry-standard disk encryption for Linux. Scrambles the entire partition so data is unreadable without the key.
-
- -
-
Argon2id
-
Memory-hard password hashing function resistant to GPU/ASIC brute-force
-
A way to protect passwords that requires lots of memory to crack, making it extremely expensive to brute-force even with specialized hardware.
-
- -
-
Nginx
-
High-performance HTTP reverse proxy and web server
-
The front door of the system. All web traffic goes through nginx, which directs each request to the right app.
-
- -
-
Reverse Proxy
-
Server that forwards client requests to backend services based on URL path or hostname
-
A traffic cop for web requests. You visit one address, and the proxy routes you to the right app behind the scenes.
-
- -
-
JSON-RPC
-
Remote procedure call protocol using JSON over HTTP/TCP
-
A simple way for one program to ask another to do something. Send a JSON message, get a JSON reply.
-
- -
-
WebSocket
-
Full-duplex TCP communication channel over HTTP upgrade
-
A persistent connection between browser and server. Instead of repeatedly asking "anything new?", the server pushes updates instantly.
-
- -
-
gRPC
-
Google's high-performance RPC framework using Protocol Buffers over HTTP/2
-
A fast, structured way for programs to communicate. Used by LND because it handles many Lightning operations efficiently.
-
- -
-
ZMQ (ZeroMQ)
-
Asynchronous messaging library for pub/sub and push/pull patterns
-
A broadcasting system. Bitcoin publishes "new block!" and every subscribed app hears it instantly.
-
- -
-
Tor
-
Onion routing network for anonymous communication via encrypted relay circuits
-
A privacy network that bounces your traffic through multiple servers so nobody can trace it back to you.
-
- -
-
Hidden Service (.onion)
-
Tor service accessible via .onion address without revealing server IP
-
A way to make your node reachable on the internet without revealing your IP address or location.
-
- -
-
Tailscale
-
WireGuard-based mesh VPN with NAT traversal and SSO integration
-
A private tunnel to your node from anywhere. Like a VPN but easier — install the app on your phone, and you can access the node from a coffee shop.
-
- -
-
Macaroon
-
Bearer token with embedded caveats (permissions) used by LND for API auth
-
A special key for LND that says exactly what you're allowed to do. A "read-only" macaroon can check balance but can't send money.
-
- -
-
CSRF Token
-
Cross-Site Request Forgery prevention token sent in cookie + header
-
A secret code that proves your browser request is genuine and not a trick from a malicious website.
-
- -
-
DID (Decentralized Identifier)
-
W3C standard for self-sovereign identity using cryptographic keypairs
-
Your digital identity that you own completely. Like a passport that no government issued — you prove who you are with math, not authority.
-
- -
-
Nostr
-
Notes and Other Stuff Transmitted by Relays — decentralized social protocol
-
A social media protocol where you own your identity. No company can ban you because your account is just a cryptographic key.
-
- -
-
Lightning Network
-
Bitcoin Layer 2 payment channel network for instant, low-fee transactions
-
A way to send Bitcoin instantly (milliseconds) for tiny fees. Works by opening "tabs" between nodes and settling on-chain later.
-
- -
-
Fedimint
-
Federated Bitcoin custody protocol with threshold signing and Chaumian e-cash
-
A community Bitcoin bank where a group of trusted guardians hold funds together. No single person can steal — you need a majority to sign.
-
- -
-
archy-net
-
Custom Podman bridge network with DNS resolution for Bitcoin-stack containers
-
A private network inside the box where Bitcoin apps can find each other by name. Like a local phone book for containers.
-
- -
-
Capability (CAP)
-
Fine-grained Linux privilege (e.g. CAP_NET_RAW, CAP_CHOWN) instead of full root
-
Instead of giving an app all admin powers, we give it only the specific abilities it needs. A file manager gets "change file ownership" but not "change network settings."
-
- -
-
Systemd
-
Linux init system and service manager (PID 1)
-
The thing that starts everything when Linux boots. Manages all services, restarts crashed ones, and enforces resource limits.
-
- -
-
RBAC
-
Role-Based Access Control — permissions assigned by user role, not individually
-
Different users get different permissions based on their role (admin, viewer, etc). Prevents regular users from doing dangerous things.
-
- -
-
- - - - -
- -

Architecture analysis sourced from Start9Labs/start-os on GitHub (master branch). Click any layer to expand.

- -
-
LXC
Container Runtime
-
Rust
Backend (startd)
-
Angular 21
Frontend
-
S9PK v2
Package Format
-
Optional
LUKS Encryption
-
btrfs
Filesystem
-
- -
-
Angular 21 + Taiga UI 5UI
Three Angular apps: admin UI, setup wizard, VPN management. Patch-DB reactive sync via CBOR diffs over WebSocket.
-
Rust Backend (startd / startbox)Service
Single binary with 5 personalities (symlinks). Built-in reverse proxy (Axum), DNS (hickory-server), ACME, WireGuard, SOCKS5.
-
LXC ContainersIsolation
Two-layer model: outer LXC per service (SquashFS + OverlayFS), inner subcontainers from S9PK images. JSON-RPC over Unix sockets.
-
Network (built-in)Network
VHostController reverse proxy, hickory-server DNS, ACME TLS, WireGuard tunnels, SOCKS5 at 10.0.3.1:1080. No nginx/caddy.
-
Optional LUKS on btrfsSecurity
User chooses encrypted or unencrypted during setup. LVM with btrfs for COW snapshots enabling safe app installs.
-
Debian BookwormOS
Same Debian 12 base. Targets x86_64, ARM64 (aarch64), and RISC-V (riscv64).
-
- -
- - - - -
-
- -
-
- Web UI — Angular 21 - Application -
-
Angular 21 + TypeScript + Taiga UI 5 components, served directly by the Rust backend (Axum)
-
The dashboard you use in your browser. Built with Angular (Google's web framework), not served by a separate web server.
-
-

Three Separate Angular Apps

-
    -
  • projects/ui/ — Main admin interface
  • -
  • projects/setup-wizard/ — Initial setup flow
  • -
  • projects/start-tunnel/ — VPN management UI
  • -
-

State Management

-
    -
  • Patch-DB: Backend pushes CBOR diffs over WebSocket
  • -
  • Frontend applies diffs and notifies observers via PatchDB.watch$()
  • -
  • Converted to Angular signals via toSignal()
  • -
  • Reactive — UI updates automatically when backend state changes
  • -
-

Communication

-
    -
  • JSON-RPC exclusively (not REST)
  • -
  • ApiService abstract class with 100+ methods
  • -
  • i18n: 5 languages (en, es, de, fr, pl)
  • -
-
-
- -
-
- Rust Backend — startd (startbox) - Service -
-
Single Rust binary (multi-personality via symlinks: startd, start-cli, start-container, registrybox, tunnelbox)
-
The brain of the system. One binary that does everything — serves the UI, manages containers, handles networking, runs the built-in reverse proxy.
-
-

Key Components

-
    -
  • Axum web server: Serves UI + JSON-RPC API (no separate web server)
  • -
  • VHostController: Built-in reverse proxy with TLS termination (no nginx/caddy)
  • -
  • Patch-DB: Custom CBOR-encoded reactive database with diff-based WebSocket sync
  • -
  • LxcManager: Container lifecycle (create, destroy, garbage collection)
  • -
  • NetController: DNS (hickory-server), SOCKS5, ACME, WiFi, WireGuard, port forwarding
  • -
  • Service Actors: Per-service state machines managing lifecycle
  • -
-

Binary Personalities (symlinks)

-
    -
  • startd — Main daemon
  • -
  • start-cli — CLI interface
  • -
  • start-container — Runs inside LXC containers, communicates with host
  • -
  • registrybox — Package registry daemon
  • -
  • tunnelbox — WireGuard VPN tunnel daemon
  • -
-

Key Dependencies

-
    -
  • Async: Tokio · Web: Axum 0.8 + Hyper 1.5 · TLS: tokio-rustls 0.26 + OpenSSL (vendored)
  • -
  • DNS: hickory-server · Crypto: blake3, ed25519, x25519-dalek, aes
  • -
  • TypeScript bindings: ts-rs (auto-generates TS types from Rust structs)
  • -
-

Systemd

-
    -
  • startd.service: Type=simple, Restart=always, RestartSec=3
  • -
  • LimitNOFILE=65536
  • -
-
-
- -
-
- Container Layer — LXC - Isolation -
-
Linux Containers (LXC) with two-layer model: outer LXC per service + inner subcontainers from S9PK images
-
Each app gets its own sealed Linux environment. Unlike Docker, these are full system containers with their own init process.
-
-

Two-Layer Container Model

-
    -
  • Outer LXC container: One per service. Created by Rust backend via lxc-create/destroy
  • -
  • Base rootfs: SquashFS image (/usr/lib/startos/container-runtime/rootfs.squashfs) mounted as OverlayFS
  • -
  • Inner subcontainers: Node.js container runtime inside each LXC can launch additional containers from S9PK-bundled images
  • -
  • Timeout: 30-second container creation timeout
  • -
-

LXC Configuration

-
    -
  • User namespaces: lxc.idmap = u 0 100000 65536
  • -
  • AppArmor profile: generated with nesting allowed
  • -
  • Network: veth bridge on lxcbr0 (10.0.3.x subnet, host at 10.0.3.1)
  • -
  • OverlayFS rootfs (base read-only squashfs, writes to overlay)
  • -
  • GPU passthrough support: /dev/dri, /dev/nvidia*, /dev/kfd
  • -
-

Communication

-
    -
  • JSON-RPC over Unix domain sockets
  • -
  • /media/startos/rpc/service.sock — Inbound (runtime listens)
  • -
  • /media/startos/rpc/host.sock — Host callbacks (effects)
  • -
-
-
- -
-
- Network Layer - Network -
-
Built-in reverse proxy (Axum/Hyper), DNS (hickory-server), SOCKS5, ACME (Let's Encrypt), WireGuard tunnels
-
No nginx or caddy — the Rust backend IS the web server, proxy, and DNS. Also manages VPN tunnels and encryption certificates.
-
-

Built-in Reverse Proxy

-
    -
  • VHostController in core/src/net/vhost.rs handles all HTTP routing
  • -
  • TLS termination via tokio-rustls with SNI-based routing
  • -
  • No external proxy software (no nginx, no caddy, no traefik)
  • -
  • Virtual hosting with per-service domain assignment
  • -
-

DNS

-
    -
  • Built-in DNS server using hickory-server (formerly trust-dns)
  • -
  • Service discovery and resolution for containers
  • -
  • mDNS via avahi-resolve-host-name for .local domains
  • -
-

TLS / Certificates

-
    -
  • Self-signed root CA per server (NIST P-256 via OpenSSL)
  • -
  • Built-in ACME client (async-acme) for Let's Encrypt with TLS-ALPN-01 challenge
  • -
  • Certificate store managed in Patch-DB
  • -
-

Connectivity

-
    -
  • SOCKS5 proxy: Built-in at 10.0.3.1:1080 for container outbound traffic
  • -
  • WireGuard: First-class support via wg-quick + x25519-dalek
  • -
  • Multi-gateway: Supports multiple interfaces (Ethernet, WiFi, WireGuard) with separate domain configs
  • -
  • Port forwarding: iptables-based via InterfacePortForwardController
  • -
-

Tor (Status: Removed in v0.4)

-
    -
  • Architecture doc mentions "Tor via Arti" but Arti is absent from current Cargo.toml
  • -
  • Previous versions (0.3.x) used the C Tor daemon for hidden services
  • -
  • Likely planned for re-integration but not yet implemented in the 0.4 rewrite
  • -
-
-
- -
-
- Encryption — Optional LUKS on btrfs - Security -
-
Optional LUKS encryption on LVM volumes, btrfs filesystem with COW snapshots for safe installs
-
Disk encryption is optional (you choose during setup). Uses btrfs which can make instant copies of data for safe app updates.
-
-

Encryption (Optional)

-
    -
  • User chooses encrypted or unencrypted during setup
  • -
  • LVM volume groups: STARTOS_<random> (encrypted) or STARTOS_<random>_UNENC
  • -
  • LUKS via cryptsetup luksFormat/luksOpen with password-based key
  • -
  • Default password: "password" (changed during setup)
  • -
-

Filesystem: btrfs

-
    -
  • Copy-on-Write (COW) snapshots for safe service installs
  • -
  • cp --reflink=always for instant volume snapshots before upgrades
  • -
  • If install fails, volumes restored from snapshot automatically
  • -
-

Volume Layout (LVM)

-
    -
  • main (8 GB) — System data, Patch-DB
  • -
  • package-data (100% remaining) — All service/app data
  • -
-
-
- -
-
- Operating System — Debian Bookworm - OS -
-
Debian 12 (same base as Archipelago), systemd services, x86_64 + ARM64 + RISC-V targets
-
Same stable Debian foundation as Archipelago. Supports more CPU architectures including RISC-V.
-
-

Platform Targets

-
    -
  • x86_64: Standard PCs and servers
  • -
  • aarch64: ARM64 (Raspberry Pi 4/5, etc.)
  • -
  • riscv64: RISC-V (emerging architecture)
  • -
-
-
- -
- -
- - - - -
- -

LXC Container Model

-
-
-
Two-Layer Architecture
-
Outer: One LXC container per service, created by the Rust backend. Base rootfs is a read-only SquashFS image mounted as OverlayFS. Inner: Node.js container runtime inside each LXC can launch subcontainers from S9PK-bundled images.
-
Each app gets its own sealed Linux environment with a read-only base. Any changes go to a separate overlay layer.
-
-
-
Communication
-
JSON-RPC over Unix domain sockets. /media/startos/rpc/service.sock (inbound) and host.sock (host callbacks). Services export init(), uninit(), main() via JavaScript ABI.
-
Apps talk to the system through socket files, not network ports. Each app implements 3 required JavaScript functions.
-
-
-
Isolation
-
User namespaces (container UID 0 → host UID 100000, range 65536). AppArmor profiles with nesting. veth bridge on lxcbr0 (10.0.3.x subnet). GPU passthrough support via manifest flag.
-
Container root maps to an unprivileged host user. Each container gets its own virtual network interface.
-
-
-
Lifecycle
-
LxcManager handles creation (30s timeout), garbage collection, and cleanup. Service actors manage per-service state machines. btrfs reflink snapshots before install/upgrade for atomic rollback.
-
-
- -

S9PK Package Format (v2)

-
-
-
Signed Merkle Archive
-
Ed25519 signatures with prehashed content (SHA-512 over blake3 merkle root). Magic bytes: 0x3b 0x3b 0x02. Enables partial downloads, integrity verification of subsets, and efficient delta updates.
-
App packages are cryptographically signed and structured so you can verify integrity without downloading the entire thing.
-
-
-
Archive Contents
-
manifest.json (metadata) + javascript.squashfs (service logic, Node.js) + images/<arch>/*.squashfs (container filesystems per CPU architecture) + assets.squashfs (optional static assets) + icon + LICENSE.md
-
Each package bundles its own containers, logic code, icon, and license in one downloadable file.
-
-
-
Service ABI (JavaScript/Node.js)
-
Services implement init(), uninit(), and main() in JavaScript. The container runtime provides an Effects interface for host callbacks (dependency queries, config, health reporting).
-
App developers write their service logic in JavaScript. The system provides a standard API for the app to interact with the host.
-
-
- -
- - - - -
- -
-
-
JSON-RPC (Host ↔ Service)
-
All communication between the Rust backend and services uses JSON-RPC over Unix domain sockets.
-
Apps and the system talk through a structured messaging format over local socket files — fast and secure.
-
- Transport: Unix domain sockets
- Inbound: /media/startos/rpc/service.sock
- Host callbacks: /media/startos/rpc/host.sock (Effects interface)
- Library: rpc-toolkit (custom Rust crate)
- Used by: All service containers ↔ startd -
-
- -
-
JSON-RPC (UI ↔ Backend)
-
The Angular frontend communicates with startd via JSON-RPC over HTTP. 100+ API methods. State sync via Patch-DB WebSocket.
-
The dashboard sends commands and gets responses in JSON format. Live updates stream automatically through a WebSocket.
-
- Transport: HTTP POST (commands) + WebSocket (state sync)
- State sync: Patch-DB pushes CBOR-encoded diffs over WebSocket
- Frontend applies: PatchDB.watch$() → Angular signals via toSignal()
- Methods: 100+ via ApiService abstract class -
-
- -
-
Patch-DB (CBOR Reactive Sync)
-
Custom reactive database using CBOR encoding. Backend pushes diffs over WebSocket — UI updates automatically without polling.
-
Instead of the UI constantly asking "what changed?", the backend pushes only what changed, in a compact binary format.
-
- Encoding: CBOR (Concise Binary Object Representation, RFC 8949)
- Sync model: Server-push diffs, not request-response
- Storage: /media/startos/data/main/
- Advantage: Much smaller than JSON, real-time without polling -
-
- -
-
HTTPS / TLS (Built-in)
-
TLS termination handled directly by the Rust backend (tokio-rustls). Self-signed root CA per server + ACME for public domains.
-
The backend IS the web server — no nginx or caddy needed. It handles encryption directly.
-
- TLS library: tokio-rustls 0.26 + OpenSSL (vendored, for cert generation)
- Local certs: Self-signed root CA (NIST P-256 keys)
- Public certs: ACME client (async-acme) with TLS-ALPN-01 challenge
- Routing: SNI-based virtual hosting via VHostController -
-
- -
-
WireGuard (VPN Tunnels)
-
First-class WireGuard support for remote access. Users add WireGuard configs as "gateways." Managed by tunnelbox daemon.
-
Built-in VPN for accessing your node from anywhere. Add a WireGuard config and get a secure tunnel.
-
- Implementation: wg-quick + x25519-dalek (Rust)
- Daemon: tunnelbox (symlink of startbox binary)
- Multi-gateway: Supports multiple interfaces with separate domain configs -
-
- -
-
DNS (hickory-server)
-
Built-in DNS server for service discovery and resolution. Also uses mDNS (avahi) for .local domain access on LAN.
-
The system runs its own DNS so containers can find each other by name. Your phone finds the node via .local address.
-
- Library: hickory-server (formerly trust-dns)
- mDNS: avahi-resolve-host-name for .local domains
- Container network: lxcbr0 bridge, host at 10.0.3.1 -
-
-
-
- - - - -
-
-

Not Implemented in StartOS

-

StartOS does not implement Web5 (DIDs, DWNs, or Verifiable Credentials).
Authentication uses password-based sessions and public/private key signatures.

-
-
- - - - -
-
-
-
LXC + User Namespaces
-
Each service in its own LXC container with UID/GID mapping (container 0 → host 100000, range 65536). AppArmor profiles with nesting. OverlayFS rootfs (base read-only).
-
Apps run in isolated Linux environments with their own user systems. Container root is mapped to an unprivileged host user.
-
-
-
Package Signing (Ed25519)
-
All S9PK packages signed with Ed25519 over blake3 merkle roots. Signature verified before installation. Prevents supply chain attacks.
-
Every app package is cryptographically signed. If someone tampers with it, the signature check fails and installation is blocked.
-
-
-
btrfs Snapshots
-
COW filesystem snapshots before every install/upgrade. If an install fails, data is atomically restored to the pre-install state.
-
The system takes a snapshot before every app update. If the update fails, your data is automatically rolled back.
-
-
-
Authentication
-
Password-based + session cookies. Local authcookie for CLI. Public/private key signatures for remote admin. Encrypted wire protocol during setup (public key exchange + encrypted password).
-
-
- -
- - - - -
-
-
-
1
-
-
Preinit Script
-
Optional /media/startos/config/preinit.sh runs before anything else. Enables local auth cookie.
-
-
-
-
2
-
-
Load Database + SSH Keys
-
Patch-DB loaded from disk (CBOR format). SSH developer keys written. MOK enrollment for Secure Boot if applicable.
-
-
-
-
3
-
-
Network Controller
-
DNS server (hickory-server), SOCKS5 proxy, VHost reverse proxy, port forwarding, ACME client, WiFi configuration — all start together.
-
-
-
-
4
-
-
System Initialization
-
Mount logs to data drive, load CA certificate, set CPU governor to performance, NTP clock sync, enable zram, hardware inventory via lshw.
-
-
-
-
5
-
-
Launch Service Intranet + Services
-
LXC bridge network (lxcbr0) created. Database validated. Service actors start all installed services. Postinit script runs.
-
-
-
- -
- - - - -
-
/media/startos/data/ ← Root data directory (optionally LUKS encrypted) - ├── main/ System data, Patch-DB (8 GB LVM volume) - └── package-data/ All service data (remaining disk space) - ├── volumes/{pkg-id}/data/{vol}/ Per-service volume data - ├── volumes/{pkg-id}/assets/{ver}/ Per-service read-only assets - └── logs/{pkg-id}/ Per-service log output - -/usr/lib/startos/ ← System binaries and base images - ├── container-runtime/rootfs.squashfs Base LXC container image - └── package/ Mounted JS from S9PK inside containers - -/var/lib/lxc/ LXC container storage -/media/startos/config/ System config (preinit.sh, postinit.sh, standby) -/media/startos/backups/ Backup mount points per service
-
- - - - - - - - - - - - - - -
- -

Architecture analysis sourced from getumbrel/umbrel on GitHub (master branch). Click any layer to expand.

- -
-
Docker
Container Runtime
-
Node.js
Backend (umbreld)
-
React 19
Frontend
-
Compose
App Format
-
None
Disk Encryption
-
A/B Boot
Rugix Partitions
-
- -
-
React 19 + Tailwind 4 + Radix UIUI
Static SPA served by umbreld's Express server. Zustand + TanStack React Query for state. tRPC for typed API. 8+ languages.
-
umbreld (Node.js 22 / TypeScript)Service
Single daemon on port 80: Express + tRPC API, app lifecycle via Docker Compose, file manager, backups (Kopia), terminal (node-pty).
-
Docker 28.5 (rootful)Containers
Each app is a Docker Compose project. Flat bridge network (10.21.0.0/16). Per-app auth proxy containers. All containers destroyed on boot.
-
NetworkingNetwork
No reverse proxy. Express serves on :80 directly. Optional Tor (containerized). mDNS via avahi. No TLS by default.
-
Debian Trixie (testing) + RugixOS
Date-pinned Debian testing. Rugix A/B root partitions for atomic OS updates with automatic rollback. /data partition persists.
-
- -
- -
-
- -
-
- Web UI — React 19 - Application -
-
React 19 + TypeScript + Vite 6 + Tailwind 4 + Radix UI, served as static SPA by umbreld's Express server
-
The dashboard you use in your browser. Built with React (Meta's web framework), styled with Tailwind.
-
-

Tech Stack

-
    -
  • Framework: React 19 + TypeScript (strict)
  • -
  • Build: Vite 6
  • -
  • Styling: Tailwind CSS 4 + Radix UI primitives + shadcn/ui patterns
  • -
  • State: Zustand (client) + TanStack React Query v5 (server)
  • -
  • API: tRPC React Query v11 for typed RPC
  • -
  • i18n: i18next (8+ languages)
  • -
  • Animations: Framer Motion (as motion package)
  • -
  • Terminal: xterm.js for in-browser terminal
  • -
  • Charts: Recharts for data visualization
  • -
-
-
- -
-
- Backend — umbreld (Node.js/TypeScript) - Service -
-
Single Node.js 22 daemon handling web server, tRPC API, app lifecycle, Tor, backups, file management, and OS updates
-
The brain of the system. A TypeScript process that does everything — web server, app manager, backup handler.
-
-

Key Modules

-
    -
  • Server: Express 4 + tRPC v11 over HTTP and WebSocket on port 80
  • -
  • Apps: Docker Compose lifecycle (install, start, stop, update, uninstall)
  • -
  • AppStore: Git-based — clones getumbrel/umbrel-apps, pulls every 5 minutes
  • -
  • User: Single-user JWT auth (bcrypt $2b$, 12 rounds) + optional TOTP 2FA
  • -
  • Files: File browser with Samba sharing, thumbnails, external storage
  • -
  • Hardware: RAID (ZFS) for Umbrel Home Pro, internal/external storage detection
  • -
  • Backups: Kopia v0.19.0 encrypted backups to external drives
  • -
  • Notifications: In-app notification system + widgets
  • -
  • Terminal: WebSocket-based terminal (node-pty + xterm.js)
  • -
  • Dbus: D-Bus interface to systemd for reboot/shutdown/hostname
  • -
-

State Storage

-
    -
  • YAML file: umbrel.yaml — no database, just a YAML file
  • -
  • Validation: Zod schemas
  • -
  • Docker: dockerode library + execa shell calls
  • -
  • Git: isomorphic-git for app store management
  • -
-

Legacy Compat Layer

-
    -
  • App lifecycle handled by a large bash script (app-script)
  • -
  • Shells out to: docker compose, yq, envsubst, openssl
  • -
  • Explicitly labeled "legacy" in the codebase
  • -
-

Systemd

-
    -
  • umbrel.service: After=network-online.target docker.service
  • -
  • Restart=always, 15-minute stop timeout, StartLimitInterval=0
  • -
-
-
- -
-
- Container Layer — Docker (rootful) - Isolation -
-
Docker 28.5.0 (rootful, not rootless) + Docker Compose v2. Each app is a separate Compose project.
-
Apps run in Docker containers managed by Docker Compose. Unlike Podman, Docker runs as root — simpler but less isolated.
-
-

Docker Setup

-
    -
  • Installed via official Docker install script, pinned to v28.5.0
  • -
  • Rootful (runs as root) — not rootless
  • -
  • Each app: separate Docker Compose project (--project-name <app-id>)
  • -
  • Legacy container naming: <app-id>_<service>_1 for DNS compat
  • -
-

Network: Flat Bridge

-
    -
  • Single shared network: umbrel_main_network (10.21.0.0/16)
  • -
  • All apps share one flat network — any container can talk to any other
  • -
  • Static IPs assigned per service (defined in exports.sh)
  • -
  • No per-app network isolation
  • -
-

Per-App Proxy

-
    -
  • Each app gets an app_proxy container (Node.js Express, getumbrel/app-proxy)
  • -
  • Handles JWT authentication for iframe embedding
  • -
  • Proxies to the actual app container on its internal port
  • -
  • UI renders apps in iframes pointing to proxy port
  • -
-

Boot Cleanup

-
    -
  • On every startup: stops ALL containers, prunes ALL networks
  • -
  • Prevents stale state from previous versions
  • -
  • Pre-loads images from /images/ (tor, auth-server baked into ISO)
  • -
-
-
- -
-
- Network Layer - Network -
-
No traditional reverse proxy. umbreld serves on port 80. Per-app proxy containers. Optional Tor via container.
-
No nginx, no caddy — the backend itself serves on port 80. Each app has its own mini proxy container for authentication.
-
-

HTTP

-
    -
  • umbreld's Express server listens directly on port 80
  • -
  • Serves UI static files + tRPC API
  • -
  • No port 443/TLS by default — HTTP only on LAN
  • -
  • mDNS via avahi for HOSTNAME.local access
  • -
-

Tor (Optional)

-
    -
  • Toggle per-system (not per-app)
  • -
  • tor_proxy container on 10.21.21.11 (SOCKS5)
  • -
  • Each app gets a tor_server container creating hidden services
  • -
  • Dashboard also gets its own hidden service
  • -
  • Provides end-to-end encryption for remote access
  • -
-

Inter-App Communication

-
    -
  • Via static IPs on the flat 10.21.0.0/16 bridge network
  • -
  • No DNS-based service discovery — IPs hardcoded in exports.sh
  • -
-
-
- -
-
- Operating System — Debian Trixie (testing) - OS -
-
Debian Trixie (testing branch, not stable), built from date-pinned snapshot for reproducibility, Rugix A/B partitions
-
Uses Debian's "testing" branch (less stable than Bookworm). Has a clever A/B partition system for safe OS updates.
-
-

OS Build

-
    -
  • Built inside Docker via multi-stage Dockerfile (umbrelos.Dockerfile)
  • -
  • Date-pinned Debian snapshot (e.g., 20251229) for reproducibility
  • -
  • Includes: NetworkManager, avahi, systemd-timesyncd, Bluetooth, SSH
  • -
  • Node.js 22.13.0 baked in
  • -
-

Rugix A/B Partitions

-
    -
  • Two root partitions — active and standby
  • -
  • OS updates write to inactive partition, then swap on reboot
  • -
  • If boot fails, automatic rollback to previous partition
  • -
  • Root filesystem committed after successful boot
  • -
-

Persistent Bind Mounts

-
    -
  • /var/log/data/umbrel-os/var/log
  • -
  • /var/lib/docker/data/umbrel-os/var/lib/docker
  • -
  • /home/data/umbrel-os/home
  • -
  • Separate /data partition persists across OS updates
  • -
-

User

-
    -
  • umbrel (UID 1000), default password umbrel
  • -
  • Synced to web UI password after onboarding
  • -
  • Has sudo access
  • -
-
-
- -
- -
- -
- -

Docker Container Model

-
-
-
Docker 28.5 (Rootful)
-
Docker daemon runs as root (not rootless). Each app is a separate Docker Compose v2 project (--project-name <app-id>). Legacy container naming: <app-id>_<service>_1 for DNS compatibility.
-
Standard Docker running with root permissions. Each app is managed as a Compose project with its own containers.
-
-
-
Flat Network (No Isolation)
-
Single shared bridge: umbrel_main_network (10.21.0.0/16). All apps share one network — any container can communicate with any other. Static IPs assigned per service via exports.sh.
-
All apps are on the same network. A compromised app could potentially reach other apps' services directly.
-
-
-
Per-App Auth Proxy
-
Each app gets an app_proxy container (getumbrel/app-proxy, Node.js Express) that handles JWT authentication for iframe embedding. Proxies to the actual app on its internal port.
-
Each app has a mini web server in front of it that checks your login before letting you in.
-
-
-
Boot Cleanup
-
On every startup: stops ALL containers, prunes ALL networks to prevent stale state. Pre-loads images from /images/ (tor, auth-server baked into ISO).
-
Every reboot starts fresh by destroying all containers and recreating them. Clean but adds startup time.
-
-
- -

App Packaging

-
-
-
umbrel-app.yml
-
App manifest with: id, name, version, port, category, dependencies, permissions (GPU), gallery images, release notes, widgets, torOnly flag, installSize. Validated by Zod schema.
-
A YAML file describing the app — what it does, what it needs, what ports it uses, and how to display it in the store.
-
-
-
docker-compose.yml
-
Standard Docker Compose v3.7. Services reference umbrel_main_network. Images pinned by SHA256 digest. Apps define their own security constraints (no enforced capability dropping).
-
Standard Docker Compose file that defines the app's containers, networks, and volumes.
-
-
-
exports.sh + hooks/
-
exports.sh exports environment variables (IPs, ports, credentials) for dependency resolution. hooks/ directory with lifecycle scripts: pre/post-install, pre/post-start, pre/post-stop, pre/post-update, pre-uninstall.
-
Shell scripts that set up environment variables so apps can find each other, plus hooks that run at key lifecycle moments.
-
-
-
App Store (Git repo)
-
Apps distributed via Git repository (getumbrel/umbrel-apps). Cloned locally, pulled every 5 minutes. Community app stores supported. implements field enables alternative implementations (e.g., Bitcoin Knots for Bitcoin Core).
-
The app store is just a Git repository. Umbrel checks for updates every 5 minutes by pulling the latest commits.
-
-
- -
- -
-
-
-
tRPC (UI ↔ Backend)
-
TypeScript-first RPC framework with end-to-end type safety. Runs over both HTTP and WebSocket on port 80.
-
A typed communication channel between the dashboard and the backend. If the API changes, TypeScript catches errors automatically.
-
- Version: tRPC v11
- Transport: HTTP + WebSocket (via Express 4)
- Port: 80 (Express serves both UI and API)
- Type safety: Server types flow directly to client (TanStack React Query v5)
- Used by: React 19 frontend ↔ umbreld -
-
- -
-
Docker Compose (App Lifecycle)
-
Each app managed via Docker Compose v2. Install/start/stop/update handled by a bash script (app-script) calling docker compose.
-
Apps are defined as Docker Compose projects. A bash script handles the lifecycle by calling docker compose commands.
-
- Compose version: v2 (docker compose plugin, not docker-compose binary)
- Lifecycle script: app-script (bash, labeled "legacy")
- Tools used: docker compose, yq, envsubst, openssl
- Hooks: pre/post-install, pre/post-start, pre/post-stop, pre/post-update, pre-uninstall -
-
- -
-
exports.sh (Dependency Resolution)
-
Shell scripts that export environment variables (IPs, ports, RPC credentials). When app B depends on app A, A's exports.sh is sourced first.
-
Apps share their connection details through environment variables set by shell scripts.
-
- Variables exported: IP addresses (static), ports, RPC passwords, hidden service hostnames
- Resolution: Transitive deps resolved in post-order (depth-first)
- Alternative implementations: settings.yml can map dependency (e.g., bitcoin → bitcoin-knots)
- Deps NOT auto-installed: UI warns users to install dependencies first -
-
- -
-
Tor (Optional, Containerized)
-
Toggle per-system. tor_proxy container provides SOCKS5 at 10.21.21.11. Per-app tor_server containers create hidden services.
-
Tor is optional and runs in its own container. When enabled, each app gets its own .onion address for remote access.
-
- SOCKS5: 10.21.21.11 (tor_proxy container)
- Per-app: tor_server container creates hidden service pointing to app_proxy
- Dashboard: Also gets its own hidden service
- Provides: End-to-end encryption for remote access (since no TLS by default) -
-
- -
-
JWT + Proxy Tokens (Auth)
-
JWT for API authentication. Separate "proxy tokens" validate iframe requests to app_proxy containers. bcrypt password hashing.
-
Login tokens that prove who you are. Separate tokens for the dashboard API and for accessing individual apps.
-
- API auth: JWT (jsonwebtoken library)
- Password: bcrypt ($2b$, 12 rounds)
- App auth: UMBREL_PROXY_TOKEN cookie validated by app_proxy containers
- 2FA: Optional TOTP -
-
- -
-
Git (App Store)
-
App store is a Git repository cloned locally via isomorphic-git. Pulled every 5 minutes for updates.
-
The app catalog is just a Git repo. Umbrel checks for new apps and updates by pulling the latest commits every 5 minutes.
-
- Default repo: getumbrel/umbrel-apps (GitHub)
- Library: isomorphic-git (pure JS Git implementation)
- Pull interval: Every 5 minutes
- Community stores: Supported (add custom Git URLs) -
-
-
-
- -
-
-

Not Implemented in umbrelOS

-

umbrelOS does not implement Web5 (DIDs, DWNs, or Verifiable Credentials).
Authentication uses a single-user JWT model. Per-app passwords are derived from a deterministic seed via HMAC-SHA256.

-
-
- -
-
-
-
No Disk Encryption
-
No LUKS, no dm-crypt. All data stored unencrypted on disk. Backups use Kopia with per-repository passwords. A deterministic seed (256-byte random token) derives per-app passwords via HMAC-SHA256.
-
If someone physically steals the drive, all data is readable. No encryption at rest.
-
-
-
Flat Network (No App Isolation)
-
All apps share one Docker bridge (10.21.0.0/16). Any container can communicate with any other container. The app_proxy adds authentication but not network isolation.
-
All apps are on the same network. A compromised app could potentially access other apps' services.
-
-
-
Authentication
-
Single user model. Password hashed with bcrypt ($2b$, 12 rounds). JWT tokens for API auth. Separate "proxy tokens" for app iframe auth. Optional TOTP 2FA. Session cookie: UMBREL_PROXY_TOKEN.
-
-
-
Rootful Docker
-
Docker daemon runs as root. Containers run as UID 1000 where possible, but no enforced capability dropping or security profiles. No --cap-drop=ALL, no no-new-privileges by default.
-
Docker has root access to the machine. Individual containers may or may not restrict their own privileges.
-
-
- -
- -
-
-
-
1
-
-
GRUB / Rugix → Select Active Partition
-
GRUB (amd64) or tryboot (RPi) loads the kernel from the active A/B partition. Rugix commits to current partition on successful boot.
-
-
-
-
2
-
-
systemd → Docker → umbreld
-
systemd starts, brings up networking (NetworkManager) and Docker daemon. umbrel.service starts umbreld --data-directory=/home/umbrel/umbrel.
-
-
-
-
3
-
-
umbreld Initialization
-
Runs startup migrations, syncs system password, restores WiFi, waits for NTP sync (10s, important for RPi with no RTC).
-
-
-
-
4
-
-
Docker Clean Slate
-
Stops and removes ALL containers, prunes ALL networks. Pre-loads images from /images/. Prevents stale state from previous versions.
-
Destructive reset on every boot — ensures clean state but adds startup time
-
-
-
-
5
-
-
Start App Environment + All Apps
-
Starts tor_proxy + auth containers first, then all installed apps in parallel. Express HTTP server starts on port 80. App store update loop begins (every 5 min).
-
-
-
- -
- -
-
/home/umbrel/umbrel/ ← Main data directory (NO encryption) - ├── umbrel.yaml Main config/state (YAML file, not a database) - ├── app-data/{app-id}/ Per-app data, compose files, manifests - ├── app-stores/ Git clones of app store repositories - ├── tor/data/app-{id}/hostname Per-app .onion addresses - ├── db/umbrel-seed/seed Deterministic seed (256-byte) for per-app passwords - ├── secrets/jwt JWT signing secret - └── home/ User files, backups - -/opt/umbreld/ umbreld daemon (npm-linked) -/opt/umbreld/ui/ React SPA static files -/images/ Pre-loaded Docker images (tor, auth-server)
-
- - - - - - - -
- -
-
3
Systems Compared
-
Rust
2/3 Backends
-
Debian
3/3 Base OS
-
3
Container Runtimes
-
3
Frontend Frameworks
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AspectArchipelagoStartOSumbrelOSNotes / Trade-offs
Core Architecture
Backend LanguageRustRustTypeScript / Node.js 22Rust: memory safety, performance, no GC pauses. Node.js: faster prototyping, larger ecosystem, but runtime overhead.
FrontendVue 3 + Vite 7 + TailwindAngular 21 + Taiga UI 5React 19 + Vite 6 + Tailwind 4All modern choices. Angular is heaviest (TypeScript-only). Vue/React are lighter. Tailwind enables rapid UI iteration.
API ProtocolJSON-RPC 2.0JSON-RPC (rpc-toolkit)tRPC v11JSON-RPC is standard and language-agnostic. tRPC gives end-to-end TypeScript type safety but couples frontend/backend.
State SyncPinia + JSON Patch over WebSocketPatch-DB (CBOR diffs over WebSocket)Zustand + TanStack React QueryPatch-DB is most efficient (binary diffs). Archipelago and StartOS push updates; Umbrel polls via React Query.
Reverse ProxyNginx (external, battle-tested)Built into Rust backend (Axum/Hyper)None (Express on :80 + per-app proxy containers)Nginx: proven, configurable, rate limiting. Built-in: fewer moving parts. Umbrel: no central proxy means no rate limiting or security headers.
Container Isolation
Container RuntimePodman (rootless, OCI)LXC (system containers, AppArmor)Docker 28.5 (rootful)Podman: no daemon, rootless by design. LXC: heavier isolation (full system containers). Docker: rootful daemon is a larger attack surface.
Rootless ContainersYes (all containers as UID 1000)User namespaces (UID 0 → host 100000)No (Docker daemon runs as root)Rootless Podman: container escape = unprivileged user. LXC: namespace mapping mitigates. Docker rootful: escape = root on host.
Capability Dropping--cap-drop=ALL + whitelistAppArmor profiles (generated)Not enforced by defaultArchipelago: explicit least-privilege. StartOS: AppArmor provides MAC. Umbrel: apps define their own security (inconsistent).
Network IsolationPer-tier networks (archy-net + bridge)Per-service veth on lxcbr0Flat bridge (10.21.0.0/16, all apps share)Archipelago/StartOS: apps can't see each other unless connected. Umbrel: any container can reach any other.
Memory LimitsPer-container (128MB–4GB)Configurable via manifestNot enforced by defaultMemory limits prevent a single app from consuming all RAM and crashing the system.
Security
Disk EncryptionMandatory LUKS2 (AES-XTS or ChaCha20-Adiantum)Optional LUKS on LVM/btrfsNonePhysical theft risk: Archipelago data is unreadable. StartOS depends on user choice. Umbrel data is fully exposed.
Security HeadersCSP, HSTS, X-Frame-Options, Permissions-PolicyPartial (built-in proxy)None (no central proxy)Headers prevent XSS, clickjacking, and protocol downgrade attacks. Critical for browser-based management.
Rate LimitingAuth 3/s, RPC 20/s, P2P 10/sRBAC via method metadataNoneRate limiting prevents brute-force password attacks and API abuse.
TLSSelf-signed cert on :443 + HSTSSelf-signed CA + ACME (Let's Encrypt)None (HTTP only on LAN)Without TLS, any device on the LAN can intercept credentials. Tor provides encryption for remote but not LAN access.
Auth ModelRBAC (Admin/Viewer/AppUser) + CSRF + session cookiesPassword + session cookies + key signaturesSingle-user JWT + optional TOTPArchipelago supports multiple roles. Others are single-user only.
App Ecosystem
App FormatContainer images from private registryS9PK v2 (signed merkle archive)docker-compose.yml + umbrel-app.ymlS9PK: most sophisticated (signed, partial downloads, delta updates). Compose: simplest for developers. Registry: fast deployment.
Package SigningRegistry-based trustEd25519 over blake3 merkle rootsDocker image digests onlyStartOS has the strongest supply chain security. Archipelago trusts its private registry. Umbrel relies on Docker content trust.
App StoreBuilt-in marketplace (curated)Registry-based (marketplace)Git repository (pulled every 5 min)Git-based: easy for devs to contribute. Registry: more control. Curated: quality gate but slower additions.
Update MechanismISO reflash / manual upgradeRegistry-based OTARugix A/B partitions (atomic, rollback)Umbrel has the smoothest update path with automatic rollback. Archipelago's ISO approach is most disruptive.
Networking & Privacy
TorSystem daemon + hidden services (always available)Removed in v0.4 (planned re-integration)Optional (containerized)Archipelago: Tor is first-class. StartOS temporarily lost Tor in 0.4 rewrite. Umbrel: toggle on/off.
VPNTailscale (WireGuard mesh)WireGuard (first-class, tunnelbox)None built-inBoth Archipelago and StartOS offer remote access without port forwarding. Umbrel relies on Tor or manual setup.
DNSSystem DNS + container NetAvark DNSBuilt-in (hickory-server)Docker DNS + static IPs in exports.shStartOS has the most integrated DNS. Archipelago uses standard tools. Umbrel hardcodes IPs.
Identity & Web5
DID Supportdid:key + did:dht + W3C DID DocumentsNoneNoneArchipelago is the only node OS with decentralized identity support. Enables credential issuance and cross-node trust.
Verifiable CredentialsW3C VC 2.0 (Ed25519Signature2020)NoneNoneArchipelago can issue and verify digital certificates without any central authority.
DWN (Data Store)Custom implementation + peer sync via TorNoneNonePersonal data store that syncs across nodes. Unique to Archipelago.
Nostr IntegrationNIP-01/04/44, nostr-provider.js in iframesNoneNoneArchipelago injects Nostr identity into every app iframe for seamless decentralized social integration.
Infrastructure
Base OSDebian 12 Bookworm (stable)Debian BookwormDebian Trixie (testing)Stable: proven, security patches. Testing: newer packages but less battle-tested, potential for regressions.
Filesystemext4btrfs (COW snapshots)ext4 (A/B partitions)btrfs snapshots enable instant rollback on failed installs. ext4 is simpler and more mature. A/B adds OS-level rollback.
Kiosk DisplayX11 + Chromium on VT7NoneNonePlug in a monitor and the dashboard appears fullscreen. Unique physical UX for dedicated hardware.
Boot RecoveryCrash recovery + container state snapshotsbtrfs snapshots + preinit/postinit hooksDestroys all containers on every bootArchipelago/StartOS: resume from last known state. Umbrel: clean slate every boot (slower but deterministic).
-
- -

Summary

-
-
-
Archipelago
-
Strengths: Security (rootless, LUKS, caps, rate limiting, CSP), identity (DIDs, VCs, DWN, Nostr), kiosk display, Tor first-class.
Trade-offs: No OTA updates (ISO reflash), ext4 lacks snapshot rollback, smaller app ecosystem.
-
-
-
StartOS
-
Strengths: Package signing (S9PK), btrfs snapshots, built-in reverse proxy (fewer moving parts), WireGuard VPN, multi-arch (x86/ARM/RISC-V).
Trade-offs: Tor removed in v0.4, no identity system, Angular is heavier, LXC is less container-ecosystem-compatible.
-
-
-
umbrelOS
-
Strengths: Easiest setup, A/B OTA updates with rollback, largest app ecosystem, Git-based app store (easy contributions), React UI polish.
Trade-offs: No disk encryption, flat network (no isolation), rootful Docker, no TLS, no rate limiting, no security headers, Node.js backend.
-
-
- -
- - - - - diff --git a/neode-ui/dev-dist/registerSW.js b/neode-ui/dev-dist/registerSW.js deleted file mode 100644 index 1d5625f4..00000000 --- a/neode-ui/dev-dist/registerSW.js +++ /dev/null @@ -1 +0,0 @@ -if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' }) \ No newline at end of file diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js deleted file mode 100644 index 9b6d2eaa..00000000 --- a/neode-ui/dev-dist/sw.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Copyright 2018 Google Inc. All Rights Reserved. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// If the loader is already loaded, just stop. -if (!self.define) { - let registry = {}; - - // Used for `eval` and `importScripts` where we can't get script URL by other means. - // In both cases, it's safe to use a global var because those functions are synchronous. - let nextDefineUri; - - const singleRequire = (uri, parentUri) => { - uri = new URL(uri + ".js", parentUri).href; - return registry[uri] || ( - - new Promise(resolve => { - if ("document" in self) { - const script = document.createElement("script"); - script.src = uri; - script.onload = resolve; - document.head.appendChild(script); - } else { - nextDefineUri = uri; - importScripts(uri); - resolve(); - } - }) - - .then(() => { - let promise = registry[uri]; - if (!promise) { - throw new Error(`Module ${uri} didn’t register its module`); - } - return promise; - }) - ); - }; - - self.define = (depsNames, factory) => { - const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href; - if (registry[uri]) { - // Module is already loading or loaded. - return; - } - let exports = {}; - const require = depUri => singleRequire(depUri, uri); - const specialDeps = { - module: { uri }, - exports, - require - }; - registry[uri] = Promise.all(depsNames.map( - depName => specialDeps[depName] || require(depName) - )).then(deps => { - factory(...deps); - return exports; - }); - }; -} -define(['./workbox-21a80088'], (function (workbox) { 'use strict'; - - self.skipWaiting(); - workbox.clientsClaim(); - - /** - * The precacheAndRoute() method efficiently caches and responds to - * requests for URLs in the manifest. - * See https://goo.gl/S9QRab - */ - workbox.precacheAndRoute([{ - "url": "registerSW.js", - "revision": "3ca0b8505b4bec776b69afdba2768812" - }, { - "url": "index.html", - "revision": "0.nnkdothias" - }], {}); - workbox.cleanupOutdatedCaches(); - workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { - allowlist: [/^\/$/], - denylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//] - })); - workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({ - "cacheName": "google-fonts-cache", - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 10, - maxAgeSeconds: 31536000 - }), new workbox.CacheableResponsePlugin({ - statuses: [0, 200] - })] - }), 'GET'); - workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({ - "cacheName": "gstatic-fonts-cache", - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 10, - maxAgeSeconds: 31536000 - }), new workbox.CacheableResponsePlugin({ - statuses: [0, 200] - })] - }), 'GET'); - workbox.registerRoute(/\/rpc\/v1\/.*/i, new workbox.NetworkFirst({ - "cacheName": "api-cache", - "networkTimeoutSeconds": 10, - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 50, - maxAgeSeconds: 300 - })] - }), 'GET'); - workbox.registerRoute(/\/assets\/.*/i, new workbox.CacheFirst({ - "cacheName": "assets-cache-v2", - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 100, - maxAgeSeconds: 2592000 - })] - }), 'GET'); - -})); diff --git a/neode-ui/dev-dist/workbox-21a80088.js b/neode-ui/dev-dist/workbox-21a80088.js deleted file mode 100644 index f3645263..00000000 --- a/neode-ui/dev-dist/workbox-21a80088.js +++ /dev/null @@ -1,4788 +0,0 @@ -define(['exports'], (function (exports) { 'use strict'; - - // @ts-ignore - try { - self['workbox:core:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const logger = (() => { - // Don't overwrite this value if it's already set. - // See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923 - if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) { - self.__WB_DISABLE_DEV_LOGS = false; - } - let inGroup = false; - const methodToColorMap = { - debug: `#7f8c8d`, - log: `#2ecc71`, - warn: `#f39c12`, - error: `#c0392b`, - groupCollapsed: `#3498db`, - groupEnd: null // No colored prefix on groupEnd - }; - const print = function (method, args) { - if (self.__WB_DISABLE_DEV_LOGS) { - return; - } - if (method === 'groupCollapsed') { - // Safari doesn't print all console.groupCollapsed() arguments: - // https://bugs.webkit.org/show_bug.cgi?id=182754 - if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { - console[method](...args); - return; - } - } - const styles = [`background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`]; - // When in a group, the workbox prefix is not displayed. - const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')]; - console[method](...logPrefix, ...args); - if (method === 'groupCollapsed') { - inGroup = true; - } - if (method === 'groupEnd') { - inGroup = false; - } - }; - // eslint-disable-next-line @typescript-eslint/ban-types - const api = {}; - const loggerMethods = Object.keys(methodToColorMap); - for (const key of loggerMethods) { - const method = key; - api[method] = (...args) => { - print(method, args); - }; - } - return api; - })(); - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const messages$1 = { - 'invalid-value': ({ - paramName, - validValueDescription, - value - }) => { - if (!paramName || !validValueDescription) { - throw new Error(`Unexpected input to 'invalid-value' error.`); - } - return `The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`; - }, - 'not-an-array': ({ - moduleName, - className, - funcName, - paramName - }) => { - if (!moduleName || !className || !funcName || !paramName) { - throw new Error(`Unexpected input to 'not-an-array' error.`); - } - return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`; - }, - 'incorrect-type': ({ - expectedType, - paramName, - moduleName, - className, - funcName - }) => { - if (!expectedType || !paramName || !moduleName || !funcName) { - throw new Error(`Unexpected input to 'incorrect-type' error.`); - } - const classNameStr = className ? `${className}.` : ''; - return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}` + `${funcName}()' must be of type ${expectedType}.`; - }, - 'incorrect-class': ({ - expectedClassName, - paramName, - moduleName, - className, - funcName, - isReturnValueProblem - }) => { - if (!expectedClassName || !moduleName || !funcName) { - throw new Error(`Unexpected input to 'incorrect-class' error.`); - } - const classNameStr = className ? `${className}.` : ''; - if (isReturnValueProblem) { - return `The return value from ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; - } - return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; - }, - 'missing-a-method': ({ - expectedMethod, - paramName, - moduleName, - className, - funcName - }) => { - if (!expectedMethod || !paramName || !moduleName || !className || !funcName) { - throw new Error(`Unexpected input to 'missing-a-method' error.`); - } - return `${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`; - }, - 'add-to-cache-list-unexpected-type': ({ - entry - }) => { - return `An unexpected entry was passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` + `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` + `strings with one or more characters, objects with a url property or ` + `Request objects.`; - }, - 'add-to-cache-list-conflicting-entries': ({ - firstEntry, - secondEntry - }) => { - if (!firstEntry || !secondEntry) { - throw new Error(`Unexpected input to ` + `'add-to-cache-list-duplicate-entries' error.`); - } - return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${firstEntry} but different revision details. Workbox is ` + `unable to cache and version the asset correctly. Please remove one ` + `of the entries.`; - }, - 'plugin-error-request-will-fetch': ({ - thrownErrorMessage - }) => { - if (!thrownErrorMessage) { - throw new Error(`Unexpected input to ` + `'plugin-error-request-will-fetch', error.`); - } - return `An error was thrown by a plugins 'requestWillFetch()' method. ` + `The thrown error message was: '${thrownErrorMessage}'.`; - }, - 'invalid-cache-name': ({ - cacheNameId, - value - }) => { - if (!cacheNameId) { - throw new Error(`Expected a 'cacheNameId' for error 'invalid-cache-name'`); - } - return `You must provide a name containing at least one character for ` + `setCacheDetails({${cacheNameId}: '...'}). Received a value of ` + `'${JSON.stringify(value)}'`; - }, - 'unregister-route-but-not-found-with-method': ({ - method - }) => { - if (!method) { - throw new Error(`Unexpected input to ` + `'unregister-route-but-not-found-with-method' error.`); - } - return `The route you're trying to unregister was not previously ` + `registered for the method type '${method}'.`; - }, - 'unregister-route-route-not-registered': () => { - return `The route you're trying to unregister was not previously ` + `registered.`; - }, - 'queue-replay-failed': ({ - name - }) => { - return `Replaying the background sync queue '${name}' failed.`; - }, - 'duplicate-queue-name': ({ - name - }) => { - return `The Queue name '${name}' is already being used. ` + `All instances of backgroundSync.Queue must be given unique names.`; - }, - 'expired-test-without-max-age': ({ - methodName, - paramName - }) => { - return `The '${methodName}()' method can only be used when the ` + `'${paramName}' is used in the constructor.`; - }, - 'unsupported-route-type': ({ - moduleName, - className, - funcName, - paramName - }) => { - return `The supplied '${paramName}' parameter was an unsupported type. ` + `Please check the docs for ${moduleName}.${className}.${funcName} for ` + `valid input types.`; - }, - 'not-array-of-class': ({ - value, - expectedClass, - moduleName, - className, - funcName, - paramName - }) => { - return `The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`; - }, - 'max-entries-or-age-required': ({ - moduleName, - className, - funcName - }) => { - return `You must define either config.maxEntries or config.maxAgeSeconds` + `in ${moduleName}.${className}.${funcName}`; - }, - 'statuses-or-headers-required': ({ - moduleName, - className, - funcName - }) => { - return `You must define either config.statuses or config.headers` + `in ${moduleName}.${className}.${funcName}`; - }, - 'invalid-string': ({ - moduleName, - funcName, - paramName - }) => { - if (!paramName || !moduleName || !funcName) { - throw new Error(`Unexpected input to 'invalid-string' error.`); - } - return `When using strings, the '${paramName}' parameter must start with ` + `'http' (for cross-origin matches) or '/' (for same-origin matches). ` + `Please see the docs for ${moduleName}.${funcName}() for ` + `more info.`; - }, - 'channel-name-required': () => { - return `You must provide a channelName to construct a ` + `BroadcastCacheUpdate instance.`; - }, - 'invalid-responses-are-same-args': () => { - return `The arguments passed into responsesAreSame() appear to be ` + `invalid. Please ensure valid Responses are used.`; - }, - 'expire-custom-caches-only': () => { - return `You must provide a 'cacheName' property when using the ` + `expiration plugin with a runtime caching strategy.`; - }, - 'unit-must-be-bytes': ({ - normalizedRangeHeader - }) => { - if (!normalizedRangeHeader) { - throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`); - } - return `The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`; - }, - 'single-range-only': ({ - normalizedRangeHeader - }) => { - if (!normalizedRangeHeader) { - throw new Error(`Unexpected input to 'single-range-only' error.`); - } - return `Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`; - }, - 'invalid-range-values': ({ - normalizedRangeHeader - }) => { - if (!normalizedRangeHeader) { - throw new Error(`Unexpected input to 'invalid-range-values' error.`); - } - return `The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`; - }, - 'no-range-header': () => { - return `No Range header was found in the Request provided.`; - }, - 'range-not-satisfiable': ({ - size, - start, - end - }) => { - return `The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`; - }, - 'attempt-to-cache-non-get-request': ({ - url, - method - }) => { - return `Unable to cache '${url}' because it is a '${method}' request and ` + `only 'GET' requests can be cached.`; - }, - 'cache-put-with-no-response': ({ - url - }) => { - return `There was an attempt to cache '${url}' but the response was not ` + `defined.`; - }, - 'no-response': ({ - url, - error - }) => { - let message = `The strategy could not generate a response for '${url}'.`; - if (error) { - message += ` The underlying error is ${error}.`; - } - return message; - }, - 'bad-precaching-response': ({ - url, - status - }) => { - return `The precaching request for '${url}' failed` + (status ? ` with an HTTP status of ${status}.` : `.`); - }, - 'non-precached-url': ({ - url - }) => { - return `createHandlerBoundToURL('${url}') was called, but that URL is ` + `not precached. Please pass in a URL that is precached instead.`; - }, - 'add-to-cache-list-conflicting-integrities': ({ - url - }) => { - return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${url} with different integrity values. Please remove one of them.`; - }, - 'missing-precache-entry': ({ - cacheName, - url - }) => { - return `Unable to find a precached response in ${cacheName} for ${url}.`; - }, - 'cross-origin-copy-response': ({ - origin - }) => { - return `workbox-core.copyResponse() can only be used with same-origin ` + `responses. It was passed a response with origin ${origin}.`; - }, - 'opaque-streams-source': ({ - type - }) => { - const message = `One of the workbox-streams sources resulted in an ` + `'${type}' response.`; - if (type === 'opaqueredirect') { - return `${message} Please do not use a navigation request that results ` + `in a redirect as a source.`; - } - return `${message} Please ensure your sources are CORS-enabled.`; - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const generatorFunction = (code, details = {}) => { - const message = messages$1[code]; - if (!message) { - throw new Error(`Unable to find message for code '${code}'.`); - } - return message(details); - }; - const messageGenerator = generatorFunction; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Workbox errors should be thrown with this class. - * This allows use to ensure the type easily in tests, - * helps developers identify errors from workbox - * easily and allows use to optimise error - * messages correctly. - * - * @private - */ - class WorkboxError extends Error { - /** - * - * @param {string} errorCode The error code that - * identifies this particular error. - * @param {Object=} details Any relevant arguments - * that will help developers identify issues should - * be added as a key on the context object. - */ - constructor(errorCode, details) { - const message = messageGenerator(errorCode, details); - super(message); - this.name = errorCode; - this.details = details; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /* - * This method throws if the supplied value is not an array. - * The destructed values are required to produce a meaningful error for users. - * The destructed and restructured object is so it's clear what is - * needed. - */ - const isArray = (value, details) => { - if (!Array.isArray(value)) { - throw new WorkboxError('not-an-array', details); - } - }; - const hasMethod = (object, expectedMethod, details) => { - const type = typeof object[expectedMethod]; - if (type !== 'function') { - details['expectedMethod'] = expectedMethod; - throw new WorkboxError('missing-a-method', details); - } - }; - const isType = (object, expectedType, details) => { - if (typeof object !== expectedType) { - details['expectedType'] = expectedType; - throw new WorkboxError('incorrect-type', details); - } - }; - const isInstance = (object, - // Need the general type to do the check later. - // eslint-disable-next-line @typescript-eslint/ban-types - expectedClass, details) => { - if (!(object instanceof expectedClass)) { - details['expectedClassName'] = expectedClass.name; - throw new WorkboxError('incorrect-class', details); - } - }; - const isOneOf = (value, validValues, details) => { - if (!validValues.includes(value)) { - details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`; - throw new WorkboxError('invalid-value', details); - } - }; - const isArrayOfClass = (value, - // Need general type to do check later. - expectedClass, - // eslint-disable-line - details) => { - const error = new WorkboxError('not-array-of-class', details); - if (!Array.isArray(value)) { - throw error; - } - for (const item of value) { - if (!(item instanceof expectedClass)) { - throw error; - } - } - }; - const finalAssertExports = { - hasMethod, - isArray, - isInstance, - isOneOf, - isType, - isArrayOfClass - }; - - // @ts-ignore - try { - self['workbox:routing:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The default HTTP method, 'GET', used when there's no specific method - * configured for a route. - * - * @type {string} - * - * @private - */ - const defaultMethod = 'GET'; - /** - * The list of valid HTTP methods associated with requests that could be routed. - * - * @type {Array} - * - * @private - */ - const validMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * @param {function()|Object} handler Either a function, or an object with a - * 'handle' method. - * @return {Object} An object with a handle method. - * - * @private - */ - const normalizeHandler = handler => { - if (handler && typeof handler === 'object') { - { - finalAssertExports.hasMethod(handler, 'handle', { - moduleName: 'workbox-routing', - className: 'Route', - funcName: 'constructor', - paramName: 'handler' - }); - } - return handler; - } else { - { - finalAssertExports.isType(handler, 'function', { - moduleName: 'workbox-routing', - className: 'Route', - funcName: 'constructor', - paramName: 'handler' - }); - } - return { - handle: handler - }; - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A `Route` consists of a pair of callback functions, "match" and "handler". - * The "match" callback determine if a route should be used to "handle" a - * request by returning a non-falsy value if it can. The "handler" callback - * is called when there is a match and should return a Promise that resolves - * to a `Response`. - * - * @memberof workbox-routing - */ - class Route { - /** - * Constructor for Route class. - * - * @param {workbox-routing~matchCallback} match - * A callback function that determines whether the route matches a given - * `fetch` event by returning a non-falsy value. - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resolving to a Response. - * @param {string} [method='GET'] The HTTP method to match the Route - * against. - */ - constructor(match, handler, method = defaultMethod) { - { - finalAssertExports.isType(match, 'function', { - moduleName: 'workbox-routing', - className: 'Route', - funcName: 'constructor', - paramName: 'match' - }); - if (method) { - finalAssertExports.isOneOf(method, validMethods, { - paramName: 'method' - }); - } - } - // These values are referenced directly by Router so cannot be - // altered by minificaton. - this.handler = normalizeHandler(handler); - this.match = match; - this.method = method; - } - /** - * - * @param {workbox-routing-handlerCallback} handler A callback - * function that returns a Promise resolving to a Response - */ - setCatchHandler(handler) { - this.catchHandler = normalizeHandler(handler); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * RegExpRoute makes it easy to create a regular expression based - * {@link workbox-routing.Route}. - * - * For same-origin requests the RegExp only needs to match part of the URL. For - * requests against third-party servers, you must define a RegExp that matches - * the start of the URL. - * - * @memberof workbox-routing - * @extends workbox-routing.Route - */ - class RegExpRoute extends Route { - /** - * If the regular expression contains - * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references}, - * the captured values will be passed to the - * {@link workbox-routing~handlerCallback} `params` - * argument. - * - * @param {RegExp} regExp The regular expression to match against URLs. - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - * @param {string} [method='GET'] The HTTP method to match the Route - * against. - */ - constructor(regExp, handler, method) { - { - finalAssertExports.isInstance(regExp, RegExp, { - moduleName: 'workbox-routing', - className: 'RegExpRoute', - funcName: 'constructor', - paramName: 'pattern' - }); - } - const match = ({ - url - }) => { - const result = regExp.exec(url.href); - // Return immediately if there's no match. - if (!result) { - return; - } - // Require that the match start at the first character in the URL string - // if it's a cross-origin request. - // See https://github.com/GoogleChrome/workbox/issues/281 for the context - // behind this behavior. - if (url.origin !== location.origin && result.index !== 0) { - { - logger.debug(`The regular expression '${regExp.toString()}' only partially matched ` + `against the cross-origin URL '${url.toString()}'. RegExpRoute's will only ` + `handle cross-origin requests if they match the entire URL.`); - } - return; - } - // If the route matches, but there aren't any capture groups defined, then - // this will return [], which is truthy and therefore sufficient to - // indicate a match. - // If there are capture groups, then it will return their values. - return result.slice(1); - }; - super(match, handler, method); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const getFriendlyURL = url => { - const urlObj = new URL(String(url), location.href); - // See https://github.com/GoogleChrome/workbox/issues/2323 - // We want to include everything, except for the origin if it's same-origin. - return urlObj.href.replace(new RegExp(`^${location.origin}`), ''); - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The Router can be used to process a `FetchEvent` using one or more - * {@link workbox-routing.Route}, responding with a `Response` if - * a matching route exists. - * - * If no route matches a given a request, the Router will use a "default" - * handler if one is defined. - * - * Should the matching Route throw an error, the Router will use a "catch" - * handler if one is defined to gracefully deal with issues and respond with a - * Request. - * - * If a request matches multiple routes, the **earliest** registered route will - * be used to respond to the request. - * - * @memberof workbox-routing - */ - class Router { - /** - * Initializes a new Router. - */ - constructor() { - this._routes = new Map(); - this._defaultHandlerMap = new Map(); - } - /** - * @return {Map>} routes A `Map` of HTTP - * method name ('GET', etc.) to an array of all the corresponding `Route` - * instances that are registered. - */ - get routes() { - return this._routes; - } - /** - * Adds a fetch event listener to respond to events when a route matches - * the event's request. - */ - addFetchListener() { - // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 - self.addEventListener('fetch', event => { - const { - request - } = event; - const responsePromise = this.handleRequest({ - request, - event - }); - if (responsePromise) { - event.respondWith(responsePromise); - } - }); - } - /** - * Adds a message event listener for URLs to cache from the window. - * This is useful to cache resources loaded on the page prior to when the - * service worker started controlling it. - * - * The format of the message data sent from the window should be as follows. - * Where the `urlsToCache` array may consist of URL strings or an array of - * URL string + `requestInit` object (the same as you'd pass to `fetch()`). - * - * ``` - * { - * type: 'CACHE_URLS', - * payload: { - * urlsToCache: [ - * './script1.js', - * './script2.js', - * ['./script3.js', {mode: 'no-cors'}], - * ], - * }, - * } - * ``` - */ - addCacheListener() { - // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 - self.addEventListener('message', event => { - // event.data is type 'any' - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (event.data && event.data.type === 'CACHE_URLS') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { - payload - } = event.data; - { - logger.debug(`Caching URLs from the window`, payload.urlsToCache); - } - const requestPromises = Promise.all(payload.urlsToCache.map(entry => { - if (typeof entry === 'string') { - entry = [entry]; - } - const request = new Request(...entry); - return this.handleRequest({ - request, - event - }); - // TODO(philipwalton): TypeScript errors without this typecast for - // some reason (probably a bug). The real type here should work but - // doesn't: `Array | undefined>`. - })); // TypeScript - event.waitUntil(requestPromises); - // If a MessageChannel was used, reply to the message on success. - if (event.ports && event.ports[0]) { - void requestPromises.then(() => event.ports[0].postMessage(true)); - } - } - }); - } - /** - * Apply the routing rules to a FetchEvent object to get a Response from an - * appropriate Route's handler. - * - * @param {Object} options - * @param {Request} options.request The request to handle. - * @param {ExtendableEvent} options.event The event that triggered the - * request. - * @return {Promise|undefined} A promise is returned if a - * registered route can handle the request. If there is no matching - * route and there's no `defaultHandler`, `undefined` is returned. - */ - handleRequest({ - request, - event - }) { - { - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'handleRequest', - paramName: 'options.request' - }); - } - const url = new URL(request.url, location.href); - if (!url.protocol.startsWith('http')) { - { - logger.debug(`Workbox Router only supports URLs that start with 'http'.`); - } - return; - } - const sameOrigin = url.origin === location.origin; - const { - params, - route - } = this.findMatchingRoute({ - event, - request, - sameOrigin, - url - }); - let handler = route && route.handler; - const debugMessages = []; - { - if (handler) { - debugMessages.push([`Found a route to handle this request:`, route]); - if (params) { - debugMessages.push([`Passing the following params to the route's handler:`, params]); - } - } - } - // If we don't have a handler because there was no matching route, then - // fall back to defaultHandler if that's defined. - const method = request.method; - if (!handler && this._defaultHandlerMap.has(method)) { - { - debugMessages.push(`Failed to find a matching route. Falling ` + `back to the default handler for ${method}.`); - } - handler = this._defaultHandlerMap.get(method); - } - if (!handler) { - { - // No handler so Workbox will do nothing. If logs is set of debug - // i.e. verbose, we should print out this information. - logger.debug(`No route found for: ${getFriendlyURL(url)}`); - } - return; - } - { - // We have a handler, meaning Workbox is going to handle the route. - // print the routing details to the console. - logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`); - debugMessages.forEach(msg => { - if (Array.isArray(msg)) { - logger.log(...msg); - } else { - logger.log(msg); - } - }); - logger.groupEnd(); - } - // Wrap in try and catch in case the handle method throws a synchronous - // error. It should still callback to the catch handler. - let responsePromise; - try { - responsePromise = handler.handle({ - url, - request, - event, - params - }); - } catch (err) { - responsePromise = Promise.reject(err); - } - // Get route's catch handler, if it exists - const catchHandler = route && route.catchHandler; - if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) { - responsePromise = responsePromise.catch(async err => { - // If there's a route catch handler, process that first - if (catchHandler) { - { - // Still include URL here as it will be async from the console group - // and may not make sense without the URL - logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`); - logger.error(`Error thrown by:`, route); - logger.error(err); - logger.groupEnd(); - } - try { - return await catchHandler.handle({ - url, - request, - event, - params - }); - } catch (catchErr) { - if (catchErr instanceof Error) { - err = catchErr; - } - } - } - if (this._catchHandler) { - { - // Still include URL here as it will be async from the console group - // and may not make sense without the URL - logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to global Catch Handler.`); - logger.error(`Error thrown by:`, route); - logger.error(err); - logger.groupEnd(); - } - return this._catchHandler.handle({ - url, - request, - event - }); - } - throw err; - }); - } - return responsePromise; - } - /** - * Checks a request and URL (and optionally an event) against the list of - * registered routes, and if there's a match, returns the corresponding - * route along with any params generated by the match. - * - * @param {Object} options - * @param {URL} options.url - * @param {boolean} options.sameOrigin The result of comparing `url.origin` - * against the current origin. - * @param {Request} options.request The request to match. - * @param {Event} options.event The corresponding event. - * @return {Object} An object with `route` and `params` properties. - * They are populated if a matching route was found or `undefined` - * otherwise. - */ - findMatchingRoute({ - url, - sameOrigin, - request, - event - }) { - const routes = this._routes.get(request.method) || []; - for (const route of routes) { - let params; - // route.match returns type any, not possible to change right now. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const matchResult = route.match({ - url, - sameOrigin, - request, - event - }); - if (matchResult) { - { - // Warn developers that using an async matchCallback is almost always - // not the right thing to do. - if (matchResult instanceof Promise) { - logger.warn(`While routing ${getFriendlyURL(url)}, an async ` + `matchCallback function was used. Please convert the ` + `following route to use a synchronous matchCallback function:`, route); - } - } - // See https://github.com/GoogleChrome/workbox/issues/2079 - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - params = matchResult; - if (Array.isArray(params) && params.length === 0) { - // Instead of passing an empty array in as params, use undefined. - params = undefined; - } else if (matchResult.constructor === Object && - // eslint-disable-line - Object.keys(matchResult).length === 0) { - // Instead of passing an empty object in as params, use undefined. - params = undefined; - } else if (typeof matchResult === 'boolean') { - // For the boolean value true (rather than just something truth-y), - // don't set params. - // See https://github.com/GoogleChrome/workbox/pull/2134#issuecomment-513924353 - params = undefined; - } - // Return early if have a match. - return { - route, - params - }; - } - } - // If no match was found above, return and empty object. - return {}; - } - /** - * Define a default `handler` that's called when no routes explicitly - * match the incoming request. - * - * Each HTTP method ('GET', 'POST', etc.) gets its own default handler. - * - * Without a default handler, unmatched requests will go against the - * network as if there were no service worker present. - * - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - * @param {string} [method='GET'] The HTTP method to associate with this - * default handler. Each method has its own default. - */ - setDefaultHandler(handler, method = defaultMethod) { - this._defaultHandlerMap.set(method, normalizeHandler(handler)); - } - /** - * If a Route throws an error while handling a request, this `handler` - * will be called and given a chance to provide a response. - * - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - */ - setCatchHandler(handler) { - this._catchHandler = normalizeHandler(handler); - } - /** - * Registers a route with the router. - * - * @param {workbox-routing.Route} route The route to register. - */ - registerRoute(route) { - { - finalAssertExports.isType(route, 'object', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route' - }); - finalAssertExports.hasMethod(route, 'match', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route' - }); - finalAssertExports.isType(route.handler, 'object', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route' - }); - finalAssertExports.hasMethod(route.handler, 'handle', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route.handler' - }); - finalAssertExports.isType(route.method, 'string', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route.method' - }); - } - if (!this._routes.has(route.method)) { - this._routes.set(route.method, []); - } - // Give precedence to all of the earlier routes by adding this additional - // route to the end of the array. - this._routes.get(route.method).push(route); - } - /** - * Unregisters a route with the router. - * - * @param {workbox-routing.Route} route The route to unregister. - */ - unregisterRoute(route) { - if (!this._routes.has(route.method)) { - throw new WorkboxError('unregister-route-but-not-found-with-method', { - method: route.method - }); - } - const routeIndex = this._routes.get(route.method).indexOf(route); - if (routeIndex > -1) { - this._routes.get(route.method).splice(routeIndex, 1); - } else { - throw new WorkboxError('unregister-route-route-not-registered'); - } - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - let defaultRouter; - /** - * Creates a new, singleton Router instance if one does not exist. If one - * does already exist, that instance is returned. - * - * @private - * @return {Router} - */ - const getOrCreateDefaultRouter = () => { - if (!defaultRouter) { - defaultRouter = new Router(); - // The helpers that use the default Router assume these listeners exist. - defaultRouter.addFetchListener(); - defaultRouter.addCacheListener(); - } - return defaultRouter; - }; - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Easily register a RegExp, string, or function with a caching - * strategy to a singleton Router instance. - * - * This method will generate a Route for you if needed and - * call {@link workbox-routing.Router#registerRoute}. - * - * @param {RegExp|string|workbox-routing.Route~matchCallback|workbox-routing.Route} capture - * If the capture param is a `Route`, all other arguments will be ignored. - * @param {workbox-routing~handlerCallback} [handler] A callback - * function that returns a Promise resulting in a Response. This parameter - * is required if `capture` is not a `Route` object. - * @param {string} [method='GET'] The HTTP method to match the Route - * against. - * @return {workbox-routing.Route} The generated `Route`. - * - * @memberof workbox-routing - */ - function registerRoute(capture, handler, method) { - let route; - if (typeof capture === 'string') { - const captureUrl = new URL(capture, location.href); - { - if (!(capture.startsWith('/') || capture.startsWith('http'))) { - throw new WorkboxError('invalid-string', { - moduleName: 'workbox-routing', - funcName: 'registerRoute', - paramName: 'capture' - }); - } - // We want to check if Express-style wildcards are in the pathname only. - // TODO: Remove this log message in v4. - const valueToCheck = capture.startsWith('http') ? captureUrl.pathname : capture; - // See https://github.com/pillarjs/path-to-regexp#parameters - const wildcards = '[*:?+]'; - if (new RegExp(`${wildcards}`).exec(valueToCheck)) { - logger.debug(`The '$capture' parameter contains an Express-style wildcard ` + `character (${wildcards}). Strings are now always interpreted as ` + `exact matches; use a RegExp for partial or wildcard matches.`); - } - } - const matchCallback = ({ - url - }) => { - { - if (url.pathname === captureUrl.pathname && url.origin !== captureUrl.origin) { - logger.debug(`${capture} only partially matches the cross-origin URL ` + `${url.toString()}. This route will only handle cross-origin requests ` + `if they match the entire URL.`); - } - } - return url.href === captureUrl.href; - }; - // If `capture` is a string then `handler` and `method` must be present. - route = new Route(matchCallback, handler, method); - } else if (capture instanceof RegExp) { - // If `capture` is a `RegExp` then `handler` and `method` must be present. - route = new RegExpRoute(capture, handler, method); - } else if (typeof capture === 'function') { - // If `capture` is a function then `handler` and `method` must be present. - route = new Route(capture, handler, method); - } else if (capture instanceof Route) { - route = capture; - } else { - throw new WorkboxError('unsupported-route-type', { - moduleName: 'workbox-routing', - funcName: 'registerRoute', - paramName: 'capture' - }); - } - const defaultRouter = getOrCreateDefaultRouter(); - defaultRouter.registerRoute(route); - return route; - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const _cacheNameDetails = { - googleAnalytics: 'googleAnalytics', - precache: 'precache-v2', - prefix: 'workbox', - runtime: 'runtime', - suffix: typeof registration !== 'undefined' ? registration.scope : '' - }; - const _createCacheName = cacheName => { - return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix].filter(value => value && value.length > 0).join('-'); - }; - const eachCacheNameDetail = fn => { - for (const key of Object.keys(_cacheNameDetails)) { - fn(key); - } - }; - const cacheNames = { - updateDetails: details => { - eachCacheNameDetail(key => { - if (typeof details[key] === 'string') { - _cacheNameDetails[key] = details[key]; - } - }); - }, - getGoogleAnalyticsName: userCacheName => { - return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics); - }, - getPrecacheName: userCacheName => { - return userCacheName || _createCacheName(_cacheNameDetails.precache); - }, - getPrefix: () => { - return _cacheNameDetails.prefix; - }, - getRuntimeName: userCacheName => { - return userCacheName || _createCacheName(_cacheNameDetails.runtime); - }, - getSuffix: () => { - return _cacheNameDetails.suffix; - } - }; - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A helper function that prevents a promise from being flagged as unused. - * - * @private - **/ - function dontWaitFor(promise) { - // Effective no-op. - void promise.then(() => {}); - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - // Callbacks to be executed whenever there's a quota error. - // Can't change Function type right now. - // eslint-disable-next-line @typescript-eslint/ban-types - const quotaErrorCallbacks = new Set(); - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Adds a function to the set of quotaErrorCallbacks that will be executed if - * there's a quota error. - * - * @param {Function} callback - * @memberof workbox-core - */ - // Can't change Function type - // eslint-disable-next-line @typescript-eslint/ban-types - function registerQuotaErrorCallback(callback) { - { - finalAssertExports.isType(callback, 'function', { - moduleName: 'workbox-core', - funcName: 'register', - paramName: 'callback' - }); - } - quotaErrorCallbacks.add(callback); - { - logger.log('Registered a callback to respond to quota errors.', callback); - } - } - - function _extends() { - return _extends = Object.assign ? Object.assign.bind() : function (n) { - for (var e = 1; e < arguments.length; e++) { - var t = arguments[e]; - for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); - } - return n; - }, _extends.apply(null, arguments); - } - - const instanceOfAny = (object, constructors) => constructors.some(c => object instanceof c); - let idbProxyableTypes; - let cursorAdvanceMethods; - // This is a function to prevent it throwing up in node environments. - function getIdbProxyableTypes() { - return idbProxyableTypes || (idbProxyableTypes = [IDBDatabase, IDBObjectStore, IDBIndex, IDBCursor, IDBTransaction]); - } - // This is a function to prevent it throwing up in node environments. - function getCursorAdvanceMethods() { - return cursorAdvanceMethods || (cursorAdvanceMethods = [IDBCursor.prototype.advance, IDBCursor.prototype.continue, IDBCursor.prototype.continuePrimaryKey]); - } - const cursorRequestMap = new WeakMap(); - const transactionDoneMap = new WeakMap(); - const transactionStoreNamesMap = new WeakMap(); - const transformCache = new WeakMap(); - const reverseTransformCache = new WeakMap(); - function promisifyRequest(request) { - const promise = new Promise((resolve, reject) => { - const unlisten = () => { - request.removeEventListener('success', success); - request.removeEventListener('error', error); - }; - const success = () => { - resolve(wrap(request.result)); - unlisten(); - }; - const error = () => { - reject(request.error); - unlisten(); - }; - request.addEventListener('success', success); - request.addEventListener('error', error); - }); - promise.then(value => { - // Since cursoring reuses the IDBRequest (*sigh*), we cache it for later retrieval - // (see wrapFunction). - if (value instanceof IDBCursor) { - cursorRequestMap.set(value, request); - } - // Catching to avoid "Uncaught Promise exceptions" - }).catch(() => {}); - // This mapping exists in reverseTransformCache but doesn't doesn't exist in transformCache. This - // is because we create many promises from a single IDBRequest. - reverseTransformCache.set(promise, request); - return promise; - } - function cacheDonePromiseForTransaction(tx) { - // Early bail if we've already created a done promise for this transaction. - if (transactionDoneMap.has(tx)) return; - const done = new Promise((resolve, reject) => { - const unlisten = () => { - tx.removeEventListener('complete', complete); - tx.removeEventListener('error', error); - tx.removeEventListener('abort', error); - }; - const complete = () => { - resolve(); - unlisten(); - }; - const error = () => { - reject(tx.error || new DOMException('AbortError', 'AbortError')); - unlisten(); - }; - tx.addEventListener('complete', complete); - tx.addEventListener('error', error); - tx.addEventListener('abort', error); - }); - // Cache it for later retrieval. - transactionDoneMap.set(tx, done); - } - let idbProxyTraps = { - get(target, prop, receiver) { - if (target instanceof IDBTransaction) { - // Special handling for transaction.done. - if (prop === 'done') return transactionDoneMap.get(target); - // Polyfill for objectStoreNames because of Edge. - if (prop === 'objectStoreNames') { - return target.objectStoreNames || transactionStoreNamesMap.get(target); - } - // Make tx.store return the only store in the transaction, or undefined if there are many. - if (prop === 'store') { - return receiver.objectStoreNames[1] ? undefined : receiver.objectStore(receiver.objectStoreNames[0]); - } - } - // Else transform whatever we get back. - return wrap(target[prop]); - }, - set(target, prop, value) { - target[prop] = value; - return true; - }, - has(target, prop) { - if (target instanceof IDBTransaction && (prop === 'done' || prop === 'store')) { - return true; - } - return prop in target; - } - }; - function replaceTraps(callback) { - idbProxyTraps = callback(idbProxyTraps); - } - function wrapFunction(func) { - // Due to expected object equality (which is enforced by the caching in `wrap`), we - // only create one new func per func. - // Edge doesn't support objectStoreNames (booo), so we polyfill it here. - if (func === IDBDatabase.prototype.transaction && !('objectStoreNames' in IDBTransaction.prototype)) { - return function (storeNames, ...args) { - const tx = func.call(unwrap(this), storeNames, ...args); - transactionStoreNamesMap.set(tx, storeNames.sort ? storeNames.sort() : [storeNames]); - return wrap(tx); - }; - } - // Cursor methods are special, as the behaviour is a little more different to standard IDB. In - // IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the - // cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense - // with real promises, so each advance methods returns a new promise for the cursor object, or - // undefined if the end of the cursor has been reached. - if (getCursorAdvanceMethods().includes(func)) { - return function (...args) { - // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use - // the original object. - func.apply(unwrap(this), args); - return wrap(cursorRequestMap.get(this)); - }; - } - return function (...args) { - // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use - // the original object. - return wrap(func.apply(unwrap(this), args)); - }; - } - function transformCachableValue(value) { - if (typeof value === 'function') return wrapFunction(value); - // This doesn't return, it just creates a 'done' promise for the transaction, - // which is later returned for transaction.done (see idbObjectHandler). - if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value); - if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps); - // Return the same value back if we're not going to transform it. - return value; - } - function wrap(value) { - // We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because - // IDB is weird and a single IDBRequest can yield many responses, so these can't be cached. - if (value instanceof IDBRequest) return promisifyRequest(value); - // If we've already transformed this value before, reuse the transformed value. - // This is faster, but it also provides object equality. - if (transformCache.has(value)) return transformCache.get(value); - const newValue = transformCachableValue(value); - // Not all types are transformed. - // These may be primitive types, so they can't be WeakMap keys. - if (newValue !== value) { - transformCache.set(value, newValue); - reverseTransformCache.set(newValue, value); - } - return newValue; - } - const unwrap = value => reverseTransformCache.get(value); - - /** - * Open a database. - * - * @param name Name of the database. - * @param version Schema version. - * @param callbacks Additional callbacks. - */ - function openDB(name, version, { - blocked, - upgrade, - blocking, - terminated - } = {}) { - const request = indexedDB.open(name, version); - const openPromise = wrap(request); - if (upgrade) { - request.addEventListener('upgradeneeded', event => { - upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event); - }); - } - if (blocked) { - request.addEventListener('blocked', event => blocked( - // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 - event.oldVersion, event.newVersion, event)); - } - openPromise.then(db => { - if (terminated) db.addEventListener('close', () => terminated()); - if (blocking) { - db.addEventListener('versionchange', event => blocking(event.oldVersion, event.newVersion, event)); - } - }).catch(() => {}); - return openPromise; - } - /** - * Delete a database. - * - * @param name Name of the database. - */ - function deleteDB(name, { - blocked - } = {}) { - const request = indexedDB.deleteDatabase(name); - if (blocked) { - request.addEventListener('blocked', event => blocked( - // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 - event.oldVersion, event)); - } - return wrap(request).then(() => undefined); - } - const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count']; - const writeMethods = ['put', 'add', 'delete', 'clear']; - const cachedMethods = new Map(); - function getMethod(target, prop) { - if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === 'string')) { - return; - } - if (cachedMethods.get(prop)) return cachedMethods.get(prop); - const targetFuncName = prop.replace(/FromIndex$/, ''); - const useIndex = prop !== targetFuncName; - const isWrite = writeMethods.includes(targetFuncName); - if ( - // Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge. - !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) { - return; - } - const method = async function (storeName, ...args) { - // isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :( - const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly'); - let target = tx.store; - if (useIndex) target = target.index(args.shift()); - // Must reject if op rejects. - // If it's a write operation, must reject if tx.done rejects. - // Must reject with op rejection first. - // Must resolve with op value. - // Must handle both promises (no unhandled rejections) - return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0]; - }; - cachedMethods.set(prop, method); - return method; - } - replaceTraps(oldTraps => _extends({}, oldTraps, { - get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver), - has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop) - })); - - // @ts-ignore - try { - self['workbox:expiration:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const DB_NAME = 'workbox-expiration'; - const CACHE_OBJECT_STORE = 'cache-entries'; - const normalizeURL = unNormalizedUrl => { - const url = new URL(unNormalizedUrl, location.href); - url.hash = ''; - return url.href; - }; - /** - * Returns the timestamp model. - * - * @private - */ - class CacheTimestampsModel { - /** - * - * @param {string} cacheName - * - * @private - */ - constructor(cacheName) { - this._db = null; - this._cacheName = cacheName; - } - /** - * Performs an upgrade of indexedDB. - * - * @param {IDBPDatabase} db - * - * @private - */ - _upgradeDb(db) { - // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we - // have to use the `id` keyPath here and create our own values (a - // concatenation of `url + cacheName`) instead of simply using - // `keyPath: ['url', 'cacheName']`, which is supported in other browsers. - const objStore = db.createObjectStore(CACHE_OBJECT_STORE, { - keyPath: 'id' - }); - // TODO(philipwalton): once we don't have to support EdgeHTML, we can - // create a single index with the keyPath `['cacheName', 'timestamp']` - // instead of doing both these indexes. - objStore.createIndex('cacheName', 'cacheName', { - unique: false - }); - objStore.createIndex('timestamp', 'timestamp', { - unique: false - }); - } - /** - * Performs an upgrade of indexedDB and deletes deprecated DBs. - * - * @param {IDBPDatabase} db - * - * @private - */ - _upgradeDbAndDeleteOldDbs(db) { - this._upgradeDb(db); - if (this._cacheName) { - void deleteDB(this._cacheName); - } - } - /** - * @param {string} url - * @param {number} timestamp - * - * @private - */ - async setTimestamp(url, timestamp) { - url = normalizeURL(url); - const entry = { - url, - timestamp, - cacheName: this._cacheName, - // Creating an ID from the URL and cache name won't be necessary once - // Edge switches to Chromium and all browsers we support work with - // array keyPaths. - id: this._getId(url) - }; - const db = await this.getDb(); - const tx = db.transaction(CACHE_OBJECT_STORE, 'readwrite', { - durability: 'relaxed' - }); - await tx.store.put(entry); - await tx.done; - } - /** - * Returns the timestamp stored for a given URL. - * - * @param {string} url - * @return {number | undefined} - * - * @private - */ - async getTimestamp(url) { - const db = await this.getDb(); - const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url)); - return entry === null || entry === void 0 ? void 0 : entry.timestamp; - } - /** - * Iterates through all the entries in the object store (from newest to - * oldest) and removes entries once either `maxCount` is reached or the - * entry's timestamp is less than `minTimestamp`. - * - * @param {number} minTimestamp - * @param {number} maxCount - * @return {Array} - * - * @private - */ - async expireEntries(minTimestamp, maxCount) { - const db = await this.getDb(); - let cursor = await db.transaction(CACHE_OBJECT_STORE).store.index('timestamp').openCursor(null, 'prev'); - const entriesToDelete = []; - let entriesNotDeletedCount = 0; - while (cursor) { - const result = cursor.value; - // TODO(philipwalton): once we can use a multi-key index, we - // won't have to check `cacheName` here. - if (result.cacheName === this._cacheName) { - // Delete an entry if it's older than the max age or - // if we already have the max number allowed. - if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) { - // TODO(philipwalton): we should be able to delete the - // entry right here, but doing so causes an iteration - // bug in Safari stable (fixed in TP). Instead we can - // store the keys of the entries to delete, and then - // delete the separate transactions. - // https://github.com/GoogleChrome/workbox/issues/1978 - // cursor.delete(); - // We only need to return the URL, not the whole entry. - entriesToDelete.push(cursor.value); - } else { - entriesNotDeletedCount++; - } - } - cursor = await cursor.continue(); - } - // TODO(philipwalton): once the Safari bug in the following issue is fixed, - // we should be able to remove this loop and do the entry deletion in the - // cursor loop above: - // https://github.com/GoogleChrome/workbox/issues/1978 - const urlsDeleted = []; - for (const entry of entriesToDelete) { - await db.delete(CACHE_OBJECT_STORE, entry.id); - urlsDeleted.push(entry.url); - } - return urlsDeleted; - } - /** - * Takes a URL and returns an ID that will be unique in the object store. - * - * @param {string} url - * @return {string} - * - * @private - */ - _getId(url) { - // Creating an ID from the URL and cache name won't be necessary once - // Edge switches to Chromium and all browsers we support work with - // array keyPaths. - return this._cacheName + '|' + normalizeURL(url); - } - /** - * Returns an open connection to the database. - * - * @private - */ - async getDb() { - if (!this._db) { - this._db = await openDB(DB_NAME, 1, { - upgrade: this._upgradeDbAndDeleteOldDbs.bind(this) - }); - } - return this._db; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The `CacheExpiration` class allows you define an expiration and / or - * limit on the number of responses stored in a - * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache). - * - * @memberof workbox-expiration - */ - class CacheExpiration { - /** - * To construct a new CacheExpiration instance you must provide at least - * one of the `config` properties. - * - * @param {string} cacheName Name of the cache to apply restrictions to. - * @param {Object} config - * @param {number} [config.maxEntries] The maximum number of entries to cache. - * Entries used the least will be removed as the maximum is reached. - * @param {number} [config.maxAgeSeconds] The maximum age of an entry before - * it's treated as stale and removed. - * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) - * that will be used when calling `delete()` on the cache. - */ - constructor(cacheName, config = {}) { - this._isRunning = false; - this._rerunRequested = false; - { - finalAssertExports.isType(cacheName, 'string', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor', - paramName: 'cacheName' - }); - if (!(config.maxEntries || config.maxAgeSeconds)) { - throw new WorkboxError('max-entries-or-age-required', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor' - }); - } - if (config.maxEntries) { - finalAssertExports.isType(config.maxEntries, 'number', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor', - paramName: 'config.maxEntries' - }); - } - if (config.maxAgeSeconds) { - finalAssertExports.isType(config.maxAgeSeconds, 'number', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor', - paramName: 'config.maxAgeSeconds' - }); - } - } - this._maxEntries = config.maxEntries; - this._maxAgeSeconds = config.maxAgeSeconds; - this._matchOptions = config.matchOptions; - this._cacheName = cacheName; - this._timestampModel = new CacheTimestampsModel(cacheName); - } - /** - * Expires entries for the given cache and given criteria. - */ - async expireEntries() { - if (this._isRunning) { - this._rerunRequested = true; - return; - } - this._isRunning = true; - const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0; - const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries); - // Delete URLs from the cache - const cache = await self.caches.open(this._cacheName); - for (const url of urlsExpired) { - await cache.delete(url, this._matchOptions); - } - { - if (urlsExpired.length > 0) { - logger.groupCollapsed(`Expired ${urlsExpired.length} ` + `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` + `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` + `'${this._cacheName}' cache.`); - logger.log(`Expired the following ${urlsExpired.length === 1 ? 'URL' : 'URLs'}:`); - urlsExpired.forEach(url => logger.log(` ${url}`)); - logger.groupEnd(); - } else { - logger.debug(`Cache expiration ran and found no entries to remove.`); - } - } - this._isRunning = false; - if (this._rerunRequested) { - this._rerunRequested = false; - dontWaitFor(this.expireEntries()); - } - } - /** - * Update the timestamp for the given URL. This ensures the when - * removing entries based on maximum entries, most recently used - * is accurate or when expiring, the timestamp is up-to-date. - * - * @param {string} url - */ - async updateTimestamp(url) { - { - finalAssertExports.isType(url, 'string', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'updateTimestamp', - paramName: 'url' - }); - } - await this._timestampModel.setTimestamp(url, Date.now()); - } - /** - * Can be used to check if a URL has expired or not before it's used. - * - * This requires a look up from IndexedDB, so can be slow. - * - * Note: This method will not remove the cached entry, call - * `expireEntries()` to remove indexedDB and Cache entries. - * - * @param {string} url - * @return {boolean} - */ - async isURLExpired(url) { - if (!this._maxAgeSeconds) { - { - throw new WorkboxError(`expired-test-without-max-age`, { - methodName: 'isURLExpired', - paramName: 'maxAgeSeconds' - }); - } - } else { - const timestamp = await this._timestampModel.getTimestamp(url); - const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000; - return timestamp !== undefined ? timestamp < expireOlderThan : true; - } - } - /** - * Removes the IndexedDB object store used to keep track of cache expiration - * metadata. - */ - async delete() { - // Make sure we don't attempt another rerun if we're called in the middle of - // a cache expiration. - this._rerunRequested = false; - await this._timestampModel.expireEntries(Infinity); // Expires all. - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * This plugin can be used in a `workbox-strategy` to regularly enforce a - * limit on the age and / or the number of cached requests. - * - * It can only be used with `workbox-strategy` instances that have a - * [custom `cacheName` property set](/web/tools/workbox/guides/configure-workbox#custom_cache_names_in_strategies). - * In other words, it can't be used to expire entries in strategy that uses the - * default runtime cache name. - * - * Whenever a cached response is used or updated, this plugin will look - * at the associated cache and remove any old or extra responses. - * - * When using `maxAgeSeconds`, responses may be used *once* after expiring - * because the expiration clean up will not have occurred until *after* the - * cached response has been used. If the response has a "Date" header, then - * a light weight expiration check is performed and the response will not be - * used immediately. - * - * When using `maxEntries`, the entry least-recently requested will be removed - * from the cache first. - * - * @memberof workbox-expiration - */ - class ExpirationPlugin { - /** - * @param {ExpirationPluginOptions} config - * @param {number} [config.maxEntries] The maximum number of entries to cache. - * Entries used the least will be removed as the maximum is reached. - * @param {number} [config.maxAgeSeconds] The maximum age of an entry before - * it's treated as stale and removed. - * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) - * that will be used when calling `delete()` on the cache. - * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to - * automatic deletion if the available storage quota has been exceeded. - */ - constructor(config = {}) { - /** - * A "lifecycle" callback that will be triggered automatically by the - * `workbox-strategies` handlers when a `Response` is about to be returned - * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to - * the handler. It allows the `Response` to be inspected for freshness and - * prevents it from being used if the `Response`'s `Date` header value is - * older than the configured `maxAgeSeconds`. - * - * @param {Object} options - * @param {string} options.cacheName Name of the cache the response is in. - * @param {Response} options.cachedResponse The `Response` object that's been - * read from a cache and whose freshness should be checked. - * @return {Response} Either the `cachedResponse`, if it's - * fresh, or `null` if the `Response` is older than `maxAgeSeconds`. - * - * @private - */ - this.cachedResponseWillBeUsed = async ({ - event, - request, - cacheName, - cachedResponse - }) => { - if (!cachedResponse) { - return null; - } - const isFresh = this._isResponseDateFresh(cachedResponse); - // Expire entries to ensure that even if the expiration date has - // expired, it'll only be used once. - const cacheExpiration = this._getCacheExpiration(cacheName); - dontWaitFor(cacheExpiration.expireEntries()); - // Update the metadata for the request URL to the current timestamp, - // but don't `await` it as we don't want to block the response. - const updateTimestampDone = cacheExpiration.updateTimestamp(request.url); - if (event) { - try { - event.waitUntil(updateTimestampDone); - } catch (error) { - { - // The event may not be a fetch event; only log the URL if it is. - if ('request' in event) { - logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache entry for ` + `'${getFriendlyURL(event.request.url)}'.`); - } - } - } - } - return isFresh ? cachedResponse : null; - }; - /** - * A "lifecycle" callback that will be triggered automatically by the - * `workbox-strategies` handlers when an entry is added to a cache. - * - * @param {Object} options - * @param {string} options.cacheName Name of the cache that was updated. - * @param {string} options.request The Request for the cached entry. - * - * @private - */ - this.cacheDidUpdate = async ({ - cacheName, - request - }) => { - { - finalAssertExports.isType(cacheName, 'string', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'cacheDidUpdate', - paramName: 'cacheName' - }); - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'cacheDidUpdate', - paramName: 'request' - }); - } - const cacheExpiration = this._getCacheExpiration(cacheName); - await cacheExpiration.updateTimestamp(request.url); - await cacheExpiration.expireEntries(); - }; - { - if (!(config.maxEntries || config.maxAgeSeconds)) { - throw new WorkboxError('max-entries-or-age-required', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'constructor' - }); - } - if (config.maxEntries) { - finalAssertExports.isType(config.maxEntries, 'number', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'constructor', - paramName: 'config.maxEntries' - }); - } - if (config.maxAgeSeconds) { - finalAssertExports.isType(config.maxAgeSeconds, 'number', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'constructor', - paramName: 'config.maxAgeSeconds' - }); - } - } - this._config = config; - this._maxAgeSeconds = config.maxAgeSeconds; - this._cacheExpirations = new Map(); - if (config.purgeOnQuotaError) { - registerQuotaErrorCallback(() => this.deleteCacheAndMetadata()); - } - } - /** - * A simple helper method to return a CacheExpiration instance for a given - * cache name. - * - * @param {string} cacheName - * @return {CacheExpiration} - * - * @private - */ - _getCacheExpiration(cacheName) { - if (cacheName === cacheNames.getRuntimeName()) { - throw new WorkboxError('expire-custom-caches-only'); - } - let cacheExpiration = this._cacheExpirations.get(cacheName); - if (!cacheExpiration) { - cacheExpiration = new CacheExpiration(cacheName, this._config); - this._cacheExpirations.set(cacheName, cacheExpiration); - } - return cacheExpiration; - } - /** - * @param {Response} cachedResponse - * @return {boolean} - * - * @private - */ - _isResponseDateFresh(cachedResponse) { - if (!this._maxAgeSeconds) { - // We aren't expiring by age, so return true, it's fresh - return true; - } - // Check if the 'date' header will suffice a quick expiration check. - // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for - // discussion. - const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse); - if (dateHeaderTimestamp === null) { - // Unable to parse date, so assume it's fresh. - return true; - } - // If we have a valid headerTime, then our response is fresh iff the - // headerTime plus maxAgeSeconds is greater than the current time. - const now = Date.now(); - return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000; - } - /** - * This method will extract the data header and parse it into a useful - * value. - * - * @param {Response} cachedResponse - * @return {number|null} - * - * @private - */ - _getDateHeaderTimestamp(cachedResponse) { - if (!cachedResponse.headers.has('date')) { - return null; - } - const dateHeader = cachedResponse.headers.get('date'); - const parsedDate = new Date(dateHeader); - const headerTime = parsedDate.getTime(); - // If the Date header was invalid for some reason, parsedDate.getTime() - // will return NaN. - if (isNaN(headerTime)) { - return null; - } - return headerTime; - } - /** - * This is a helper method that performs two operations: - * - * - Deletes *all* the underlying Cache instances associated with this plugin - * instance, by calling caches.delete() on your behalf. - * - Deletes the metadata from IndexedDB used to keep track of expiration - * details for each Cache instance. - * - * When using cache expiration, calling this method is preferable to calling - * `caches.delete()` directly, since this will ensure that the IndexedDB - * metadata is also cleanly removed and open IndexedDB instances are deleted. - * - * Note that if you're *not* using cache expiration for a given cache, calling - * `caches.delete()` and passing in the cache's name should be sufficient. - * There is no Workbox-specific method needed for cleanup in that case. - */ - async deleteCacheAndMetadata() { - // Do this one at a time instead of all at once via `Promise.all()` to - // reduce the chance of inconsistency if a promise rejects. - for (const [cacheName, cacheExpiration] of this._cacheExpirations) { - await self.caches.delete(cacheName); - await cacheExpiration.delete(); - } - // Reset this._cacheExpirations to its initial state. - this._cacheExpirations = new Map(); - } - } - - // @ts-ignore - try { - self['workbox:cacheable-response:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * This class allows you to set up rules determining what - * status codes and/or headers need to be present in order for a - * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) - * to be considered cacheable. - * - * @memberof workbox-cacheable-response - */ - class CacheableResponse { - /** - * To construct a new CacheableResponse instance you must provide at least - * one of the `config` properties. - * - * If both `statuses` and `headers` are specified, then both conditions must - * be met for the `Response` to be considered cacheable. - * - * @param {Object} config - * @param {Array} [config.statuses] One or more status codes that a - * `Response` can have and be considered cacheable. - * @param {Object} [config.headers] A mapping of header names - * and expected values that a `Response` can have and be considered cacheable. - * If multiple headers are provided, only one needs to be present. - */ - constructor(config = {}) { - { - if (!(config.statuses || config.headers)) { - throw new WorkboxError('statuses-or-headers-required', { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'constructor' - }); - } - if (config.statuses) { - finalAssertExports.isArray(config.statuses, { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'constructor', - paramName: 'config.statuses' - }); - } - if (config.headers) { - finalAssertExports.isType(config.headers, 'object', { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'constructor', - paramName: 'config.headers' - }); - } - } - this._statuses = config.statuses; - this._headers = config.headers; - } - /** - * Checks a response to see whether it's cacheable or not, based on this - * object's configuration. - * - * @param {Response} response The response whose cacheability is being - * checked. - * @return {boolean} `true` if the `Response` is cacheable, and `false` - * otherwise. - */ - isResponseCacheable(response) { - { - finalAssertExports.isInstance(response, Response, { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'isResponseCacheable', - paramName: 'response' - }); - } - let cacheable = true; - if (this._statuses) { - cacheable = this._statuses.includes(response.status); - } - if (this._headers && cacheable) { - cacheable = Object.keys(this._headers).some(headerName => { - return response.headers.get(headerName) === this._headers[headerName]; - }); - } - { - if (!cacheable) { - logger.groupCollapsed(`The request for ` + `'${getFriendlyURL(response.url)}' returned a response that does ` + `not meet the criteria for being cached.`); - logger.groupCollapsed(`View cacheability criteria here.`); - logger.log(`Cacheable statuses: ` + JSON.stringify(this._statuses)); - logger.log(`Cacheable headers: ` + JSON.stringify(this._headers, null, 2)); - logger.groupEnd(); - const logFriendlyHeaders = {}; - response.headers.forEach((value, key) => { - logFriendlyHeaders[key] = value; - }); - logger.groupCollapsed(`View response status and headers here.`); - logger.log(`Response status: ${response.status}`); - logger.log(`Response headers: ` + JSON.stringify(logFriendlyHeaders, null, 2)); - logger.groupEnd(); - logger.groupCollapsed(`View full response details here.`); - logger.log(response.headers); - logger.log(response); - logger.groupEnd(); - logger.groupEnd(); - } - } - return cacheable; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A class implementing the `cacheWillUpdate` lifecycle callback. This makes it - * easier to add in cacheability checks to requests made via Workbox's built-in - * strategies. - * - * @memberof workbox-cacheable-response - */ - class CacheableResponsePlugin { - /** - * To construct a new CacheableResponsePlugin instance you must provide at - * least one of the `config` properties. - * - * If both `statuses` and `headers` are specified, then both conditions must - * be met for the `Response` to be considered cacheable. - * - * @param {Object} config - * @param {Array} [config.statuses] One or more status codes that a - * `Response` can have and be considered cacheable. - * @param {Object} [config.headers] A mapping of header names - * and expected values that a `Response` can have and be considered cacheable. - * If multiple headers are provided, only one needs to be present. - */ - constructor(config) { - /** - * @param {Object} options - * @param {Response} options.response - * @return {Response|null} - * @private - */ - this.cacheWillUpdate = async ({ - response - }) => { - if (this._cacheableResponse.isResponseCacheable(response)) { - return response; - } - return null; - }; - this._cacheableResponse = new CacheableResponse(config); - } - } - - /* - Copyright 2020 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - function stripParams(fullURL, ignoreParams) { - const strippedURL = new URL(fullURL); - for (const param of ignoreParams) { - strippedURL.searchParams.delete(param); - } - return strippedURL.href; - } - /** - * Matches an item in the cache, ignoring specific URL params. This is similar - * to the `ignoreSearch` option, but it allows you to ignore just specific - * params (while continuing to match on the others). - * - * @private - * @param {Cache} cache - * @param {Request} request - * @param {Object} matchOptions - * @param {Array} ignoreParams - * @return {Promise} - */ - async function cacheMatchIgnoreParams(cache, request, ignoreParams, matchOptions) { - const strippedRequestURL = stripParams(request.url, ignoreParams); - // If the request doesn't include any ignored params, match as normal. - if (request.url === strippedRequestURL) { - return cache.match(request, matchOptions); - } - // Otherwise, match by comparing keys - const keysOptions = Object.assign(Object.assign({}, matchOptions), { - ignoreSearch: true - }); - const cacheKeys = await cache.keys(request, keysOptions); - for (const cacheKey of cacheKeys) { - const strippedCacheKeyURL = stripParams(cacheKey.url, ignoreParams); - if (strippedRequestURL === strippedCacheKeyURL) { - return cache.match(cacheKey, matchOptions); - } - } - return; - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The Deferred class composes Promises in a way that allows for them to be - * resolved or rejected from outside the constructor. In most cases promises - * should be used directly, but Deferreds can be necessary when the logic to - * resolve a promise must be separate. - * - * @private - */ - class Deferred { - /** - * Creates a promise and exposes its resolve and reject functions as methods. - */ - constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Runs all of the callback functions, one at a time sequentially, in the order - * in which they were registered. - * - * @memberof workbox-core - * @private - */ - async function executeQuotaErrorCallbacks() { - { - logger.log(`About to run ${quotaErrorCallbacks.size} ` + `callbacks to clean up caches.`); - } - for (const callback of quotaErrorCallbacks) { - await callback(); - { - logger.log(callback, 'is complete.'); - } - } - { - logger.log('Finished running callbacks.'); - } - } - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Returns a promise that resolves and the passed number of milliseconds. - * This utility is an async/await-friendly version of `setTimeout`. - * - * @param {number} ms - * @return {Promise} - * @private - */ - function timeout(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - // @ts-ignore - try { - self['workbox:strategies:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - function toRequest(input) { - return typeof input === 'string' ? new Request(input) : input; - } - /** - * A class created every time a Strategy instance calls - * {@link workbox-strategies.Strategy~handle} or - * {@link workbox-strategies.Strategy~handleAll} that wraps all fetch and - * cache actions around plugin callbacks and keeps track of when the strategy - * is "done" (i.e. all added `event.waitUntil()` promises have resolved). - * - * @memberof workbox-strategies - */ - class StrategyHandler { - /** - * Creates a new instance associated with the passed strategy and event - * that's handling the request. - * - * The constructor also initializes the state that will be passed to each of - * the plugins handling this request. - * - * @param {workbox-strategies.Strategy} strategy - * @param {Object} options - * @param {Request|string} options.request A request to run this strategy for. - * @param {ExtendableEvent} options.event The event associated with the - * request. - * @param {URL} [options.url] - * @param {*} [options.params] The return value from the - * {@link workbox-routing~matchCallback} (if applicable). - */ - constructor(strategy, options) { - this._cacheKeys = {}; - /** - * The request the strategy is performing (passed to the strategy's - * `handle()` or `handleAll()` method). - * @name request - * @instance - * @type {Request} - * @memberof workbox-strategies.StrategyHandler - */ - /** - * The event associated with this request. - * @name event - * @instance - * @type {ExtendableEvent} - * @memberof workbox-strategies.StrategyHandler - */ - /** - * A `URL` instance of `request.url` (if passed to the strategy's - * `handle()` or `handleAll()` method). - * Note: the `url` param will be present if the strategy was invoked - * from a workbox `Route` object. - * @name url - * @instance - * @type {URL|undefined} - * @memberof workbox-strategies.StrategyHandler - */ - /** - * A `param` value (if passed to the strategy's - * `handle()` or `handleAll()` method). - * Note: the `param` param will be present if the strategy was invoked - * from a workbox `Route` object and the - * {@link workbox-routing~matchCallback} returned - * a truthy value (it will be that value). - * @name params - * @instance - * @type {*|undefined} - * @memberof workbox-strategies.StrategyHandler - */ - { - finalAssertExports.isInstance(options.event, ExtendableEvent, { - moduleName: 'workbox-strategies', - className: 'StrategyHandler', - funcName: 'constructor', - paramName: 'options.event' - }); - } - Object.assign(this, options); - this.event = options.event; - this._strategy = strategy; - this._handlerDeferred = new Deferred(); - this._extendLifetimePromises = []; - // Copy the plugins list (since it's mutable on the strategy), - // so any mutations don't affect this handler instance. - this._plugins = [...strategy.plugins]; - this._pluginStateMap = new Map(); - for (const plugin of this._plugins) { - this._pluginStateMap.set(plugin, {}); - } - this.event.waitUntil(this._handlerDeferred.promise); - } - /** - * Fetches a given request (and invokes any applicable plugin callback - * methods) using the `fetchOptions` (for non-navigation requests) and - * `plugins` defined on the `Strategy` object. - * - * The following plugin lifecycle methods are invoked when using this method: - * - `requestWillFetch()` - * - `fetchDidSucceed()` - * - `fetchDidFail()` - * - * @param {Request|string} input The URL or request to fetch. - * @return {Promise} - */ - async fetch(input) { - const { - event - } = this; - let request = toRequest(input); - if (request.mode === 'navigate' && event instanceof FetchEvent && event.preloadResponse) { - const possiblePreloadResponse = await event.preloadResponse; - if (possiblePreloadResponse) { - { - logger.log(`Using a preloaded navigation response for ` + `'${getFriendlyURL(request.url)}'`); - } - return possiblePreloadResponse; - } - } - // If there is a fetchDidFail plugin, we need to save a clone of the - // original request before it's either modified by a requestWillFetch - // plugin or before the original request's body is consumed via fetch(). - const originalRequest = this.hasCallback('fetchDidFail') ? request.clone() : null; - try { - for (const cb of this.iterateCallbacks('requestWillFetch')) { - request = await cb({ - request: request.clone(), - event - }); - } - } catch (err) { - if (err instanceof Error) { - throw new WorkboxError('plugin-error-request-will-fetch', { - thrownErrorMessage: err.message - }); - } - } - // The request can be altered by plugins with `requestWillFetch` making - // the original request (most likely from a `fetch` event) different - // from the Request we make. Pass both to `fetchDidFail` to aid debugging. - const pluginFilteredRequest = request.clone(); - try { - let fetchResponse; - // See https://github.com/GoogleChrome/workbox/issues/1796 - fetchResponse = await fetch(request, request.mode === 'navigate' ? undefined : this._strategy.fetchOptions); - if ("development" !== 'production') { - logger.debug(`Network request for ` + `'${getFriendlyURL(request.url)}' returned a response with ` + `status '${fetchResponse.status}'.`); - } - for (const callback of this.iterateCallbacks('fetchDidSucceed')) { - fetchResponse = await callback({ - event, - request: pluginFilteredRequest, - response: fetchResponse - }); - } - return fetchResponse; - } catch (error) { - { - logger.log(`Network request for ` + `'${getFriendlyURL(request.url)}' threw an error.`, error); - } - // `originalRequest` will only exist if a `fetchDidFail` callback - // is being used (see above). - if (originalRequest) { - await this.runCallbacks('fetchDidFail', { - error: error, - event, - originalRequest: originalRequest.clone(), - request: pluginFilteredRequest.clone() - }); - } - throw error; - } - } - /** - * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on - * the response generated by `this.fetch()`. - * - * The call to `this.cachePut()` automatically invokes `this.waitUntil()`, - * so you do not have to manually call `waitUntil()` on the event. - * - * @param {Request|string} input The request or URL to fetch and cache. - * @return {Promise} - */ - async fetchAndCachePut(input) { - const response = await this.fetch(input); - const responseClone = response.clone(); - void this.waitUntil(this.cachePut(input, responseClone)); - return response; - } - /** - * Matches a request from the cache (and invokes any applicable plugin - * callback methods) using the `cacheName`, `matchOptions`, and `plugins` - * defined on the strategy object. - * - * The following plugin lifecycle methods are invoked when using this method: - * - cacheKeyWillBeUsed() - * - cachedResponseWillBeUsed() - * - * @param {Request|string} key The Request or URL to use as the cache key. - * @return {Promise} A matching response, if found. - */ - async cacheMatch(key) { - const request = toRequest(key); - let cachedResponse; - const { - cacheName, - matchOptions - } = this._strategy; - const effectiveRequest = await this.getCacheKey(request, 'read'); - const multiMatchOptions = Object.assign(Object.assign({}, matchOptions), { - cacheName - }); - cachedResponse = await caches.match(effectiveRequest, multiMatchOptions); - { - if (cachedResponse) { - logger.debug(`Found a cached response in '${cacheName}'.`); - } else { - logger.debug(`No cached response found in '${cacheName}'.`); - } - } - for (const callback of this.iterateCallbacks('cachedResponseWillBeUsed')) { - cachedResponse = (await callback({ - cacheName, - matchOptions, - cachedResponse, - request: effectiveRequest, - event: this.event - })) || undefined; - } - return cachedResponse; - } - /** - * Puts a request/response pair in the cache (and invokes any applicable - * plugin callback methods) using the `cacheName` and `plugins` defined on - * the strategy object. - * - * The following plugin lifecycle methods are invoked when using this method: - * - cacheKeyWillBeUsed() - * - cacheWillUpdate() - * - cacheDidUpdate() - * - * @param {Request|string} key The request or URL to use as the cache key. - * @param {Response} response The response to cache. - * @return {Promise} `false` if a cacheWillUpdate caused the response - * not be cached, and `true` otherwise. - */ - async cachePut(key, response) { - const request = toRequest(key); - // Run in the next task to avoid blocking other cache reads. - // https://github.com/w3c/ServiceWorker/issues/1397 - await timeout(0); - const effectiveRequest = await this.getCacheKey(request, 'write'); - { - if (effectiveRequest.method && effectiveRequest.method !== 'GET') { - throw new WorkboxError('attempt-to-cache-non-get-request', { - url: getFriendlyURL(effectiveRequest.url), - method: effectiveRequest.method - }); - } - // See https://github.com/GoogleChrome/workbox/issues/2818 - const vary = response.headers.get('Vary'); - if (vary) { - logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} ` + `has a 'Vary: ${vary}' header. ` + `Consider setting the {ignoreVary: true} option on your strategy ` + `to ensure cache matching and deletion works as expected.`); - } - } - if (!response) { - { - logger.error(`Cannot cache non-existent response for ` + `'${getFriendlyURL(effectiveRequest.url)}'.`); - } - throw new WorkboxError('cache-put-with-no-response', { - url: getFriendlyURL(effectiveRequest.url) - }); - } - const responseToCache = await this._ensureResponseSafeToCache(response); - if (!responseToCache) { - { - logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' ` + `will not be cached.`, responseToCache); - } - return false; - } - const { - cacheName, - matchOptions - } = this._strategy; - const cache = await self.caches.open(cacheName); - const hasCacheUpdateCallback = this.hasCallback('cacheDidUpdate'); - const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams( - // TODO(philipwalton): the `__WB_REVISION__` param is a precaching - // feature. Consider into ways to only add this behavior if using - // precaching. - cache, effectiveRequest.clone(), ['__WB_REVISION__'], matchOptions) : null; - { - logger.debug(`Updating the '${cacheName}' cache with a new Response ` + `for ${getFriendlyURL(effectiveRequest.url)}.`); - } - try { - await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache); - } catch (error) { - if (error instanceof Error) { - // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError - if (error.name === 'QuotaExceededError') { - await executeQuotaErrorCallbacks(); - } - throw error; - } - } - for (const callback of this.iterateCallbacks('cacheDidUpdate')) { - await callback({ - cacheName, - oldResponse, - newResponse: responseToCache.clone(), - request: effectiveRequest, - event: this.event - }); - } - return true; - } - /** - * Checks the list of plugins for the `cacheKeyWillBeUsed` callback, and - * executes any of those callbacks found in sequence. The final `Request` - * object returned by the last plugin is treated as the cache key for cache - * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have - * been registered, the passed request is returned unmodified - * - * @param {Request} request - * @param {string} mode - * @return {Promise} - */ - async getCacheKey(request, mode) { - const key = `${request.url} | ${mode}`; - if (!this._cacheKeys[key]) { - let effectiveRequest = request; - for (const callback of this.iterateCallbacks('cacheKeyWillBeUsed')) { - effectiveRequest = toRequest(await callback({ - mode, - request: effectiveRequest, - event: this.event, - // params has a type any can't change right now. - params: this.params // eslint-disable-line - })); - } - this._cacheKeys[key] = effectiveRequest; - } - return this._cacheKeys[key]; - } - /** - * Returns true if the strategy has at least one plugin with the given - * callback. - * - * @param {string} name The name of the callback to check for. - * @return {boolean} - */ - hasCallback(name) { - for (const plugin of this._strategy.plugins) { - if (name in plugin) { - return true; - } - } - return false; - } - /** - * Runs all plugin callbacks matching the given name, in order, passing the - * given param object (merged ith the current plugin state) as the only - * argument. - * - * Note: since this method runs all plugins, it's not suitable for cases - * where the return value of a callback needs to be applied prior to calling - * the next callback. See - * {@link workbox-strategies.StrategyHandler#iterateCallbacks} - * below for how to handle that case. - * - * @param {string} name The name of the callback to run within each plugin. - * @param {Object} param The object to pass as the first (and only) param - * when executing each callback. This object will be merged with the - * current plugin state prior to callback execution. - */ - async runCallbacks(name, param) { - for (const callback of this.iterateCallbacks(name)) { - // TODO(philipwalton): not sure why `any` is needed. It seems like - // this should work with `as WorkboxPluginCallbackParam[C]`. - await callback(param); - } - } - /** - * Accepts a callback and returns an iterable of matching plugin callbacks, - * where each callback is wrapped with the current handler state (i.e. when - * you call each callback, whatever object parameter you pass it will - * be merged with the plugin's current state). - * - * @param {string} name The name fo the callback to run - * @return {Array} - */ - *iterateCallbacks(name) { - for (const plugin of this._strategy.plugins) { - if (typeof plugin[name] === 'function') { - const state = this._pluginStateMap.get(plugin); - const statefulCallback = param => { - const statefulParam = Object.assign(Object.assign({}, param), { - state - }); - // TODO(philipwalton): not sure why `any` is needed. It seems like - // this should work with `as WorkboxPluginCallbackParam[C]`. - return plugin[name](statefulParam); - }; - yield statefulCallback; - } - } - } - /** - * Adds a promise to the - * [extend lifetime promises]{@link https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises} - * of the event associated with the request being handled (usually a - * `FetchEvent`). - * - * Note: you can await - * {@link workbox-strategies.StrategyHandler~doneWaiting} - * to know when all added promises have settled. - * - * @param {Promise} promise A promise to add to the extend lifetime promises - * of the event that triggered the request. - */ - waitUntil(promise) { - this._extendLifetimePromises.push(promise); - return promise; - } - /** - * Returns a promise that resolves once all promises passed to - * {@link workbox-strategies.StrategyHandler~waitUntil} - * have settled. - * - * Note: any work done after `doneWaiting()` settles should be manually - * passed to an event's `waitUntil()` method (not this handler's - * `waitUntil()` method), otherwise the service worker thread may be killed - * prior to your work completing. - */ - async doneWaiting() { - while (this._extendLifetimePromises.length) { - const promises = this._extendLifetimePromises.splice(0); - const result = await Promise.allSettled(promises); - const firstRejection = result.find(i => i.status === 'rejected'); - if (firstRejection) { - throw firstRejection.reason; - } - } - } - /** - * Stops running the strategy and immediately resolves any pending - * `waitUntil()` promises. - */ - destroy() { - this._handlerDeferred.resolve(null); - } - /** - * This method will call cacheWillUpdate on the available plugins (or use - * status === 200) to determine if the Response is safe and valid to cache. - * - * @param {Request} options.request - * @param {Response} options.response - * @return {Promise} - * - * @private - */ - async _ensureResponseSafeToCache(response) { - let responseToCache = response; - let pluginsUsed = false; - for (const callback of this.iterateCallbacks('cacheWillUpdate')) { - responseToCache = (await callback({ - request: this.request, - response: responseToCache, - event: this.event - })) || undefined; - pluginsUsed = true; - if (!responseToCache) { - break; - } - } - if (!pluginsUsed) { - if (responseToCache && responseToCache.status !== 200) { - responseToCache = undefined; - } - { - if (responseToCache) { - if (responseToCache.status !== 200) { - if (responseToCache.status === 0) { - logger.warn(`The response for '${this.request.url}' ` + `is an opaque response. The caching strategy that you're ` + `using will not cache opaque responses by default.`); - } else { - logger.debug(`The response for '${this.request.url}' ` + `returned a status code of '${response.status}' and won't ` + `be cached as a result.`); - } - } - } - } - } - return responseToCache; - } - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * An abstract base class that all other strategy classes must extend from: - * - * @memberof workbox-strategies - */ - class Strategy { - /** - * Creates a new instance of the strategy and sets all documented option - * properties as public instance properties. - * - * Note: if a custom strategy class extends the base Strategy class and does - * not need more than these properties, it does not need to define its own - * constructor. - * - * @param {Object} [options] - * @param {string} [options.cacheName] Cache name to store and retrieve - * requests. Defaults to the cache names provided by - * {@link workbox-core.cacheNames}. - * @param {Array} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} - * to use in conjunction with this caching strategy. - * @param {Object} [options.fetchOptions] Values passed along to the - * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) - * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) - * `fetch()` requests made by this strategy. - * @param {Object} [options.matchOptions] The - * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} - * for any `cache.match()` or `cache.put()` calls made by this strategy. - */ - constructor(options = {}) { - /** - * Cache name to store and retrieve - * requests. Defaults to the cache names provided by - * {@link workbox-core.cacheNames}. - * - * @type {string} - */ - this.cacheName = cacheNames.getRuntimeName(options.cacheName); - /** - * The list - * [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} - * used by this strategy. - * - * @type {Array} - */ - this.plugins = options.plugins || []; - /** - * Values passed along to the - * [`init`]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters} - * of all fetch() requests made by this strategy. - * - * @type {Object} - */ - this.fetchOptions = options.fetchOptions; - /** - * The - * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} - * for any `cache.match()` or `cache.put()` calls made by this strategy. - * - * @type {Object} - */ - this.matchOptions = options.matchOptions; - } - /** - * Perform a request strategy and returns a `Promise` that will resolve with - * a `Response`, invoking all relevant plugin callbacks. - * - * When a strategy instance is registered with a Workbox - * {@link workbox-routing.Route}, this method is automatically - * called when the route matches. - * - * Alternatively, this method can be used in a standalone `FetchEvent` - * listener by passing it to `event.respondWith()`. - * - * @param {FetchEvent|Object} options A `FetchEvent` or an object with the - * properties listed below. - * @param {Request|string} options.request A request to run this strategy for. - * @param {ExtendableEvent} options.event The event associated with the - * request. - * @param {URL} [options.url] - * @param {*} [options.params] - */ - handle(options) { - const [responseDone] = this.handleAll(options); - return responseDone; - } - /** - * Similar to {@link workbox-strategies.Strategy~handle}, but - * instead of just returning a `Promise` that resolves to a `Response` it - * it will return an tuple of `[response, done]` promises, where the former - * (`response`) is equivalent to what `handle()` returns, and the latter is a - * Promise that will resolve once any promises that were added to - * `event.waitUntil()` as part of performing the strategy have completed. - * - * You can await the `done` promise to ensure any extra work performed by - * the strategy (usually caching responses) completes successfully. - * - * @param {FetchEvent|Object} options A `FetchEvent` or an object with the - * properties listed below. - * @param {Request|string} options.request A request to run this strategy for. - * @param {ExtendableEvent} options.event The event associated with the - * request. - * @param {URL} [options.url] - * @param {*} [options.params] - * @return {Array} A tuple of [response, done] - * promises that can be used to determine when the response resolves as - * well as when the handler has completed all its work. - */ - handleAll(options) { - // Allow for flexible options to be passed. - if (options instanceof FetchEvent) { - options = { - event: options, - request: options.request - }; - } - const event = options.event; - const request = typeof options.request === 'string' ? new Request(options.request) : options.request; - const params = 'params' in options ? options.params : undefined; - const handler = new StrategyHandler(this, { - event, - request, - params - }); - const responseDone = this._getResponse(handler, request, event); - const handlerDone = this._awaitComplete(responseDone, handler, request, event); - // Return an array of promises, suitable for use with Promise.all(). - return [responseDone, handlerDone]; - } - async _getResponse(handler, request, event) { - await handler.runCallbacks('handlerWillStart', { - event, - request - }); - let response = undefined; - try { - response = await this._handle(request, handler); - // The "official" Strategy subclasses all throw this error automatically, - // but in case a third-party Strategy doesn't, ensure that we have a - // consistent failure when there's no response or an error response. - if (!response || response.type === 'error') { - throw new WorkboxError('no-response', { - url: request.url - }); - } - } catch (error) { - if (error instanceof Error) { - for (const callback of handler.iterateCallbacks('handlerDidError')) { - response = await callback({ - error, - event, - request - }); - if (response) { - break; - } - } - } - if (!response) { - throw error; - } else { - logger.log(`While responding to '${getFriendlyURL(request.url)}', ` + `an ${error instanceof Error ? error.toString() : ''} error occurred. Using a fallback response provided by ` + `a handlerDidError plugin.`); - } - } - for (const callback of handler.iterateCallbacks('handlerWillRespond')) { - response = await callback({ - event, - request, - response - }); - } - return response; - } - async _awaitComplete(responseDone, handler, request, event) { - let response; - let error; - try { - response = await responseDone; - } catch (error) { - // Ignore errors, as response errors should be caught via the `response` - // promise above. The `done` promise will only throw for errors in - // promises passed to `handler.waitUntil()`. - } - try { - await handler.runCallbacks('handlerDidRespond', { - event, - request, - response - }); - await handler.doneWaiting(); - } catch (waitUntilError) { - if (waitUntilError instanceof Error) { - error = waitUntilError; - } - } - await handler.runCallbacks('handlerDidComplete', { - event, - request, - response, - error: error - }); - handler.destroy(); - if (error) { - throw error; - } - } - } - /** - * Classes extending the `Strategy` based class should implement this method, - * and leverage the {@link workbox-strategies.StrategyHandler} - * arg to perform all fetching and cache logic, which will ensure all relevant - * cache, cache options, fetch options and plugins are used (per the current - * strategy instance). - * - * @name _handle - * @instance - * @abstract - * @function - * @param {Request} request - * @param {workbox-strategies.StrategyHandler} handler - * @return {Promise} - * - * @memberof workbox-strategies.Strategy - */ - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const messages = { - strategyStart: (strategyName, request) => `Using ${strategyName} to respond to '${getFriendlyURL(request.url)}'`, - printFinalResponse: response => { - if (response) { - logger.groupCollapsed(`View the final response here.`); - logger.log(response || '[No response returned]'); - logger.groupEnd(); - } - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * An implementation of a [cache-first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#cache-first-falling-back-to-network) - * request strategy. - * - * A cache first strategy is useful for assets that have been revisioned, - * such as URLs like `/styles/example.a8f5f1.css`, since they - * can be cached for long periods of time. - * - * If the network request fails, and there is no cache match, this will throw - * a `WorkboxError` exception. - * - * @extends workbox-strategies.Strategy - * @memberof workbox-strategies - */ - class CacheFirst extends Strategy { - /** - * @private - * @param {Request|string} request A request to run this strategy for. - * @param {workbox-strategies.StrategyHandler} handler The event that - * triggered the request. - * @return {Promise} - */ - async _handle(request, handler) { - const logs = []; - { - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-strategies', - className: this.constructor.name, - funcName: 'makeRequest', - paramName: 'request' - }); - } - let response = await handler.cacheMatch(request); - let error = undefined; - if (!response) { - { - logs.push(`No response found in the '${this.cacheName}' cache. ` + `Will respond with a network request.`); - } - try { - response = await handler.fetchAndCachePut(request); - } catch (err) { - if (err instanceof Error) { - error = err; - } - } - { - if (response) { - logs.push(`Got response from network.`); - } else { - logs.push(`Unable to get a response from the network.`); - } - } - } else { - { - logs.push(`Found a cached response in the '${this.cacheName}' cache.`); - } - } - { - logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); - for (const log of logs) { - logger.log(log); - } - messages.printFinalResponse(response); - logger.groupEnd(); - } - if (!response) { - throw new WorkboxError('no-response', { - url: request.url, - error - }); - } - return response; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const cacheOkAndOpaquePlugin = { - /** - * Returns a valid response (to allow caching) if the status is 200 (OK) or - * 0 (opaque). - * - * @param {Object} options - * @param {Response} options.response - * @return {Response|null} - * - * @private - */ - cacheWillUpdate: async ({ - response - }) => { - if (response.status === 200 || response.status === 0) { - return response; - } - return null; - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * An implementation of a - * [network first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#network-first-falling-back-to-cache) - * request strategy. - * - * By default, this strategy will cache responses with a 200 status code as - * well as [opaque responses](https://developer.chrome.com/docs/workbox/caching-resources-during-runtime/#opaque-responses). - * Opaque responses are are cross-origin requests where the response doesn't - * support [CORS](https://enable-cors.org/). - * - * If the network request fails, and there is no cache match, this will throw - * a `WorkboxError` exception. - * - * @extends workbox-strategies.Strategy - * @memberof workbox-strategies - */ - class NetworkFirst extends Strategy { - /** - * @param {Object} [options] - * @param {string} [options.cacheName] Cache name to store and retrieve - * requests. Defaults to cache names provided by - * {@link workbox-core.cacheNames}. - * @param {Array} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} - * to use in conjunction with this caching strategy. - * @param {Object} [options.fetchOptions] Values passed along to the - * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) - * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) - * `fetch()` requests made by this strategy. - * @param {Object} [options.matchOptions] [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) - * @param {number} [options.networkTimeoutSeconds] If set, any network requests - * that fail to respond within the timeout will fallback to the cache. - * - * This option can be used to combat - * "[lie-fi]{@link https://developers.google.com/web/fundamentals/performance/poor-connectivity/#lie-fi}" - * scenarios. - */ - constructor(options = {}) { - super(options); - // If this instance contains no plugins with a 'cacheWillUpdate' callback, - // prepend the `cacheOkAndOpaquePlugin` plugin to the plugins list. - if (!this.plugins.some(p => 'cacheWillUpdate' in p)) { - this.plugins.unshift(cacheOkAndOpaquePlugin); - } - this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0; - { - if (this._networkTimeoutSeconds) { - finalAssertExports.isType(this._networkTimeoutSeconds, 'number', { - moduleName: 'workbox-strategies', - className: this.constructor.name, - funcName: 'constructor', - paramName: 'networkTimeoutSeconds' - }); - } - } - } - /** - * @private - * @param {Request|string} request A request to run this strategy for. - * @param {workbox-strategies.StrategyHandler} handler The event that - * triggered the request. - * @return {Promise} - */ - async _handle(request, handler) { - const logs = []; - { - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-strategies', - className: this.constructor.name, - funcName: 'handle', - paramName: 'makeRequest' - }); - } - const promises = []; - let timeoutId; - if (this._networkTimeoutSeconds) { - const { - id, - promise - } = this._getTimeoutPromise({ - request, - logs, - handler - }); - timeoutId = id; - promises.push(promise); - } - const networkPromise = this._getNetworkPromise({ - timeoutId, - request, - logs, - handler - }); - promises.push(networkPromise); - const response = await handler.waitUntil((async () => { - // Promise.race() will resolve as soon as the first promise resolves. - return (await handler.waitUntil(Promise.race(promises))) || ( - // If Promise.race() resolved with null, it might be due to a network - // timeout + a cache miss. If that were to happen, we'd rather wait until - // the networkPromise resolves instead of returning null. - // Note that it's fine to await an already-resolved promise, so we don't - // have to check to see if it's still "in flight". - await networkPromise); - })()); - { - logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); - for (const log of logs) { - logger.log(log); - } - messages.printFinalResponse(response); - logger.groupEnd(); - } - if (!response) { - throw new WorkboxError('no-response', { - url: request.url - }); - } - return response; - } - /** - * @param {Object} options - * @param {Request} options.request - * @param {Array} options.logs A reference to the logs array - * @param {Event} options.event - * @return {Promise} - * - * @private - */ - _getTimeoutPromise({ - request, - logs, - handler - }) { - let timeoutId; - const timeoutPromise = new Promise(resolve => { - const onNetworkTimeout = async () => { - { - logs.push(`Timing out the network response at ` + `${this._networkTimeoutSeconds} seconds.`); - } - resolve(await handler.cacheMatch(request)); - }; - timeoutId = setTimeout(onNetworkTimeout, this._networkTimeoutSeconds * 1000); - }); - return { - promise: timeoutPromise, - id: timeoutId - }; - } - /** - * @param {Object} options - * @param {number|undefined} options.timeoutId - * @param {Request} options.request - * @param {Array} options.logs A reference to the logs Array. - * @param {Event} options.event - * @return {Promise} - * - * @private - */ - async _getNetworkPromise({ - timeoutId, - request, - logs, - handler - }) { - let error; - let response; - try { - response = await handler.fetchAndCachePut(request); - } catch (fetchError) { - if (fetchError instanceof Error) { - error = fetchError; - } - } - if (timeoutId) { - clearTimeout(timeoutId); - } - { - if (response) { - logs.push(`Got response from network.`); - } else { - logs.push(`Unable to get a response from the network. Will respond ` + `with a cached response.`); - } - } - if (error || !response) { - response = await handler.cacheMatch(request); - { - if (response) { - logs.push(`Found a cached response in the '${this.cacheName}'` + ` cache.`); - } else { - logs.push(`No response found in the '${this.cacheName}' cache.`); - } - } - } - return response; - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Claim any currently available clients once the service worker - * becomes active. This is normally used in conjunction with `skipWaiting()`. - * - * @memberof workbox-core - */ - function clientsClaim() { - self.addEventListener('activate', () => self.clients.claim()); - } - - /* - Copyright 2020 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A utility method that makes it easier to use `event.waitUntil` with - * async functions and return the result. - * - * @param {ExtendableEvent} event - * @param {Function} asyncFn - * @return {Function} - * @private - */ - function waitUntil(event, asyncFn) { - const returnPromise = asyncFn(); - event.waitUntil(returnPromise); - return returnPromise; - } - - // @ts-ignore - try { - self['workbox:precaching:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - // Name of the search parameter used to store revision info. - const REVISION_SEARCH_PARAM = '__WB_REVISION__'; - /** - * Converts a manifest entry into a versioned URL suitable for precaching. - * - * @param {Object|string} entry - * @return {string} A URL with versioning info. - * - * @private - * @memberof workbox-precaching - */ - function createCacheKey(entry) { - if (!entry) { - throw new WorkboxError('add-to-cache-list-unexpected-type', { - entry - }); - } - // If a precache manifest entry is a string, it's assumed to be a versioned - // URL, like '/app.abcd1234.js'. Return as-is. - if (typeof entry === 'string') { - const urlObject = new URL(entry, location.href); - return { - cacheKey: urlObject.href, - url: urlObject.href - }; - } - const { - revision, - url - } = entry; - if (!url) { - throw new WorkboxError('add-to-cache-list-unexpected-type', { - entry - }); - } - // If there's just a URL and no revision, then it's also assumed to be a - // versioned URL. - if (!revision) { - const urlObject = new URL(url, location.href); - return { - cacheKey: urlObject.href, - url: urlObject.href - }; - } - // Otherwise, construct a properly versioned URL using the custom Workbox - // search parameter along with the revision info. - const cacheKeyURL = new URL(url, location.href); - const originalURL = new URL(url, location.href); - cacheKeyURL.searchParams.set(REVISION_SEARCH_PARAM, revision); - return { - cacheKey: cacheKeyURL.href, - url: originalURL.href - }; - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A plugin, designed to be used with PrecacheController, to determine the - * of assets that were updated (or not updated) during the install event. - * - * @private - */ - class PrecacheInstallReportPlugin { - constructor() { - this.updatedURLs = []; - this.notUpdatedURLs = []; - this.handlerWillStart = async ({ - request, - state - }) => { - // TODO: `state` should never be undefined... - if (state) { - state.originalRequest = request; - } - }; - this.cachedResponseWillBeUsed = async ({ - event, - state, - cachedResponse - }) => { - if (event.type === 'install') { - if (state && state.originalRequest && state.originalRequest instanceof Request) { - // TODO: `state` should never be undefined... - const url = state.originalRequest.url; - if (cachedResponse) { - this.notUpdatedURLs.push(url); - } else { - this.updatedURLs.push(url); - } - } - } - return cachedResponse; - }; - } - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A plugin, designed to be used with PrecacheController, to translate URLs into - * the corresponding cache key, based on the current revision info. - * - * @private - */ - class PrecacheCacheKeyPlugin { - constructor({ - precacheController - }) { - this.cacheKeyWillBeUsed = async ({ - request, - params - }) => { - // Params is type any, can't change right now. - /* eslint-disable */ - const cacheKey = (params === null || params === void 0 ? void 0 : params.cacheKey) || this._precacheController.getCacheKeyForURL(request.url); - /* eslint-enable */ - return cacheKey ? new Request(cacheKey, { - headers: request.headers - }) : request; - }; - this._precacheController = precacheController; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * @param {string} groupTitle - * @param {Array} deletedURLs - * - * @private - */ - const logGroup = (groupTitle, deletedURLs) => { - logger.groupCollapsed(groupTitle); - for (const url of deletedURLs) { - logger.log(url); - } - logger.groupEnd(); - }; - /** - * @param {Array} deletedURLs - * - * @private - * @memberof workbox-precaching - */ - function printCleanupDetails(deletedURLs) { - const deletionCount = deletedURLs.length; - if (deletionCount > 0) { - logger.groupCollapsed(`During precaching cleanup, ` + `${deletionCount} cached ` + `request${deletionCount === 1 ? ' was' : 's were'} deleted.`); - logGroup('Deleted Cache Requests', deletedURLs); - logger.groupEnd(); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * @param {string} groupTitle - * @param {Array} urls - * - * @private - */ - function _nestedGroup(groupTitle, urls) { - if (urls.length === 0) { - return; - } - logger.groupCollapsed(groupTitle); - for (const url of urls) { - logger.log(url); - } - logger.groupEnd(); - } - /** - * @param {Array} urlsToPrecache - * @param {Array} urlsAlreadyPrecached - * - * @private - * @memberof workbox-precaching - */ - function printInstallDetails(urlsToPrecache, urlsAlreadyPrecached) { - const precachedCount = urlsToPrecache.length; - const alreadyPrecachedCount = urlsAlreadyPrecached.length; - if (precachedCount || alreadyPrecachedCount) { - let message = `Precaching ${precachedCount} file${precachedCount === 1 ? '' : 's'}.`; - if (alreadyPrecachedCount > 0) { - message += ` ${alreadyPrecachedCount} ` + `file${alreadyPrecachedCount === 1 ? ' is' : 's are'} already cached.`; - } - logger.groupCollapsed(message); - _nestedGroup(`View newly precached URLs.`, urlsToPrecache); - _nestedGroup(`View previously precached URLs.`, urlsAlreadyPrecached); - logger.groupEnd(); - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - let supportStatus; - /** - * A utility function that determines whether the current browser supports - * constructing a new `Response` from a `response.body` stream. - * - * @return {boolean} `true`, if the current browser can successfully - * construct a `Response` from a `response.body` stream, `false` otherwise. - * - * @private - */ - function canConstructResponseFromBodyStream() { - if (supportStatus === undefined) { - const testResponse = new Response(''); - if ('body' in testResponse) { - try { - new Response(testResponse.body); - supportStatus = true; - } catch (error) { - supportStatus = false; - } - } - supportStatus = false; - } - return supportStatus; - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Allows developers to copy a response and modify its `headers`, `status`, - * or `statusText` values (the values settable via a - * [`ResponseInit`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#Syntax} - * object in the constructor). - * To modify these values, pass a function as the second argument. That - * function will be invoked with a single object with the response properties - * `{headers, status, statusText}`. The return value of this function will - * be used as the `ResponseInit` for the new `Response`. To change the values - * either modify the passed parameter(s) and return it, or return a totally - * new object. - * - * This method is intentionally limited to same-origin responses, regardless of - * whether CORS was used or not. - * - * @param {Response} response - * @param {Function} modifier - * @memberof workbox-core - */ - async function copyResponse(response, modifier) { - let origin = null; - // If response.url isn't set, assume it's cross-origin and keep origin null. - if (response.url) { - const responseURL = new URL(response.url); - origin = responseURL.origin; - } - if (origin !== self.location.origin) { - throw new WorkboxError('cross-origin-copy-response', { - origin - }); - } - const clonedResponse = response.clone(); - // Create a fresh `ResponseInit` object by cloning the headers. - const responseInit = { - headers: new Headers(clonedResponse.headers), - status: clonedResponse.status, - statusText: clonedResponse.statusText - }; - // Apply any user modifications. - const modifiedResponseInit = modifier ? modifier(responseInit) : responseInit; - // Create the new response from the body stream and `ResponseInit` - // modifications. Note: not all browsers support the Response.body stream, - // so fall back to reading the entire body into memory as a blob. - const body = canConstructResponseFromBodyStream() ? clonedResponse.body : await clonedResponse.blob(); - return new Response(body, modifiedResponseInit); - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A {@link workbox-strategies.Strategy} implementation - * specifically designed to work with - * {@link workbox-precaching.PrecacheController} - * to both cache and fetch precached assets. - * - * Note: an instance of this class is created automatically when creating a - * `PrecacheController`; it's generally not necessary to create this yourself. - * - * @extends workbox-strategies.Strategy - * @memberof workbox-precaching - */ - class PrecacheStrategy extends Strategy { - /** - * - * @param {Object} [options] - * @param {string} [options.cacheName] Cache name to store and retrieve - * requests. Defaults to the cache names provided by - * {@link workbox-core.cacheNames}. - * @param {Array} [options.plugins] {@link https://developers.google.com/web/tools/workbox/guides/using-plugins|Plugins} - * to use in conjunction with this caching strategy. - * @param {Object} [options.fetchOptions] Values passed along to the - * {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init} - * of all fetch() requests made by this strategy. - * @param {Object} [options.matchOptions] The - * {@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions|CacheQueryOptions} - * for any `cache.match()` or `cache.put()` calls made by this strategy. - * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to - * get the response from the network if there's a precache miss. - */ - constructor(options = {}) { - options.cacheName = cacheNames.getPrecacheName(options.cacheName); - super(options); - this._fallbackToNetwork = options.fallbackToNetwork === false ? false : true; - // Redirected responses cannot be used to satisfy a navigation request, so - // any redirected response must be "copied" rather than cloned, so the new - // response doesn't contain the `redirected` flag. See: - // https://bugs.chromium.org/p/chromium/issues/detail?id=669363&desc=2#c1 - this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin); - } - /** - * @private - * @param {Request|string} request A request to run this strategy for. - * @param {workbox-strategies.StrategyHandler} handler The event that - * triggered the request. - * @return {Promise} - */ - async _handle(request, handler) { - const response = await handler.cacheMatch(request); - if (response) { - return response; - } - // If this is an `install` event for an entry that isn't already cached, - // then populate the cache. - if (handler.event && handler.event.type === 'install') { - return await this._handleInstall(request, handler); - } - // Getting here means something went wrong. An entry that should have been - // precached wasn't found in the cache. - return await this._handleFetch(request, handler); - } - async _handleFetch(request, handler) { - let response; - const params = handler.params || {}; - // Fall back to the network if we're configured to do so. - if (this._fallbackToNetwork) { - { - logger.warn(`The precached response for ` + `${getFriendlyURL(request.url)} in ${this.cacheName} was not ` + `found. Falling back to the network.`); - } - const integrityInManifest = params.integrity; - const integrityInRequest = request.integrity; - const noIntegrityConflict = !integrityInRequest || integrityInRequest === integrityInManifest; - // Do not add integrity if the original request is no-cors - // See https://github.com/GoogleChrome/workbox/issues/3096 - response = await handler.fetch(new Request(request, { - integrity: request.mode !== 'no-cors' ? integrityInRequest || integrityInManifest : undefined - })); - // It's only "safe" to repair the cache if we're using SRI to guarantee - // that the response matches the precache manifest's expectations, - // and there's either a) no integrity property in the incoming request - // or b) there is an integrity, and it matches the precache manifest. - // See https://github.com/GoogleChrome/workbox/issues/2858 - // Also if the original request users no-cors we don't use integrity. - // See https://github.com/GoogleChrome/workbox/issues/3096 - if (integrityInManifest && noIntegrityConflict && request.mode !== 'no-cors') { - this._useDefaultCacheabilityPluginIfNeeded(); - const wasCached = await handler.cachePut(request, response.clone()); - { - if (wasCached) { - logger.log(`A response for ${getFriendlyURL(request.url)} ` + `was used to "repair" the precache.`); - } - } - } - } else { - // This shouldn't normally happen, but there are edge cases: - // https://github.com/GoogleChrome/workbox/issues/1441 - throw new WorkboxError('missing-precache-entry', { - cacheName: this.cacheName, - url: request.url - }); - } - { - const cacheKey = params.cacheKey || (await handler.getCacheKey(request, 'read')); - // Workbox is going to handle the route. - // print the routing details to the console. - logger.groupCollapsed(`Precaching is responding to: ` + getFriendlyURL(request.url)); - logger.log(`Serving the precached url: ${getFriendlyURL(cacheKey instanceof Request ? cacheKey.url : cacheKey)}`); - logger.groupCollapsed(`View request details here.`); - logger.log(request); - logger.groupEnd(); - logger.groupCollapsed(`View response details here.`); - logger.log(response); - logger.groupEnd(); - logger.groupEnd(); - } - return response; - } - async _handleInstall(request, handler) { - this._useDefaultCacheabilityPluginIfNeeded(); - const response = await handler.fetch(request); - // Make sure we defer cachePut() until after we know the response - // should be cached; see https://github.com/GoogleChrome/workbox/issues/2737 - const wasCached = await handler.cachePut(request, response.clone()); - if (!wasCached) { - // Throwing here will lead to the `install` handler failing, which - // we want to do if *any* of the responses aren't safe to cache. - throw new WorkboxError('bad-precaching-response', { - url: request.url, - status: response.status - }); - } - return response; - } - /** - * This method is complex, as there a number of things to account for: - * - * The `plugins` array can be set at construction, and/or it might be added to - * to at any time before the strategy is used. - * - * At the time the strategy is used (i.e. during an `install` event), there - * needs to be at least one plugin that implements `cacheWillUpdate` in the - * array, other than `copyRedirectedCacheableResponsesPlugin`. - * - * - If this method is called and there are no suitable `cacheWillUpdate` - * plugins, we need to add `defaultPrecacheCacheabilityPlugin`. - * - * - If this method is called and there is exactly one `cacheWillUpdate`, then - * we don't have to do anything (this might be a previously added - * `defaultPrecacheCacheabilityPlugin`, or it might be a custom plugin). - * - * - If this method is called and there is more than one `cacheWillUpdate`, - * then we need to check if one is `defaultPrecacheCacheabilityPlugin`. If so, - * we need to remove it. (This situation is unlikely, but it could happen if - * the strategy is used multiple times, the first without a `cacheWillUpdate`, - * and then later on after manually adding a custom `cacheWillUpdate`.) - * - * See https://github.com/GoogleChrome/workbox/issues/2737 for more context. - * - * @private - */ - _useDefaultCacheabilityPluginIfNeeded() { - let defaultPluginIndex = null; - let cacheWillUpdatePluginCount = 0; - for (const [index, plugin] of this.plugins.entries()) { - // Ignore the copy redirected plugin when determining what to do. - if (plugin === PrecacheStrategy.copyRedirectedCacheableResponsesPlugin) { - continue; - } - // Save the default plugin's index, in case it needs to be removed. - if (plugin === PrecacheStrategy.defaultPrecacheCacheabilityPlugin) { - defaultPluginIndex = index; - } - if (plugin.cacheWillUpdate) { - cacheWillUpdatePluginCount++; - } - } - if (cacheWillUpdatePluginCount === 0) { - this.plugins.push(PrecacheStrategy.defaultPrecacheCacheabilityPlugin); - } else if (cacheWillUpdatePluginCount > 1 && defaultPluginIndex !== null) { - // Only remove the default plugin; multiple custom plugins are allowed. - this.plugins.splice(defaultPluginIndex, 1); - } - // Nothing needs to be done if cacheWillUpdatePluginCount is 1 - } - } - PrecacheStrategy.defaultPrecacheCacheabilityPlugin = { - async cacheWillUpdate({ - response - }) { - if (!response || response.status >= 400) { - return null; - } - return response; - } - }; - PrecacheStrategy.copyRedirectedCacheableResponsesPlugin = { - async cacheWillUpdate({ - response - }) { - return response.redirected ? await copyResponse(response) : response; - } - }; - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Performs efficient precaching of assets. - * - * @memberof workbox-precaching - */ - class PrecacheController { - /** - * Create a new PrecacheController. - * - * @param {Object} [options] - * @param {string} [options.cacheName] The cache to use for precaching. - * @param {string} [options.plugins] Plugins to use when precaching as well - * as responding to fetch events for precached assets. - * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to - * get the response from the network if there's a precache miss. - */ - constructor({ - cacheName, - plugins = [], - fallbackToNetwork = true - } = {}) { - this._urlsToCacheKeys = new Map(); - this._urlsToCacheModes = new Map(); - this._cacheKeysToIntegrities = new Map(); - this._strategy = new PrecacheStrategy({ - cacheName: cacheNames.getPrecacheName(cacheName), - plugins: [...plugins, new PrecacheCacheKeyPlugin({ - precacheController: this - })], - fallbackToNetwork - }); - // Bind the install and activate methods to the instance. - this.install = this.install.bind(this); - this.activate = this.activate.bind(this); - } - /** - * @type {workbox-precaching.PrecacheStrategy} The strategy created by this controller and - * used to cache assets and respond to fetch events. - */ - get strategy() { - return this._strategy; - } - /** - * Adds items to the precache list, removing any duplicates and - * stores the files in the - * {@link workbox-core.cacheNames|"precache cache"} when the service - * worker installs. - * - * This method can be called multiple times. - * - * @param {Array} [entries=[]] Array of entries to precache. - */ - precache(entries) { - this.addToCacheList(entries); - if (!this._installAndActiveListenersAdded) { - self.addEventListener('install', this.install); - self.addEventListener('activate', this.activate); - this._installAndActiveListenersAdded = true; - } - } - /** - * This method will add items to the precache list, removing duplicates - * and ensuring the information is valid. - * - * @param {Array} entries - * Array of entries to precache. - */ - addToCacheList(entries) { - { - finalAssertExports.isArray(entries, { - moduleName: 'workbox-precaching', - className: 'PrecacheController', - funcName: 'addToCacheList', - paramName: 'entries' - }); - } - const urlsToWarnAbout = []; - for (const entry of entries) { - // See https://github.com/GoogleChrome/workbox/issues/2259 - if (typeof entry === 'string') { - urlsToWarnAbout.push(entry); - } else if (entry && entry.revision === undefined) { - urlsToWarnAbout.push(entry.url); - } - const { - cacheKey, - url - } = createCacheKey(entry); - const cacheMode = typeof entry !== 'string' && entry.revision ? 'reload' : 'default'; - if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) { - throw new WorkboxError('add-to-cache-list-conflicting-entries', { - firstEntry: this._urlsToCacheKeys.get(url), - secondEntry: cacheKey - }); - } - if (typeof entry !== 'string' && entry.integrity) { - if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) { - throw new WorkboxError('add-to-cache-list-conflicting-integrities', { - url - }); - } - this._cacheKeysToIntegrities.set(cacheKey, entry.integrity); - } - this._urlsToCacheKeys.set(url, cacheKey); - this._urlsToCacheModes.set(url, cacheMode); - if (urlsToWarnAbout.length > 0) { - const warningMessage = `Workbox is precaching URLs without revision ` + `info: ${urlsToWarnAbout.join(', ')}\nThis is generally NOT safe. ` + `Learn more at https://bit.ly/wb-precache`; - { - logger.warn(warningMessage); - } - } - } - } - /** - * Precaches new and updated assets. Call this method from the service worker - * install event. - * - * Note: this method calls `event.waitUntil()` for you, so you do not need - * to call it yourself in your event handlers. - * - * @param {ExtendableEvent} event - * @return {Promise} - */ - install(event) { - // waitUntil returns Promise - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return waitUntil(event, async () => { - const installReportPlugin = new PrecacheInstallReportPlugin(); - this.strategy.plugins.push(installReportPlugin); - // Cache entries one at a time. - // See https://github.com/GoogleChrome/workbox/issues/2528 - for (const [url, cacheKey] of this._urlsToCacheKeys) { - const integrity = this._cacheKeysToIntegrities.get(cacheKey); - const cacheMode = this._urlsToCacheModes.get(url); - const request = new Request(url, { - integrity, - cache: cacheMode, - credentials: 'same-origin' - }); - await Promise.all(this.strategy.handleAll({ - params: { - cacheKey - }, - request, - event - })); - } - const { - updatedURLs, - notUpdatedURLs - } = installReportPlugin; - { - printInstallDetails(updatedURLs, notUpdatedURLs); - } - return { - updatedURLs, - notUpdatedURLs - }; - }); - } - /** - * Deletes assets that are no longer present in the current precache manifest. - * Call this method from the service worker activate event. - * - * Note: this method calls `event.waitUntil()` for you, so you do not need - * to call it yourself in your event handlers. - * - * @param {ExtendableEvent} event - * @return {Promise} - */ - activate(event) { - // waitUntil returns Promise - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return waitUntil(event, async () => { - const cache = await self.caches.open(this.strategy.cacheName); - const currentlyCachedRequests = await cache.keys(); - const expectedCacheKeys = new Set(this._urlsToCacheKeys.values()); - const deletedURLs = []; - for (const request of currentlyCachedRequests) { - if (!expectedCacheKeys.has(request.url)) { - await cache.delete(request); - deletedURLs.push(request.url); - } - } - { - printCleanupDetails(deletedURLs); - } - return { - deletedURLs - }; - }); - } - /** - * Returns a mapping of a precached URL to the corresponding cache key, taking - * into account the revision information for the URL. - * - * @return {Map} A URL to cache key mapping. - */ - getURLsToCacheKeys() { - return this._urlsToCacheKeys; - } - /** - * Returns a list of all the URLs that have been precached by the current - * service worker. - * - * @return {Array} The precached URLs. - */ - getCachedURLs() { - return [...this._urlsToCacheKeys.keys()]; - } - /** - * Returns the cache key used for storing a given URL. If that URL is - * unversioned, like `/index.html', then the cache key will be the original - * URL with a search parameter appended to it. - * - * @param {string} url A URL whose cache key you want to look up. - * @return {string} The versioned URL that corresponds to a cache key - * for the original URL, or undefined if that URL isn't precached. - */ - getCacheKeyForURL(url) { - const urlObject = new URL(url, location.href); - return this._urlsToCacheKeys.get(urlObject.href); - } - /** - * @param {string} url A cache key whose SRI you want to look up. - * @return {string} The subresource integrity associated with the cache key, - * or undefined if it's not set. - */ - getIntegrityForCacheKey(cacheKey) { - return this._cacheKeysToIntegrities.get(cacheKey); - } - /** - * This acts as a drop-in replacement for - * [`cache.match()`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match) - * with the following differences: - * - * - It knows what the name of the precache is, and only checks in that cache. - * - It allows you to pass in an "original" URL without versioning parameters, - * and it will automatically look up the correct cache key for the currently - * active revision of that URL. - * - * E.g., `matchPrecache('index.html')` will find the correct precached - * response for the currently active service worker, even if the actual cache - * key is `'/index.html?__WB_REVISION__=1234abcd'`. - * - * @param {string|Request} request The key (without revisioning parameters) - * to look up in the precache. - * @return {Promise} - */ - async matchPrecache(request) { - const url = request instanceof Request ? request.url : request; - const cacheKey = this.getCacheKeyForURL(url); - if (cacheKey) { - const cache = await self.caches.open(this.strategy.cacheName); - return cache.match(cacheKey); - } - return undefined; - } - /** - * Returns a function that looks up `url` in the precache (taking into - * account revision information), and returns the corresponding `Response`. - * - * @param {string} url The precached URL which will be used to lookup the - * `Response`. - * @return {workbox-routing~handlerCallback} - */ - createHandlerBoundToURL(url) { - const cacheKey = this.getCacheKeyForURL(url); - if (!cacheKey) { - throw new WorkboxError('non-precached-url', { - url - }); - } - return options => { - options.request = new Request(url); - options.params = Object.assign({ - cacheKey - }, options.params); - return this.strategy.handle(options); - }; - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - let precacheController; - /** - * @return {PrecacheController} - * @private - */ - const getOrCreatePrecacheController = () => { - if (!precacheController) { - precacheController = new PrecacheController(); - } - return precacheController; - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Removes any URL search parameters that should be ignored. - * - * @param {URL} urlObject The original URL. - * @param {Array} ignoreURLParametersMatching RegExps to test against - * each search parameter name. Matches mean that the search parameter should be - * ignored. - * @return {URL} The URL with any ignored search parameters removed. - * - * @private - * @memberof workbox-precaching - */ - function removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching = []) { - // Convert the iterable into an array at the start of the loop to make sure - // deletion doesn't mess up iteration. - for (const paramName of [...urlObject.searchParams.keys()]) { - if (ignoreURLParametersMatching.some(regExp => regExp.test(paramName))) { - urlObject.searchParams.delete(paramName); - } - } - return urlObject; - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Generator function that yields possible variations on the original URL to - * check, one at a time. - * - * @param {string} url - * @param {Object} options - * - * @private - * @memberof workbox-precaching - */ - function* generateURLVariations(url, { - ignoreURLParametersMatching = [/^utm_/, /^fbclid$/], - directoryIndex = 'index.html', - cleanURLs = true, - urlManipulation - } = {}) { - const urlObject = new URL(url, location.href); - urlObject.hash = ''; - yield urlObject.href; - const urlWithoutIgnoredParams = removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching); - yield urlWithoutIgnoredParams.href; - if (directoryIndex && urlWithoutIgnoredParams.pathname.endsWith('/')) { - const directoryURL = new URL(urlWithoutIgnoredParams.href); - directoryURL.pathname += directoryIndex; - yield directoryURL.href; - } - if (cleanURLs) { - const cleanURL = new URL(urlWithoutIgnoredParams.href); - cleanURL.pathname += '.html'; - yield cleanURL.href; - } - if (urlManipulation) { - const additionalURLs = urlManipulation({ - url: urlObject - }); - for (const urlToAttempt of additionalURLs) { - yield urlToAttempt.href; - } - } - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A subclass of {@link workbox-routing.Route} that takes a - * {@link workbox-precaching.PrecacheController} - * instance and uses it to match incoming requests and handle fetching - * responses from the precache. - * - * @memberof workbox-precaching - * @extends workbox-routing.Route - */ - class PrecacheRoute extends Route { - /** - * @param {PrecacheController} precacheController A `PrecacheController` - * instance used to both match requests and respond to fetch events. - * @param {Object} [options] Options to control how requests are matched - * against the list of precached URLs. - * @param {string} [options.directoryIndex=index.html] The `directoryIndex` will - * check cache entries for a URLs ending with '/' to see if there is a hit when - * appending the `directoryIndex` value. - * @param {Array} [options.ignoreURLParametersMatching=[/^utm_/, /^fbclid$/]] An - * array of regex's to remove search params when looking for a cache match. - * @param {boolean} [options.cleanURLs=true] The `cleanURLs` option will - * check the cache for the URL with a `.html` added to the end of the end. - * @param {workbox-precaching~urlManipulation} [options.urlManipulation] - * This is a function that should take a URL and return an array of - * alternative URLs that should be checked for precache matches. - */ - constructor(precacheController, options) { - const match = ({ - request - }) => { - const urlsToCacheKeys = precacheController.getURLsToCacheKeys(); - for (const possibleURL of generateURLVariations(request.url, options)) { - const cacheKey = urlsToCacheKeys.get(possibleURL); - if (cacheKey) { - const integrity = precacheController.getIntegrityForCacheKey(cacheKey); - return { - cacheKey, - integrity - }; - } - } - { - logger.debug(`Precaching did not find a match for ` + getFriendlyURL(request.url)); - } - return; - }; - super(match, precacheController.strategy); - } - } - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Add a `fetch` listener to the service worker that will - * respond to - * [network requests]{@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Custom_responses_to_requests} - * with precached assets. - * - * Requests for assets that aren't precached, the `FetchEvent` will not be - * responded to, allowing the event to fall through to other `fetch` event - * listeners. - * - * @param {Object} [options] See the {@link workbox-precaching.PrecacheRoute} - * options. - * - * @memberof workbox-precaching - */ - function addRoute(options) { - const precacheController = getOrCreatePrecacheController(); - const precacheRoute = new PrecacheRoute(precacheController, options); - registerRoute(precacheRoute); - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Adds items to the precache list, removing any duplicates and - * stores the files in the - * {@link workbox-core.cacheNames|"precache cache"} when the service - * worker installs. - * - * This method can be called multiple times. - * - * Please note: This method **will not** serve any of the cached files for you. - * It only precaches files. To respond to a network request you call - * {@link workbox-precaching.addRoute}. - * - * If you have a single array of files to precache, you can just call - * {@link workbox-precaching.precacheAndRoute}. - * - * @param {Array} [entries=[]] Array of entries to precache. - * - * @memberof workbox-precaching - */ - function precache(entries) { - const precacheController = getOrCreatePrecacheController(); - precacheController.precache(entries); - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * This method will add entries to the precache list and add a route to - * respond to fetch events. - * - * This is a convenience method that will call - * {@link workbox-precaching.precache} and - * {@link workbox-precaching.addRoute} in a single call. - * - * @param {Array} entries Array of entries to precache. - * @param {Object} [options] See the - * {@link workbox-precaching.PrecacheRoute} options. - * - * @memberof workbox-precaching - */ - function precacheAndRoute(entries, options) { - precache(entries); - addRoute(options); - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const SUBSTRING_TO_FIND = '-precache-'; - /** - * Cleans up incompatible precaches that were created by older versions of - * Workbox, by a service worker registered under the current scope. - * - * This is meant to be called as part of the `activate` event. - * - * This should be safe to use as long as you don't include `substringToFind` - * (defaulting to `-precache-`) in your non-precache cache names. - * - * @param {string} currentPrecacheName The cache name currently in use for - * precaching. This cache won't be deleted. - * @param {string} [substringToFind='-precache-'] Cache names which include this - * substring will be deleted (excluding `currentPrecacheName`). - * @return {Array} A list of all the cache names that were deleted. - * - * @private - * @memberof workbox-precaching - */ - const deleteOutdatedCaches = async (currentPrecacheName, substringToFind = SUBSTRING_TO_FIND) => { - const cacheNames = await self.caches.keys(); - const cacheNamesToDelete = cacheNames.filter(cacheName => { - return cacheName.includes(substringToFind) && cacheName.includes(self.registration.scope) && cacheName !== currentPrecacheName; - }); - await Promise.all(cacheNamesToDelete.map(cacheName => self.caches.delete(cacheName))); - return cacheNamesToDelete; - }; - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Adds an `activate` event listener which will clean up incompatible - * precaches that were created by older versions of Workbox. - * - * @memberof workbox-precaching - */ - function cleanupOutdatedCaches() { - // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 - self.addEventListener('activate', event => { - const cacheName = cacheNames.getPrecacheName(); - event.waitUntil(deleteOutdatedCaches(cacheName).then(cachesDeleted => { - { - if (cachesDeleted.length > 0) { - logger.log(`The following out-of-date precaches were cleaned up ` + `automatically:`, cachesDeleted); - } - } - })); - }); - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * NavigationRoute makes it easy to create a - * {@link workbox-routing.Route} that matches for browser - * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}. - * - * It will only match incoming Requests whose - * {@link https://fetch.spec.whatwg.org/#concept-request-mode|mode} - * is set to `navigate`. - * - * You can optionally only apply this route to a subset of navigation requests - * by using one or both of the `denylist` and `allowlist` parameters. - * - * @memberof workbox-routing - * @extends workbox-routing.Route - */ - class NavigationRoute extends Route { - /** - * If both `denylist` and `allowlist` are provided, the `denylist` will - * take precedence and the request will not match this route. - * - * The regular expressions in `allowlist` and `denylist` - * are matched against the concatenated - * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname} - * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search} - * portions of the requested URL. - * - * *Note*: These RegExps may be evaluated against every destination URL during - * a navigation. Avoid using - * [complex RegExps](https://github.com/GoogleChrome/workbox/issues/3077), - * or else your users may see delays when navigating your site. - * - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - * @param {Object} options - * @param {Array} [options.denylist] If any of these patterns match, - * the route will not handle the request (even if a allowlist RegExp matches). - * @param {Array} [options.allowlist=[/./]] If any of these patterns - * match the URL's pathname and search parameter, the route will handle the - * request (assuming the denylist doesn't match). - */ - constructor(handler, { - allowlist = [/./], - denylist = [] - } = {}) { - { - finalAssertExports.isArrayOfClass(allowlist, RegExp, { - moduleName: 'workbox-routing', - className: 'NavigationRoute', - funcName: 'constructor', - paramName: 'options.allowlist' - }); - finalAssertExports.isArrayOfClass(denylist, RegExp, { - moduleName: 'workbox-routing', - className: 'NavigationRoute', - funcName: 'constructor', - paramName: 'options.denylist' - }); - } - super(options => this._match(options), handler); - this._allowlist = allowlist; - this._denylist = denylist; - } - /** - * Routes match handler. - * - * @param {Object} options - * @param {URL} options.url - * @param {Request} options.request - * @return {boolean} - * - * @private - */ - _match({ - url, - request - }) { - if (request && request.mode !== 'navigate') { - return false; - } - const pathnameAndSearch = url.pathname + url.search; - for (const regExp of this._denylist) { - if (regExp.test(pathnameAndSearch)) { - { - logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL matches this denylist pattern: ` + `${regExp.toString()}`); - } - return false; - } - } - if (this._allowlist.some(regExp => regExp.test(pathnameAndSearch))) { - { - logger.debug(`The navigation route ${pathnameAndSearch} ` + `is being used.`); - } - return true; - } - { - logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL being navigated to doesn't ` + `match the allowlist.`); - } - return false; - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Helper function that calls - * {@link PrecacheController#createHandlerBoundToURL} on the default - * {@link PrecacheController} instance. - * - * If you are creating your own {@link PrecacheController}, then call the - * {@link PrecacheController#createHandlerBoundToURL} on that instance, - * instead of using this function. - * - * @param {string} url The precached URL which will be used to lookup the - * `Response`. - * @param {boolean} [fallbackToNetwork=true] Whether to attempt to get the - * response from the network if there's a precache miss. - * @return {workbox-routing~handlerCallback} - * - * @memberof workbox-precaching - */ - function createHandlerBoundToURL(url) { - const precacheController = getOrCreatePrecacheController(); - return precacheController.createHandlerBoundToURL(url); - } - - exports.CacheFirst = CacheFirst; - exports.CacheableResponsePlugin = CacheableResponsePlugin; - exports.ExpirationPlugin = ExpirationPlugin; - exports.NavigationRoute = NavigationRoute; - exports.NetworkFirst = NetworkFirst; - exports.cleanupOutdatedCaches = cleanupOutdatedCaches; - exports.clientsClaim = clientsClaim; - exports.createHandlerBoundToURL = createHandlerBoundToURL; - exports.precacheAndRoute = precacheAndRoute; - exports.registerRoute = registerRoute; - -}));