Render the forum shell, resources and contacts on the server
This commit is contained in:
parent
a7b5d8f223
commit
40e0be35d9
16 changed files with 259 additions and 30 deletions
|
|
@ -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<Record<string, string[]>>({});
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
46
src/lib/forum/snapshot.ts
Normal file
46
src/lib/forum/snapshot.ts
Normal 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)}]`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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<GroupMetadata | null>(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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GroupSummary[]>([]);
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Partial[]>([]);
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, ProfileEntry>();
|
||||
|
|
|
|||
|
|
@ -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<Resource[]>([]);
|
|||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
74
src/lib/ssr/relay.ts
Normal file
74
src/lib/ssr/relay.ts
Normal 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
29
src/lib/ssr/shell.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue