diff --git a/README.md b/README.md index 003ead9..02842c1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Browser → RelayKit App → Dokploy API | Strfry | [hoytech/strfry](https://github.com/hoytech/strfry) | | | Blossom | [hzrd149/blossom](https://github.com/hzrd149/blossom) | | | nPanel | [hzrd149/nsite-gateway](https://github.com/hzrd149/nsite-gateway) | Static sites on Nostr (NIP-5A) plus same-domain NIP-05 responses. Deploys nsite-gateway, a NIP-05 JSON sidecar, and a Caddy sidecar that serves `/.well-known/nostr.json` while rewriting `Host` for gateway traffic. | +| Compute DVM | [`app/presets/dvm-compute/worker/`](app/presets/dvm-compute/worker/) | Programmable, caching DVM (NIP-90): runs whitelisted Nostr code snippets (NIP-C0) in a quickjs sandbox and publishes cached results (`kind:31338`) clients read instead of crawling raw events. | ## Project Structure @@ -184,6 +185,12 @@ issues: - [ ] dns check in prod not working right next: +- [ ] data functions + - [ ] code ui rendering + - [ ] info to help query the new data + - [ ] human readable ttls + - [ ] runtime logs per run? + - [ ] fork a 1337/31337 - [ ] convey real deployment status in UI (not assumed success) - especially as it starts up, to know when it's "ready" - [ ] inc adding a new service, doesn't appear immediately in the ui - [ ] restart ui option. diff --git a/app/frontend/src/components/RelayInput.tsx b/app/frontend/src/components/RelayInput.tsx new file mode 100644 index 0000000..1a9047d --- /dev/null +++ b/app/frontend/src/components/RelayInput.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { ActionIcon, Group, TagsInput, Text } from '@mantine/core'; +import { IconX } from '@tabler/icons-react'; +import { trpc } from '../trpc'; +import { isRelayType } from '../../../shared/serviceType'; + +// normalize a host/url to a ws(s) url +export const toWs = (url: string): string => { + const u = url.trim(); + if (!u) return ''; + if (u.startsWith('ws://') || u.startsWith('wss://')) return u; + if (u.startsWith('http://')) return 'ws://' + u.slice('http://'.length); + if (u.startsWith('https://')) return 'wss://' + u.slice('https://'.length); + return 'wss://' + u; +}; + +// relays the user has typed before, remembered across sessions +const RELAY_HISTORY_KEY = 'relaykit:relay-history'; +const loadRelayHistory = (): string[] => { + try { + const r = JSON.parse(localStorage.getItem(RELAY_HISTORY_KEY) || '[]'); + return Array.isArray(r) ? r.filter((x) => typeof x === 'string') : []; + } catch { + return []; + } +}; +const rememberRelays = (relays: string[]): string[] => { + const merged = [...new Set([...relays, ...loadRelayHistory()].map((r) => r.trim()).filter(Boolean))].slice(0, 30); + localStorage.setItem(RELAY_HISTORY_KEY, JSON.stringify(merged)); + return merged; +}; + +type RelayInputProps = { + label?: ReactNode; + description?: string; + placeholder?: string; + value: string[]; + onChange: (relays: string[]) => void; + // single relay (renders as one chip) vs many + multiple?: boolean; + required?: boolean; +}; + +// Shared relay picker: chips + free entry, a dropdown seeded with this instance's relays plus the +// user's remembered relays, and an × to forget a remembered one. +export const RelayInput = ({ + label, + description, + placeholder, + value, + onChange, + multiple = true, + required, +}: RelayInputProps) => { + const [relayHistory, setRelayHistory] = useState(loadRelayHistory); + const [appRelays, setAppRelays] = useState([]); + + useEffect(() => { + let cancelled = false; + trpc.listServices + .query() + .then((services: any[]) => { + if (cancelled) return; + const urls = services + .filter((s) => isRelayType(s.type)) + .map((s) => s.domains?.[0]?.host ?? s.hostname) + .filter((h: string) => h && h !== 'No hostname configured') + .map((h: string) => toWs(h)); + setAppRelays([...new Set(urls)]); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const removeFromHistory = (relay: string) => { + const next = loadRelayHistory().filter((r) => r !== relay); + localStorage.setItem(RELAY_HISTORY_KEY, JSON.stringify(next)); + setRelayHistory(next); + }; + + return ( + { + const cleaned = vals.map(toWs).filter(Boolean); + onChange(cleaned); + setRelayHistory(rememberRelays(cleaned)); + }} + renderOption={({ option }) => ( + + {option.value} + {!appRelays.includes(option.value) && ( + { + e.preventDefault(); + e.stopPropagation(); + removeFromHistory(option.value); + }} + > + + + )} + + )} + /> + ); +}; diff --git a/app/frontend/src/components/ServiceConfigPanel.tsx b/app/frontend/src/components/ServiceConfigPanel.tsx new file mode 100644 index 0000000..f29f5c3 --- /dev/null +++ b/app/frontend/src/components/ServiceConfigPanel.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Group, Loader, ScrollArea, Stack, Switch, Text, TextInput } from '@mantine/core'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import { toast } from 'sonner'; +import { trpc } from '../trpc'; +import { SERVICE_TYPE, isNpanelType } from '../../../shared/serviceType'; +import { NsiteDeployFields, prepareNsiteConfigForSave } from './NsiteDeployFields'; + +type Field = { id: string; name: string; description?: string; type?: string; required?: boolean; default?: string }; + +// The service's deploy-time config (env vars from the preset's requiredConfig), rendered inline +// as a tab. Saving redeploys the service — same backend path the old modal used. +export const ServiceConfigPanel = ({ service, onSaved }: { service: any; onSaved?: () => void }) => { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [fields, setFields] = useState([]); + const [values, setValues] = useState>({}); + + const isNsite = isNpanelType(service?.type); + + useEffect(() => { + let cancelled = false; + setLoading(true); + trpc.getServiceConfig + .query({ composeId: service.composeId }) + .then((res: any) => { + if (cancelled) return; + setFields(res.fields || []); + setValues(res.config || {}); + }) + .catch((e: any) => !cancelled && setError(e?.message ?? 'failed to load config')) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [service.composeId]); + + const setField = (id: string, value: string) => setValues((v) => ({ ...v, [id]: value })); + + const missingRequired = useMemo( + () => !isNsite && fields.some((f) => f.required && f.type !== 'boolean' && !(values[f.id] || '').trim()), + [fields, values, isNsite], + ); + + const save = async () => { + setSaving(true); + try { + const config = isNsite ? prepareNsiteConfigForSave(values) : values; + await trpc.updateServiceConfig.mutate({ composeId: service.composeId, config }); + toast.success('config updated, redeploy started'); + onSaved?.(); + } catch (e: any) { + toast.error(`failed to update config: ${e?.message ?? e}`); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + + + loading config… + + ); + } + + if (error) { + return ( + } title="error"> + {error} + + ); + } + + const renderField = (field: Field) => { + if (field.type === 'boolean') { + const checked = (values[field.id] || String(field.default || 'false')).toLowerCase() === 'true'; + return ( + setField(field.id, e.currentTarget.checked ? 'true' : 'false')} + /> + ); + } + return ( + setField(field.id, e.currentTarget.value)} + /> + ); + }; + + return ( + + + {fields.length === 0 ? ( + no editable config for this service. + ) : ( + <> + {isNsite ? ( + setValues((prev) => (typeof next === 'function' ? next(prev) : next))} + ownerPubkeyHex={null} + /> + ) : ( + fields.map(renderField) + )} + + + + + )} + + + ); +}; diff --git a/app/frontend/src/components/ServiceDetailsContent.tsx b/app/frontend/src/components/ServiceDetailsContent.tsx index 764183f..aacf2b9 100644 --- a/app/frontend/src/components/ServiceDetailsContent.tsx +++ b/app/frontend/src/components/ServiceDetailsContent.tsx @@ -10,6 +10,8 @@ import { Text, Group, Anchor, Tooltip, ActionIcon, Button, Stack, Badge, Tabs, B import { IconExternalLink, IconCheck, IconX, IconAlertOctagon, IconAlertTriangle, IconCircleCheck, IconRefresh, IconPencil } from '@tabler/icons-react'; import { InlineTextEditRow } from './InlineTextEditRow'; import { CopyControl } from './CopyControl'; +import { ServiceDetailsScripts } from './ServiceDetailsScripts'; +import { ServiceConfigPanel } from './ServiceConfigPanel'; import { trpc } from '../trpc'; import { serviceTypeToRubixLoaderColor } from '../lib/serviceTypeColor'; import { formatBytes, formatBytesPerSecond, formatPercent, formatWindow, getInsightSeverity, getOverallSeverity, getSeverityColor } from '../../../shared/insights'; @@ -921,6 +923,8 @@ export const ServiceDetailsContent = (props: ServiceDetailsContentProps) => { const hasDNS = domain && serverIp; const hasInsights = !!service.composeId; const hasLogs = !!service.composeId; + const hasScripts = !!service.composeId && service.presetId === 'dvm-compute'; + const hasConfig = !!service.composeId; const activePanelBg = colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0]; const panelShadow = colorScheme === 'dark' ? '0 4px 12px rgba(0,0,0,0.18)' @@ -1017,9 +1021,13 @@ export const ServiceDetailsContent = (props: ServiceDetailsContentProps) => { > info - dns - insights + {!hasScripts && dns} + {!hasScripts && insights} logs + {hasScripts && ( + data functions + )} + {hasConfig && config} { )} + {hasScripts && ( + + {(tStyle) => ( + + + + )} + + )} + {hasConfig && ( + + {(tStyle) => ( + + + + )} + + )} diff --git a/app/frontend/src/components/ServiceDetailsScripts.tsx b/app/frontend/src/components/ServiceDetailsScripts.tsx new file mode 100644 index 0000000..2c69e97 --- /dev/null +++ b/app/frontend/src/components/ServiceDetailsScripts.tsx @@ -0,0 +1,882 @@ +import { useEffect, useState } from 'react'; +import { SimplePool, getPublicKey, nip19 } from 'nostr-tools'; +import type { Event, Filter } from 'nostr-tools'; +import { + ActionIcon, + Alert, + Badge, + Button, + Card, + Code, + Divider, + Group, + Loader, + ScrollArea, + Select, + Stack, + Text, + TextInput, + Textarea, + Tooltip, +} from '@mantine/core'; +import { + IconAlertTriangle, + IconArrowLeft, + IconBolt, + IconDatabase, + IconPlus, + IconRefresh, + IconServer, + IconTrash, + IconUpload, +} from '@tabler/icons-react'; +import { trpc } from '../trpc'; +import { RelayInput, toWs } from './RelayInput'; + +// kinds the compute DVM speaks (mirror of dvm-compute/src/kinds.ts) +const KIND = { + codeSnippet: 1337, + dataFunction: 31337, + jobRequest: 5910, + jobResult: 6910, + jobFeedback: 7000, + cachedResult: 31338, +} as const; + +const BLANK = `async function main(inputs, nostr) { + // query nostr, return any json-serializable value + const events = await nostr.query([{ kinds: [1], limit: 100 }]); + return { count: events.length }; +}`; + +const SAMPLE = `async function main(inputs, nostr) { + const since = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60; + const events = await nostr.query([{ kinds: [4223], '#t': ['weather'], since }]); + const temps = events + .map((e) => e.tags.find((t) => t[0] === 'temp')) + .filter(Boolean) + .map((t) => parseFloat(t[1])) + .filter((n) => !Number.isNaN(n)); + const avg = temps.length ? temps.reduce((a, b) => a + b, 0) / temps.length : null; + return { avg_temp: avg, samples: temps.length, since }; +}`; + +const prettyResult = (raw: string): string => { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +}; + +const errMsg = (e: any): string => { + if (e?.name === 'AggregateError' && Array.isArray(e.errors)) { + const msgs = e.errors.map((x: any) => x?.message || String(x)).filter(Boolean); + return msgs.length ? `relay unreachable / rejected: ${msgs.join('; ')}` : 'relay unreachable'; + } + return e?.message ?? String(e); +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const sha256Hex = async (s: string): Promise => { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join(''); +}; + +const hexToBytes = (hex: string): Uint8Array => { + const clean = hex.trim().toLowerCase(); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + return out; +}; + +// derive the dvm's public key from its secret (nsec or hex). the pubkey is public; safe to show. +const pubkeyFromSecret = (secret: string): string => { + try { + const s = (secret ?? '').trim(); + if (!s) return ''; + const sk = s.startsWith('nsec') ? (nip19.decode(s).data as Uint8Array) : hexToBytes(s); + return getPublicKey(sk); + } catch { + return ''; + } +}; + +const fmtTime = (sec: number): string => new Date(sec * 1000).toLocaleString(); + +const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); +const relTime = (sec: number): string => { + const diff = sec * 1000 - Date.now(); + const table: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 31536000000], + ['month', 2592000000], + ['week', 604800000], + ['day', 86400000], + ['hour', 3600000], + ['minute', 60000], + ['second', 1000], + ]; + for (const [unit, ms] of table) { + if (Math.abs(diff) >= ms || unit === 'second') return rtf.format(Math.round(diff / ms), unit); + } + return 'just now'; +}; + +const host = (url: string): string => url.replace(/^wss?:\/\//, '').replace(/\/$/, ''); + +// absolute time with the relative time in smaller brackets after it +const TimeAgo = ({ sec, prefix }: { sec: number; prefix?: string }) => ( + + {prefix ? `${prefix} ` : ''} + {fmtTime(sec)} ({relTime(sec)}) + +); + +// role → colour, shared by the relay inputs and the list badges +const RELAY_COLOR = { home: 'violet', source: 'blue', output: 'cyan' } as const; + +const relayLabel = (color: string, text: string) => ( + + + {text} + +); + +// Bounded query: resolve on EOSE or after `ms`, so a slow relay can't stall the UI. +const queryBounded = (pool: SimplePool, relays: string[], filter: Filter, ms = 3000): Promise => + new Promise((resolve) => { + const out = new Map(); + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + try { sub.close(); } catch { /* noop */ } + resolve([...out.values()]); + }; + const timer = setTimeout(finish, ms); + const sub = pool.subscribeMany(relays, filter, { + onevent: (ev) => out.set(ev.id, ev), + oneose: finish, + }); + }); + +type Param = { key: string; value: string }; +type Inputs = { + subjectValue: string; + subjectType: string; + params: Param[]; + sourceRelays: string[]; +}; + +// Content-addressed cache key — must stay in sync with dvm-compute/src/cache.ts computeCacheKey. +const computeCacheKey = async (scriptAddress: string, inputs: Inputs): Promise => { + const params = Object.fromEntries( + inputs.params + .filter((p) => p.key.trim()) + .map((p) => [p.key.trim(), p.value] as const) + .sort((a, b) => a[0].localeCompare(b[0])), + ); + const canonical = JSON.stringify({ + script: scriptAddress, + subject: inputs.subjectValue.trim() ? { value: inputs.subjectValue.trim(), type: inputs.subjectType } : null, + params, + relays: [...inputs.sourceRelays].sort(), + }); + return sha256Hex(canonical); +}; + +const jobTags = (scriptAddress: string, dvmRelay: string, inputs: Inputs, extra: string[][]): string[][] => { + const tags: string[][] = [['a', scriptAddress, dvmRelay]]; + if (inputs.subjectValue.trim()) tags.push(['i', inputs.subjectValue.trim(), inputs.subjectType]); + inputs.params.filter((p) => p.key.trim()).forEach((p) => tags.push(['param', p.key.trim(), p.value])); + if (inputs.sourceRelays.length) tags.push(['relays', ...inputs.sourceRelays]); + return [...tags, ...extra]; +}; + +type Feedback = { status: string; message: string }; +type Result = { label: string; content: string; ts?: number } | null; + +export const ServiceDetailsScripts = ({ composeId }: { composeId: string }) => { + const [relayUrl, setRelayUrl] = useState(''); + const [dvmPubkey, setDvmPubkey] = useState(''); + const [defaultSourceRelays, setDefaultSourceRelays] = useState([]); + // operator ceilings from the service config (authoritative default lives in the preset config, + // not hardcoded here) — seed per-data-function limits and cap them. + const [defaultRuntimeMs, setDefaultRuntimeMs] = useState(''); + const [defaultMemoryMb, setDefaultMemoryMb] = useState(''); + const [loadingConfig, setLoadingConfig] = useState(true); + + const fnCacheKey = `relaykit:dvm-fns:${composeId}`; + const loadFnCache = (): Record => { + try { + return JSON.parse(localStorage.getItem(fnCacheKey) || '{}'); + } catch { + return {}; + } + }; + + const [view, setView] = useState<'list' | 'editor'>('list'); + // seeded from localStorage so the list shows instantly, then refreshed in the background. + const [functions, setFunctions] = useState>(loadFnCache); + const [loadingList, setLoadingList] = useState(false); + + // editor state + const [dTag, setDTag] = useState(''); + const [code, setCode] = useState(BLANK); + const [sourceRelays, setSourceRelays] = useState([]); + const [outputRelay, setOutputRelay] = useState(''); + const [ttl, setTtl] = useState('3600'); + const [runtimeMs, setRuntimeMs] = useState(''); + const [memoryMb, setMemoryMb] = useState(''); + const [subjectValue, setSubjectValue] = useState(''); + const [subjectType, setSubjectType] = useState('text'); + const [params, setParams] = useState([]); + const [author, setAuthor] = useState(null); + const [published, setPublished] = useState(false); + const [updatedAt, setUpdatedAt] = useState(null); + const [cacheKeyHex, setCacheKeyHex] = useState(''); + + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [feedback, setFeedback] = useState([]); + const [result, setResult] = useState(null); + + useEffect(() => { + let cancelled = false; + trpc.getServiceConfig + .query({ composeId }) + .then((res) => { + if (cancelled) return; + setRelayUrl(toWs(res.config?.RELAY_URL ?? '')); + setDvmPubkey(pubkeyFromSecret(res.config?.DVM_SECRET_KEY ?? '')); + const configured = (res.config?.SOURCE_RELAYS ?? '').split(',').map((s) => s.trim()).filter(Boolean); + setDefaultSourceRelays(configured.map(toWs)); + if (res.config?.MAX_RUNTIME_MS) setDefaultRuntimeMs(String(res.config.MAX_RUNTIME_MS)); + if (res.config?.MAX_MEMORY_MB) setDefaultMemoryMb(String(res.config.MAX_MEMORY_MB)); + }) + .catch((e) => !cancelled && setError(e?.message ?? 'failed to load config')) + .finally(() => !cancelled && setLoadingConfig(false)); + return () => { + cancelled = true; + }; + }, [composeId]); + + const ensureNostr = () => { + if (!window.nostr) throw new Error('no nostr extension found (install alby / nos2x)'); + return window.nostr; + }; + + const inputs: Inputs = { subjectValue, subjectType, params, sourceRelays }; + + // the content-addressed `d` of the cached result for the current inputs (shown in the consume box). + useEffect(() => { + if (!author || !dTag.trim()) { + setCacheKeyHex(''); + return; + } + let cancelled = false; + const address = `${KIND.dataFunction}:${author}:${dTag.trim()}`; + computeCacheKey(address, { subjectValue, subjectType, params, sourceRelays }).then((k) => !cancelled && setCacheKeyHex(k)); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [author, dTag, subjectValue, subjectType, params, sourceRelays]); + + const loadFunctions = async (quiet = false) => { + if (!quiet) setError(null); + setLoadingList(true); + const relay = toWs(relayUrl); + const pool = new SimplePool(); + try { + const pk = await ensureNostr().getPublicKey(); + const events = await queryBounded(pool, [relay], { kinds: [KIND.dataFunction], authors: [pk] }); + const byD: Record = {}; + for (const ev of events) { + const d = ev.tags.find((t) => t[0] === 'd')?.[1] ?? ''; + if (!d) continue; + if (!byD[d] || ev.created_at > byD[d].created_at) byD[d] = ev; + } + setFunctions(byD); + localStorage.setItem(fnCacheKey, JSON.stringify(byD)); + } catch (e: any) { + if (!quiet) setError(errMsg(e)); + } finally { + pool.close([relay]); + setLoadingList(false); + } + }; + + // auto-populate the list when the tab opens (if a signer is available), so it isn't empty. + useEffect(() => { + if (relayUrl && window.nostr) loadFunctions(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [relayUrl]); + + const resetEditor = () => { + setError(null); + setFeedback([]); + setResult(null); + setBusy(null); + }; + + const openNew = () => { + resetEditor(); + setDTag(''); + setCode(SAMPLE); + setSourceRelays(defaultSourceRelays); + setOutputRelay(relayUrl); + setTtl('3600'); + setRuntimeMs(defaultRuntimeMs); + setMemoryMb(defaultMemoryMb); + setSubjectValue(''); + setSubjectType('text'); + setParams([]); + setAuthor(null); + setPublished(false); + setUpdatedAt(null); + setView('editor'); + }; + + // open a kind:31337 definition: restore its config, then fetch the referenced kind:1337 code. + const openExisting = async (def: Event) => { + resetEditor(); + setView('editor'); + const tagVal = (n: string) => def.tags.find((t) => t[0] === n); + setDTag(tagVal('d')?.[1] ?? ''); + const relays = def.tags.filter((t) => t[0] === 'relays').flatMap((t) => t.slice(1)).filter(Boolean); + setSourceRelays(relays.length ? relays.map(toWs) : defaultSourceRelays); + setOutputRelay(tagVal('output')?.[1] || relayUrl); + setTtl(tagVal('ttl')?.[1] ?? '3600'); + setRuntimeMs(tagVal('runtime_ms')?.[1] ?? defaultRuntimeMs); + setMemoryMb(tagVal('memory_mb')?.[1] ?? defaultMemoryMb); + const i = tagVal('i'); + setSubjectValue(i?.[1] ?? ''); + setSubjectType(i?.[2] ?? 'text'); + setParams(def.tags.filter((t) => t[0] === 'param').map((t) => ({ key: t[1] ?? '', value: t[2] ?? '' }))); + setAuthor(def.pubkey); + setPublished(true); + setUpdatedAt(def.created_at); + + const codeAddr = tagVal('code')?.[1]; + setCode('// loading code…'); + if (!codeAddr) { + setCode(BLANK); + return; + } + const relay = toWs(relayUrl); + const pool = new SimplePool(); + try { + const [, pk, d] = codeAddr.split(':'); + const events = await pool.querySync([relay], { kinds: [KIND.codeSnippet], authors: [pk], '#d': [d] }); + const latest = events.sort((a, b) => b.created_at - a.created_at)[0]; + setCode(latest?.content ?? BLANK); + } catch { + setCode(BLANK); + } finally { + pool.close([relay]); + } + }; + + const addParam = () => setParams((p) => [...p, { key: '', value: '' }]); + const removeParam = (i: number) => setParams((p) => p.filter((_, idx) => idx !== i)); + const updateParam = (i: number, field: keyof Param, val: string) => + setParams((p) => p.map((row, idx) => (idx === i ? { ...row, [field]: val } : row))); + + // 1) test run — execute the code right here in the browser, no relay job, no signing. + const testRun = async () => { + setError(null); + setResult(null); + setFeedback([]); + setBusy('test'); + const relays = (sourceRelays.length ? sourceRelays : [relayUrl]).map(toWs).filter(Boolean); + const pool = new SimplePool(); + const touched = new Set(relays); + try { + if (!code.trim()) throw new Error('function body is empty'); + if (!relays.length) throw new Error('set a source relay (or the dvm relay url) to query'); + const nostr = { + query: async (filters: Filter | Filter[], queryRelays?: string[]) => { + const target = Array.isArray(queryRelays) && queryRelays.length ? queryRelays.map(toWs) : relays; + target.forEach((r) => touched.add(r)); + const list = Array.isArray(filters) ? filters : [filters]; + const byId = new Map(); + for (const f of list) { + // bounded wait (slow relays can take ~10s to EOSE); collect whatever arrives. + const evs = await queryBounded(pool, target, f, 15000); + for (const e of evs) byId.set(e.id, e); + } + return [...byId.values()]; + }, + }; + const runInputs = { + subject: subjectValue.trim() ? { value: subjectValue.trim(), type: subjectType } : undefined, + params: Object.fromEntries(params.filter((p) => p.key.trim()).map((p) => [p.key.trim(), p.value])), + }; + setFeedback([{ status: 'running', message: `querying ${relays.join(', ')}` }]); + // eslint-disable-next-line no-new-func + const factory = new Function( + 'inputs', + 'nostr', + `"use strict";\n${code}\nif (typeof main !== 'function') throw new Error('must define async function main(inputs, nostr)');\nreturn main(inputs, nostr);`, + ); + const out = await factory(runInputs, nostr); + setResult({ label: 'local test', content: JSON.stringify(out ?? null), ts: Math.floor(Date.now() / 1000) }); + setFeedback([{ status: 'success', message: 'ran in browser' }]); + } catch (e: any) { + setError(errMsg(e)); + setFeedback([]); + } finally { + pool.close([...touched]); + setBusy(null); + } + }; + + // 2) get cached — read the kind:31338 the worker stored for this exact (function, inputs, relays). + const getCached = async () => { + setError(null); + setResult(null); + setFeedback([]); + setBusy('get'); + const relay = toWs(relayUrl); + const pool = new SimplePool(); + try { + if (!author || !dTag.trim()) throw new Error('save the data function first so it has an address'); + const address = `${KIND.dataFunction}:${author}:${dTag.trim()}`; + const key = await computeCacheKey(address, inputs); + const events = await pool.querySync([relay], { kinds: [KIND.cachedResult], '#d': [key] }); + const latest = events.sort((a, b) => b.created_at - a.created_at)[0]; + if (!latest) { + setFeedback([{ status: 'miss', message: 'nothing cached for these inputs — recache to compute it' }]); + return; + } + const exp = Number(latest.tags.find((t) => t[0] === 'expiration')?.[1] ?? 0); + const expired = exp > 0 && exp < Math.floor(Date.now() / 1000); + setResult({ label: expired ? 'cached (expired)' : 'cached', content: latest.content, ts: latest.created_at }); + } catch (e: any) { + setError(errMsg(e)); + } finally { + pool.close([relay]); + setBusy(null); + } + }; + + // shared path for recache/clear — publish a kind:5910 job and watch for feedback/result. + const submitJob = async (mode: 'recache' | 'clear') => { + setError(null); + setResult(null); + setFeedback([]); + setBusy(mode); + const relay = toWs(relayUrl); + const pool = new SimplePool(); + try { + if (!author || !dTag.trim()) throw new Error('save the data function first so it has an address'); + if (!relay) throw new Error('set the dvm relay url'); + const address = `${KIND.dataFunction}:${author}:${dTag.trim()}`; + const extra = [['cache', mode === 'clear' ? 'clear' : 'no']]; + if (ttl.trim()) extra.push(['ttl', ttl.trim()]); + const signed = (await ensureNostr().signEvent({ + kind: KIND.jobRequest, + created_at: Math.floor(Date.now() / 1000), + tags: jobTags(address, relay, inputs, extra), + content: '', + })) as Event; + await Promise.any(pool.publish([relay], signed)); + setFeedback([{ status: 'submitted', message: mode === 'clear' ? 'clearing…' : 'recomputing…' }]); + + const seen = new Set(); + for (let i = 0; i < 20; i++) { + await sleep(1000); + let terminal = false; + const fbs = await pool.querySync([relay], { kinds: [KIND.jobFeedback], '#e': [signed.id] }); + for (const ev of fbs.sort((a, b) => a.created_at - b.created_at)) { + if (seen.has(ev.id)) continue; + seen.add(ev.id); + const s = ev.tags.find((t) => t[0] === 'status'); + if (!s) continue; + setFeedback((prev) => [...prev, { status: s[1], message: s[2] ?? '' }]); + if (s[1] === 'error' || (mode === 'clear' && s[1] === 'success')) terminal = true; + } + if (mode === 'recache') { + const res = await pool.querySync([relay], { kinds: [KIND.jobResult], '#e': [signed.id] }); + const latest = res.sort((a, b) => b.created_at - a.created_at)[0]; + if (latest) { + setResult({ label: 'recomputed', content: latest.content, ts: latest.created_at }); + return; + } + } + if (terminal) return; + } + setFeedback((prev) => [...prev, { status: 'timeout', message: 'no response from worker (is dvm-compute running?)' }]); + } catch (e: any) { + setError(errMsg(e)); + } finally { + pool.close([relay]); + setBusy(null); + } + }; + + // save = publish two events: the reusable code (kind:1337) and the data function definition + // (kind:31337) that references it and bundles relays / output / params / subject / ttl. + const save = async () => { + setError(null); + setBusy('save'); + const relay = toWs(relayUrl); + const pool = new SimplePool(); + try { + const id = dTag.trim(); + if (!id) throw new Error('data function id (d tag) is required'); + if (!relay) throw new Error('set the dvm relay url'); + const now = Math.floor(Date.now() / 1000); + + const codeEv = (await ensureNostr().signEvent({ + kind: KIND.codeSnippet, + created_at: now, + tags: [['d', id]], + content: code, + })) as Event; + + const defTags: string[][] = [ + ['d', id], + ['code', `${KIND.codeSnippet}:${codeEv.pubkey}:${id}`], + ]; + if (sourceRelays.length) defTags.push(['relays', ...sourceRelays]); + if (outputRelay.trim()) defTags.push(['output', toWs(outputRelay)]); + if (ttl.trim()) defTags.push(['ttl', ttl.trim()]); + if (runtimeMs.trim()) defTags.push(['runtime_ms', runtimeMs.trim()]); + if (memoryMb.trim()) defTags.push(['memory_mb', memoryMb.trim()]); + if (subjectValue.trim()) defTags.push(['i', subjectValue.trim(), subjectType]); + params.filter((p) => p.key.trim()).forEach((p) => defTags.push(['param', p.key.trim(), p.value])); + const defEv = (await ensureNostr().signEvent({ + kind: KIND.dataFunction, + created_at: now, + tags: defTags, + content: '', + })) as Event; + + await Promise.any(pool.publish([relay], codeEv)); + await Promise.any(pool.publish([relay], defEv)); + setAuthor(defEv.pubkey); + setPublished(true); + setUpdatedAt(now); + setFunctions((prev) => { + const next = { ...prev, [id]: defEv }; + localStorage.setItem(fnCacheKey, JSON.stringify(next)); + return next; + }); + setFeedback([{ status: 'success', message: `saved ${id}` }]); + } catch (e: any) { + setError(errMsg(e)); + } finally { + pool.close([relay]); + setBusy(null); + } + }; + + if (loadingConfig) { + return ( + + + loading service config… + + ); + } + + // ── list view ──────────────────────────────────────────────────────────── + if (view === 'list') { + const items = Object.values(functions).sort((a, b) => b.created_at - a.created_at); + return ( + + + + a data function (kind:31337) bundles reusable code (kind:1337) with its relays, params & ttl. + pick one to edit & test, or create a new one. + + + + + + + + {error && ( + } title="error"> + {error} + + )} + + {items.length === 0 ? ( + + + no data functions yet. + hit refresh to load yours from the relay, or create a new one. + + + ) : ( + + {items.map((ev) => { + const d = ev.tags.find((t) => t[0] === 'd')?.[1] ?? ''; + const source = ev.tags.filter((t) => t[0] === 'relays').flatMap((t) => t.slice(1)).filter(Boolean); + const out = ev.tags.find((t) => t[0] === 'output')?.[1]; + const paramKeys = ev.tags.filter((t) => t[0] === 'param').map((t) => t[1]).filter(Boolean); + const ttlTag = ev.tags.find((t) => t[0] === 'ttl')?.[1]; + const relayBadge = (role: keyof typeof RELAY_COLOR, url: string) => ( + + }>{host(url)} + + ); + return ( + openExisting(ev)} style={{ cursor: 'pointer' }}> + + + {d || '(no id)'} + + {relayUrl && relayBadge('home', relayUrl)} + {source.map((r) => relayBadge('source', r))} + {out && relayBadge('output', out)} + {paramKeys.map((k) => ( + {k} + ))} + {ttlTag && ttl {ttlTag}s} + + + + kind:31337 + + + ); + })} + + )} + + ); + } + + // ── editor view ────────────────────────────────────────────────────────── + const canCache = published && !!author && !!dTag.trim(); + return ( + + + + + + {published ? saved : draft} + {updatedAt && } + + + + + + { + setDTag(e.currentTarget.value); + setPublished(false); + }} + /> + +