Files
eranos/src/components/NsitePreviewDialog.tsx
T
Alex Gleason dc81585f9a Pre-fetch all nsite blobs on Android before WebView navigates
Android's shouldInterceptRequest blocks a pool of ~6 IO threads, each
waiting for JS to respond via the Capacitor bridge. With 200+ files
each requiring a network round-trip to Blossom, loading is painfully
slow. iOS doesn't have this problem — WKURLSchemeHandler is async.

Split the native plugin lifecycle into create() and navigate():
- create() adds the WebView container with spinner overlay (visible)
- navigate() loads the entry URL (triggers fetch interception)

On Android, onReady downloads all manifest blobs in parallel (12
concurrent fetches) into an in-memory cache while the native
ProgressBar spinner animates. Once navigate() fires, every resolveFile
call is an instant cache hit.

On iOS/web, onReady is a no-op and navigate() fires immediately.
2026-04-11 17:20:21 -05:00

355 lines
13 KiB
TypeScript

import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Package, X } from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { Button } from '@/components/ui/button';
import { SandboxFrame } from '@/components/SandboxFrame';
import { useCenterColumn } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
import { getPreviewInjectedScript } from '@/lib/previewInjectedScript';
import { getMimeType } from '@/lib/sandbox';
import type { FileResponse, InjectedScript } from '@/lib/sandbox';
interface Rect { left: number; top: number; width: number; height: number }
/** Track the viewport-relative bounding rect of an element, updating on resize. */
function useElementRect(el: HTMLElement | null): Rect | null {
const [rect, setRect] = useState<Rect | null>(null);
useEffect(() => {
if (!el) { setRect(null); return; }
const measure = () => {
const r = el.getBoundingClientRect();
setRect({ left: r.left, top: r.top, width: r.width, height: r.height });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, [el]);
return rect;
}
/**
* Build the path→sha256 manifest from a nsite event's `path` tags.
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
*/
function buildManifest(event: NostrEvent): Map<string, string> {
const manifest = new Map<string, string>();
for (const tag of event.tags) {
if (tag[0] === 'path' && tag[1] && tag[2]) {
manifest.set(tag[1], tag[2]);
}
}
return manifest;
}
/**
* Resolve the Blossom servers for a nsite event.
* Prefers the `server` tags on the event; falls back to the provided app servers.
*/
function resolveServers(event: NostrEvent, appServers: string[]): string[] {
const eventServers = event.tags
.filter(([name]) => name === 'server')
.map(([, url]) => url)
.filter((url) => {
try { new URL(url); return true; } catch { return false; }
});
return eventServers.length > 0 ? eventServers : appServers;
}
/**
* Module-level preferred server. Once a Blossom server successfully serves
* a blob, it is promoted here so subsequent requests try it first — avoiding
* the round-trip penalty of 404s on servers that don't have the content.
*/
let preferredServer: string | null = null;
/**
* Fetch a blob from the given sha256 by trying Blossom servers.
*
* If a server previously succeeded (the "preferred" server), it is tried
* first. On success the preferred server is reinforced; on failure we fall
* through to the remaining servers in order. Whichever server ultimately
* succeeds is promoted to preferred for the next call.
*/
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
let lastError: unknown;
/** Try a single server. Returns the Response on success, or null. */
async function tryServer(server: string): Promise<Response | null> {
const base = server.replace(/\/+$/, '');
const url = `${base}/${sha256}`;
try {
const res = await fetch(url);
if (res.ok) {
preferredServer = server;
return res;
}
} catch (err) {
lastError = err;
}
return null;
}
// Try the preferred server first if it's in the list.
if (preferredServer && servers.includes(preferredServer)) {
const res = await tryServer(preferredServer);
if (res) return res;
}
// Fall through to the full list, skipping the preferred (already tried).
for (const server of servers) {
if (server === preferredServer) continue;
const res = await tryServer(server);
if (res) return res;
}
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
}
/** Max concurrent Blossom fetches during pre-fetch. */
const PREFETCH_CONCURRENCY = 12;
/**
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
*
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
* If each response requires a network round-trip to Blossom, the 6-at-a-time
* serialisation makes loading 200+ files extremely slow. By downloading
* every blob *before* the WebView starts loading, each bridge round-trip
* drops from seconds (network) to ~1-5ms (memory).
*
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
* handle many concurrent requests without any thread pool bottleneck.
*
* Uses bounded concurrency to saturate the network without overwhelming it.
*/
async function prefetchAllBlobs(
manifest: Map<string, string>,
servers: string[],
cache: Map<string, Uint8Array>,
): Promise<void> {
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
const uniqueHashes = [...new Set(manifest.values())];
// Skip hashes already in the cache (e.g. from a previous open).
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
if (toFetch.length === 0) return;
let cursor = 0;
const total = toFetch.length;
async function worker(): Promise<void> {
while (cursor < total) {
const idx = cursor++;
const sha256 = toFetch[idx];
try {
const res = await fetchFromBlossom(sha256, servers);
const buffer = await res.arrayBuffer();
cache.set(sha256, new Uint8Array(buffer));
} catch {
// Non-fatal — resolveFile will fetch on demand for cache misses.
}
}
}
const workers = Array.from(
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
() => worker(),
);
await Promise.all(workers);
}
interface NsitePreviewDialogProps {
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
event: NostrEvent;
/** Display name for the app. */
appName: string;
/** Optional app icon URL. */
appPicture?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* An in-app preview panel that covers the center column and loads an nsite in
* a sandboxed iframe.
*
* Files are served directly from Blossom servers using the manifest data
* embedded in the nsite event's `path` tags. Each path tag maps a file path
* to its sha256 hash, which is used to construct a Blossom content-addressed URL.
*
* The panel is portaled into the center column DOM element (via CenterColumnContext)
* and uses `position: fixed` to fill the viewport column area.
*/
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
const centerColumn = useCenterColumn();
const columnRect = useElementRect(open ? centerColumn : null);
const { config } = useAppContext();
// Use the NIP-5A canonical subdomain as the stable identifier, then derive
// a private HMAC-SHA256 subdomain so the raw identifier is never exposed as
// a sandbox origin (preventing cross-app localStorage/IndexedDB collisions).
const nsiteSubdomain = getNsiteSubdomain(event);
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
// Build the manifest and server list from the event (memoised per event identity)
const manifest = useRef<Map<string, string>>(new Map());
const servers = useRef<string[]>([]);
/**
* In-memory blob cache: sha256 → raw bytes.
* On Android, populated by a blocking pre-fetch in `onReady` so every
* `resolveFile` call is an instant cache hit with no network wait.
*/
const blobCache = useRef<Map<string, Uint8Array>>(new Map());
useEffect(() => {
manifest.current = buildManifest(event);
const appServers = getEffectiveBlossomServers(
config.blossomServerMetadata,
config.useAppBlossomServers ?? true,
);
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
/** Injected scripts: just the path normalisation snippet for SPA support. */
const injectedScripts = useMemo<InjectedScript[]>(() => [{
path: '__injected__/preview.js',
content: getPreviewInjectedScript(),
}], []);
/**
* Called by SandboxFrame before the native WebView is created.
*
* On Android: blocks until all blobs are pre-fetched. Android's WebView
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
* response requires a network fetch the whole thing is painfully slow.
* The native ProgressBar spinner (render thread) stays visible and
* animating during the download. Once the WebView starts, every
* resolveFile call is an instant cache hit.
*
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
* requests without a thread pool bottleneck.
*
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
*/
const onReady = useCallback(async () => {
if (Capacitor.getPlatform() !== 'android') return;
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
}, []);
/** Resolve a pathname to file content from the Blossom manifest. */
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
// Look up the sha256 for this path in the manifest.
// If not found, fall back to /index.html (SPA client-side routing).
let sha256 = manifest.current.get(pathname);
let servingPath = pathname;
if (!sha256) {
sha256 = manifest.current.get('/index.html');
servingPath = '/index.html';
}
if (!sha256) return null;
// Serve from cache if available (pre-fetched on Android).
const cached = blobCache.current.get(sha256);
if (cached) {
const contentType = getMimeType(servingPath);
return { status: 200, contentType, body: cached };
}
// Cache miss — fetch from Blossom (normal path on iOS/web).
const res = await fetchFromBlossom(sha256, servers.current);
const buffer = await res.arrayBuffer();
const body = new Uint8Array(buffer);
// Store in cache for future requests (e.g. SPA navigations).
blobCache.current.set(sha256, body);
// Always determine content type from the file extension.
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
// files), which causes browsers to reject module scripts. The file path from
// the manifest is authoritative for the correct MIME type.
const contentType = getMimeType(servingPath);
return { status: 200, contentType, body };
}, []);
if (!open || !centerColumn || !columnRect) return null;
// If the user has scrolled down, columnRect.top is negative (the column top
// is above the viewport). Clamp to 0 so the panel always starts at the
// viewport top edge and never grows taller than the viewport.
const panelTop = Math.max(0, columnRect.top);
const panelHeight = window.innerHeight - panelTop;
return createPortal(
<div
className="fixed z-50 flex flex-col bg-background"
style={{
left: columnRect.left,
top: panelTop,
width: columnRect.width,
height: panelHeight,
}}
>
{/* Nav bar */}
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
<img
src={appPicture}
alt={appName}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Package className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{appName}</span>
</div>
{/* Close */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => onOpenChange(false)}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
{/* Sandboxed iframe */}
<div className="flex-1 min-h-0 bg-background">
<SandboxFrame
key={`${previewSubdomain}-${open}`}
id={previewSubdomain}
resolveFile={resolveFile}
onReady={onReady}
injectedScripts={injectedScripts}
className="w-full h-full border-0"
title={`${appName} preview`}
/>
</div>
</div>,
document.body,
);
}