Skip to content

Support read-only sparse backing stores for HTTP range and WASM lockbox reads #314

Description

@bsutton

Request

Add a supported read-only random-access/sparse backing-store API so reVault can read a remote lockbox using HTTP byte ranges, particularly from browser WASM, without downloading or extracting the whole archive first.

This is a feature proposal with a working prototype, not a request to merge the experimental patch unchanged. Related: #311 concerns cold-read performance; this proposal concerns supplying bytes from an external range source.

Working experiment

A native documentation generator creates unencrypted, unsigned lockboxes containing gzip-compressed JSON bundles and viewer assets. A browser service worker uses reVault compiled to WASM to read those files. The server serves only raw archive bytes; it does not parse reVault.

The prototype adds a feature-gated SparseArchive storage variant and a read-only open_sparse_unencrypted entrypoint. The cache contains only supplied segments, not a zero-filled full-size archive. A synchronous core read reports missing bytes; JavaScript fetches them asynchronously and retries. Existing format, page decoders and cryptography are unchanged.

The base revision is 301c34efad3f5ef3ae5e34ea47bfa8000e0c619c. The full minimal core patch below has paths normalized for the reVault repository. WASM adapter and native regression tests follow, so this issue does not depend on unpublished branches or temporary files.

HTTP/client contract used

  • HEAD obtains archive size, Accept-Ranges: bytes, and a strong ETag.
  • Prefetch first/last 64 KiB blocks, then load requested aligned blocks; coalesce adjacent missing blocks.
  • Each GET sends Range and If-Match. Require exact HTTP 206, matching Content-Range/total length, unchanged ETag, exact body length and no HTTP content encoding.
  • Reject replacement, malformed/truncated ranges and an ignored Range returning 200; no silent full-download fallback.
  • Serialize each archive's operations; retain loaded blocks and successful decoded-page cache entries between retries.
  • Check missing-range state even after an apparently successful core open. Some existing fallback paths swallow read errors; accepting such success could accept incomplete metadata.
  • The demo allows only public unencrypted archives, caps archives at 256 MiB / entries at 64 MiB / retries at 4096. These limits and retry-from-start behavior are prototype choices, not a proposed permanent ABI.

Results

Same archive files, viewer and WASM artifact for full/range modes. First library view, excluding shared WASM:

Package Full archive Range bytes Reduction Range GETs
dcli 551,232 B 354,624 B 35.7% 5
hugeicons 5,786,944 B 478,528 B 91.7% 7

Opening metadata itself needed 92,480 B / 85,312 B. With runtime already loaded, a server model of 25 ms per request plus aggregate 10 Mbit/s response-body bandwidth gave median first-page times (3 runs): dcli full 683 ms / range 623 ms; hugeicons full 5,033 ms / range 1,102 ms. This is a modeled connection, not production/WAN measurement. Local cold hugeicons was slower with ranges (188 vs 246 ms); extra round trips matter.

48 browser loads covered two packages, full/range, cold/already-loaded runtime, local/modeled network, three repetitions. All 16 dcli + 49 hugeicons files read through WASM matched directory counterparts by SHA-256. Search, navigation, lazy source, mobile drawer, reload/no repeated downloads and missing-file behavior passed. Changed ETag/412, ignored Range/200, wrong Content-Range and truncated-body cases were rejected. Two native tests below passed. After source/search, dcli had eventually fetched all bytes; hugeicons had fetched 937,280 B. No generation-speed claim. Browsing every file eventually caches the entire archive.

Suggested upstream design/acceptance

  • Define a stable external read-at abstraction usable by native remote storage and WASM; avoid exporting mutable internals.
  • Make incomplete reads a typed, distinguishable condition and preserve it through recovery/fallback paths. Decide whether to support resumable async operations or a documented retry contract.
  • Preserve bounds, integrity checks, immutability and read-only semantics; never fabricate bytes for holes.
  • Bound cache memory, define eviction/retry/cancellation behavior and prevent repeated whole-operation work from becoming pathological.
  • Support versioned remote archives without mixing revisions. Document HTTP transport requirements separately from the core.
  • Test reads spanning blocks, corruption, truncation, changed versions, concurrent clients and retry progress, along with byte equality and request/byte amplification.
  • Keep native file/memory backends unchanged when the feature is disabled. Evaluate encrypted remote reads separately; the demo does not establish their support.

