Render the forum shell, resources and contacts on the server

This commit is contained in:
dtonon 2026-08-17 13:25:44 +01:00
parent a7b5d8f223
commit 40e0be35d9
16 changed files with 259 additions and 30 deletions

8
src/app.d.ts vendored
View file

@ -1,10 +1,16 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
import type { ForumShell, Profiles } from "$lib/forum/snapshot";
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // 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 PageState {}
// interface Platform {} // interface Platform {}
} }

View file

@ -1,3 +1,4 @@
import { page } from "$app/state";
import { MODE } from "$lib/config"; import { MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
@ -8,12 +9,13 @@ let byRoom = $state<Record<string, string[]>>({});
let loaded = $state(false); let loaded = $state(false);
let loadedKey = ""; let loadedKey = "";
// Until the live fetch lands, the server snapshot (if any) stands in.
export const roomAdminsStore = { export const roomAdminsStore = {
get byRoom() { get byRoom() {
return byRoom; return loaded ? byRoom : (page.data.shell?.adminsByRoom ?? {});
}, },
get loaded() { get loaded() {
return loaded; return loaded || !!page.data.shell;
}, },
}; };
@ -22,11 +24,11 @@ export const roomAdminsStore = {
export const adminPubkeys = { export const adminPubkeys = {
get list(): string[] { get list(): string[] {
return MODE === "full" return MODE === "full"
? [...new Set(Object.values(byRoom).flat())] ? [...new Set(Object.values(roomAdminsStore.byRoom).flat())]
: (groupStore.data?.admins ?? []); : (groupStore.data?.admins ?? []);
}, },
get loaded(): boolean { get loaded(): boolean {
return MODE === "full" ? loaded : groupStore.loaded; return MODE === "full" ? roomAdminsStore.loaded : groupStore.loaded;
}, },
}; };
@ -38,7 +40,7 @@ export function isGroupAdmin(
): boolean { ): boolean {
if (!pubkey || !groupId) return false; if (!pubkey || !groupId) return false;
return MODE === "full" return MODE === "full"
? (byRoom[groupId]?.includes(pubkey) ?? false) ? (roomAdminsStore.byRoom[groupId]?.includes(pubkey) ?? false)
: (groupStore.data?.admins?.includes(pubkey) ?? false); : (groupStore.data?.admins?.includes(pubkey) ?? false);
} }

View file

@ -3,6 +3,14 @@ import * as nip19 from "@nostr/tools/nip19";
import type { NostrUser } from "$lib/gadgets"; import type { NostrUser } from "$lib/gadgets";
import type { Query } from "./query"; 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 // Same shape @nostr/gadgets produces, built here from raw kind-0 events so the
// server can resolve authors without that (browser-only) library. // server can resolve authors without that (browser-only) library.
export function bareUser(pubkey: string): NostrUser { export function bareUser(pubkey: string): NostrUser {

46
src/lib/forum/snapshot.ts Normal file
View file

@ -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<string, string[]>; // full mode
admins: string[]; // the trust gate for resources and partials
resources: Resource[];
partials: Partial[];
};
export type Profiles = Record<string, NostrUser>;
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<T>(
fetch: Fetch,
url: string,
): Promise<T | null> {
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)}]`,
);
}

View file

@ -1,3 +1,4 @@
import { page } from "$app/state";
import { GROUP_ID, MODE } from "$lib/config"; import { GROUP_ID, MODE } from "$lib/config";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { groupsStore } from "$lib/groups.svelte"; import { groupsStore } from "$lib/groups.svelte";
@ -15,12 +16,13 @@ export type GroupFlags = {
let group = $state<GroupMetadata | null>(null); let group = $state<GroupMetadata | null>(null);
let loaded = $state(false); let loaded = $state(false);
// Until the live fetch lands, the server snapshot (if any) stands in.
export const groupStore = { export const groupStore = {
get data() { get data() {
return group; return group ?? page.data.shell?.group ?? null;
}, },
get loaded() { get loaded() {
return loaded; return loaded || !!page.data.shell;
}, },
}; };

View file

@ -1,3 +1,4 @@
import { page } from "$app/state";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { fetchGroups, type GroupSummary } from "$lib/forum/groups"; import { fetchGroups, type GroupSummary } from "$lib/forum/groups";
@ -6,12 +7,13 @@ export type { GroupSummary };
let list = $state<GroupSummary[]>([]); let list = $state<GroupSummary[]>([]);
let loaded = $state(false); let loaded = $state(false);
// Until the live fetch lands, the server snapshot (if any) stands in.
export const groupsStore = { export const groupsStore = {
get list() { get list() {
return list; return loaded ? list : (page.data.shell?.groups ?? []);
}, },
get loaded() { get loaded() {
return loaded; return loaded || !!page.data.shell;
}, },
}; };

View file

@ -1,3 +1,4 @@
import { page } from "$app/state";
import { adminPubkeys } from "$lib/admins.svelte"; import { adminPubkeys } from "$lib/admins.svelte";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { import {
@ -12,12 +13,14 @@ export type { Partial, PartialSlot };
let all = $state<Partial[]>([]); let all = $state<Partial[]>([]);
let loaded = $state(false); let loaded = $state(false);
// Until the live fetch lands, the server snapshot (if any) stands in.
export const partialsStore = { export const partialsStore = {
get(slot: PartialSlot): Partial | undefined { 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() { get loaded() {
return loaded; return loaded || !!page.data.shell;
}, },
}; };

View file

@ -4,6 +4,7 @@ import { SvelteMap, SvelteSet } from "svelte/reactivity";
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
import { GROUP_ID } from "$lib/config"; import { GROUP_ID } from "$lib/config";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { PROFILE_RELAYS } from "$lib/forum/profiles";
export type ProfileEntry = { export type ProfileEntry = {
pubkey: string; pubkey: string;
@ -27,12 +28,6 @@ const SEARCH_RELAYS = [
// Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost // 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 // never has these — they live on each author's outbox relays — so we query
// a small set of well-known aggregators. // 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 FETCH_BATCH_SIZE = 500;
const profiles = new SvelteMap<string, ProfileEntry>(); const profiles = new SvelteMap<string, ProfileEntry>();

View file

@ -1,3 +1,4 @@
import { page } from "$app/state";
import { adminPubkeys } from "$lib/admins.svelte"; import { adminPubkeys } from "$lib/admins.svelte";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { fetchResources, type Resource } from "$lib/forum/resources"; import { fetchResources, type Resource } from "$lib/forum/resources";
@ -8,14 +9,16 @@ let all = $state<Resource[]>([]);
let loaded = $state(false); let loaded = $state(false);
// Only admin-authored resources are surfaced: the relay query is open, so the // 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 = { export const resourcesStore = {
get list() { get list() {
const admins = new Set(adminPubkeys.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() { get loaded() {
return loaded; return loaded || !!page.data.shell;
}, },
}; };

74
src/lib/ssr/relay.ts Normal file
View file

@ -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<string, { at: number; result: Promise<Event[]> }>();
// 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<Event[]> {
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<Event[]> {
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);

29
src/lib/ssr/shell.ts Normal file
View file

@ -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<ForumShell> {
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 };
}

View file

@ -1,3 +1,8 @@
import { SSR_ENABLED } from "$lib/config"; import { SSR_ENABLED } from "$lib/config";
import { snapshot, type ForumShell } from "$lib/forum/snapshot";
export const ssr = SSR_ENABLED; export const ssr = SSR_ENABLED;
export async function load({ fetch }) {
return { shell: await snapshot<ForumShell>(fetch, "/api/shell") };
}

View file

@ -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) });
}

View file

@ -0,0 +1,6 @@
import { json } from "@sveltejs/kit";
import { loadShell } from "$lib/ssr/shell";
export async function GET() {
return json(await loadShell());
}

View file

@ -15,6 +15,7 @@
} from "$lib/profiles.svelte"; } from "$lib/profiles.svelte";
import { partialsStore } from "$lib/partials.svelte"; import { partialsStore } from "$lib/partials.svelte";
import PostContent from "$lib/components/PostContent.svelte"; import PostContent from "$lib/components/PostContent.svelte";
import { page } from "$app/state";
const partial = $derived(partialsStore.get("contacts")); const partial = $derived(partialsStore.get("contacts"));
@ -48,11 +49,30 @@
const contacts = $derived<Contact[]>( const contacts = $derived<Contact[]>(
adminPubkeys.list.map((pk) => { 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 }; 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 { function displayName(c: Contact): string {
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`; return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
} }
@ -81,14 +101,18 @@
{/if} {/if}
{#if loading} {#if loading}
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">Loading…</p> <p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{:else if contacts.length === 0} {:else if contacts.length === 0}
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">No admins listed.</p> <p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
No admins listed.
</p>
{:else} {:else}
<ul class="mt-2 space-y-3"> <ul class="mt-2 space-y-3">
{#each contacts as c (c.pubkey)} {#each contacts as c (c.pubkey)}
<li <li
class="rounded-lg border border-neutral-100 dark:border-neutral-800 p-4 shadow-sm" class="rounded-lg border border-neutral-100 p-4 shadow-sm dark:border-neutral-800"
> >
<div class="flex gap-4"> <div class="flex gap-4">
{#if c.entry?.picture} {#if c.entry?.picture}
@ -99,7 +123,7 @@
/> />
{:else} {:else}
<span <span
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-700 text-lg font-semibold text-neutral-500 dark:text-neutral-400" class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-lg font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
> >
{displayName(c)[0].toUpperCase()} {displayName(c)[0].toUpperCase()}
</span> </span>
@ -107,7 +131,9 @@
<div class="flex min-w-0 flex-1 items-start justify-between gap-3"> <div class="flex min-w-0 flex-1 items-start justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"> <p
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"
>
{displayName(c)} {displayName(c)}
</p> </p>
{#if c.entry?.nip05} {#if c.entry?.nip05}
@ -129,7 +155,9 @@
<div class="mt-3 md:pl-18"> <div class="mt-3 md:pl-18">
{#if c.entry?.about} {#if c.entry?.about}
<p class="whitespace-pre-line text-neutral-600 dark:text-neutral-400"> <p
class="whitespace-pre-line text-neutral-600 dark:text-neutral-400"
>
{c.entry.about} {c.entry.about}
</p> </p>
{/if} {/if}
@ -147,7 +175,10 @@
</a> </a>
{/if} {/if}
{#if c.entry?.lud16} {#if c.entry?.lud16}
<span class="text-neutral-500 dark:text-neutral-400" title="Lightning address"> <span
class="text-neutral-500 dark:text-neutral-400"
title="Lightning address"
>
⚡ {c.entry.lud16} ⚡ {c.entry.lud16}
</span> </span>
{/if} {/if}
@ -158,7 +189,9 @@
{@const rooms = roomsOf(c.pubkey)} {@const rooms = roomsOf(c.pubkey)}
{#if rooms.length > 0} {#if rooms.length > 0}
<p class="mt-2 text-neutral-500 dark:text-neutral-400"> <p class="mt-2 text-neutral-500 dark:text-neutral-400">
<span class="text-neutral-400 dark:text-neutral-500">Manages:</span> <span class="text-neutral-400 dark:text-neutral-500"
>Manages:</span
>
{#each rooms as r, i}<a {#each rooms as r, i}<a
href="/room/{r.id}" href="/room/{r.id}"
class="text-accent hover:underline">{r.name}</a class="text-accent hover:underline">{r.name}</a

View file

@ -0,0 +1,6 @@
import { snapshot, type Profiles } from "$lib/forum/snapshot";
export async function load({ fetch }) {
const data = await snapshot<{ profiles: Profiles }>(fetch, "/api/contacts");
return { profiles: data?.profiles ?? null };
}