Network-disabled offline-region reference

This small browser host demonstrates the public @honua/sdk-js/offline contracts without adding an SDK-owned service-worker policy. Its online pass downloads one integrity-checked feature resource into the IndexedDB region store. The host-owned worker caches only the reviewed same-origin application shell, with ceilings of 128 assets, 4 MiB per asset, and 16 MiB total. A later reload can therefore boot with networking disabled, read the resource through createOfflineRegionFetchHandler(), and show its stale state, version provenance, and attribution.

A disconnected pass also records one field edit in the durable IndexedDB edit queue under a stable idempotency key, so a repeated disconnected launch returns the existing edit rather than queueing a second copy. A connected launch runs one bounded replayOfflineEditPass() over that same partition. The page then composes the region diagnostic, the queued edits, and its own connectivity decision through createLocalFirstStatus() and renders the single resulting state. Connectivity is the host's decision here — derived from the shell's retained-generation result and navigator.onLine — because the SDK deliberately refuses to infer endpoint reachability. The composed state shows the documented precedence in practice: with a readable region the disconnected pass reports pending rather than stale, and after the region is removed it reports partial / missing-regions, so a queued edit can never mask a missing cache.

The replay transport stays application-owned. This host binds it to /offline-edit-replay, a loopback fixture endpoint that mirrors the documented acknowledgement shape and keeps no replica, upload cursor, or conflict store. That boundary is enough to prove the local transitions and nothing more: an applied acknowledgement retires the edit once, so a second pass claims nothing and the endpoint is never invoked again; a conflicted acknowledgement leaves a durable typed conflict that is terminal rather than retried; an acknowledgement naming a different operation identity records an unacknowledged / identity-mismatch outcome and leaves the lease recoverable once it expires; and a retryable acknowledgement schedules a durable backoff instead of re-invoking the endpoint on the next launch. The request carries the edit, its operation identity, and its source id — never the authorization scope, lease, or audit state — and the fixture records only identities. This is not evidence of hosted replica synchronization. End-to-end exactly-once delivery is a server property; the fixture neither implements it nor stands in for it.

Because the page supplies a replica binding, createLocalFirstStatus() also publishes each conflicted edit projected onto the SDK's shipped sync-conflict vocabulary as localFirst.syncConflicts — a SyncConflictId, the replica-sync kind, the client operation, the feature, and the server generation cursor when the acknowledgement carried one. The replica and dataset identifiers are fixture constants owned by this page, because the SDK cannot derive a replica from a queue partition and refuses to guess one. Every member of SyncConflictDetail that only a live server can observe or adjudicate is listed under unavailable rather than filled in, so the projection widens the vocabulary without widening the claim.

shell-manifest.v1.json identifies one deployment and pins every document and transitive SDK module by URL, byte length, SHA-256, and media type. The worker fetches this manifest fresh and commits nothing unless every response matches, so a rollout cannot combine a new entry point with an older dependency graph. The media-type pin is checked before any body is staged: a deployment that returns the pinned bytes under a Content-Type the browser refuses for that role — a module served as text/plain — is rejected instead of replacing a generation that still boots, because fetch itself enforces no module MIME rule and the failure would otherwise surface only on the next offline reload. Manifest and asset bodies are read incrementally and canceled as soon as their declared or fixed byte ceiling is exceeded, so the ceilings also bound worker memory use for a malformed response. Content-Length is used as an early check only for identity encoding; compressed responses are bounded by their decoded stream so valid small resources are not rejected by a larger wire size. The same streaming ceiling is applied to the declared offline data resource before the SDK receives any bytes for persistence.

Because the worker commits nothing when a single pin disagrees, a source change that shifts any pinned dist/ module leaves shellReady false and fails the whole offline browser suite at once. scripts/offline-shell-manifest.mjs recomputes the pins from the files themselves. npm run offline:shell-manifest:generate rebuilds dist/ and then refreshes the manifest in place — it pins the bytes CI will serve rather than whatever stale build happens to be on disk — and npm run offline:shell-manifest:check — run in CI before the browser suite — fails naming the drifted resource and its old and new values. Which URLs belong in the shell stays a reviewed decision; the script only refreshes what the manifest already lists.

Shell refresh is best effort once a complete generation exists. The worker stages and validates the complete replacement under a new cache name, commits it by changing one persistent active-generation pointer, and then removes the previous generation. A failed request or budget check deletes the staging cache and retains the prior shell. Replacing the whole generation also prevents obsolete URLs from accumulating past the entry or total-byte ceilings across deployments. Only a generation named by the persistent commit pointer qualifies as retained; unpointed or legacy caches are cleaned up but never trusted as a complete shell. The normalized worker scope is part of every cache and lock name, so sibling copies on one origin cannot delete each other's generations. A scope-specific update lock serializes replacement across old and newly activated worker instances, preventing either version from deleting the other's staging or newly committed generation. A committed-generation marker is written only after its pointer update, and cleanup re-reads the active pointer immediately before removing each inactive generation, so overlapping worker lifecycles also fail safe. Per-generation reader locks keep a selected immutable generation alive through page probes and fetch matches. Readers acquire those locks without queueing and retry the current pointer if cleanup already owns one, so a stalled refresh cannot block an offline read of the prior committed shell.

The Playwright coverage in test/playwright/offline-indexeddb.spec.mjs serves these files from an isolated loopback origin. It captures the edit with browser networking disabled, reloads with networking still disabled, and observes the same durable record — identity, payload, and audit history — before restoring networking and observing one bounded pass apply it exactly once. It also removes the downloaded region through the public cache-admin contract before a disconnected reload, proving that a cache miss is visibly unavailable rather than an empty successful result. It also covers an unreachable origin while navigator.onLine remains true, failed and oversized shell refreshes, and replacement of an intentionally overfilled prior generation. A hanging refresh is aborted before the host's response timeout, including while it waits behind another refresh, so the committed shell remains usable across concurrent tabs. The host also verifies an existing committed generation before refreshing and treats a worker replacement, send failure, or late reply as retained only when that prior shell is present. The worker's receipt deadline races the complete replacement task, including non-abortable digest and Cache API work. Query-bearing launch URLs are replaced in browser history with the credential-free canonical document URL before the shell is declared ready.

This remains a bounded reference. "Reconnect" here means only that the loopback fixture endpoint became reachable again: the workflow does not resume a realtime cursor, reconcile a snapshot, implement replica synchronization, or review conflict content, and its fixture acknowledgements are not server semantics.