Prototype core patch

Apply against the base revision, then review/adapt to current upstream:

Complete core patch
diff --git a/rust/revault_lockbox_api/Cargo.toml b/rust/revault_lockbox_api/Cargo.toml
index 6eb12b4..c10b30e 100644
--- a/rust/revault_lockbox_api/Cargo.toml
+++ b/rust/revault_lockbox_api/Cargo.toml
@@ -14,6 +14,7 @@ name = "revault_lockbox_api"
 path = "src/lib.rs"
 
 [features]
+range-experiment = []
 default = ["issue303-protocol"]
 vault-integration = []
 bindings = []
diff --git a/rust/revault_lockbox_api/src/lib.rs b/rust/revault_lockbox_api/src/lib.rs
index 9d5007b..041553a 100644
--- a/rust/revault_lockbox_api/src/lib.rs
+++ b/rust/revault_lockbox_api/src/lib.rs
@@ -53,6 +53,8 @@ mod paths;
 mod scan;
 mod security;
 mod storage;
+#[cfg(feature = "range-experiment")]
+pub use storage::sparse::SparseArchive;
 mod toc;
 #[cfg(feature = "vault-integration")]
 pub mod vault_integration;
diff --git a/rust/revault_lockbox_api/src/lockbox/key_management.rs b/rust/revault_lockbox_api/src/lockbox/key_management.rs
index 5ccf917..113cc69 100644
--- a/rust/revault_lockbox_api/src/lockbox/key_management.rs
+++ b/rust/revault_lockbox_api/src/lockbox/key_management.rs
@@ -649,6 +649,13 @@ impl Lockbox {
         )
     }
 
