feat: nip-5a nsites

This commit is contained in:
Alejandro Gómez
2026-03-27 14:27:28 +01:00
parent 4a501634c5
commit 48ce35cbea
6 changed files with 769 additions and 0 deletions

View File

@@ -0,0 +1,243 @@
import { useState } from "react";
import {
Globe,
FileText,
HardDrive,
ExternalLink,
Code,
Radio,
} from "lucide-react";
import {
getNsitePaths,
getNsiteServers,
getNsiteRelays,
getNsiteIdentifier,
getNsiteSource,
getNsiteGatewayUrl,
} from "@/lib/nip5a-helpers";
import { useNsiteMetadata } from "@/hooks/useNsiteMetadata";
import { useAddWindow } from "@/core/state";
import { RelayLink } from "../RelayLink";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import type { NostrEvent } from "@/types/nostr";
function NsiteIcon({
faviconUrl,
className,
}: {
faviconUrl?: string;
className?: string;
}) {
const [failed, setFailed] = useState(false);
if (!faviconUrl || failed) {
return <Globe className={`${className} text-muted-foreground`} />;
}
return (
<img
src={faviconUrl}
alt=""
className={`${className} object-contain`}
onError={() => setFailed(true)}
/>
);
}
function NsiteFileRow({
path,
hash,
serverUrl,
}: {
path: string;
hash: string;
serverUrl?: string;
}) {
const addWindow = useAddWindow();
const handleClick = () => {
addWindow(
"blossom",
{ subcommand: "blob", sha256: hash, serverUrl },
`blossom blob ${hash.slice(0, 8)}`,
undefined,
);
};
return (
<div
className="flex items-center gap-2 py-0.5 px-1 -mx-1 text-xs rounded hover:bg-muted/30 cursor-pointer group"
onClick={handleClick}
>
<span className="truncate flex-1">{path}</span>
<span className="font-mono text-muted-foreground shrink-0">
{hash.slice(0, 8)}{hash.slice(-4)}
</span>
</div>
);
}
/**
* Shared detail view for all nsite kinds
*/
function NsiteDetailView({
event,
legacy = false,
}: {
event: NostrEvent;
legacy?: boolean;
}) {
const paths = getNsitePaths(event);
const servers = getNsiteServers(event);
const relays = getNsiteRelays(event);
const identifier = getNsiteIdentifier(event);
const source = getNsiteSource(event);
const gatewayUrl = getNsiteGatewayUrl(event);
const { title, description, faviconUrl } = useNsiteMetadata(event);
const addWindow = useAddWindow();
const displayTitle = title || (identifier ? `/${identifier}` : "Nsite");
const handleServerClick = (serverUrl: string) => {
addWindow(
"blossom",
{ subcommand: "server", serverUrl },
`blossom server ${serverUrl}`,
undefined,
);
};
return (
<div className="flex flex-col gap-4 p-4">
{/* Header */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<NsiteIcon faviconUrl={faviconUrl} className="size-5" />
<h2 className="text-lg font-semibold">{displayTitle}</h2>
{identifier && <Label>{identifier}</Label>}
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{legacy && (
<p className="text-xs text-yellow-500">
This is a legacy nsite event (kind 34128). New sites should use kind
15128 or 35128.
</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a href={gatewayUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5" />
View Site
</a>
</Button>
{source && (
<Button variant="ghost" size="sm" asChild>
<a href={source} target="_blank" rel="noopener noreferrer">
<Code className="size-3.5" />
Source
</a>
</Button>
)}
</div>
{/* Files */}
{paths.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="size-4" />
<span>Files ({paths.length})</span>
</div>
<div className="flex flex-col gap-0.5">
{[...paths]
.sort((a, b) => a.path.localeCompare(b.path))
.map(({ path, hash }) => (
<NsiteFileRow
key={path}
path={path}
hash={hash}
serverUrl={servers[0]}
/>
))}
</div>
</div>
)}
{/* Servers */}
{servers.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive className="size-4" />
<span>Blossom Servers ({servers.length})</span>
</div>
<div className="flex flex-col gap-0.5">
{servers.map((url) => (
<div
key={url}
className="flex items-center gap-2 py-0.5 group cursor-pointer hover:bg-muted/30 rounded px-1 -mx-1"
onClick={() => handleServerClick(url)}
>
<HardDrive className="size-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs underline decoration-dotted flex-1 truncate">
{url}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
window.open(url, "_blank");
}}
>
<ExternalLink className="size-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Relays */}
{relays.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Radio className="size-4" />
<span>Relays ({relays.length})</span>
</div>
<div className="flex flex-col gap-0.5">
{relays.map((url) => (
<RelayLink key={url} url={url} />
))}
</div>
</div>
)}
</div>
);
}
/**
* Kind 15128 Detail Renderer - Root Nsite Manifest
*/
export function NsiteRootDetailRenderer({ event }: { event: NostrEvent }) {
return <NsiteDetailView event={event} />;
}
/**
* Kind 35128 Detail Renderer - Named Nsite Manifest
*/
export function NsiteNamedDetailRenderer({ event }: { event: NostrEvent }) {
return <NsiteDetailView event={event} />;
}
/**
* Kind 34128 Detail Renderer - Legacy Nsite (deprecated)
*/
export function NsiteLegacyDetailRenderer({ event }: { event: NostrEvent }) {
return <NsiteDetailView event={event} legacy />;
}

View File

@@ -0,0 +1,125 @@
import { useState } from "react";
import { Globe, FileText, HardDrive, ExternalLink } from "lucide-react";
import {
getNsitePaths,
getNsiteServers,
getNsiteIdentifier,
getNsiteGatewayUrl,
} from "@/lib/nip5a-helpers";
import { useNsiteMetadata } from "@/hooks/useNsiteMetadata";
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
/**
* Shows favicon with Globe fallback on error or when unavailable
*/
function NsiteIcon({
faviconUrl,
className,
}: {
faviconUrl?: string;
className?: string;
}) {
const [failed, setFailed] = useState(false);
if (!faviconUrl || failed) {
return <Globe className={`${className} text-muted-foreground`} />;
}
return (
<img
src={faviconUrl}
alt=""
className={`${className} object-contain`}
onError={() => setFailed(true)}
/>
);
}
/**
* Shared nsite feed renderer
*/
function NsiteRendererInner({
event,
legacy = false,
}: BaseEventProps & { legacy?: boolean }) {
const paths = getNsitePaths(event);
const servers = getNsiteServers(event);
const identifier = getNsiteIdentifier(event);
const { title, faviconUrl } = useNsiteMetadata(event);
const gatewayUrl = getNsiteGatewayUrl(event);
const displayTitle = title || (identifier ? `/${identifier}` : "Nsite");
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<NsiteIcon faviconUrl={faviconUrl} className="size-4" />
<span>{displayTitle}</span>
{legacy && (
<span className="text-xs text-muted-foreground font-normal">
(legacy)
</span>
)}
</ClickableEventTitle>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{paths.length > 0 && (
<div className="flex items-center gap-1">
<FileText className="size-3.5" />
<span>
{paths.length} file{paths.length !== 1 ? "s" : ""}
</span>
</div>
)}
{servers.length > 0 && (
<div className="flex items-center gap-1">
<HardDrive className="size-3.5" />
<span>
{servers.length} server{servers.length !== 1 ? "s" : ""}
</span>
</div>
)}
<a
href={gatewayUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-auto flex items-center gap-1 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3.5" />
<span>Visit</span>
</a>
</div>
</div>
</BaseEventContainer>
);
}
/**
* Kind 15128 Renderer - Root Nsite Manifest (Feed View)
*/
export function NsiteRootRenderer({ event }: BaseEventProps) {
return <NsiteRendererInner event={event} />;
}
/**
* Kind 35128 Renderer - Named Nsite Manifest (Feed View)
*/
export function NsiteNamedRenderer({ event }: BaseEventProps) {
return <NsiteRendererInner event={event} />;
}
/**
* Kind 34128 Renderer - Legacy Nsite (Feed View, deprecated)
*/
export function NsiteLegacyRenderer({ event }: BaseEventProps) {
return <NsiteRendererInner event={event} legacy />;
}

View File

@@ -176,6 +176,16 @@ import {
import { PlaylistRenderer, PlaylistDetailRenderer } from "./PlaylistRenderer";
import { EducationalResourceRenderer } from "./EducationalResourceRenderer";
import { EducationalResourceDetailRenderer } from "./EducationalResourceDetailRenderer";
import {
NsiteRootRenderer,
NsiteNamedRenderer,
NsiteLegacyRenderer,
} from "./NsiteRenderer";
import {
NsiteRootDetailRenderer,
NsiteNamedDetailRenderer,
NsiteLegacyDetailRenderer,
} from "./NsiteDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -240,6 +250,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
10317: Kind10317Renderer, // User Grasp List (NIP-34)
10777: FavoriteSpellsRenderer, // Favorite Spells (Grimoire)
13534: RelayMembersRenderer, // Relay Members (NIP-43)
15128: NsiteRootRenderer, // Root Nsite Manifest (NIP-5A)
30000: FollowSetRenderer, // Follow Sets (NIP-51)
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
30003: BookmarkSetRenderer, // Bookmark Sets (NIP-51)
@@ -265,6 +276,8 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
36787: MusicTrackRenderer, // Music Track
34128: NsiteLegacyRenderer, // Legacy Nsite (NIP-5A, deprecated)
35128: NsiteNamedRenderer, // Named Nsite Manifest (NIP-5A)
30617: RepositoryRenderer, // Repository (NIP-34)
30618: RepositoryStateRenderer, // Repository State (NIP-34)
30777: SpellbookRenderer, // Spellbook (Grimoire)
@@ -356,6 +369,7 @@ const detailRenderers: Record<
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
10777: FavoriteSpellsDetailRenderer, // Favorite Spells Detail (Grimoire)
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
15128: NsiteRootDetailRenderer, // Root Nsite Manifest Detail (NIP-5A)
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
30003: BookmarkSetDetailRenderer, // Bookmark Sets Detail (NIP-51)
30004: ArticleCurationSetDetailRenderer, // Article Curation Sets Detail (NIP-51)
@@ -376,6 +390,8 @@ const detailRenderers: Record<
30383: TrustedAssertionDetailRenderer, // Event Assertion Detail (NIP-85)
30384: TrustedAssertionDetailRenderer, // Address Assertion Detail (NIP-85)
30385: TrustedAssertionDetailRenderer, // External Assertion Detail (NIP-85)
34128: NsiteLegacyDetailRenderer, // Legacy Nsite Detail (NIP-5A, deprecated)
35128: NsiteNamedDetailRenderer, // Named Nsite Detail (NIP-5A)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)

View File

@@ -0,0 +1,226 @@
import { useState, useEffect } from "react";
import type { NostrEvent } from "@/types/nostr";
import {
getNsiteTitle,
getNsiteDescription,
getNsiteIndexHash,
getNsiteServers,
getNsiteGatewayUrl,
} from "@/lib/nip5a-helpers";
import { getBlobUrl } from "@/services/blossom";
import { blossomServerCache } from "@/services/blossom-server-cache";
import db from "@/services/db";
export interface NsiteMetadata {
title?: string;
description?: string;
faviconUrl?: string;
}
// In-memory fast layer (populated from Dexie on first access)
const memoryCache = new Map<string, NsiteMetadata>();
// Track in-flight fetches to avoid duplicate requests
const pendingFetches = new Map<string, Promise<NsiteMetadata | null>>();
async function getCachedMetadata(hash: string): Promise<NsiteMetadata | null> {
// Memory first
const mem = memoryCache.get(hash);
if (mem) return mem;
// Then Dexie
try {
const cached = await db.nsiteMetadata.get(hash);
if (cached) {
const metadata: NsiteMetadata = {
title: cached.title,
description: cached.description,
faviconUrl: cached.faviconUrl,
};
memoryCache.set(hash, metadata);
return metadata;
}
} catch {
// Dexie error, continue to fetch
}
return null;
}
async function persistMetadata(
hash: string,
metadata: NsiteMetadata,
): Promise<void> {
memoryCache.set(hash, metadata);
try {
await db.nsiteMetadata.put({ hash, ...metadata });
} catch {
// Non-critical, memory cache is enough
}
}
async function fetchAndParseIndexHtml(
hash: string,
servers: string[],
baseUrl: string,
signal: AbortSignal,
): Promise<NsiteMetadata | null> {
for (const server of servers) {
try {
const url = getBlobUrl(server, hash);
const response = await fetch(url, { signal });
if (!response.ok) continue;
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Resolve favicon: try <link rel="icon">, then <link rel="shortcut icon">
let faviconUrl: string | undefined;
const iconLink =
doc.querySelector('link[rel="icon"]') ??
doc.querySelector('link[rel="shortcut icon"]');
const iconHref = iconLink?.getAttribute("href");
if (iconHref) {
if (iconHref.startsWith("data:")) {
faviconUrl = iconHref;
} else {
try {
faviconUrl = new URL(iconHref, baseUrl).href;
} catch {
// Invalid URL, skip
}
}
}
const metadata: NsiteMetadata = {
title: doc.querySelector("title")?.textContent?.trim() || undefined,
description:
doc
.querySelector('meta[name="description"]')
?.getAttribute("content")
?.trim() || undefined,
faviconUrl,
};
return metadata;
} catch {
if (signal.aborted) return null;
// Try next server
}
}
return null;
}
/**
* Hook that returns site metadata for an nsite manifest event.
*
* Strategy (progressive):
* 1. Use title/description tags from the event if present
* 2. Check in-memory cache, then Dexie (persistent across sessions)
* 3. Fetch index.html from Blossom, parse <head> for title/description/favicon
* 4. Persist to both caches (keyed by sha256 hash — immutable, no TTL)
*/
export function useNsiteMetadata(event: NostrEvent): {
title?: string;
description?: string;
faviconUrl?: string;
loading: boolean;
} {
const tagTitle = getNsiteTitle(event);
const tagDescription = getNsiteDescription(event);
const gatewayUrl = getNsiteGatewayUrl(event);
const [fetched, setFetched] = useState<NsiteMetadata | null>(() => {
// Sync init from memory cache
const indexHash = getNsiteIndexHash(event);
return indexHash ? (memoryCache.get(indexHash) ?? null) : null;
});
const [loading, setLoading] = useState(false);
const indexHash = getNsiteIndexHash(event);
const needsFetch = !!indexHash;
useEffect(() => {
if (!needsFetch || !indexHash) return;
// Already have it in memory
const mem = memoryCache.get(indexHash);
if (mem) {
setFetched(mem);
return;
}
const controller = new AbortController();
let cancelled = false;
setLoading(true);
(async () => {
// Check Dexie
const cached = await getCachedMetadata(indexHash);
if (cached) {
if (!cancelled) {
setFetched(cached);
setLoading(false);
}
return;
}
// Deduplicate in-flight fetches
let fetchPromise = pendingFetches.get(indexHash);
if (!fetchPromise) {
fetchPromise = (async () => {
const eventServers = getNsiteServers(event);
const authorServers =
(await blossomServerCache.getServers(event.pubkey)) ?? [];
const allServers = [...new Set([...eventServers, ...authorServers])];
if (allServers.length === 0) return null;
return fetchAndParseIndexHtml(
indexHash,
allServers,
gatewayUrl,
controller.signal,
);
})();
pendingFetches.set(indexHash, fetchPromise);
}
try {
const result = await fetchPromise;
pendingFetches.delete(indexHash);
if (cancelled) return;
if (result) {
await persistMetadata(indexHash, result);
setFetched(result);
}
} catch {
pendingFetches.delete(indexHash);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [needsFetch, indexHash, event, gatewayUrl]);
if (tagTitle) {
return {
title: tagTitle,
description: tagDescription,
faviconUrl: fetched?.faviconUrl,
loading: false,
};
}
return {
title: fetched?.title,
description: fetched?.description ?? tagDescription,
faviconUrl: fetched?.faviconUrl,
loading,
};
}

133
src/lib/nip5a-helpers.ts Normal file
View File

@@ -0,0 +1,133 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
import { nip19 } from "nostr-tools";
/**
* NIP-5A Helper Functions
* Utility functions for parsing NIP-5A pubkey static website events
*
* All helper functions use applesauce's getOrComputeCachedValue to cache
* computed values on the event object itself. This means you don't need
* useMemo when calling these functions.
*/
export const DEFAULT_NSITE_GATEWAY = "nsite.lol";
// Cache symbols
const NsitePathsSymbol = Symbol("nsitePaths");
const NsiteServersSymbol = Symbol("nsiteServers");
const NsiteRelaysSymbol = Symbol("nsiteRelays");
const NsiteGatewayUrlSymbol = Symbol("nsiteGatewayUrl");
export interface NsitePath {
path: string;
hash: string;
}
/**
* Get the site title from a site manifest event
*/
export function getNsiteTitle(event: NostrEvent): string | undefined {
return getTagValue(event, "title");
}
/**
* Get the site description from a site manifest event
*/
export function getNsiteDescription(event: NostrEvent): string | undefined {
return getTagValue(event, "description");
}
/**
* Get the source code URL from a site manifest event
*/
export function getNsiteSource(event: NostrEvent): string | undefined {
return getTagValue(event, "source");
}
/**
* Get all path-to-hash mappings from a site manifest event
*/
export function getNsitePaths(event: NostrEvent): NsitePath[] {
return getOrComputeCachedValue(event, NsitePathsSymbol, () =>
event.tags
.filter((t) => t[0] === "path" && t[1] && t[2])
.map((t) => ({ path: t[1], hash: t[2] })),
);
}
/**
* Get all blossom server hints from a site manifest event
*/
export function getNsiteServers(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, NsiteServersSymbol, () => [
...new Set(
event.tags.filter((t) => t[0] === "server" && t[1]).map((t) => t[1]),
),
]);
}
/**
* Get relay hints from a site manifest event
*/
export function getNsiteRelays(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, NsiteRelaysSymbol, () => [
...new Set(
event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]),
),
]);
}
/**
* Get the sha256 hash for /index.html from the site manifest
*/
export function getNsiteIndexHash(event: NostrEvent): string | undefined {
const paths = getNsitePaths(event);
return paths.find((p) => p.path === "/index.html")?.hash;
}
/**
* Get the sha256 hash for /favicon.ico from the site manifest
*/
export function getNsiteFaviconHash(event: NostrEvent): string | undefined {
const paths = getNsitePaths(event);
return paths.find((p) => p.path === "/favicon.ico")?.hash;
}
/**
* Get the site identifier (d tag) for named sites (kind 35128)
*/
export function getNsiteIdentifier(event: NostrEvent): string | undefined {
return getTagValue(event, "d");
}
/**
* Convert a hex pubkey to base36 (50 chars, zero-padded)
* Used for named site subdomain construction per NIP-5A spec
*/
function hexToBase36(hex: string): string {
const num = BigInt("0x" + hex);
return num.toString(36).padStart(50, "0");
}
/**
* Get the gateway URL to view this site
* Root sites (15128): https://<npub>.nsite.lol
* Named sites (35128): https://<pubkeyB36><dTag>.nsite.lol
*/
export function getNsiteGatewayUrl(
event: NostrEvent,
gateway: string = DEFAULT_NSITE_GATEWAY,
): string {
return getOrComputeCachedValue(event, NsiteGatewayUrlSymbol, () => {
if (event.kind === 35128) {
const dTag = getNsiteIdentifier(event);
if (dTag) {
const pubkeyB36 = hexToBase36(event.pubkey);
return `https://${pubkeyB36}${dTag}.${gateway}`;
}
}
const npub = nip19.npubEncode(event.pubkey);
return `https://${npub}.${gateway}`;
});
}

View File

@@ -100,6 +100,13 @@ export interface LnurlCache {
fetchedAt: number; // Timestamp for cache invalidation
}
export interface CachedNsiteMetadata {
hash: string; // Primary key - sha256 of index.html (content-addressable, never expires)
title?: string;
description?: string;
faviconUrl?: string;
}
export interface GrimoireZap {
eventId: string; // Primary key - zap receipt event ID
senderPubkey: string; // Who sent the zap
@@ -120,6 +127,7 @@ class GrimoireDb extends Dexie {
spells!: Table<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
lnurlCache!: Table<LnurlCache>;
nsiteMetadata!: Table<CachedNsiteMetadata>;
grimoireZaps!: Table<GrimoireZap>;
constructor(name: string) {
@@ -366,6 +374,24 @@ class GrimoireDb extends Dexie {
grimoireZaps:
"&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]",
});
// Version 18: Add nsite metadata caching (NIP-5A)
this.version(18).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
blossomServers: "&pubkey, updatedAt",
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
lnurlCache: "&address, fetchedAt",
grimoireZaps:
"&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]",
nsiteMetadata: "&hash",
});
}
}