mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat: nip-5c scrolls (WASM programs)
Adds support for NIP-5C Scrolls — self-contained WebAssembly programs published as Nostr events. Includes kind registration (1227, 10027), feed/detail renderers, favorite scrolls list, a full WASM host runtime, and an interactive executor UI with param input, execution controls, and tabbed output (results, logs, subs, trace). - Kind 1227 (Scroll) and 10027 (Favorite Scrolls) registered - WASM host runtime with complete nostr.* import API - Dedicated RelayPool + EventStore per execution for isolation - Relay selection enhanced to derive authors from event refs - Configurable encoding (LE/BE endianness, presence bytes) - Extracted reusable scroll executor components
This commit is contained in:
153
src/components/nostr/kinds/FavoriteScrollsRenderer.tsx
Normal file
153
src/components/nostr/kinds/FavoriteScrollsRenderer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { ScrollText, Star } from "lucide-react";
|
||||
import {
|
||||
getScrollName,
|
||||
getScrollParams,
|
||||
getScrollContentSize,
|
||||
getScrollIcon,
|
||||
formatBytes,
|
||||
} from "@/lib/nip5c-helpers";
|
||||
import {
|
||||
BaseEventProps,
|
||||
BaseEventContainer,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { ScrollIconImage } from "./ScrollRenderer";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useFavoriteList, getListPointers } from "@/hooks/useFavoriteList";
|
||||
import { FAVORITE_LISTS } from "@/config/favorite-lists";
|
||||
import { SCROLL_KIND } from "@/constants/kinds";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { EventPointer } from "nostr-tools/nip19";
|
||||
|
||||
/**
|
||||
* Individual scroll reference item for the detail view
|
||||
*/
|
||||
function ScrollRefItem({
|
||||
pointer,
|
||||
onUnfavorite,
|
||||
canModify,
|
||||
}: {
|
||||
pointer: EventPointer;
|
||||
onUnfavorite?: (event: NostrEvent) => void;
|
||||
canModify: boolean;
|
||||
}) {
|
||||
const scrollEvent = useNostrEvent(pointer);
|
||||
|
||||
if (!scrollEvent) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border border-border/50 rounded">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const name = getScrollName(scrollEvent);
|
||||
const iconUrl = getScrollIcon(scrollEvent);
|
||||
const params = getScrollParams(scrollEvent);
|
||||
const contentSize = getScrollContentSize(scrollEvent);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border border-border/50 rounded group hover:bg-muted/30 transition-colors">
|
||||
<ScrollIconImage iconUrl={iconUrl} className="size-4" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{name || "Unnamed Scroll"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{params.length > 0 && (
|
||||
<span>
|
||||
{params.length} param{params.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{params.length > 0 && contentSize > 0 && <span> · </span>}
|
||||
{contentSize > 0 && <span>{formatBytes(contentSize)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{canModify && onUnfavorite && (
|
||||
<button
|
||||
onClick={() => onUnfavorite(scrollEvent)}
|
||||
className="p-1.5 text-muted-foreground hover:text-yellow-500 transition-colors flex-shrink-0"
|
||||
title="Remove from favorites"
|
||||
>
|
||||
<Star className="size-3.5 fill-current" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 10027 Renderer - Favorite Scrolls (Feed View)
|
||||
*/
|
||||
export function FavoriteScrollsRenderer({ event }: BaseEventProps) {
|
||||
const pointers = getListPointers(event, "e");
|
||||
|
||||
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"
|
||||
>
|
||||
<ScrollText className="size-4 text-muted-foreground" />
|
||||
<span>Favorite Scrolls</span>
|
||||
</ClickableEventTitle>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pointers.length === 0
|
||||
? "No favorite scrolls"
|
||||
: `${pointers.length} favorite scroll${pointers.length !== 1 ? "s" : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 10027 Detail Renderer - Favorite Scrolls (Full View)
|
||||
*/
|
||||
export function FavoriteScrollsDetailRenderer({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const { canSign } = useAccount();
|
||||
const { toggleFavorite } = useFavoriteList(FAVORITE_LISTS[SCROLL_KIND]);
|
||||
|
||||
const pointers = getListPointers(event, "e");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="size-6 text-muted-foreground" />
|
||||
<span className="text-lg font-semibold">Favorite Scrolls</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({pointers.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pointers.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
No favorite scrolls yet. Star a scroll to add it here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pointers.map((pointer) => (
|
||||
<ScrollRefItem
|
||||
key={pointer.id}
|
||||
pointer={pointer}
|
||||
onUnfavorite={canSign ? toggleFavorite : undefined}
|
||||
canModify={canSign}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/nostr/kinds/ScrollRenderer.tsx
Normal file
154
src/components/nostr/kinds/ScrollRenderer.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ScrollText,
|
||||
Binary,
|
||||
User,
|
||||
FileText,
|
||||
Type,
|
||||
Hash,
|
||||
Calendar,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getScrollName,
|
||||
getScrollDescription,
|
||||
getScrollIcon,
|
||||
getScrollParams,
|
||||
getScrollContentSize,
|
||||
formatBytes,
|
||||
} from "@/lib/nip5c-helpers";
|
||||
import type { ScrollParamType } from "@/lib/nip5c-helpers";
|
||||
import {
|
||||
BaseEventProps,
|
||||
BaseEventContainer,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { ScrollExecutor } from "@/components/scroll/ScrollExecutor";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export function ScrollIconImage({
|
||||
iconUrl,
|
||||
className,
|
||||
}: {
|
||||
iconUrl?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!iconUrl || failed) {
|
||||
return <ScrollText className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
className={`${className} object-contain rounded-sm`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const PARAM_CONFIG: Record<
|
||||
ScrollParamType,
|
||||
{ icon: LucideIcon; placeholder: string; inputType: string }
|
||||
> = {
|
||||
public_key: {
|
||||
icon: User,
|
||||
placeholder: "hex pubkey or npub...",
|
||||
inputType: "text",
|
||||
},
|
||||
event: {
|
||||
icon: FileText,
|
||||
placeholder: "event ID, note1..., or nevent...",
|
||||
inputType: "text",
|
||||
},
|
||||
string: { icon: Type, placeholder: "text value...", inputType: "text" },
|
||||
number: { icon: Hash, placeholder: "0", inputType: "number" },
|
||||
timestamp: {
|
||||
icon: Calendar,
|
||||
placeholder: "unix timestamp",
|
||||
inputType: "number",
|
||||
},
|
||||
relay: { icon: Radio, placeholder: "wss://...", inputType: "text" },
|
||||
};
|
||||
|
||||
export function ScrollRenderer({ event }: BaseEventProps) {
|
||||
const name = getScrollName(event);
|
||||
const description = getScrollDescription(event);
|
||||
const iconUrl = getScrollIcon(event);
|
||||
const params = getScrollParams(event);
|
||||
const contentSize = getScrollContentSize(event);
|
||||
|
||||
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"
|
||||
>
|
||||
<ScrollIconImage iconUrl={iconUrl} className="size-4" />
|
||||
<span>{name || "Unnamed Scroll"}</span>
|
||||
</ClickableEventTitle>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{params.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 text-xs text-muted-foreground">
|
||||
{params.map((param) => {
|
||||
const { icon: Icon } = PARAM_CONFIG[param.type];
|
||||
return (
|
||||
<div key={param.name} className="flex items-center gap-1.5">
|
||||
<Icon className="size-3 flex-shrink-0" />
|
||||
<span>{param.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentSize > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Binary className="size-3.5" />
|
||||
<span>{formatBytes(contentSize)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScrollDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const name = getScrollName(event);
|
||||
const description = getScrollDescription(event);
|
||||
const iconUrl = getScrollIcon(event);
|
||||
const contentSize = getScrollContentSize(event);
|
||||
const params = getScrollParams(event);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 h-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollIconImage iconUrl={iconUrl} className="size-5" />
|
||||
<h2 className="text-lg font-semibold">{name || "Unnamed Scroll"}</h2>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{contentSize > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Binary className="size-3.5" />
|
||||
<span>~{formatBytes(contentSize)} WASM</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollExecutor params={params} wasmBase64={event.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +190,11 @@ import {
|
||||
NsiteNamedRenderer,
|
||||
NsiteLegacyRenderer,
|
||||
} from "./NsiteRenderer";
|
||||
import { ScrollRenderer, ScrollDetailRenderer } from "./ScrollRenderer";
|
||||
import {
|
||||
FavoriteScrollsRenderer,
|
||||
FavoriteScrollsDetailRenderer,
|
||||
} from "./FavoriteScrollsRenderer";
|
||||
import {
|
||||
NsiteRootDetailRenderer,
|
||||
NsiteNamedDetailRenderer,
|
||||
@@ -222,6 +227,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
1111: Kind1111Renderer, // Post (NIP-22)
|
||||
1222: VoiceMessageRenderer, // Voice Message (NIP-A0)
|
||||
1311: LiveChatMessageRenderer, // Live Chat Message (NIP-53)
|
||||
1227: ScrollRenderer, // Scroll (NIP-5C)
|
||||
1244: VoiceMessageRenderer, // Voice Message Reply (NIP-A0)
|
||||
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
|
||||
3367: ColorMomentRenderer, // Color Moment
|
||||
@@ -253,6 +259,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10017: GitAuthorsRenderer, // Git Authors (NIP-51)
|
||||
10018: FavoriteReposRenderer, // Favorite Repositories (NIP-51)
|
||||
10020: MediaFollowListRenderer, // Media Follow List (NIP-51)
|
||||
10027: FavoriteScrollsRenderer, // Favorite Scrolls (NIP-5C)
|
||||
10030: EmojiListRenderer, // User Emoji List (NIP-51)
|
||||
10040: TrustedProviderListRenderer, // Trusted Provider List (NIP-85)
|
||||
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
|
||||
@@ -356,6 +363,7 @@ const detailRenderers: Record<
|
||||
8: BadgeAwardDetailRenderer, // Badge Award Detail (NIP-58)
|
||||
777: SpellDetailRenderer, // Spell Detail
|
||||
1068: PollDetailRenderer, // Poll Detail (NIP-88)
|
||||
1227: ScrollDetailRenderer, // Scroll Detail (NIP-5C)
|
||||
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
|
||||
3367: ColorMomentDetailRenderer, // Color Moment Detail
|
||||
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
|
||||
@@ -381,6 +389,7 @@ const detailRenderers: Record<
|
||||
10018: FavoriteReposDetailRenderer, // Favorite Repositories Detail (NIP-34)
|
||||
10040: TrustedProviderListDetailRenderer, // Trusted Provider List Detail (NIP-85)
|
||||
10020: MediaFollowListDetailRenderer, // Media Follow List Detail (NIP-51)
|
||||
10027: FavoriteScrollsDetailRenderer, // Favorite Scrolls Detail (NIP-5C)
|
||||
10030: EmojiListDetailRenderer, // User Emoji List Detail (NIP-51)
|
||||
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
|
||||
10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51)
|
||||
|
||||
99
src/components/scroll/ScrollControls.tsx
Normal file
99
src/components/scroll/ScrollControls.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Play, Square, Loader2, Settings } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { ScrollRuntimeState } from "@/lib/scroll-runtime";
|
||||
|
||||
interface ScrollControlsProps {
|
||||
runtimeState: ScrollRuntimeState;
|
||||
onRun: () => void;
|
||||
onStop: () => void;
|
||||
runDisabled?: boolean;
|
||||
endianness: "LE" | "BE";
|
||||
presenceBytes: boolean;
|
||||
onEndiannessChange: (v: "LE" | "BE") => void;
|
||||
onPresenceBytesChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function ScrollControls({
|
||||
runtimeState,
|
||||
onRun,
|
||||
onStop,
|
||||
runDisabled,
|
||||
endianness,
|
||||
presenceBytes,
|
||||
onEndiannessChange,
|
||||
onPresenceBytesChange,
|
||||
}: ScrollControlsProps) {
|
||||
const canRun =
|
||||
runtimeState === "idle" ||
|
||||
runtimeState === "stopped" ||
|
||||
runtimeState === "completed" ||
|
||||
runtimeState === "error";
|
||||
const isActive = runtimeState === "loading" || runtimeState === "running";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={onRun} disabled={!canRun || runDisabled}>
|
||||
{isActive ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={onStop}
|
||||
disabled={!isActive}
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
Stop
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 ml-auto"
|
||||
disabled={isActive}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Encoding</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={endianness}
|
||||
onValueChange={(v) => onEndiannessChange(v as "LE" | "BE")}
|
||||
>
|
||||
<DropdownMenuRadioItem value="LE">
|
||||
Little-endian (spec)
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="BE">
|
||||
Big-endian (legacy)
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={presenceBytes}
|
||||
onCheckedChange={(v) => onPresenceBytesChange(v === true)}
|
||||
>
|
||||
Presence bytes
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
src/components/scroll/ScrollExecutor.tsx
Normal file
188
src/components/scroll/ScrollExecutor.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { resolveParamValue } from "@/lib/nip5c-helpers";
|
||||
import type { ScrollParam, ParamValue } from "@/lib/nip5c-helpers";
|
||||
import {
|
||||
runScroll,
|
||||
fetchEventParam,
|
||||
type ScrollRuntimeController,
|
||||
type ScrollRuntimeState,
|
||||
type TraceEntry,
|
||||
type SubscriptionInfo,
|
||||
} from "@/lib/scroll-runtime";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { ScrollParamForm } from "./ScrollParamForm";
|
||||
import { ScrollControls } from "./ScrollControls";
|
||||
import { ScrollOutput } from "./ScrollOutput";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
interface ScrollExecutorProps {
|
||||
/** Parsed parameter definitions */
|
||||
params: ScrollParam[];
|
||||
/** Base64-encoded WASM binary */
|
||||
wasmBase64: string;
|
||||
}
|
||||
|
||||
export function ScrollExecutor({ params, wasmBase64 }: ScrollExecutorProps) {
|
||||
const { pubkey } = useAccount();
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
const connectedRelays = Object.entries(relayStates)
|
||||
.filter(([, state]) => state.connectionState === "connected")
|
||||
.map(([url]) => url);
|
||||
|
||||
// Pre-fill "me" params with logged-in pubkey
|
||||
const defaultValues: Record<string, string> = {};
|
||||
for (const p of params) {
|
||||
if (p.name === "me" && p.type === "public_key" && pubkey) {
|
||||
defaultValues[p.name] = pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
const [runtimeState, setRuntimeState] = useState<ScrollRuntimeState>("idle");
|
||||
const [paramValues, setParamValues] =
|
||||
useState<Record<string, string>>(defaultValues);
|
||||
const [displayedEventsMap, setDisplayedEventsMap] = useState<
|
||||
Map<string, NostrEvent>
|
||||
>(new Map());
|
||||
const [logEntries, setLogEntries] = useState<string[]>([]);
|
||||
const [traceEntries, setTraceEntries] = useState<TraceEntry[]>([]);
|
||||
const [activeSubs, setActiveSubs] = useState<SubscriptionInfo[]>([]);
|
||||
const [eventCount, setEventCount] = useState(0);
|
||||
const controllerRef = useRef<ScrollRuntimeController | null>(null);
|
||||
|
||||
// Encoding options
|
||||
const [endianness, setEndianness] = useState<"LE" | "BE">("BE");
|
||||
const [presenceBytes, setPresenceBytes] = useState(false);
|
||||
|
||||
const isActive = runtimeState === "loading" || runtimeState === "running";
|
||||
|
||||
// Sorted, deduplicated display events (newest first)
|
||||
const displayedEvents = useMemo(
|
||||
() =>
|
||||
Array.from(displayedEventsMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at,
|
||||
),
|
||||
[displayedEventsMap],
|
||||
);
|
||||
|
||||
const requiredParamsMissing = params.some(
|
||||
(p) => p.required && !paramValues[p.name]?.trim(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
controllerRef.current?.stop();
|
||||
|
||||
setDisplayedEventsMap(new Map());
|
||||
setLogEntries([]);
|
||||
setTraceEntries([]);
|
||||
setActiveSubs([]);
|
||||
setEventCount(0);
|
||||
setRuntimeState("loading");
|
||||
|
||||
// Resolve param values — fetch all event params before running
|
||||
const resolved = new Map<string, ParamValue>();
|
||||
for (const param of params) {
|
||||
const raw = paramValues[param.name];
|
||||
if (!raw?.trim()) {
|
||||
if (param.required) {
|
||||
setLogEntries([`Error: required param "${param.name}" is missing`]);
|
||||
setRuntimeState("error");
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const value = resolveParamValue(param.type, raw);
|
||||
if (value === null) {
|
||||
setLogEntries([
|
||||
`Error: invalid value for param "${param.name}" (type: ${param.type})`,
|
||||
]);
|
||||
setRuntimeState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (param.type === "event" && typeof value === "string") {
|
||||
const eventObj = await fetchEventParam(value);
|
||||
if (!eventObj) {
|
||||
setLogEntries([
|
||||
`Error: could not fetch event "${value}" for param "${param.name}"`,
|
||||
]);
|
||||
setRuntimeState("error");
|
||||
return;
|
||||
}
|
||||
resolved.set(param.name, eventObj);
|
||||
} else {
|
||||
resolved.set(param.name, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = await runScroll(wasmBase64, params, {
|
||||
paramValues: resolved,
|
||||
endianness,
|
||||
presenceBytes,
|
||||
onDisplay: (ev) =>
|
||||
setDisplayedEventsMap((prev) => {
|
||||
if (prev.has(ev.id)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(ev.id, ev);
|
||||
return next;
|
||||
}),
|
||||
onLog: (msg) => setLogEntries((prev) => [...prev, msg]),
|
||||
onStateChange: setRuntimeState,
|
||||
onEventCount: setEventCount,
|
||||
onSubscriptionsChange: setActiveSubs,
|
||||
onTrace: (entry) => setTraceEntries((prev) => [...prev, entry]),
|
||||
onError: (err) =>
|
||||
setLogEntries((prev) => [...prev, `Error: ${err.message}`]),
|
||||
});
|
||||
controllerRef.current = controller;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setLogEntries((prev) => [...prev, `Fatal: ${msg}`]);
|
||||
setRuntimeState("error");
|
||||
}
|
||||
}, [wasmBase64, params, paramValues, endianness, presenceBytes]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
controllerRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<ScrollParamForm
|
||||
params={params}
|
||||
values={paramValues}
|
||||
onChange={setParamValues}
|
||||
connectedRelays={connectedRelays}
|
||||
disabled={isActive}
|
||||
/>
|
||||
|
||||
<ScrollControls
|
||||
runtimeState={runtimeState}
|
||||
onRun={handleRun}
|
||||
onStop={handleStop}
|
||||
runDisabled={requiredParamsMissing}
|
||||
endianness={endianness}
|
||||
presenceBytes={presenceBytes}
|
||||
onEndiannessChange={setEndianness}
|
||||
onPresenceBytesChange={setPresenceBytes}
|
||||
/>
|
||||
|
||||
<ScrollOutput
|
||||
displayedEvents={displayedEvents}
|
||||
logEntries={logEntries}
|
||||
traceEntries={traceEntries}
|
||||
activeSubs={activeSubs}
|
||||
eventCount={eventCount}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
src/components/scroll/ScrollOutput.tsx
Normal file
285
src/components/scroll/ScrollOutput.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useState, memo } from "react";
|
||||
import {
|
||||
List,
|
||||
Terminal,
|
||||
Wifi,
|
||||
Activity,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
GalleryVertical,
|
||||
} from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RelayLink } from "@/components/nostr/RelayLink";
|
||||
import { FeedEvent } from "@/components/nostr/Feed";
|
||||
import { MemoizedCompactEventRow } from "@/components/nostr/CompactEventRow";
|
||||
import { CopyableJsonViewer } from "@/components/JsonViewer";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { TraceEntry, SubscriptionInfo } from "@/lib/scroll-runtime";
|
||||
|
||||
function TraceRow({ entry }: { entry: TraceEntry }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDetail = entry.args || entry.result;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/50">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-mono px-2 py-1 ${hasDetail ? "cursor-pointer hover:bg-muted/30" : ""}`}
|
||||
onClick={() => hasDetail && setExpanded(!expanded)}
|
||||
>
|
||||
{hasDetail ? (
|
||||
expanded ? (
|
||||
<ChevronDown className="size-3 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 flex-shrink-0 text-muted-foreground" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`font-semibold ${entry.direction === "program" ? "text-highlight" : "text-accent"}`}
|
||||
>
|
||||
{entry.direction === "program" ? "program" : "host"}
|
||||
</span>
|
||||
<span className="text-foreground">{entry.fn}</span>
|
||||
</div>
|
||||
{expanded && hasDetail && (
|
||||
<div className="pl-7 pr-2 pb-2">
|
||||
<CopyableJsonViewer
|
||||
json={JSON.stringify(
|
||||
{
|
||||
...(entry.args && { args: entry.args }),
|
||||
...(entry.result && { result: entry.result }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p className="text-xs">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScrollOutputProps {
|
||||
displayedEvents: NostrEvent[];
|
||||
logEntries: string[];
|
||||
traceEntries: TraceEntry[];
|
||||
activeSubs: SubscriptionInfo[];
|
||||
eventCount: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const MemoizedFeedEvent = memo(
|
||||
FeedEvent,
|
||||
(prev, next) => prev.event.id === next.event.id,
|
||||
);
|
||||
|
||||
export function ScrollOutput({
|
||||
displayedEvents,
|
||||
logEntries,
|
||||
traceEntries,
|
||||
activeSubs,
|
||||
eventCount,
|
||||
isActive,
|
||||
}: ScrollOutputProps) {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const openSubsCount = activeSubs.filter((s) => !s.closed).length;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="results" className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center border-b border-border">
|
||||
<TabsList className="h-auto bg-transparent p-0 rounded-none">
|
||||
<TabsTrigger
|
||||
value="results"
|
||||
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<List className="size-3" />
|
||||
Results
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="logs"
|
||||
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
Logs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="subs"
|
||||
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<Wifi className="size-3" />
|
||||
Subs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="trace"
|
||||
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<Activity className="size-3" />
|
||||
Trace
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="results"
|
||||
className="flex-1 min-h-0 mt-0 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center px-2 py-1 border-b border-border text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wifi className="size-3" />
|
||||
{openSubsCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="size-3" />
|
||||
{eventCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<List className="size-3" />
|
||||
{displayedEvents.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCompact((v) => !v)}
|
||||
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={compact ? "Switch to list view" : "Switch to compact view"}
|
||||
>
|
||||
{compact ? (
|
||||
<GalleryVertical className="size-3" />
|
||||
) : (
|
||||
<List className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{displayedEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
message={
|
||||
isActive
|
||||
? "Waiting for results..."
|
||||
: "Run the scroll to see results"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso
|
||||
style={{ height: "100%" }}
|
||||
data={displayedEvents}
|
||||
computeItemKey={(_index, ev) => ev.id}
|
||||
itemContent={(_index, ev) =>
|
||||
compact ? (
|
||||
<MemoizedCompactEventRow event={ev} />
|
||||
) : (
|
||||
<MemoizedFeedEvent event={ev} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="flex-1 min-h-0 mt-0">
|
||||
{logEntries.length === 0 ? (
|
||||
<EmptyState
|
||||
message={isActive ? "Waiting for logs..." : "No log output"}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso
|
||||
style={{ height: "100%" }}
|
||||
data={logEntries}
|
||||
followOutput="smooth"
|
||||
computeItemKey={(index) => index}
|
||||
itemContent={(_index, entry) => (
|
||||
<div className="text-xs font-mono px-2 py-0.5 border-b border-border/50 whitespace-pre-wrap break-all">
|
||||
{entry}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subs" className="flex-1 min-h-0 mt-0">
|
||||
{activeSubs.length === 0 ? (
|
||||
<EmptyState
|
||||
message={
|
||||
isActive
|
||||
? "No active subscriptions"
|
||||
: "Run the scroll to see subscriptions"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-auto h-full p-2 flex flex-col gap-2">
|
||||
{[...activeSubs]
|
||||
.sort((a, b) => a.handle - b.handle)
|
||||
.map((sub) => (
|
||||
<div
|
||||
key={sub.handle}
|
||||
className="border border-border/50 rounded p-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono font-semibold">
|
||||
SUB #{sub.handle}
|
||||
</span>
|
||||
<Label size="sm">{sub.eventCount} events</Label>
|
||||
{sub.eosed && <Label size="sm">EOSE</Label>}
|
||||
<span
|
||||
className={`ml-auto flex items-center gap-1 ${sub.closed ? "text-muted-foreground" : "text-green-400"}`}
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${sub.closed ? "bg-muted-foreground" : "bg-green-400"}`}
|
||||
/>
|
||||
{sub.closed ? "closed" : "open"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Filter:</span>
|
||||
<pre className="mt-1 text-[11px] font-mono bg-muted/30 rounded p-1.5 overflow-x-auto">
|
||||
{JSON.stringify(sub.filter, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{sub.relays.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Relays ({sub.relays.length}):
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sub.relays.map((url) => (
|
||||
<RelayLink key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trace" className="flex-1 min-h-0 mt-0">
|
||||
{traceEntries.length === 0 ? (
|
||||
<EmptyState
|
||||
message={isActive ? "Waiting for trace data..." : "No trace data"}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso
|
||||
style={{ height: "100%" }}
|
||||
data={traceEntries}
|
||||
followOutput="smooth"
|
||||
computeItemKey={(index) => index}
|
||||
itemContent={(_index, entry) => <TraceRow entry={entry} />}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
109
src/components/scroll/ScrollParamForm.tsx
Normal file
109
src/components/scroll/ScrollParamForm.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { PARAM_CONFIG } from "@/components/nostr/kinds/ScrollRenderer";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ScrollParam } from "@/lib/nip5c-helpers";
|
||||
|
||||
interface ScrollParamFormProps {
|
||||
params: ScrollParam[];
|
||||
values: Record<string, string>;
|
||||
onChange: (values: Record<string, string>) => void;
|
||||
connectedRelays: string[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ScrollParamForm({
|
||||
params,
|
||||
values,
|
||||
onChange,
|
||||
connectedRelays,
|
||||
disabled,
|
||||
}: ScrollParamFormProps) {
|
||||
if (params.length === 0) return null;
|
||||
|
||||
const setValue = (name: string, value: string) => {
|
||||
onChange({ ...values, [name]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Settings className="size-4" />
|
||||
<span>Parameters</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{params.map((param) => {
|
||||
const {
|
||||
icon: Icon,
|
||||
placeholder,
|
||||
inputType,
|
||||
} = PARAM_CONFIG[param.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={param.name}
|
||||
className="flex flex-col gap-1 px-3 py-2 border border-border/50 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium font-mono">
|
||||
{param.name}
|
||||
</span>
|
||||
<Label size="sm">{param.type}</Label>
|
||||
{param.required && <Label size="sm">required</Label>}
|
||||
{param.supportedKinds && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
kinds: {param.supportedKinds}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{param.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
{param.type === "relay" ? (
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
value={values[param.name] || ""}
|
||||
onChange={(e) => setValue(param.name, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-transparent px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">Select relay...</option>
|
||||
{connectedRelays.map((url) => (
|
||||
<option key={url} value={url}>
|
||||
{url}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="or type wss://..."
|
||||
value={
|
||||
connectedRelays.includes(values[param.name] || "")
|
||||
? ""
|
||||
: values[param.name] || ""
|
||||
}
|
||||
onChange={(e) => setValue(param.name, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={inputType}
|
||||
placeholder={placeholder}
|
||||
value={values[param.name] || ""}
|
||||
onChange={(e) => setValue(param.name, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SPELL_KIND } from "@/constants/kinds";
|
||||
import { SPELL_KIND, SCROLL_KIND } from "@/constants/kinds";
|
||||
import type { TagStrategy } from "@/lib/favorite-tag-strategies";
|
||||
import { groupTagStrategy } from "@/lib/favorite-tag-strategies";
|
||||
|
||||
@@ -36,6 +36,11 @@ export const FAVORITE_LISTS: Record<number, FavoriteListConfig> = {
|
||||
elementKind: 30030,
|
||||
label: "Emoji Sets",
|
||||
},
|
||||
[SCROLL_KIND]: {
|
||||
listKind: 10027,
|
||||
elementKind: SCROLL_KIND,
|
||||
label: "Favorite Scrolls",
|
||||
},
|
||||
39000: {
|
||||
listKind: 10009,
|
||||
elementKind: 39000,
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
WandSparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
ScrollText,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -96,6 +97,7 @@ export interface EventKind {
|
||||
|
||||
export const SPELL_KIND = 777;
|
||||
export const SPELLBOOK_KIND = 30777;
|
||||
export const SCROLL_KIND = 1227;
|
||||
|
||||
export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
// Core protocol kinds
|
||||
@@ -473,6 +475,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "A0",
|
||||
icon: Mic,
|
||||
},
|
||||
1227: {
|
||||
kind: 1227,
|
||||
name: "Scroll",
|
||||
description: "WebAssembly Scroll Program",
|
||||
nip: "5C",
|
||||
icon: ScrollText,
|
||||
},
|
||||
1311: {
|
||||
kind: 1311,
|
||||
name: "Live Chat",
|
||||
@@ -868,6 +877,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "51",
|
||||
icon: Play,
|
||||
},
|
||||
10027: {
|
||||
kind: 10027,
|
||||
name: "Favorite Scrolls",
|
||||
description: "Favorite scrolls list",
|
||||
nip: "5C",
|
||||
icon: ScrollText,
|
||||
},
|
||||
10030: {
|
||||
kind: 10030,
|
||||
name: "Emoji List",
|
||||
|
||||
135
src/lib/nip5c-helpers.ts
Normal file
135
src/lib/nip5c-helpers.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { hexToBytes } from "@noble/hashes/utils";
|
||||
import { isValidHexPubkey, isValidHexEventId } from "@/lib/nostr-validation";
|
||||
import { isValidRelayURL } from "@/lib/relay-url";
|
||||
|
||||
/**
|
||||
* NIP-5C Helper Functions
|
||||
* Utility functions for parsing NIP-5C Scroll events (kind 1227)
|
||||
*
|
||||
* 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 type ScrollParamType =
|
||||
| "public_key"
|
||||
| "event"
|
||||
| "string"
|
||||
| "number"
|
||||
| "timestamp"
|
||||
| "relay";
|
||||
|
||||
export interface ScrollParam {
|
||||
name: string;
|
||||
description: string;
|
||||
type: ScrollParamType;
|
||||
required: boolean;
|
||||
/** For event params, comma-separated list of supported kinds */
|
||||
supportedKinds?: string;
|
||||
}
|
||||
|
||||
const VALID_PARAM_TYPES = new Set<string>([
|
||||
"public_key",
|
||||
"event",
|
||||
"string",
|
||||
"number",
|
||||
"timestamp",
|
||||
"relay",
|
||||
]);
|
||||
|
||||
// Cache symbols
|
||||
const ScrollParamsSymbol = Symbol("scrollParams");
|
||||
const ScrollContentSizeSymbol = Symbol("scrollContentSize");
|
||||
|
||||
export function getScrollName(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "name");
|
||||
}
|
||||
|
||||
export function getScrollDescription(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "description");
|
||||
}
|
||||
|
||||
export function getScrollIcon(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "icon");
|
||||
}
|
||||
|
||||
/** Parses ["param", name, description, type, required, ...extra] tags */
|
||||
export function getScrollParams(event: NostrEvent): ScrollParam[] {
|
||||
return getOrComputeCachedValue(event, ScrollParamsSymbol, () =>
|
||||
event.tags
|
||||
.filter((t) => t[0] === "param" && t[1])
|
||||
.map((t) => ({
|
||||
name: t[1],
|
||||
description: t[2] || "",
|
||||
type: (VALID_PARAM_TYPES.has(t[3])
|
||||
? t[3]
|
||||
: "string") as ScrollParamType,
|
||||
required: t[4] === "required",
|
||||
supportedKinds: t[3] === "event" && t[5] ? t[5] : undefined,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Estimates decoded WASM binary size from base64 content length */
|
||||
export function getScrollContentSize(event: NostrEvent): number {
|
||||
return getOrComputeCachedValue(event, ScrollContentSizeSymbol, () => {
|
||||
if (!event.content) return 0;
|
||||
// base64 encodes 3 bytes per 4 chars, minus padding
|
||||
const padding = (event.content.match(/=+$/) || [""])[0].length;
|
||||
return Math.floor((event.content.length * 3) / 4) - padding;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export type ParamValue = Uint8Array | string | number | NostrEvent;
|
||||
|
||||
/** For "event" type, returns the event ID string — caller must fetch the actual event */
|
||||
export function resolveParamValue(
|
||||
type: ScrollParamType,
|
||||
rawValue: string,
|
||||
): ParamValue | null {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
switch (type) {
|
||||
case "public_key": {
|
||||
if (trimmed.startsWith("npub")) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === "npub") return hexToBytes(decoded.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return isValidHexPubkey(trimmed) ? hexToBytes(trimmed) : null;
|
||||
}
|
||||
case "event": {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === "nevent") return decoded.data.id;
|
||||
if (decoded.type === "note") return decoded.data;
|
||||
} catch {
|
||||
// Not a bech32 string — fall through to hex check
|
||||
}
|
||||
return isValidHexEventId(trimmed) ? trimmed : null;
|
||||
}
|
||||
case "string":
|
||||
return trimmed;
|
||||
case "number":
|
||||
case "timestamp": {
|
||||
const n = parseInt(trimmed, 10);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
case "relay":
|
||||
return isValidRelayURL(trimmed) ? trimmed : null;
|
||||
}
|
||||
}
|
||||
862
src/lib/scroll-runtime.ts
Normal file
862
src/lib/scroll-runtime.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
/**
|
||||
* NIP-5C Scroll WASM Host Runtime
|
||||
*
|
||||
* Standalone host that instantiates and runs NIP-5C scroll programs.
|
||||
* Provides the full nostr.* import API to WASM modules.
|
||||
*
|
||||
* Each execution uses a dedicated RelayPool and EventStore for isolation.
|
||||
* The global eventStore is only used for relay selection (NIP-65 relay lists).
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import type { Subscription } from "rxjs";
|
||||
import { firstValueFrom, timeout as rxTimeout } from "rxjs";
|
||||
import { RelayPool } from "applesauce-relay";
|
||||
import { EventStore } from "applesauce-core";
|
||||
import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
|
||||
import globalEventStore from "@/services/event-store";
|
||||
import { selectRelaysForFilter } from "@/services/relay-selection";
|
||||
import { AGGREGATOR_RELAYS, eventLoader } from "@/services/loaders";
|
||||
import type { ScrollParam, ParamValue } from "@/lib/nip5c-helpers";
|
||||
import { isNostrEvent } from "@/lib/type-guards";
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Fetch a NostrEvent by ID, checking the global store first then relays.
|
||||
*/
|
||||
export async function fetchEventParam(
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | undefined> {
|
||||
const existing = globalEventStore.getEvent(eventId);
|
||||
if (existing) return existing;
|
||||
try {
|
||||
return await firstValueFrom(
|
||||
eventLoader({ id: eventId }).pipe(rxTimeout(10_000)),
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type ScrollRuntimeState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "completed"
|
||||
| "error";
|
||||
|
||||
export type TraceDirection = "program" | "host";
|
||||
|
||||
export interface TraceEntry {
|
||||
direction: TraceDirection;
|
||||
fn: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ScrollRuntimeOptions {
|
||||
paramValues: Map<string, ParamValue>;
|
||||
onDisplay: (event: NostrEvent) => void;
|
||||
onLog: (message: string) => void;
|
||||
onStateChange: (state: ScrollRuntimeState) => void;
|
||||
/** Called with total event count when any event is received from subscriptions */
|
||||
onEventCount?: (count: number) => void;
|
||||
/** Called when subscriptions change (opened, closed, events received) */
|
||||
onSubscriptionsChange?: (subs: SubscriptionInfo[]) => void;
|
||||
/** Called for every host↔WASM function call (debug trace) */
|
||||
onTrace?: (entry: TraceEntry) => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** Byte order for host-written numbers. Default: "LE" (NIP-5C spec) */
|
||||
endianness?: "LE" | "BE";
|
||||
/** Whether to prefix each param with a presence byte. Default: true (NIP-5C spec) */
|
||||
presenceBytes?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
handle: number;
|
||||
filter: Filter;
|
||||
relays: string[];
|
||||
eventCount: number;
|
||||
eosed: boolean;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export interface ScrollRuntimeController {
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
interface ReqData {
|
||||
filter: Filter;
|
||||
relays: string[];
|
||||
closeOnEose: boolean;
|
||||
}
|
||||
|
||||
interface SubData {
|
||||
rxSubscription: Subscription | null;
|
||||
filter: Filter;
|
||||
relays: string[];
|
||||
eventCount: number;
|
||||
eosed: boolean;
|
||||
}
|
||||
|
||||
type HandleEntry =
|
||||
| { type: "req"; data: ReqData }
|
||||
| { type: "event"; data: NostrEvent }
|
||||
| { type: "sub"; data: SubData };
|
||||
|
||||
function pushUnique<T>(arr: T[], value: T): void {
|
||||
if (!arr.includes(value)) arr.push(value);
|
||||
}
|
||||
|
||||
export async function runScroll(
|
||||
wasmBase64: string,
|
||||
paramSpecs: ScrollParam[],
|
||||
options: ScrollRuntimeOptions,
|
||||
): Promise<ScrollRuntimeController> {
|
||||
let stopped = false;
|
||||
let totalEventsReceived = 0;
|
||||
const littleEndian = (options.endianness ?? "LE") === "LE";
|
||||
const usePresenceBytes = options.presenceBytes ?? true;
|
||||
|
||||
// Dedicated instances for this execution
|
||||
const privatePool = new RelayPool();
|
||||
const privateEventStore = new EventStore();
|
||||
|
||||
let nextHandleId = 1;
|
||||
const handles = new Map<number, HandleEntry>();
|
||||
const closedSubs: SubscriptionInfo[] = [];
|
||||
|
||||
function allocHandle(type: "req", data: ReqData): number;
|
||||
function allocHandle(type: "event", data: NostrEvent): number;
|
||||
function allocHandle(type: "sub", data: SubData): number;
|
||||
function allocHandle(type: string, data: unknown): number {
|
||||
const h = nextHandleId++;
|
||||
handles.set(h, { type, data } as HandleEntry);
|
||||
return h;
|
||||
}
|
||||
|
||||
function getReq(h: number): ReqData {
|
||||
const entry = handles.get(h);
|
||||
if (!entry || entry.type !== "req")
|
||||
throw new Error(`bad handle ${h} for type req`);
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function getEvent(h: number): NostrEvent {
|
||||
const entry = handles.get(h);
|
||||
if (!entry || entry.type !== "event")
|
||||
throw new Error(`bad handle ${h} for type event`);
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function dropHandle(h: number): void {
|
||||
if (h === 0) return;
|
||||
const entry = handles.get(h);
|
||||
if (!entry) return;
|
||||
if (entry.type === "sub" && entry.data.rxSubscription) {
|
||||
entry.data.rxSubscription.unsubscribe();
|
||||
}
|
||||
handles.delete(h);
|
||||
}
|
||||
|
||||
// State management
|
||||
function setState(s: ScrollRuntimeState): void {
|
||||
options.onStateChange(s);
|
||||
}
|
||||
|
||||
function snapshotSub(
|
||||
h: number,
|
||||
data: SubData,
|
||||
closed: boolean,
|
||||
): SubscriptionInfo {
|
||||
return {
|
||||
handle: h,
|
||||
filter: data.filter,
|
||||
relays: data.relays,
|
||||
eventCount: data.eventCount,
|
||||
eosed: data.eosed,
|
||||
closed,
|
||||
};
|
||||
}
|
||||
|
||||
function getAllSubscriptions(): SubscriptionInfo[] {
|
||||
const active: SubscriptionInfo[] = [];
|
||||
for (const [h, entry] of handles) {
|
||||
if (entry.type === "sub") {
|
||||
active.push(snapshotSub(h, entry.data, false));
|
||||
}
|
||||
}
|
||||
return [...closedSubs, ...active];
|
||||
}
|
||||
|
||||
let subsNotifyPending = false;
|
||||
function notifySubsChanged(): void {
|
||||
if (subsNotifyPending || !options.onSubscriptionsChange) return;
|
||||
subsNotifyPending = true;
|
||||
queueMicrotask(() => {
|
||||
subsNotifyPending = false;
|
||||
if (!stopped) {
|
||||
options.onSubscriptionsChange?.(getAllSubscriptions());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function trace(
|
||||
direction: TraceDirection,
|
||||
fn: string,
|
||||
args?: Record<string, unknown>,
|
||||
result?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!options.onTrace) return;
|
||||
options.onTrace({ direction, fn, args, result, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
// WASM instance references
|
||||
let instance: WebAssembly.Instance | null = null;
|
||||
let memory: WebAssembly.Memory;
|
||||
|
||||
// --- Memory I/O ---
|
||||
|
||||
function readBuf(ptr: number, len: number): Uint8Array {
|
||||
return new Uint8Array(memory.buffer.slice(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function readStr(ptr: number, len: number): string {
|
||||
return textDecoder.decode(new Uint8Array(memory.buffer, ptr, len));
|
||||
}
|
||||
|
||||
function alloc(size: number): number {
|
||||
return (instance!.exports.alloc as (size: number) => number)(size);
|
||||
}
|
||||
|
||||
function writeHostBuf(buf: Uint8Array, prefixLen: boolean): number {
|
||||
const prefixOffset = prefixLen ? 4 : 0;
|
||||
const ptr = alloc(buf.length + prefixOffset);
|
||||
const view = new DataView(memory.buffer);
|
||||
if (prefixLen) {
|
||||
view.setUint32(ptr, buf.length, littleEndian);
|
||||
}
|
||||
new Uint8Array(memory.buffer, ptr + prefixOffset, buf.length).set(buf);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function writeHostStr(str: string, prefixLen: boolean = true): number {
|
||||
const encoded = textEncoder.encode(str);
|
||||
return writeHostBuf(encoded, prefixLen);
|
||||
}
|
||||
|
||||
// --- Build WASM imports ---
|
||||
|
||||
const nostrImports = {
|
||||
req_new: (): number => {
|
||||
if (stopped) return 0;
|
||||
const h = allocHandle("req", {
|
||||
filter: {},
|
||||
relays: [],
|
||||
closeOnEose: false,
|
||||
});
|
||||
trace("program", "req_new", undefined, { handle: h });
|
||||
return h;
|
||||
},
|
||||
|
||||
req_add_author: (req: number, ptr: number): void => {
|
||||
if (stopped) return;
|
||||
const author = bytesToHex(readBuf(ptr, 32));
|
||||
const r = getReq(req);
|
||||
r.filter.authors = r.filter.authors || [];
|
||||
pushUnique(r.filter.authors, author);
|
||||
trace("program", "req_add_author", { req, author });
|
||||
},
|
||||
|
||||
req_add_author_hex: (req: number, ptr: number): void => {
|
||||
if (stopped) return;
|
||||
const author = readStr(ptr, 64);
|
||||
const r = getReq(req);
|
||||
r.filter.authors = r.filter.authors || [];
|
||||
pushUnique(r.filter.authors, author);
|
||||
trace("program", "req_add_author_hex", { req, author });
|
||||
},
|
||||
|
||||
req_add_id: (req: number, ptr: number): void => {
|
||||
if (stopped) return;
|
||||
const id = bytesToHex(readBuf(ptr, 32));
|
||||
const r = getReq(req);
|
||||
r.filter.ids = r.filter.ids || [];
|
||||
pushUnique(r.filter.ids, id);
|
||||
trace("program", "req_add_id", { req, id });
|
||||
},
|
||||
|
||||
req_add_id_hex: (req: number, ptr: number): void => {
|
||||
if (stopped) return;
|
||||
const id = readStr(ptr, 64);
|
||||
const r = getReq(req);
|
||||
r.filter.ids = r.filter.ids || [];
|
||||
pushUnique(r.filter.ids, id);
|
||||
trace("program", "req_add_id_hex", { req, id });
|
||||
},
|
||||
|
||||
req_add_kind: (req: number, kind: number): void => {
|
||||
if (stopped) return;
|
||||
const r = getReq(req);
|
||||
r.filter.kinds = r.filter.kinds || [];
|
||||
pushUnique(r.filter.kinds, kind);
|
||||
trace("program", "req_add_kind", { req, kind });
|
||||
},
|
||||
|
||||
req_add_tag: (
|
||||
req: number,
|
||||
tag: number,
|
||||
vPtr: number,
|
||||
vLen: number,
|
||||
): void => {
|
||||
if (stopped) return;
|
||||
const code = tag & 0xff;
|
||||
const tagChar = String.fromCharCode(code);
|
||||
if (!/^[A-Za-z]$/.test(tagChar)) return;
|
||||
const value = readStr(vPtr, vLen);
|
||||
const r = getReq(req);
|
||||
const key = `#${tagChar}` as `#${string}`;
|
||||
const tags = (r.filter as Record<string, string[]>)[key] || [];
|
||||
pushUnique(tags, value);
|
||||
(r.filter as Record<string, string[]>)[key] = tags;
|
||||
trace("program", "req_add_tag", { req, tag: `#${tagChar}`, value });
|
||||
},
|
||||
|
||||
req_add_tag_bin32: (req: number, tag: number, vPtr: number): void => {
|
||||
if (stopped) return;
|
||||
const code = tag & 0xff;
|
||||
const tagChar = String.fromCharCode(code);
|
||||
if (!/^[A-Za-z]$/.test(tagChar)) return;
|
||||
const value = bytesToHex(readBuf(vPtr, 32));
|
||||
const r = getReq(req);
|
||||
const key = `#${tagChar}` as `#${string}`;
|
||||
const tags = (r.filter as Record<string, string[]>)[key] || [];
|
||||
pushUnique(tags, value);
|
||||
(r.filter as Record<string, string[]>)[key] = tags;
|
||||
trace("program", "req_add_tag_bin32", {
|
||||
req,
|
||||
tag: `#${tagChar}`,
|
||||
value,
|
||||
});
|
||||
},
|
||||
|
||||
req_set_limit: (req: number, n: number): void => {
|
||||
if (stopped) return;
|
||||
getReq(req).filter.limit = n;
|
||||
trace("program", "req_set_limit", { req, limit: n });
|
||||
},
|
||||
|
||||
req_set_since: (req: number, ts: number): void => {
|
||||
if (stopped) return;
|
||||
getReq(req).filter.since = ts;
|
||||
trace("program", "req_set_since", { req, since: ts });
|
||||
},
|
||||
|
||||
req_set_until: (req: number, ts: number): void => {
|
||||
if (stopped) return;
|
||||
getReq(req).filter.until = ts;
|
||||
trace("program", "req_set_until", { req, until: ts });
|
||||
},
|
||||
|
||||
req_set_search: (req: number, ptr: number, len: number): void => {
|
||||
if (stopped) return;
|
||||
const search = readStr(ptr, len);
|
||||
getReq(req).filter.search = search;
|
||||
trace("program", "req_set_search", { req, search });
|
||||
},
|
||||
|
||||
req_add_relay: (req: number, ptr: number, len: number): void => {
|
||||
if (stopped) return;
|
||||
const relay = readStr(ptr, len);
|
||||
getReq(req).relays.push(relay);
|
||||
trace("program", "req_add_relay", { req, relay });
|
||||
},
|
||||
|
||||
req_close_on_eose: (req: number): void => {
|
||||
if (stopped) return;
|
||||
getReq(req).closeOnEose = true;
|
||||
trace("program", "req_close_on_eose", { req });
|
||||
},
|
||||
|
||||
subscribe: (req: number): number => {
|
||||
if (stopped) return 0;
|
||||
const reqData = getReq(req);
|
||||
handles.delete(req); // consume the req handle
|
||||
|
||||
const subHandle = allocHandle("sub", {
|
||||
rxSubscription: null,
|
||||
filter: reqData.filter,
|
||||
relays: [],
|
||||
eventCount: 0,
|
||||
eosed: false,
|
||||
});
|
||||
trace(
|
||||
"program",
|
||||
"subscribe",
|
||||
{
|
||||
req,
|
||||
filter: reqData.filter,
|
||||
relayHints: reqData.relays,
|
||||
closeOnEose: reqData.closeOnEose,
|
||||
},
|
||||
{ sub: subHandle },
|
||||
);
|
||||
|
||||
const on_event = instance!.exports.on_event as (
|
||||
sub: number,
|
||||
ev: number,
|
||||
eosed: number,
|
||||
) => void;
|
||||
const on_eose = instance!.exports.on_eose as (sub: number) => void;
|
||||
|
||||
(async () => {
|
||||
let relays: string[] = reqData.relays;
|
||||
|
||||
if (!relays.length) {
|
||||
try {
|
||||
const result = await selectRelaysForFilter(
|
||||
globalEventStore,
|
||||
reqData.filter,
|
||||
);
|
||||
relays = result.relays;
|
||||
} catch {
|
||||
relays = [...AGGREGATOR_RELAYS];
|
||||
}
|
||||
}
|
||||
|
||||
trace("host", "subscribe:connected", { sub: subHandle, relays });
|
||||
|
||||
if (!relays.length || stopped || !handles.has(subHandle)) return;
|
||||
|
||||
// Update sub data with resolved relays
|
||||
const subEntry = handles.get(subHandle);
|
||||
if (subEntry?.type === "sub") {
|
||||
subEntry.data.relays = relays;
|
||||
notifySubsChanged();
|
||||
}
|
||||
|
||||
let eosed = false;
|
||||
|
||||
const observable = privatePool.subscription(relays, [reqData.filter]);
|
||||
const rxSub = observable.subscribe({
|
||||
next: (response) => {
|
||||
if (stopped || !handles.has(subHandle)) return;
|
||||
|
||||
if (typeof response === "string" && response === "EOSE") {
|
||||
eosed = true;
|
||||
const se = handles.get(subHandle);
|
||||
if (se?.type === "sub") se.data.eosed = true;
|
||||
trace("host", "on_eose", { sub: subHandle });
|
||||
on_eose(subHandle);
|
||||
|
||||
if (reqData.closeOnEose) {
|
||||
const entry = handles.get(subHandle);
|
||||
if (entry?.type === "sub") {
|
||||
entry.data.rxSubscription?.unsubscribe();
|
||||
closedSubs.push(snapshotSub(subHandle, entry.data, true));
|
||||
}
|
||||
handles.delete(subHandle);
|
||||
notifySubsChanged();
|
||||
trace("host", "sub:closed_on_eose", { sub: subHandle });
|
||||
}
|
||||
} else if (isNostrEvent(response)) {
|
||||
privateEventStore.add(response);
|
||||
totalEventsReceived++;
|
||||
const se2 = handles.get(subHandle);
|
||||
if (se2?.type === "sub") se2.data.eventCount++;
|
||||
options.onEventCount?.(totalEventsReceived);
|
||||
notifySubsChanged();
|
||||
const evHandle = allocHandle("event", response);
|
||||
trace("host", "on_event", {
|
||||
sub: subHandle,
|
||||
ev: evHandle,
|
||||
kind: response.kind,
|
||||
id: response.id,
|
||||
pubkey: response.pubkey,
|
||||
eosed: eosed ? 1 : 0,
|
||||
});
|
||||
on_event(subHandle, evHandle, eosed ? 1 : 0);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
trace("host", "sub:error", {
|
||||
sub: subHandle,
|
||||
error: String(err),
|
||||
});
|
||||
const entry = handles.get(subHandle);
|
||||
if (entry?.type === "sub") {
|
||||
closedSubs.push(snapshotSub(subHandle, entry.data, true));
|
||||
}
|
||||
handles.delete(subHandle);
|
||||
notifySubsChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const entry = handles.get(subHandle);
|
||||
if (entry?.type === "sub") {
|
||||
entry.data.rxSubscription = rxSub;
|
||||
} else {
|
||||
rxSub.unsubscribe();
|
||||
}
|
||||
})();
|
||||
|
||||
return subHandle;
|
||||
},
|
||||
|
||||
// --- Event accessors ---
|
||||
|
||||
event_get_id: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const e = getEvent(ev);
|
||||
trace("program", "event_get_id", { ev }, { id: e.id });
|
||||
return writeHostBuf(hexToBytes(e.id), false);
|
||||
},
|
||||
|
||||
event_get_id_hex: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const e = getEvent(ev);
|
||||
trace("program", "event_get_id_hex", { ev }, { id: e.id });
|
||||
return writeHostStr(e.id, false);
|
||||
},
|
||||
|
||||
event_get_pubkey: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const e = getEvent(ev);
|
||||
trace("program", "event_get_pubkey", { ev }, { pubkey: e.pubkey });
|
||||
return writeHostBuf(hexToBytes(e.pubkey), false);
|
||||
},
|
||||
|
||||
event_get_pubkey_hex: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const e = getEvent(ev);
|
||||
trace("program", "event_get_pubkey_hex", { ev }, { pubkey: e.pubkey });
|
||||
return writeHostStr(e.pubkey, false);
|
||||
},
|
||||
|
||||
event_get_kind: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const kind = getEvent(ev).kind;
|
||||
trace("program", "event_get_kind", { ev }, { kind });
|
||||
return kind;
|
||||
},
|
||||
|
||||
event_get_created_at: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const created_at = getEvent(ev).created_at;
|
||||
trace("program", "event_get_created_at", { ev }, { created_at });
|
||||
return created_at;
|
||||
},
|
||||
|
||||
event_get_content: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const content = getEvent(ev).content;
|
||||
trace("program", "event_get_content", { ev }, { content });
|
||||
return writeHostStr(content);
|
||||
},
|
||||
|
||||
event_get_tag_count: (ev: number): number => {
|
||||
if (stopped) return 0;
|
||||
const count = (getEvent(ev).tags ?? []).length;
|
||||
trace("program", "event_get_tag_count", { ev }, { count });
|
||||
return count;
|
||||
},
|
||||
|
||||
event_get_tag_item_count: (ev: number, ti: number): number => {
|
||||
if (stopped) return 0;
|
||||
const count = (getEvent(ev).tags?.[ti] ?? []).length;
|
||||
trace(
|
||||
"program",
|
||||
"event_get_tag_item_count",
|
||||
{ ev, tagIndex: ti },
|
||||
{ count },
|
||||
);
|
||||
return count;
|
||||
},
|
||||
|
||||
event_get_tag_item: (ev: number, ti: number, ii: number): number => {
|
||||
if (stopped) return 0;
|
||||
const val = getEvent(ev).tags?.[ti]?.[ii];
|
||||
trace(
|
||||
"program",
|
||||
"event_get_tag_item",
|
||||
{ ev, tagIndex: ti, itemIndex: ii },
|
||||
{ value: val ?? null },
|
||||
);
|
||||
return val != null ? writeHostStr(val) : 0;
|
||||
},
|
||||
|
||||
event_get_tag_item_bin32: (ev: number, ti: number, ii: number): number => {
|
||||
if (stopped) return 0;
|
||||
const val = getEvent(ev).tags?.[ti]?.[ii];
|
||||
if (typeof val !== "string" || val.length !== 64) {
|
||||
trace(
|
||||
"program",
|
||||
"event_get_tag_item_bin32",
|
||||
{ ev, tagIndex: ti, itemIndex: ii },
|
||||
{ value: null },
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
trace(
|
||||
"program",
|
||||
"event_get_tag_item_bin32",
|
||||
{ ev, tagIndex: ti, itemIndex: ii },
|
||||
{ value: val },
|
||||
);
|
||||
return writeHostBuf(hexToBytes(val), false);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
event_get_tag_item_by_name: (
|
||||
ev: number,
|
||||
nPtr: number,
|
||||
nLen: number,
|
||||
ii: number,
|
||||
): number => {
|
||||
if (stopped) return 0;
|
||||
const name = readStr(nPtr, nLen);
|
||||
const tag = (getEvent(ev).tags ?? []).find((t) => t[0] === name);
|
||||
const val = tag?.[ii];
|
||||
trace(
|
||||
"program",
|
||||
"event_get_tag_item_by_name",
|
||||
{ ev, name, itemIndex: ii },
|
||||
{ value: val ?? null },
|
||||
);
|
||||
return val != null ? writeHostStr(val) : 0;
|
||||
},
|
||||
|
||||
event_get_tag_item_by_name_bin32: (
|
||||
ev: number,
|
||||
nPtr: number,
|
||||
nLen: number,
|
||||
ii: number,
|
||||
): number => {
|
||||
if (stopped) return 0;
|
||||
const name = readStr(nPtr, nLen);
|
||||
const tag = (getEvent(ev).tags ?? []).find((t) => t[0] === name);
|
||||
const val = tag?.[ii];
|
||||
if (typeof val !== "string" || val.length !== 64) {
|
||||
trace(
|
||||
"program",
|
||||
"event_get_tag_item_by_name_bin32",
|
||||
{ ev, name, itemIndex: ii },
|
||||
{ value: null },
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
trace(
|
||||
"program",
|
||||
"event_get_tag_item_by_name_bin32",
|
||||
{ ev, name, itemIndex: ii },
|
||||
{ value: val },
|
||||
);
|
||||
return writeHostBuf(hexToBytes(val), false);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
// --- Display and logging ---
|
||||
|
||||
display: (ev: number): void => {
|
||||
if (stopped) return;
|
||||
const event = getEvent(ev);
|
||||
trace("program", "display", {
|
||||
ev,
|
||||
kind: event.kind,
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
});
|
||||
options.onDisplay(event);
|
||||
},
|
||||
|
||||
log: (ptr: number, len: number): void => {
|
||||
if (stopped) return;
|
||||
const msg = readStr(ptr, len);
|
||||
trace("program", "log", { message: msg });
|
||||
options.onLog(msg);
|
||||
},
|
||||
|
||||
drop: (h: number): void => {
|
||||
if (stopped) return;
|
||||
const entry = handles.get(h);
|
||||
trace("program", "drop", { handle: h, type: entry?.type ?? "unknown" });
|
||||
dropHandle(h);
|
||||
},
|
||||
};
|
||||
|
||||
// --- Param encoding ---
|
||||
|
||||
function encodeParams(): number {
|
||||
if (paramSpecs.length === 0) return 0;
|
||||
|
||||
// First pass: calculate buffer size
|
||||
let bufSize = usePresenceBytes ? paramSpecs.length : 0;
|
||||
|
||||
// All event params must already be resolved to NostrEvent objects by the caller.
|
||||
const resolvedValues: (ParamValue | null)[] = [];
|
||||
|
||||
for (const spec of paramSpecs) {
|
||||
const value = options.paramValues.get(spec.name) ?? null;
|
||||
|
||||
if (value === null) {
|
||||
resolvedValues.push(null);
|
||||
trace("host", "param:encode", {
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
present: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedValues.push(value);
|
||||
trace("host", "param:encode", {
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
value:
|
||||
value instanceof Uint8Array
|
||||
? bytesToHex(value)
|
||||
: typeof value === "object" && "id" in value
|
||||
? {
|
||||
id: (value as NostrEvent).id,
|
||||
kind: (value as NostrEvent).kind,
|
||||
}
|
||||
: value,
|
||||
});
|
||||
|
||||
switch (spec.type) {
|
||||
case "public_key":
|
||||
bufSize += 32;
|
||||
break;
|
||||
case "event":
|
||||
bufSize += 4;
|
||||
break;
|
||||
case "string":
|
||||
case "relay": {
|
||||
const encoded = textEncoder.encode(value as string);
|
||||
bufSize += 4 + encoded.length;
|
||||
break;
|
||||
}
|
||||
case "number":
|
||||
case "timestamp":
|
||||
bufSize += 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const ptr = alloc(bufSize);
|
||||
const view = new DataView(memory.buffer, ptr, bufSize);
|
||||
let offset = 0;
|
||||
|
||||
for (const value of resolvedValues) {
|
||||
if (usePresenceBytes) {
|
||||
view.setUint8(offset, value === null ? 0 : 1);
|
||||
offset += 1;
|
||||
}
|
||||
if (value === null) continue;
|
||||
|
||||
if (typeof value === "number") {
|
||||
view.setInt32(offset, value, littleEndian);
|
||||
offset += 4;
|
||||
} else if (typeof value === "string") {
|
||||
const encoded = textEncoder.encode(value);
|
||||
view.setInt32(offset, encoded.length, littleEndian);
|
||||
offset += 4;
|
||||
new Uint8Array(memory.buffer, ptr + offset, encoded.length).set(
|
||||
encoded,
|
||||
);
|
||||
offset += encoded.length;
|
||||
} else if ("id" in value && "kind" in value) {
|
||||
const evHandle = allocHandle("event", value as NostrEvent);
|
||||
view.setInt32(offset, evHandle, littleEndian);
|
||||
offset += 4;
|
||||
} else if (value instanceof Uint8Array) {
|
||||
new Uint8Array(memory.buffer, ptr + offset, value.length).set(value);
|
||||
offset += value.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Trace the raw encoded buffer
|
||||
trace("host", "params:encoded", {
|
||||
ptr,
|
||||
size: bufSize,
|
||||
hex: bytesToHex(new Uint8Array(memory.buffer, ptr, bufSize)),
|
||||
});
|
||||
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
stopped = true;
|
||||
for (const [h] of handles) {
|
||||
dropHandle(h);
|
||||
}
|
||||
handles.clear();
|
||||
// Force-close all private relay connections
|
||||
for (const [, relay] of privatePool.relays) {
|
||||
try {
|
||||
relay.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
instance = null;
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) return;
|
||||
cleanup();
|
||||
setState("stopped");
|
||||
}
|
||||
|
||||
try {
|
||||
setState("loading");
|
||||
|
||||
const binaryStr = atob(wasmBase64);
|
||||
const wasmBytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
wasmBytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
const wasm = await WebAssembly.instantiate(wasmBytes.buffer, {
|
||||
env: {
|
||||
memory: new WebAssembly.Memory({ initial: 1 }),
|
||||
__memory_base: 0,
|
||||
__table_base: 0,
|
||||
abort() {
|
||||
options.onLog("[scroll] abort() called");
|
||||
stop();
|
||||
},
|
||||
},
|
||||
nostr: nostrImports,
|
||||
});
|
||||
|
||||
instance = wasm.instance;
|
||||
memory = instance.exports.memory as WebAssembly.Memory;
|
||||
|
||||
const paramsPtr = encodeParams();
|
||||
setState("running");
|
||||
(instance.exports.run as (ptr: number) => void)(paramsPtr);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
options.onLog(`Error: ${error.message}`);
|
||||
options.onError?.(error);
|
||||
cleanup();
|
||||
setState("error");
|
||||
}
|
||||
|
||||
return { stop };
|
||||
}
|
||||
@@ -86,6 +86,38 @@ function sanitizeRelays(relays: string[]): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives author pubkeys from event references in a filter.
|
||||
* Looks up events by ids, #e tags, and #a tags in the EventStore
|
||||
* to extract their author pubkeys for relay selection.
|
||||
*/
|
||||
function deriveAuthorsFromEventRefs(
|
||||
store: IEventStore,
|
||||
filter: NostrFilter,
|
||||
): string[] {
|
||||
const pubkeys = new Set<string>();
|
||||
|
||||
// From ids filter — look up events directly
|
||||
for (const id of filter.ids || []) {
|
||||
const event = store.getEvent(id);
|
||||
if (event) pubkeys.add(event.pubkey);
|
||||
}
|
||||
|
||||
// From #e tag filter — same lookup
|
||||
for (const id of filter["#e"] || []) {
|
||||
const event = store.getEvent(id);
|
||||
if (event) pubkeys.add(event.pubkey);
|
||||
}
|
||||
|
||||
// From #a tag filter — parse as replaceable address
|
||||
for (const addr of filter["#a"] || []) {
|
||||
const parsed = parseReplaceableAddress(addr);
|
||||
if (parsed) pubkeys.add(parsed.pubkey);
|
||||
}
|
||||
|
||||
return [...pubkeys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets outbox (write) relays for a pubkey
|
||||
* Checks cache first, falls back to EventStore
|
||||
@@ -388,15 +420,23 @@ export async function selectRelaysForFilter(
|
||||
} = options;
|
||||
|
||||
// Extract pubkeys from filter
|
||||
const authors = filter.authors || [];
|
||||
let authors = filter.authors || [];
|
||||
const pTags = filter["#p"] || [];
|
||||
|
||||
// If no pubkeys, return fallback immediately
|
||||
// If no authors or p-tags, try to derive authors from referenced events
|
||||
if (authors.length === 0 && pTags.length === 0) {
|
||||
console.debug(
|
||||
"[RelaySelection] No authors or #p tags, using fallback relays",
|
||||
);
|
||||
return createFallbackResult(fallbackRelays);
|
||||
const derivedAuthors = deriveAuthorsFromEventRefs(eventStore, filter);
|
||||
if (derivedAuthors.length > 0) {
|
||||
console.debug(
|
||||
`[RelaySelection] Derived ${derivedAuthors.length} authors from event refs`,
|
||||
);
|
||||
authors = derivedAuthors;
|
||||
} else {
|
||||
console.debug(
|
||||
"[RelaySelection] No authors, #p tags, or event refs, using fallback relays",
|
||||
);
|
||||
return createFallbackResult(fallbackRelays);
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(
|
||||
|
||||
Reference in New Issue
Block a user