+    /// Experimental read-only sparse storage; caller must satisfy missing ranges
+    /// and retry before accepting even a successful result.
+    #[cfg(feature = "range-experiment")]
+    pub fn open_sparse_unencrypted(source: crate::SparseArchive) -> Result<Lockbox<ReadOnly>> {
+        Ok(Self::open_unencrypted_storage(StorageBackend::Sparse(source), false)?.into_state())
+    }
+
     fn attach_signing_choice(&mut self, signing: crate::Signing<'_>) -> Result<()> {
         match signing {
             crate::Signing::None if self.format_mode.signed() => Err(Error::InvalidInput(
diff --git a/rust/revault_lockbox_api/src/storage/mod.rs b/rust/revault_lockbox_api/src/storage/mod.rs
index a717fbe..29bb86f 100644
--- a/rust/revault_lockbox_api/src/storage/mod.rs
+++ b/rust/revault_lockbox_api/src/storage/mod.rs
@@ -5,6 +5,8 @@ pub(crate) mod file_lock;
 pub(crate) mod free_index;
 pub(crate) mod free_slot;
 pub(crate) mod page_cache;
+#[cfg(feature = "range-experiment")]
+pub mod sparse;
 
 use crate::secret_vec::SecureVec;
 use crate::{Error, Result};
@@ -45,6 +47,8 @@ pub(crate) trait Storage: Clone + std::fmt::Debug {
 pub(crate) enum StorageBackend {
     Memory(MemoryStore),
     File(FileStore),
+    #[cfg(feature = "range-experiment")]
+    Sparse(sparse::SparseArchive),
 }
 
 impl StorageBackend {
@@ -97,6 +101,8 @@ impl StorageBackend {
     }
 
     pub(crate) fn is_read_only(&self) -> bool {
+        #[cfg(feature = "range-experiment")]
+        if matches!(self, Self::Sparse(_)) { return true; }
         matches!(self, Self::File(store) if !store.writable)
     }
 
@@ -145,6 +151,8 @@ impl StorageBackend {
     pub(crate) fn path(&self) -> Option<&Path> {
         match self {
             Self::Memory(_) => None,
+            #[cfg(feature = "range-experiment")]
+            Self::Sparse(_) => None,
             Self::File(store) => Some(store.path()),
         }
     }
@@ -154,6 +162,8 @@ impl Storage for StorageBackend {
     fn len(&self) -> Result<u64> {
         match self {
             Self::Memory(store) => store.len(),
+            #[cfg(feature = "range-experiment")]
+            Self::Sparse(store) => store.len(),
             Self::File(store) => store.len(),
         }
     }
@@ -161,6 +171,8 @@ impl Storage for StorageBackend {
     fn read_at(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
         match self {
             Self::Memory(store) => store.read_at(offset, len),
+            #[cfg(feature = "range-experiment")]
+            Self::Sparse(store) => store.read_at(offset, len),
             Self::File(store) => store.read_at(offset, len),
         }
     }
@@ -168,6 +180,8 @@ impl Storage for StorageBackend {
     fn read_at_into(&self, offset: u64, out: &mut [u8]) -> Result<()> {
         match self {
             Self::Memory(store) => store.read_at_into(offset, out),
+            #[cfg(feature = "range-experiment")]
+            Self::Sparse(store) => store.read_at_into(offset, out),
             Self::File(store) => store.read_at_into(offset, out),
         }
     }
@@ -175,6 +189,8 @@ impl Storage for StorageBackend {
     fn append(&mut self, bytes: &[u8]) -> Result<u64> {
         match self {
             Self::Memory(store) => store.append(bytes),
+            #[cfg(feature = "range-experiment")]
+            Self::Sparse(_) => Err(Error::Io("sparse archive is read-only".into())),
             Self::File(store) => store.append(bytes),
         }
     }
@@ -182,6 +198,8 @@ impl Storage for StorageBackend {
     fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<()> {
         match self {
             Self::Memory(store) => store.write_at(offset, bytes),
+            #[cfg(feature = "range-experiment")]
+            Self::Sparse(_) => Err(Error::Io("sparse archive is read-only".into())),
             Self::File(store) => store.write_at(offset, bytes),
         }
     }
@@ -189,6 +207,8 @@ impl Storage for StorageBackend {
     fn sync(&self) -> Result<()> {
         match self {
             Self::Memory(store) => store.sync(),
+            #[cfg(feature = "range-experiment")]
+            Self::Sparse(_) => Ok(()),
             Self::File(store) => store.sync(),
         }
     }
@@ -392,42 +412,42 @@ impl StorageBackend {
     pub(crate) fn fail_memory_operation_after_successes(&mut self, successes: usize) {
         match self {
             Self::Memory(store) => store.fail_operation_after_successes(successes),
-            Self::File(_) => panic!("failure injection is only available for memory storage"),
+            _ => panic!("failure injection is only available for memory storage"),
         }
     }
 
     pub(crate) fn memory_operation_count(&self) -> usize {
         match self {
             Self::Memory(store) => store.operation_count(),
-            Self::File(_) => panic!("operation counting is only available for memory storage"),
+            _ => panic!("operation counting is only available for memory storage"),
         }
     }
 
     pub(crate) fn reset_memory_operation_count(&self) {
         match self {
             Self::Memory(store) => store.reset_operation_count(),
-            Self::File(_) => panic!("operation counting is only available for memory storage"),
+            _ => panic!("operation counting is only available for memory storage"),
         }
     }
 
     pub(crate) fn fail_memory_append_after_successes(&mut self, successes: usize) {
         match self {
             Self::Memory(store) => store.fail_append_after_successes(successes),
-            Self::File(_) => panic!("failure injection is only available for memory storage"),
+            _ => panic!("failure injection is only available for memory storage"),
         }
     }
 
     pub(crate) fn fail_memory_next_write_at(&mut self, offset: u64) {
         match self {
             Self::Memory(store) => store.fail_next_write_at(offset),
-            Self::File(_) => panic!("failure injection is only available for memory storage"),
+            _ => panic!("failure injection is only available for memory storage"),
         }
     }
 
     pub(crate) fn fail_memory_sync_after_successes(&mut self, successes: usize) {
         match self {
             Self::Memory(store) => store.fail_sync_after_successes(successes),
-            Self::File(_) => panic!("failure injection is only available for memory storage"),
+            _ => panic!("failure injection is only available for memory storage"),
         }
     }
 }
@@ -437,7 +457,7 @@ impl StorageBackend {
     pub(crate) fn inject_test_write_failure_at(&mut self, offset: u64) {
         match self {
             Self::Memory(store) => store.fail_next_write_at(offset),
-            Self::File(_) => panic!("test write failure is only available for memory storage"),
+            _ => panic!("test write failure is only available for memory storage"),
         }
     }
 }
diff --git a/rust/revault_lockbox_api/src/storage/sparse.rs b/rust/revault_lockbox_api/src/storage/sparse.rs
new file mode 100644
index 0000000..3be54f1
--- /dev/null
+++ b/rust/revault_lockbox_api/src/storage/sparse.rs
@@ -0,0 +1,105 @@
+//! Experiment only: immutable sparse bytes, never fabricated zero-filled holes.
+use super::Storage;
+use crate::{Error, Result};
+use std::collections::BTreeMap;
+use std::sync::{Arc, Mutex};
+
+#[derive(Clone, Debug)]
+/// Shared read-only archive bytes supplied incrementally by an external reader.
+pub struct SparseArchive {
+    length: u64,
+    state: Arc<Mutex<State>>,
+}
+#[derive(Debug, Default)]
+struct State {
+    segments: BTreeMap<u64, Vec<u8>>,
+    missing: Option<(u64, usize)>,
+}
+impl SparseArchive {
+    /// Creates an empty cache with the archive's immutable logical length.
+    pub fn new(length: u64) -> Self {
+        Self {
+            length,
+            state: Arc::new(Mutex::new(State::default())),
+        }
+    }
+    /// Supplies an immutable, non-overlapping range (exact repeats are allowed).
+    pub fn supply(&self, offset: u64, bytes: Vec<u8>) -> Result<()> {
+        let end = offset
+            .checked_add(bytes.len() as u64)
+            .ok_or(Error::Truncated)?;
+        if end > self.length || bytes.is_empty() {
+            return Err(Error::Truncated);
+        }
+        let mut state = self.state.lock().unwrap();
+        // Never replace previously supplied bytes with a different revision.
+        for (&start, previous) in &state.segments {
+            let left = offset.max(start);
+            let right = end.min(start + previous.len() as u64);
+            if left < right {
+                if start == offset && *previous == bytes {
+                    return Ok(());
+                }
+                return Err(Error::Io("overlapping sparse archive ranges".into()));
+            }
+        }
+        state.segments.insert(offset, bytes);
+        Ok(())
+    }
+    /// Clears the missing-range signal before beginning a synchronous read.
+    pub fn clear_missing(&self) {
+        self.state.lock().unwrap().missing = None;
+    }
+    /// Returns the first unsatisfied read; inspect even after a successful open.
+    pub fn missing(&self) -> Option<(u64, usize)> {
+        self.state.lock().unwrap().missing
+    }
+}
+impl Storage for SparseArchive {
+    fn len(&self) -> Result<u64> {
+        Ok(self.length)
+    }
+    fn read_at(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
+        let mut bytes = vec![0; len];
+        self.read_at_into(offset, &mut bytes)?;
+        Ok(bytes)
+    }
+    fn read_at_into(&self, offset: u64, out: &mut [u8]) -> Result<()> {
+        let end = offset
+            .checked_add(out.len() as u64)
+            .ok_or(Error::Truncated)?;
+        if end > self.length {
+            return Err(Error::Truncated);
+        }
+        let mut state = self.state.lock().unwrap();
+        let mut position = offset;
+        while position < end {
+            if let Some((&start, bytes)) = state.segments.range(..=position).next_back() {
+                let available_end = start + bytes.len() as u64;
+                if available_end > position {
+                    let take_end = end.min(available_end);
+                    out[(position - offset) as usize..(take_end - offset) as usize]
+                        .copy_from_slice(
+                            &bytes[(position - start) as usize..(take_end - start) as usize],
+                        );
+                    position = take_end;
+                    continue;
+                }
+            }
+            if state.missing.is_none() {
+                state.missing = Some((position, (end - position) as usize));
+            }
+            return Err(Error::Io("range not loaded".into()));
+        }
+        Ok(())
+    }
+    fn append(&mut self, _: &[u8]) -> Result<u64> {
+        Err(Error::Io("read-only".into()))
+    }
+    fn write_at(&mut self, _: u64, _: &[u8]) -> Result<()> {
+        Err(Error::Io("read-only".into()))
+    }
+    fn sync(&self) -> Result<()> {
+        Ok(())
+    }
+}

WASM adapter

use revault_lockbox_api::{Lockbox, LockboxOpen, LockboxPath, ReadOnly, SparseArchive};
use std::io::Read;
use wasm_bindgen::prelude::*;

fn error(value: impl std::fmt::Display) -> JsValue {
    JsValue::from_str(&value.to_string())
}

#[wasm_bindgen]
pub struct RangeLockbox {
    source: SparseArchive,
    archive: Option<Lockbox<ReadOnly>>,
}
#[wasm_bindgen]
impl RangeLockbox {
    #[wasm_bindgen(constructor)]
    pub fn new(length: u32) -> Result<Self, JsValue> {
        if length == 0 || length > 256 * 1024 * 1024 {
            return Err(error("Demo archive size limit: 256 MiB"));
        }
        revault_page_api::set_weakened_allocation_allowed(true);
        Ok(Self {
            source: SparseArchive::new(length as u64),
            archive: None,
        })
    }
    pub fn supply(&self, offset: u32, bytes: &[u8]) -> Result<(), JsValue> {
        self.source
            .supply(offset as u64, bytes.to_vec())
            .map_err(error)
    }
    pub fn missing(&self) -> Vec<u32> {
        self.source
            .missing()
            .map(|(offset, len)| vec![offset as u32, len as u32])
            .unwrap_or_default()
    }
    pub fn try_open(&mut self) -> Result<bool, JsValue> {
        self.source.clear_missing();
        let result = Lockbox::open_sparse_unencrypted(self.source.clone());
        // The core has fallback/recovery paths that may swallow read errors.
        // Never accept partial metadata, even if such a path returns Ok.
        if self.source.missing().is_some() {
            return Ok(false);
        }
        self.archive = Some(result.map_err(error)?);
        Ok(true)
    }
    pub fn read(&self, path: &str) -> Result<Vec<u8>, JsValue> {
        self.source.clear_missing();
        let result = (|| {
            let archive = self
                .archive
                .as_ref()
                .ok_or_else(|| error("Archive not open"))?;
            let path = LockboxPath::new(path).map_err(error)?;
            let mut file = archive
                .open_file(&path)
                .map_err(error)?
                .take(64 * 1024 * 1024 + 1);
            let mut bytes = Vec::new();
            file.read_to_end(&mut bytes).map_err(error)?;
            if bytes.len() > 64 * 1024 * 1024 {
                return Err(error("Entry exceeds 64 MiB"));
            }
            Ok(bytes)
        })();
        if self.source.missing().is_some() {
            return Err(error("Range required"));
        }
        result
    }
    pub fn exists(&self, path: &str) -> Result<bool, JsValue> {
        let archive = self
            .archive
            .as_ref()
            .ok_or_else(|| error("Archive not open"))?;
        Ok(archive.exists(&LockboxPath::new(path).map_err(error)?))
    }
    /// Same-runtime whole-download control for the paired browser experiment.
    pub fn open_full(bytes: &[u8]) -> Result<RangeLockbox, JsValue> {
        let mut value = Self::new(bytes.len().try_into().map_err(error)?)?;
        value.archive =
            Some(Lockbox::open_bytes(bytes.to_vec(), LockboxOpen::Unencrypted).map_err(error)?);
        Ok(value)
    }
}

Build as cdylib with wasm-bindgen 0.2.121, reVault core feature range-experiment, and revault_page_api; wasm32-unknown-unknown, wasm-bindgen --target web.

Native regression tests

use revault_lockbox_api::{
    Compression, Encryption, Lockbox, LockboxCreateOptions, LockboxPath, Signing, SparseArchive,
};
use std::io::Read;

#[test]
fn rejects_overflow_overlaps_and_changed_bytes() {
    let source = SparseArchive::new(8);
    assert!(source.supply(u64::MAX, vec![1]).is_err());
    assert!(source.supply(7, vec![1, 2]).is_err());
    source.supply(0, vec![1, 2, 3, 4]).unwrap();
    source.supply(0, vec![1, 2, 3, 4]).unwrap();
    assert!(source.supply(0, vec![1, 2, 3, 5]).is_err());
    assert!(source.supply(2, vec![3, 4]).is_err());
}

#[test]
fn missing_bytes_are_retryable_and_complete_reads_match() {
    let mut options = LockboxCreateOptions::new(Encryption::None, Signing::None);
    options.compression = Compression::None;
    let mut writer = Lockbox::create_in_memory_with_options(options).unwrap();
    let path = LockboxPath::new("/payload.bin").unwrap();
    let expected: Vec<u8> = (0..300_000).map(|n| ((n * 73) % 251) as u8).collect();
    writer.add_file(&path, &expected, false).unwrap();
    writer.commit().unwrap();
    let bytes = writer.try_to_bytes().unwrap();
    let source = SparseArchive::new(bytes.len() as u64);
    let load = || {
        let (offset, _) = source
            .missing()
            .expect("failed read must report missing bytes");
        let start = offset as usize / 4096 * 4096;
        source
            .supply(
                start as u64,
                bytes[start..bytes.len().min(start + 4096)].to_vec(),
            )
            .unwrap();
    };
    let mut attempts = 0;
    let reader = loop {
        source.clear_missing();
        let result = Lockbox::open_sparse_unencrypted(source.clone());
        if source.missing().is_none() {
            break result.unwrap();
        }
        load();
        attempts += 1;
        assert!(attempts < 1000);
    };
    assert!(attempts > 0);
    loop {
        source.clear_missing();
        let mut actual = Vec::new();
        let result = reader.open_file(&path).unwrap().read_to_end(&mut actual);
        if source.missing().is_none() {
            result.unwrap();
            assert_eq!(actual, expected);
            break;
        }
        load();
        attempts += 1;
        assert!(attempts < 1000);
    }
}

Complete client implementation from the experiment

Service worker (HTTP transport and sparse-read retries)
import init, { RangeLockbox } from './wasm/doc_range_wasm.js';
const root = self.registration.scope;
const prefix = new URL('__range/', root).pathname;
const boxes = new Map();
const stats = { archives:{}, errors:[] };
let wasm;
const ready = init().then(value => { wasm = value; });
const blockSize = 65536;
const allowedPackages = new Set(['dcli','hugeicons']);

self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));

async function openArchive(mode, name) {
  await ready;
  const url = new URL(`archives/${name}.lbox`, root);
  const metrics = { mode, totalBytes:0, rangeBytes:0, rangeRequests:0, reads:[], ranges:[], retries:0 };
  stats.archives[`${mode}/${name}`] = metrics;
  const state = {reader:null, queue:Promise.resolve(), metrics, blocks:new Set()};
  if (mode === 'full') {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`Lockbox HTTP ${response.status}`);
    const bytes = new Uint8Array(await response.arrayBuffer());
    metrics.totalBytes = bytes.length;
    state.reader = RangeLockbox.open_full(bytes);
  } else {
    const response = await fetch(url, {method:'HEAD',cache:'no-store'});
    const size = Number(response.headers.get('Content-Length'));
    const etag = response.headers.get('ETag');
    if (!response.ok || !Number.isSafeInteger(size) || size <= 0 || size > 256*1024*1024 ||
        !etag || !etag.startsWith('"') || !etag.endsWith('"') || response.headers.get('Accept-Ranges') !== 'bytes') {
      throw new Error('Range delivery requires a valid size, strong ETag and Accept-Ranges: bytes');
    }
    metrics.totalBytes = size;
    state.reader = new RangeLockbox(size);
    state.load = async (offset, length) => {
      const first = Math.floor(offset/blockSize)*blockSize;
      const end = Math.min(size, Math.ceil((offset+length)/blockSize)*blockSize);
      for (let start=first; start<end;) {
        if (state.blocks.has(start)) {start+=blockSize;continue;}
        let stop=start+blockSize;
        while(stop<end && !state.blocks.has(stop)) stop+=blockSize;
        stop=Math.min(stop,size);
        const part = await fetch(url, {headers:{Range:`bytes=${start}-${stop-1}`,'If-Match':etag},cache:'no-store'});
        if (part.status !== 206 || part.headers.get('ETag') !== etag ||
            part.headers.get('Content-Range') !== `bytes ${start}-${stop-1}/${size}` ||
            (part.headers.get('Content-Encoding') && part.headers.get('Content-Encoding') !== 'identity')) {
          await part.body?.cancel();
          throw new Error('Archive changed or server did not honor the exact range');
        }
        const bytes = new Uint8Array(await part.arrayBuffer());
        if(bytes.length!==stop-start) throw new Error('Truncated range response');
        state.reader.supply(start,bytes);
        for(let block=start;block<stop;block+=blockSize) state.blocks.add(block);
        metrics.rangeBytes+=bytes.length;metrics.rangeRequests++;
        metrics.ranges.push([start,stop]);start=stop;
      }
    };
    // The archive header and newest commit/index are normally at opposite ends.
    const last=Math.floor((size-1)/blockSize)*blockSize;
    await Promise.all([state.load(0,1), ...(last ? [state.load(last,1)] : [])]);
    for(let attempt=0; !state.reader.try_open(); attempt++) {
      if(attempt>=4096) throw new Error('Too many sparse-open retries');
      metrics.retries++;
      const [offset,length]=state.reader.missing();
      if(!length) throw new Error('Reader made no progress');
      await state.load(offset,length);
    }
    metrics.openRangeBytes=metrics.rangeBytes;
  }
  return state;
}

function archive(mode,name) {
  const key=`${mode}/${name}`;
  if (!boxes.has(key)) {
    const pending=openArchive(mode,name);
    boxes.set(key,pending);
    pending.catch(()=>boxes.delete(key));
  }
  return boxes.get(key);
}
const mime = path => ({html:'text/html; charset=utf-8',css:'text/css; charset=utf-8',js:'text/javascript; charset=utf-8',
  png:'image/png',svg:'image/svg+xml',gz:'application/gzip',json:'application/json',woff2:'font/woff2'})[path.split('.').pop()] || 'application/octet-stream';

async function serve(request,url) {
  try {
    if(!['GET','HEAD'].includes(request.method)) return new Response('Method not allowed',{status:405});
    const [mode,name,...parts]=decodeURIComponent(url.pathname.slice(prefix.length)).split('/');
    if(!['range','full'].includes(mode) || !allowedPackages.has(name)) return new Response('Unknown package',{status:404});
    const state=await archive(mode,name);
    const operation=state.queue.then(async()=>{
      const path='/'+(parts.join('/') || 'index.html');
      if(!state.reader.exists(path)) return new Response('Documentation entry not found',{status:404});
      let bytes;
      for(let attempt=0;;attempt++) {
        try { bytes=state.reader.read(path);break; }
        catch(error) {
          const [offset,length]=state.reader.missing();
          if(mode!=='range' || !length || attempt>=4096) throw error;
          state.metrics.retries++;
          await state.load(offset,length);
        }
      }
      state.metrics.reads.push({path,bytes:bytes.length,archiveBytes:state.metrics.rangeBytes});
      if(state.metrics.reads.length>1000) state.metrics.reads.shift();
      return new Response(request.method==='HEAD'?null:bytes,{headers:{'Content-Type':mime(path),
        'X-Doc-Transport':`revault-wasm-${mode}`,'X-Content-Type-Options':'nosniff'}});
    });
    state.queue=operation.catch(()=>{});
    return await operation;
  } catch(error) {
    stats.errors.push(String(error));
    return new Response(`Unable to read documentation lockbox: ${error}`,{status:502});
  }
}
self.addEventListener('fetch',event=>{
  const url=new URL(event.request.url);
  if(url.origin===self.location.origin && url.pathname.startsWith(prefix)) event.respondWith(serve(event.request,url));
});
self.addEventListener('message',event=>{
  if(event.data?.type==='stats') event.waitUntil(ready.then(()=>event.ports[0]?.postMessage({...stats,wasmMemoryBytes:wasm.memory.buffer.byteLength})));
});

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    implementedImplementation is completein progressWork is actively in progresstestedVerification has passed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions