diff --git a/src/app.d.ts b/src/app.d.ts index 520c421..db98703 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,10 +1,16 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { ForumShell, Profiles } from "$lib/forum/snapshot"; + declare global { namespace App { // interface Error {} // interface Locals {} - // interface PageData {} + // Server-rendered snapshots; null in the browser and in client-only builds + interface PageData { + shell?: ForumShell | null; + profiles?: Profiles | null; + } // interface PageState {} // interface Platform {} } diff --git a/src/lib/admins.svelte.ts b/src/lib/admins.svelte.ts index 9848cc5..19c208f 100644 --- a/src/lib/admins.svelte.ts +++ b/src/lib/admins.svelte.ts @@ -1,3 +1,4 @@ +import { page } from "$app/state"; import { MODE } from "$lib/config"; import { groupStore } from "$lib/group.svelte"; import { queryForum } from "$lib/relay"; @@ -8,12 +9,13 @@ let byRoom = $state>({}); let loaded = $state(false); let loadedKey = ""; +// Until the live fetch lands, the server snapshot (if any) stands in. export const roomAdminsStore = { get byRoom() { - return byRoom; + return loaded ? byRoom : (page.data.shell?.adminsByRoom ?? {}); }, get loaded() { - return loaded; + return loaded || !!page.data.shell; }, }; @@ -22,11 +24,11 @@ export const roomAdminsStore = { export const adminPubkeys = { get list(): string[] { return MODE === "full" - ? [...new Set(Object.values(byRoom).flat())] + ? [...new Set(Object.values(roomAdminsStore.byRoom).flat())] : (groupStore.data?.admins ?? []); }, get loaded(): boolean { - return MODE === "full" ? loaded : groupStore.loaded; + return MODE === "full" ? roomAdminsStore.loaded : groupStore.loaded; }, }; @@ -38,7 +40,7 @@ export function isGroupAdmin( ): boolean { if (!pubkey || !groupId) return false; return MODE === "full" - ? (byRoom[groupId]?.includes(pubkey) ?? false) + ? (roomAdminsStore.byRoom[groupId]?.includes(pubkey) ?? false) : (groupStore.data?.admins?.includes(pubkey) ?? false); } diff --git a/src/lib/forum/profiles.ts b/src/lib/forum/profiles.ts index 29208aa..b77eaef 100644 --- a/src/lib/forum/profiles.ts +++ b/src/lib/forum/profiles.ts @@ -3,6 +3,14 @@ import * as nip19 from "@nostr/tools/nip19"; import type { NostrUser } from "$lib/gadgets"; import type { Query } from "./query"; +// Public relays with broad kind-0 coverage. +export const PROFILE_RELAYS = [ + "wss://purplepag.es", + "wss://relay.nostr.band", + "wss://relay.damus.io", + "wss://nos.lol", +]; + // Same shape @nostr/gadgets produces, built here from raw kind-0 events so the // server can resolve authors without that (browser-only) library. export function bareUser(pubkey: string): NostrUser { diff --git a/src/lib/forum/snapshot.ts b/src/lib/forum/snapshot.ts new file mode 100644 index 0000000..2ae6904 --- /dev/null +++ b/src/lib/forum/snapshot.ts @@ -0,0 +1,46 @@ +import { browser } from "$app/environment"; +import type { NostrUser } from "$lib/gadgets"; +import type { GroupMetadata, GroupSummary } from "./groups"; +import type { Resource } from "./resources"; +import type { Partial } from "./partials"; + +// What the server rendered a page with. Stores treat these as the baseline +// (see the `page.data` fallbacks) until their own live fetch lands. +export type ForumShell = { + group: GroupMetadata | null; // simple mode + groups: GroupSummary[]; // full mode + adminsByRoom: Record; // full mode + admins: string[]; // the trust gate for resources and partials + resources: Resource[]; + partials: Partial[]; +}; + +export type Profiles = Record; + +type Fetch = typeof fetch; + +// Read a snapshot endpoint. On the server the response is inlined into the +// HTML; in the browser it is consumed only while hydrating that HTML. Client +// navigations get null, leaving the stores to fetch live (and authenticated) +// data as they always did — the same path a client-only build takes. +export async function snapshot( + fetch: Fetch, + url: string, +): Promise { + if (browser && !hasInlinedResponse(url)) return null; + try { + const res = await fetch(url); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +// SvelteKit inlines each SSR fetch as a script tag keyed by URL and removes +// it once replayed, so its presence marks the hydration pass. +function hasInlinedResponse(url: string): boolean { + return !!document.querySelector( + `script[data-sveltekit-fetched][data-url=${JSON.stringify(url)}]`, + ); +} diff --git a/src/lib/group.svelte.ts b/src/lib/group.svelte.ts index c2612a1..4ac11dd 100644 --- a/src/lib/group.svelte.ts +++ b/src/lib/group.svelte.ts @@ -1,3 +1,4 @@ +import { page } from "$app/state"; import { GROUP_ID, MODE } from "$lib/config"; import { queryForum } from "$lib/relay"; import { groupsStore } from "$lib/groups.svelte"; @@ -15,12 +16,13 @@ export type GroupFlags = { let group = $state(null); let loaded = $state(false); +// Until the live fetch lands, the server snapshot (if any) stands in. export const groupStore = { get data() { - return group; + return group ?? page.data.shell?.group ?? null; }, get loaded() { - return loaded; + return loaded || !!page.data.shell; }, }; diff --git a/src/lib/groups.svelte.ts b/src/lib/groups.svelte.ts index 25173d9..976259b 100644 --- a/src/lib/groups.svelte.ts +++ b/src/lib/groups.svelte.ts @@ -1,3 +1,4 @@ +import { page } from "$app/state"; import { queryForum } from "$lib/relay"; import { fetchGroups, type GroupSummary } from "$lib/forum/groups"; @@ -6,12 +7,13 @@ export type { GroupSummary }; let list = $state([]); let loaded = $state(false); +// Until the live fetch lands, the server snapshot (if any) stands in. export const groupsStore = { get list() { - return list; + return loaded ? list : (page.data.shell?.groups ?? []); }, get loaded() { - return loaded; + return loaded || !!page.data.shell; }, }; diff --git a/src/lib/partials.svelte.ts b/src/lib/partials.svelte.ts index 9545d7c..79eda7a 100644 --- a/src/lib/partials.svelte.ts +++ b/src/lib/partials.svelte.ts @@ -1,3 +1,4 @@ +import { page } from "$app/state"; import { adminPubkeys } from "$lib/admins.svelte"; import { queryForum } from "$lib/relay"; import { @@ -12,12 +13,14 @@ export type { Partial, PartialSlot }; let all = $state([]); let loaded = $state(false); +// Until the live fetch lands, the server snapshot (if any) stands in. export const partialsStore = { get(slot: PartialSlot): Partial | undefined { - return pickPartial(all, slot, adminPubkeys.list); + const source = loaded ? all : (page.data.shell?.partials ?? []); + return pickPartial(source, slot, adminPubkeys.list); }, get loaded() { - return loaded; + return loaded || !!page.data.shell; }, }; diff --git a/src/lib/profiles.svelte.ts b/src/lib/profiles.svelte.ts index c56428a..9e9d49d 100644 --- a/src/lib/profiles.svelte.ts +++ b/src/lib/profiles.svelte.ts @@ -4,6 +4,7 @@ import { SvelteMap, SvelteSet } from "svelte/reactivity"; import type { NostrUser } from "@nostr/gadgets/metadata"; import { GROUP_ID } from "$lib/config"; import { queryForum } from "$lib/relay"; +import { PROFILE_RELAYS } from "$lib/forum/profiles"; export type ProfileEntry = { pubkey: string; @@ -27,12 +28,6 @@ const SEARCH_RELAYS = [ // Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost // never has these — they live on each author's outbox relays — so we query // a small set of well-known aggregators. -const PROFILE_RELAYS = [ - "wss://purplepag.es", - "wss://relay.nostr.band", - "wss://relay.damus.io", - "wss://nos.lol", -]; const FETCH_BATCH_SIZE = 500; const profiles = new SvelteMap(); diff --git a/src/lib/resources.svelte.ts b/src/lib/resources.svelte.ts index 2e29e26..aa21b65 100644 --- a/src/lib/resources.svelte.ts +++ b/src/lib/resources.svelte.ts @@ -1,3 +1,4 @@ +import { page } from "$app/state"; import { adminPubkeys } from "$lib/admins.svelte"; import { queryForum } from "$lib/relay"; import { fetchResources, type Resource } from "$lib/forum/resources"; @@ -8,14 +9,16 @@ let all = $state([]); let loaded = $state(false); // Only admin-authored resources are surfaced: the relay query is open, so the -// admin set is the trust gate. +// admin set is the trust gate. Until the live fetch lands, the server +// snapshot (if any) stands in. export const resourcesStore = { get list() { const admins = new Set(adminPubkeys.list); - return all.filter((r) => admins.has(r.pubkey)); + const source = loaded ? all : (page.data.shell?.resources ?? []); + return source.filter((r) => admins.has(r.pubkey)); }, get loaded() { - return loaded; + return loaded || !!page.data.shell; }, }; diff --git a/src/lib/ssr/relay.ts b/src/lib/ssr/relay.ts new file mode 100644 index 0000000..0eb7d06 --- /dev/null +++ b/src/lib/ssr/relay.ts @@ -0,0 +1,74 @@ +import { SimplePool } from "@nostr/tools"; +import type { Event } from "@nostr/tools/core"; +import type { Filter } from "@nostr/tools/filter"; +import { RELAY_URL } from "$lib/config"; +import { PROFILE_RELAYS } from "$lib/forum/profiles"; +import type { Query } from "$lib/forum/query"; + +// Anonymous relay access for server rendering. The server has no signer, so +// it only ever sees what the relay serves the public; members get their +// private rooms once the client takes over. Every query is bounded so a slow +// relay degrades to an empty page, never a hung request, and results are +// cached briefly to absorb crawler bursts. +const QUERY_TIMEOUT = 2500; +const PROFILE_TIMEOUT = 1500; +const CACHE_TTL = 30_000; +const CACHE_MAX = 2000; + +const pool = new SimplePool(); +const cache = new Map }>(); + +// Resolve at EOSE from every relay or at the deadline, whichever comes first, +// with whatever arrived: a stalled relay must not cost the events the others +// already delivered. +function boundedQuery( + relays: string[], + filter: Filter, + maxWait: number, +): Promise { + return new Promise((resolve) => { + const events: Event[] = []; + let done = false; + let sub: { close(): void } | null = null; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + sub?.close(); + resolve(events); + }; + const timer = setTimeout(finish, maxWait); + try { + sub = pool.subscribeMany(relays, filter, { + onevent: (e) => events.push(e), + oneose: finish, + }); + if (done) sub.close(); // EOSE fired synchronously + } catch { + finish(); + } + }); +} + +function cachedQuery( + relays: string[], + filter: Filter, + maxWait: number, +): Promise { + const key = relays.join(",") + "|" + JSON.stringify(filter); + const now = Date.now(); + const hit = cache.get(key); + if (hit && now - hit.at < CACHE_TTL) return hit.result; + if (cache.size >= CACHE_MAX) cache.clear(); + const result = boundedQuery(relays, filter, maxWait); + cache.set(key, { at: now, result }); + return result; +} + +export const forumQuery: Query = (filter) => + cachedQuery([RELAY_URL], filter, QUERY_TIMEOUT); + +// Profiles mostly live off the forum relay; ask it too for members who only +// published there. +export const profileQuery: Query = (filter) => + cachedQuery([RELAY_URL, ...PROFILE_RELAYS], filter, PROFILE_TIMEOUT); diff --git a/src/lib/ssr/shell.ts b/src/lib/ssr/shell.ts new file mode 100644 index 0000000..4b522d9 --- /dev/null +++ b/src/lib/ssr/shell.ts @@ -0,0 +1,29 @@ +import { MODE, GROUP_ID } from "$lib/config"; +import { fetchGroup, fetchGroups, fetchRoomAdmins } from "$lib/forum/groups"; +import { fetchResources } from "$lib/forum/resources"; +import { fetchPartials } from "$lib/forum/partials"; +import type { ForumShell } from "$lib/forum/snapshot"; +import { forumQuery } from "./relay"; + +// Everything the layout needs on every page: the group(s), the admin set, +// sidebar resources and partials. +export async function loadShell(): Promise { + const [group, groups, resources, partials] = await Promise.all([ + MODE === "simple" ? fetchGroup(forumQuery, GROUP_ID) : null, + MODE === "full" ? fetchGroups(forumQuery) : [], + fetchResources(forumQuery), + fetchPartials(forumQuery), + ]); + const adminsByRoom = + MODE === "full" + ? await fetchRoomAdmins( + forumQuery, + groups.map((g) => g.id), + ) + : {}; + const admins = + MODE === "full" + ? [...new Set(Object.values(adminsByRoom).flat())] + : (group?.admins ?? []); + return { group, groups, adminsByRoom, admins, resources, partials }; +} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 544f9f8..7ecaeb3 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,3 +1,8 @@ import { SSR_ENABLED } from "$lib/config"; +import { snapshot, type ForumShell } from "$lib/forum/snapshot"; export const ssr = SSR_ENABLED; + +export async function load({ fetch }) { + return { shell: await snapshot(fetch, "/api/shell") }; +} diff --git a/src/routes/api/contacts/+server.ts b/src/routes/api/contacts/+server.ts new file mode 100644 index 0000000..ab0cf62 --- /dev/null +++ b/src/routes/api/contacts/+server.ts @@ -0,0 +1,9 @@ +import { json } from "@sveltejs/kit"; +import { fetchProfiles } from "$lib/forum/profiles"; +import { loadShell } from "$lib/ssr/shell"; +import { profileQuery } from "$lib/ssr/relay"; + +export async function GET() { + const shell = await loadShell(); + return json({ profiles: await fetchProfiles(profileQuery, shell.admins) }); +} diff --git a/src/routes/api/shell/+server.ts b/src/routes/api/shell/+server.ts new file mode 100644 index 0000000..f2c0519 --- /dev/null +++ b/src/routes/api/shell/+server.ts @@ -0,0 +1,6 @@ +import { json } from "@sveltejs/kit"; +import { loadShell } from "$lib/ssr/shell"; + +export async function GET() { + return json(await loadShell()); +} diff --git a/src/routes/contacts/+page.svelte b/src/routes/contacts/+page.svelte index 0fe768a..bab7483 100644 --- a/src/routes/contacts/+page.svelte +++ b/src/routes/contacts/+page.svelte @@ -15,6 +15,7 @@ } from "$lib/profiles.svelte"; import { partialsStore } from "$lib/partials.svelte"; import PostContent from "$lib/components/PostContent.svelte"; + import { page } from "$app/state"; const partial = $derived(partialsStore.get("contacts")); @@ -48,11 +49,30 @@ const contacts = $derived( adminPubkeys.list.map((pk) => { - const entry = profileStore.profiles.get(pk); + const entry = profileStore.profiles.get(pk) ?? snapshotEntry(pk); return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry }; }), ); + // Server-rendered profile, used until the live one is fetched. + function snapshotEntry(pk: string): ProfileEntry | undefined { + const u = page.data.profiles?.[pk]; + if (!u) return undefined; + const md = u.metadata ?? {}; + return { + pubkey: pk, + npub: u.npub, + name: md.name, + displayName: md.display_name, + nip05: md.nip05, + picture: md.picture ?? u.image, + about: md.about, + website: md.website, + lud16: md.lud16, + fetchedAt: u.lastUpdated || 0, + }; + } + function displayName(c: Contact): string { return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`; } @@ -81,14 +101,18 @@ {/if} {#if loading} -

Loading…

+

+ Loading… +

{:else if contacts.length === 0} -

No admins listed.

+

+ No admins listed. +

{:else}
    {#each contacts as c (c.pubkey)}
  • {#if c.entry?.picture} @@ -99,7 +123,7 @@ /> {:else} {displayName(c)[0].toUpperCase()} @@ -107,7 +131,9 @@
    -

    +

    {displayName(c)}

    {#if c.entry?.nip05} @@ -129,7 +155,9 @@
    {#if c.entry?.about} -

    +

    {c.entry.about}

    {/if} @@ -147,7 +175,10 @@ {/if} {#if c.entry?.lud16} - + ⚡ {c.entry.lud16} {/if} @@ -158,7 +189,9 @@ {@const rooms = roomsOf(c.pubkey)} {#if rooms.length > 0}

    - Manages: + Manages: {#each rooms as r, i}{r.name}(fetch, "/api/contacts"); + return { profiles: data?.profiles ?? null }; +}