Optimize bunker connections on mobile
This commit is contained in:
parent
0f487eb33a
commit
d4e644a964
14 changed files with 343 additions and 124 deletions
|
|
@ -206,12 +206,14 @@ export async function loginWithBunker(input: string) {
|
||||||
|
|
||||||
// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops
|
// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops
|
||||||
// waiting when the user leaves the view.
|
// waiting when the user leaves the view.
|
||||||
export function loginWithNostrConnect(): {
|
// `onAck` reports the signer's first reply so the UI can show progress while
|
||||||
|
// the session is finalized.
|
||||||
|
export function loginWithNostrConnect(onAck?: () => void): {
|
||||||
uri: string;
|
uri: string;
|
||||||
done: Promise<void>;
|
done: Promise<void>;
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
} {
|
} {
|
||||||
const nc: NostrConnect = startNostrConnect();
|
const nc: NostrConnect = startNostrConnect(onAck);
|
||||||
return {
|
return {
|
||||||
uri: nc.uri,
|
uri: nc.uri,
|
||||||
done: nc.session.then(loginWithBunkerSession),
|
done: nc.session.then(loginWithBunkerSession),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import {
|
||||||
createNostrConnectURI,
|
createNostrConnectURI,
|
||||||
parseBunkerInput,
|
parseBunkerInput,
|
||||||
toBunkerURL,
|
toBunkerURL,
|
||||||
|
type BunkerPointer,
|
||||||
} from "@nostr/tools/nip46";
|
} from "@nostr/tools/nip46";
|
||||||
|
import { SimplePool } from "@nostr/tools/pool";
|
||||||
|
import { NostrConnect as NostrConnectKind } from "@nostr/tools/kinds";
|
||||||
|
import { decrypt, getConversationKey } from "@nostr/tools/nip44";
|
||||||
import { generateSecretKey, getPublicKey } from "@nostr/tools/pure";
|
import { generateSecretKey, getPublicKey } from "@nostr/tools/pure";
|
||||||
import { bytesToHex, hexToBytes } from "@nostr/tools/utils";
|
import { bytesToHex, hexToBytes } from "@nostr/tools/utils";
|
||||||
import { NOSTRCONNECT_RELAYS } from "$lib/config";
|
import { NOSTRCONNECT_RELAYS } from "$lib/config";
|
||||||
|
|
@ -21,7 +25,37 @@ export type BunkerSession = {
|
||||||
clientSecretKey: string;
|
clientSecretKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The signer app usually lives on the phone, and on mobile the browser tab is
|
||||||
|
// backgrounded while the user approves there, which can drop the socket. A
|
||||||
|
// reconnecting pool keeps the subscriptions alive across that.
|
||||||
|
const pool = new SimplePool({ enableReconnect: true });
|
||||||
|
|
||||||
|
// The approval happens in the signer app, so the tab is backgrounded and its
|
||||||
|
// sockets can drop; nostr-tools only retries after a 10s backoff. Reconnect
|
||||||
|
// the relays this flow has used as soon as the tab or the network is back,
|
||||||
|
// and keep a shorter backoff for drops in between.
|
||||||
|
const FAST_BACKOFF = [1000, 2000, 5000, 10000];
|
||||||
|
const usedRelays = new Set<string>();
|
||||||
|
function touchRelays(urls: string[] = []) {
|
||||||
|
for (const url of urls) usedRelays.add(url);
|
||||||
|
for (const url of usedRelays) {
|
||||||
|
pool
|
||||||
|
.ensureRelay(url)
|
||||||
|
.then((relay) => {
|
||||||
|
relay.resubscribeBackoff = FAST_BACKOFF;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) touchRelays();
|
||||||
|
});
|
||||||
|
window.addEventListener("online", () => touchRelays());
|
||||||
|
}
|
||||||
|
|
||||||
const signerParams = {
|
const signerParams = {
|
||||||
|
pool,
|
||||||
onauth: (url: string) => {
|
onauth: (url: string) => {
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
},
|
},
|
||||||
|
|
@ -33,11 +67,16 @@ function stripSecret(url: string): string {
|
||||||
return u.toString();
|
return u.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
export async function connectBunker(input: string): Promise<BunkerSession> {
|
export async function connectBunker(input: string): Promise<BunkerSession> {
|
||||||
const bp = await parseBunkerInput(input.trim());
|
const bp = await parseBunkerInput(input.trim());
|
||||||
if (!bp) throw new Error("Invalid bunker");
|
if (!bp) throw new Error("Invalid bunker");
|
||||||
if (bp.relays.length === 0) throw new Error("Bunker URL has no relay");
|
if (bp.relays.length === 0) throw new Error("Bunker URL has no relay");
|
||||||
const sk = generateSecretKey();
|
const sk = generateSecretKey();
|
||||||
|
touchRelays(bp.relays);
|
||||||
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
||||||
try {
|
try {
|
||||||
await signer.connect();
|
await signer.connect();
|
||||||
|
|
@ -50,7 +89,7 @@ export async function connectBunker(input: string): Promise<BunkerSession> {
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
signer.close().catch(() => {});
|
signer.close().catch(() => {});
|
||||||
throw e;
|
throw new Error(`Bunker error: ${describe(e)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,29 +100,105 @@ export type NostrConnect = {
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function startNostrConnect(): NostrConnect {
|
// Waits for the signer's reply to a nostrconnect:// URI. Unlike the library's
|
||||||
|
// helper, the filter has no `limit: 0`, so a reply published while the tab was
|
||||||
|
// offline is replayed once the relay reconnects.
|
||||||
|
function waitForConnect(
|
||||||
|
sk: Uint8Array,
|
||||||
|
secret: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<BunkerPointer> {
|
||||||
|
const clientPubkey = getPublicKey(sk);
|
||||||
|
touchRelays(NOSTRCONNECT_RELAYS);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (fn: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
sub.close();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
const sub = pool.subscribe(
|
||||||
|
NOSTRCONNECT_RELAYS,
|
||||||
|
{
|
||||||
|
kinds: [NostrConnectKind],
|
||||||
|
"#p": [clientPubkey],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onevent: (event) => {
|
||||||
|
let reply: { result?: string; error?: string };
|
||||||
|
try {
|
||||||
|
const key = getConversationKey(sk, event.pubkey);
|
||||||
|
reply = JSON.parse(decrypt(event.content, key));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("nostrconnect: undecryptable reply", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reply.result === secret) {
|
||||||
|
finish(() =>
|
||||||
|
resolve({
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
relays: NOSTRCONNECT_RELAYS,
|
||||||
|
secret,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (reply.error) {
|
||||||
|
finish(() => reject(new Error(`Signer refused: ${reply.error}`)));
|
||||||
|
} else {
|
||||||
|
finish(() =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Signer replied "${reply.result}" instead of the secret`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose: (reasons) =>
|
||||||
|
finish(() =>
|
||||||
|
reject(new Error(`Relay closed: ${reasons.join("; ")}`)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Closes the subscription on every relay; the pool's own abort handling
|
||||||
|
// only reaches the last one
|
||||||
|
signal.addEventListener("abort", () =>
|
||||||
|
finish(() => reject(new Error("Cancelled"))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// `onAck` fires the moment the signer answers the URI, before the follow-up
|
||||||
|
// round-trip that completes the session — the point where "waiting for the
|
||||||
|
// signer" becomes "finishing up".
|
||||||
|
export function startNostrConnect(onAck?: () => void): NostrConnect {
|
||||||
const sk = generateSecretKey();
|
const sk = generateSecretKey();
|
||||||
|
const secret = Math.random().toString(36).substring(2, 10);
|
||||||
const uri = createNostrConnectURI({
|
const uri = createNostrConnectURI({
|
||||||
clientPubkey: getPublicKey(sk),
|
clientPubkey: getPublicKey(sk),
|
||||||
relays: NOSTRCONNECT_RELAYS,
|
relays: NOSTRCONNECT_RELAYS,
|
||||||
secret: Math.random().toString(36).substring(2, 10),
|
secret,
|
||||||
name: location.host,
|
name: location.host,
|
||||||
url: location.origin,
|
url: location.origin,
|
||||||
});
|
});
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
const session = BunkerSigner.fromURI(
|
const session = waitForConnect(sk, secret, abort.signal).then(async (bp) => {
|
||||||
sk,
|
onAck?.();
|
||||||
uri,
|
// The signer already accepted this client key; no connect round-trip
|
||||||
signerParams,
|
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
||||||
abort.signal,
|
try {
|
||||||
).then(async (signer) => {
|
|
||||||
const pubkey = await signer.getPublicKey();
|
const pubkey = await signer.getPublicKey();
|
||||||
return {
|
return {
|
||||||
signer,
|
signer,
|
||||||
pubkey,
|
pubkey,
|
||||||
bunkerUrl: stripSecret(toBunkerURL(signer.bp)),
|
bunkerUrl: stripSecret(toBunkerURL(bp)),
|
||||||
clientSecretKey: bytesToHex(sk),
|
clientSecretKey: bytesToHex(sk),
|
||||||
};
|
};
|
||||||
|
} catch (e) {
|
||||||
|
signer.close().catch(() => {});
|
||||||
|
throw new Error(`Bunker error: ${describe(e)}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return { uri, session, cancel: () => abort.abort() };
|
return { uri, session, cancel: () => abort.abort() };
|
||||||
}
|
}
|
||||||
|
|
@ -96,5 +211,6 @@ export async function restoreBunker(
|
||||||
): Promise<BunkerSigner> {
|
): Promise<BunkerSigner> {
|
||||||
const bp = await parseBunkerInput(bunkerUrl);
|
const bp = await parseBunkerInput(bunkerUrl);
|
||||||
if (!bp) throw new Error("Invalid bunker");
|
if (!bp) throw new Error("Invalid bunker");
|
||||||
|
touchRelays(bp.relays);
|
||||||
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
|
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,15 @@
|
||||||
(typeof location !== "undefined" ? location.host : ""),
|
(typeof location !== "undefined" ? location.host : ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Client-initiated NIP-46 flow, alive only while the bunker view is shown
|
// Client-initiated NIP-46 flow, alive only while the bunker view is shown.
|
||||||
let connect = $state<ReturnType<typeof loginWithNostrConnect> | null>(null);
|
// `finalizing` flips when the signer first answers, so the QR gives way to
|
||||||
|
// a progress state while the session completes.
|
||||||
|
// $state.raw so identity checks against the raw object (connect === nc)
|
||||||
|
// hold; the object is replaced wholesale, never mutated.
|
||||||
|
let connect = $state.raw<ReturnType<typeof loginWithNostrConnect> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
let finalizing = $state(false);
|
||||||
const qrSvg = $derived(
|
const qrSvg = $derived(
|
||||||
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
|
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
|
||||||
);
|
);
|
||||||
|
|
@ -57,6 +64,7 @@
|
||||||
function stopConnect() {
|
function stopConnect() {
|
||||||
connect?.cancel();
|
connect?.cancel();
|
||||||
connect = null;
|
connect = null;
|
||||||
|
finalizing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
|
|
@ -116,7 +124,9 @@
|
||||||
|
|
||||||
function startConnect() {
|
function startConnect() {
|
||||||
stopConnect();
|
stopConnect();
|
||||||
const nc = loginWithNostrConnect();
|
const nc = loginWithNostrConnect(() => {
|
||||||
|
if (connect === nc) finalizing = true;
|
||||||
|
});
|
||||||
connect = nc;
|
connect = nc;
|
||||||
nc.done
|
nc.done
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
@ -127,6 +137,7 @@
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (connect !== nc) return; // cancelled by leaving the view
|
if (connect !== nc) return; // cancelled by leaving the view
|
||||||
connect = null;
|
connect = null;
|
||||||
|
finalizing = false;
|
||||||
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
|
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +336,20 @@
|
||||||
Sign up
|
Sign up
|
||||||
</button>
|
</button>
|
||||||
{:else if view === "bunker"}
|
{:else if view === "bunker"}
|
||||||
|
{#if finalizing}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-3 py-10"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-neutral-300 border-t-neutral-600 dark:border-neutral-600 dark:border-t-neutral-300"
|
||||||
|
></span>
|
||||||
|
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Connected to your signer — finishing login…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
{#if connect}
|
{#if connect}
|
||||||
<a
|
<a
|
||||||
href={connect.uri}
|
href={connect.uri}
|
||||||
|
|
@ -339,14 +364,17 @@
|
||||||
aria-label={copied ? "Copied" : "Copy the connection string"}
|
aria-label={copied ? "Copied" : "Copy the connection string"}
|
||||||
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate font-mono">{connect.uri}</span>
|
<span class="min-w-0 flex-1 truncate font-mono"
|
||||||
|
>{connect.uri}</span
|
||||||
|
>
|
||||||
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
|
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
|
||||||
</button>
|
</button>
|
||||||
<p
|
<p
|
||||||
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
|
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
Scan or paste this in your signer app, then approve the connection.
|
Scan or paste this in your signer app, then approve the
|
||||||
|
connection.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -390,6 +418,7 @@
|
||||||
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
A NIP-05 address that points to a bunker works too.
|
A NIP-05 address that points to a bunker works too.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={showExtensionView}
|
onclick={showExtensionView}
|
||||||
|
|
|
||||||
26
src/lib/ssr/endpoint.ts
Normal file
26
src/lib/ssr/endpoint.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { error, type RequestEvent } from "@sveltejs/kit";
|
||||||
|
import { CACHE_CONTROL } from "$lib/config";
|
||||||
|
import { RelayUnavailable } from "./relay";
|
||||||
|
|
||||||
|
// Wraps a snapshot endpoint: answers carry the shared cache policy, while a
|
||||||
|
// relay that could not be asked becomes an uncached 503, which snapshot()
|
||||||
|
// treats as "no snapshot" so the page hydrates empty and the client takes the
|
||||||
|
// same path as a client-only build (probe, gate or live fetch).
|
||||||
|
export function snapshotHandler<E extends RequestEvent>(
|
||||||
|
handler: (event: E) => Promise<Response>,
|
||||||
|
): (event: E) => Promise<Response> {
|
||||||
|
return async (event) => {
|
||||||
|
try {
|
||||||
|
const res = await handler(event);
|
||||||
|
event.setHeaders({ "cache-control": CACHE_CONTROL });
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
const unavailable = e instanceof RelayUnavailable;
|
||||||
|
event.setHeaders({
|
||||||
|
"cache-control": unavailable ? "no-store" : CACHE_CONTROL,
|
||||||
|
});
|
||||||
|
if (unavailable) error(503, "Relay unavailable");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,11 @@ import type { Query } from "$lib/forum/query";
|
||||||
// private rooms once the client takes over. Every query is bounded so a slow
|
// private rooms once the client takes over. Every query is bounded so a slow
|
||||||
// relay degrades to an empty page, never a hung request.
|
// relay degrades to an empty page, never a hung request.
|
||||||
//
|
//
|
||||||
|
// A relay that is down or refuses anonymous reads (NIP-42 auth-required) is
|
||||||
|
// not the same as an empty result: the query rejects with RelayUnavailable so
|
||||||
|
// endpoints answer 503 rather than 404, and the client renders as it would
|
||||||
|
// without SSR (probing the relay and showing the gate when it must).
|
||||||
|
//
|
||||||
// Results are cached in memory with the fresh/stale windows from config:
|
// Results are cached in memory with the fresh/stale windows from config:
|
||||||
// fresh entries are returned as they are, stale ones are returned at once and
|
// fresh entries are returned as they are, stale ones are returned at once and
|
||||||
// refreshed in the background, expired ones are fetched again. Profiles
|
// refreshed in the background, expired ones are fetched again. Profiles
|
||||||
|
|
@ -23,6 +28,13 @@ const STALE = Math.max(SSR_CACHE_STALE * 1000, FORUM_FRESH);
|
||||||
const DEAD_RELAY_TTL = 5 * 60_000;
|
const DEAD_RELAY_TTL = 5 * 60_000;
|
||||||
const CACHE_MAX = 2000;
|
const CACHE_MAX = 2000;
|
||||||
|
|
||||||
|
export class RelayUnavailable extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("No relay answered");
|
||||||
|
this.name = "RelayUnavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
// Open relay sockets keep the process alive after SIGTERM until systemd kills it
|
// Open relay sockets keep the process alive after SIGTERM until systemd kills it
|
||||||
process.on("sveltekit:shutdown", () => pool.destroy());
|
process.on("sveltekit:shutdown", () => pool.destroy());
|
||||||
|
|
@ -35,15 +47,18 @@ const deadUntil = new Map<string, number>();
|
||||||
// Ask every reachable relay and resolve as soon as each has answered (EOSE or
|
// Ask every reachable relay and resolve as soon as each has answered (EOSE or
|
||||||
// closed), or at the deadline, whichever comes first — always with whatever
|
// closed), or at the deadline, whichever comes first — always with whatever
|
||||||
// arrived, so one stalled relay never costs the events the others delivered.
|
// arrived, so one stalled relay never costs the events the others delivered.
|
||||||
|
// Rejects when no relay reached EOSE: a CLOSED (auth-required), a failed
|
||||||
|
// connection or the deadline are not answers.
|
||||||
function boundedQuery(
|
function boundedQuery(
|
||||||
relays: string[],
|
relays: string[],
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
maxWait: number,
|
maxWait: number,
|
||||||
): Promise<Event[]> {
|
): Promise<Event[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
const events: Event[] = [];
|
const events: Event[] = [];
|
||||||
const subs: { close(): void }[] = [];
|
const subs: { close(): void }[] = [];
|
||||||
let done = false;
|
let done = false;
|
||||||
|
let answered = false;
|
||||||
let pending = 0;
|
let pending = 0;
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
|
|
@ -56,7 +71,8 @@ function boundedQuery(
|
||||||
// Already closed
|
// Already closed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve(events);
|
if (answered) resolve(events);
|
||||||
|
else reject(new RelayUnavailable());
|
||||||
};
|
};
|
||||||
const timer = setTimeout(finish, maxWait);
|
const timer = setTimeout(finish, maxWait);
|
||||||
|
|
||||||
|
|
@ -78,10 +94,14 @@ function boundedQuery(
|
||||||
const sub = relay.subscribe([filter], {
|
const sub = relay.subscribe([filter], {
|
||||||
onevent: (e) => events.push(e),
|
onevent: (e) => events.push(e),
|
||||||
oneose: () => {
|
oneose: () => {
|
||||||
|
answered = true;
|
||||||
sub.close();
|
sub.close();
|
||||||
settle();
|
settle();
|
||||||
},
|
},
|
||||||
onclose: settle,
|
onclose: settle,
|
||||||
|
// The library fakes an EOSE after its own timeout; the deadline
|
||||||
|
// above must win so a silent relay does not count as an answer
|
||||||
|
eoseTimeout: maxWait * 2,
|
||||||
});
|
});
|
||||||
subs.push(sub);
|
subs.push(sub);
|
||||||
},
|
},
|
||||||
|
|
@ -109,20 +129,31 @@ function cachedQuery(
|
||||||
if (age < STALE) {
|
if (age < STALE) {
|
||||||
if (!hit.refreshing) {
|
if (!hit.refreshing) {
|
||||||
hit.refreshing = true;
|
hit.refreshing = true;
|
||||||
boundedQuery(relays, filter, maxWait).then((events) => {
|
boundedQuery(relays, filter, maxWait).then(
|
||||||
|
(events) => {
|
||||||
cache.set(key, {
|
cache.set(key, {
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
result: Promise.resolve(events),
|
result: Promise.resolve(events),
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
// Keep serving the stale entry, try again next time
|
||||||
|
() => {
|
||||||
|
hit.refreshing = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return hit.result;
|
return hit.result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cache.size >= CACHE_MAX) cache.clear();
|
if (cache.size >= CACHE_MAX) cache.clear();
|
||||||
const result = boundedQuery(relays, filter, maxWait);
|
const result = boundedQuery(relays, filter, maxWait);
|
||||||
cache.set(key, { at: now, result, refreshing: false });
|
const entry: Entry = { at: now, result, refreshing: false };
|
||||||
|
cache.set(key, entry);
|
||||||
|
// A failure is never cached, so the next request asks the relay again
|
||||||
|
result.catch(() => {
|
||||||
|
if (cache.get(key) === entry) cache.delete(key);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ import { snapshot, type ForumShell } from "$lib/forum/snapshot";
|
||||||
|
|
||||||
export const ssr = SSR_ENABLED;
|
export const ssr = SSR_ENABLED;
|
||||||
|
|
||||||
// Runs for every page, so the cache policy for rendered HTML is set here.
|
// Runs for every page, so the cache policy for rendered HTML is set here. A
|
||||||
|
// page rendered without its shell (relay down or refusing anonymous reads) is
|
||||||
|
// an empty placeholder the client fills in, not worth keeping in a shared cache.
|
||||||
export async function load({ fetch, setHeaders }) {
|
export async function load({ fetch, setHeaders }) {
|
||||||
if (!browser) setHeaders({ "cache-control": CACHE_CONTROL });
|
const shell = await snapshot<ForumShell>(fetch, "/api/shell");
|
||||||
return { shell: await snapshot<ForumShell>(fetch, "/api/shell") };
|
if (!browser)
|
||||||
|
setHeaders({ "cache-control": shell ? CACHE_CONTROL : "no-store" });
|
||||||
|
return { shell };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { json } from "@sveltejs/kit";
|
import { json } from "@sveltejs/kit";
|
||||||
import { CACHE_CONTROL } from "$lib/config";
|
|
||||||
import { fetchProfiles } from "$lib/forum/profiles";
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
import { loadShell } from "$lib/ssr/shell";
|
import { loadShell } from "$lib/ssr/shell";
|
||||||
import { profileQuery } from "$lib/ssr/relay";
|
import { profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
export async function GET({ setHeaders }) {
|
export const GET: RequestHandler = snapshotHandler(async () => {
|
||||||
setHeaders({ "cache-control": CACHE_CONTROL });
|
|
||||||
const shell = await loadShell();
|
const shell = await loadShell();
|
||||||
return json({ profiles: await fetchProfiles(profileQuery, shell.admins) });
|
return json({ profiles: await fetchProfiles(profileQuery, shell.admins) });
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { json } from "@sveltejs/kit";
|
import { json } from "@sveltejs/kit";
|
||||||
import { MODE, GROUP_ID, CACHE_CONTROL } from "$lib/config";
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
import { fetchGroups } from "$lib/forum/groups";
|
import { fetchGroups } from "$lib/forum/groups";
|
||||||
import { fetchOverview, overviewPubkeys } from "$lib/forum/overview";
|
import { fetchOverview, overviewPubkeys } from "$lib/forum/overview";
|
||||||
import { fetchProfiles } from "$lib/forum/profiles";
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
import type { OverviewSnapshot } from "$lib/forum/snapshot";
|
import type { OverviewSnapshot } from "$lib/forum/snapshot";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
// Room activity and the latest discussions: every room in full mode, the
|
// Room activity and the latest discussions: every room in full mode, the
|
||||||
// single group in simple mode (mirrors LatestDiscussions' own room set).
|
// single group in simple mode (mirrors LatestDiscussions' own room set).
|
||||||
export async function GET({ setHeaders }) {
|
export const GET: RequestHandler = snapshotHandler(async () => {
|
||||||
setHeaders({ "cache-control": CACHE_CONTROL });
|
|
||||||
const roomIds =
|
const roomIds =
|
||||||
MODE === "full"
|
MODE === "full"
|
||||||
? (await fetchGroups(forumQuery)).map((g) => g.id)
|
? (await fetchGroups(forumQuery)).map((g) => g.id)
|
||||||
|
|
@ -18,4 +19,4 @@ export async function GET({ setHeaders }) {
|
||||||
const profiles = await fetchProfiles(profileQuery, overviewPubkeys(overview));
|
const profiles = await fetchProfiles(profileQuery, overviewPubkeys(overview));
|
||||||
const body: OverviewSnapshot = { roomIds, ...overview, profiles };
|
const body: OverviewSnapshot = { roomIds, ...overview, profiles };
|
||||||
return json(body);
|
return json(body);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { error, json } from "@sveltejs/kit";
|
import { error, json } from "@sveltejs/kit";
|
||||||
import { CACHE_CONTROL, GROUP_ID, MODE } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { decodeProfileId, fetchProfiles } from "$lib/forum/profiles";
|
import { decodeProfileId, fetchProfiles } from "$lib/forum/profiles";
|
||||||
import { fetchUserPosts } from "$lib/forum/userPosts";
|
import { fetchUserPosts } from "$lib/forum/userPosts";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
export async function GET({ params, setHeaders }) {
|
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
|
||||||
setHeaders({ "cache-control": CACHE_CONTROL });
|
|
||||||
const pubkey = decodeProfileId(params.id);
|
const pubkey = decodeProfileId(params.id);
|
||||||
if (!pubkey) error(404, "Not found");
|
if (!pubkey) error(404, "Not found");
|
||||||
const [profiles, posts] = await Promise.all([
|
const [profiles, posts] = await Promise.all([
|
||||||
|
|
@ -17,4 +18,4 @@ export async function GET({ params, setHeaders }) {
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
return json({ pubkey, posts, profiles });
|
return json({ pubkey, posts, profiles });
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { json } from "@sveltejs/kit";
|
import { json } from "@sveltejs/kit";
|
||||||
import { CACHE_CONTROL } from "$lib/config";
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
import { loadShell } from "$lib/ssr/shell";
|
import { loadShell } from "$lib/ssr/shell";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
export async function GET({ setHeaders }) {
|
export const GET: RequestHandler = snapshotHandler(async () =>
|
||||||
setHeaders({ "cache-control": CACHE_CONTROL });
|
json(await loadShell()),
|
||||||
return json(await loadShell());
|
);
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { error, json } from "@sveltejs/kit";
|
import { error, json } from "@sveltejs/kit";
|
||||||
import { GROUP_ID, CACHE_CONTROL } from "$lib/config";
|
import { GROUP_ID } from "$lib/config";
|
||||||
import { fetchThread, threadAuthors } from "$lib/forum/thread";
|
import { fetchThread, threadAuthors } from "$lib/forum/thread";
|
||||||
import { fetchProfiles } from "$lib/forum/profiles";
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
export async function GET({ params, setHeaders }) {
|
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
|
||||||
setHeaders({ "cache-control": CACHE_CONTROL });
|
|
||||||
if (!/^[0-9a-f]{64}$/.test(params.id)) error(404, "Not found");
|
if (!/^[0-9a-f]{64}$/.test(params.id)) error(404, "Not found");
|
||||||
const thread = await fetchThread(forumQuery, params.id, GROUP_ID);
|
const thread = await fetchThread(forumQuery, params.id, GROUP_ID);
|
||||||
if (!thread) error(404, "Not found");
|
if (!thread) error(404, "Not found");
|
||||||
const profiles = await fetchProfiles(profileQuery, threadAuthors(thread));
|
const profiles = await fetchProfiles(profileQuery, threadAuthors(thread));
|
||||||
return json({ thread, profiles });
|
return json({ thread, profiles });
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { error, json } from "@sveltejs/kit";
|
import { error, json } from "@sveltejs/kit";
|
||||||
import { MODE, GROUP_ID, CACHE_CONTROL } from "$lib/config";
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
import { fetchGroups } from "$lib/forum/groups";
|
import { fetchGroups } from "$lib/forum/groups";
|
||||||
import {
|
import {
|
||||||
fetchThreadPage,
|
fetchThreadPage,
|
||||||
|
|
@ -8,12 +8,13 @@ import {
|
||||||
} from "$lib/forum/threads";
|
} from "$lib/forum/threads";
|
||||||
import { fetchProfiles } from "$lib/forum/profiles";
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
import type { ThreadsSnapshot } from "$lib/forum/snapshot";
|
import type { ThreadsSnapshot } from "$lib/forum/snapshot";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
// First page of a group's listing. Unknown rooms are a 404 so crawlers don't
|
// First page of a group's listing. Unknown rooms are a 404 so crawlers don't
|
||||||
// index empty shells; private rooms are simply empty to the anonymous server.
|
// index empty shells; private rooms are simply empty to the anonymous server.
|
||||||
export async function GET({ params, url, setHeaders }) {
|
export const GET: RequestHandler = snapshotHandler(async ({ params, url }) => {
|
||||||
setHeaders({ "cache-control": CACHE_CONTROL });
|
|
||||||
const groupId = params.group;
|
const groupId = params.group;
|
||||||
if (MODE === "simple") {
|
if (MODE === "simple") {
|
||||||
if (groupId !== GROUP_ID) error(404, "Not found");
|
if (groupId !== GROUP_ID) error(404, "Not found");
|
||||||
|
|
@ -41,4 +42,4 @@ export async function GET({ params, url, setHeaders }) {
|
||||||
profiles,
|
profiles,
|
||||||
};
|
};
|
||||||
return json(body);
|
return json(body);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,12 @@ body {
|
||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
.prose :is(h2) {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
.prose :is(h3) {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
.prose :is(p) {
|
.prose :is(p) {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { CACHE_CONTROL, MODE, GROUP_ID } from "$lib/config";
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
import { fetchGroups } from "$lib/forum/groups";
|
import { fetchGroups } from "$lib/forum/groups";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
import { forumQuery } from "$lib/ssr/relay";
|
import { forumQuery } from "$lib/ssr/relay";
|
||||||
import { loadShell } from "$lib/ssr/shell";
|
import { loadShell } from "$lib/ssr/shell";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
const THREADS_PER_ROOM = 1000;
|
const THREADS_PER_ROOM = 1000;
|
||||||
|
|
||||||
|
|
@ -25,7 +27,7 @@ function xml(origin: string, entries: Entry[]): string {
|
||||||
|
|
||||||
// Public pages only: rooms the anonymous server can see, their threads, and
|
// Public pages only: rooms the anonymous server can see, their threads, and
|
||||||
// the admin-authored resources.
|
// the admin-authored resources.
|
||||||
export async function GET({ url, setHeaders }) {
|
export const GET: RequestHandler = snapshotHandler(async ({ url }) => {
|
||||||
const entries: Entry[] = [{ path: "/" }, { path: "/contacts" }];
|
const entries: Entry[] = [{ path: "/" }, { path: "/contacts" }];
|
||||||
const shell = await loadShell();
|
const shell = await loadShell();
|
||||||
const admins = new Set(shell.admins);
|
const admins = new Set(shell.admins);
|
||||||
|
|
@ -49,8 +51,7 @@ export async function GET({ url, setHeaders }) {
|
||||||
for (const e of ops)
|
for (const e of ops)
|
||||||
entries.push({ path: `/thread/${e.id}`, lastmod: e.created_at });
|
entries.push({ path: `/thread/${e.id}`, lastmod: e.created_at });
|
||||||
}
|
}
|
||||||
setHeaders({ "cache-control": CACHE_CONTROL });
|
|
||||||
return new Response(xml(url.origin, entries), {
|
return new Response(xml(url.origin, entries), {
|
||||||
headers: { "content-type": "application/xml" },
|
headers: { "content-type": "application/xml" },
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue