Optimize bunker connections on mobile

This commit is contained in:
dtonon 2026-09-19 17:49:45 +02:00
parent 0f487eb33a
commit d4e644a964
14 changed files with 343 additions and 124 deletions

View file

@ -206,12 +206,14 @@ export async function loginWithBunker(input: string) {
// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops
// 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;
done: Promise<void>;
cancel: () => void;
} {
const nc: NostrConnect = startNostrConnect();
const nc: NostrConnect = startNostrConnect(onAck);
return {
uri: nc.uri,
done: nc.session.then(loginWithBunkerSession),

View file

@ -3,7 +3,11 @@ import {
createNostrConnectURI,
parseBunkerInput,
toBunkerURL,
type BunkerPointer,
} 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 { bytesToHex, hexToBytes } from "@nostr/tools/utils";
import { NOSTRCONNECT_RELAYS } from "$lib/config";
@ -21,7 +25,37 @@ export type BunkerSession = {
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 = {
pool,
onauth: (url: string) => {
window.open(url, "_blank");
},
@ -33,11 +67,16 @@ function stripSecret(url: string): string {
return u.toString();
}
function describe(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export async function connectBunker(input: string): Promise<BunkerSession> {
const bp = await parseBunkerInput(input.trim());
if (!bp) throw new Error("Invalid bunker");
if (bp.relays.length === 0) throw new Error("Bunker URL has no relay");
const sk = generateSecretKey();
touchRelays(bp.relays);
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
try {
await signer.connect();
@ -50,7 +89,7 @@ export async function connectBunker(input: string): Promise<BunkerSession> {
};
} catch (e) {
signer.close().catch(() => {});
throw e;
throw new Error(`Bunker error: ${describe(e)}`);
}
}
@ -61,29 +100,105 @@ export type NostrConnect = {
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 secret = Math.random().toString(36).substring(2, 10);
const uri = createNostrConnectURI({
clientPubkey: getPublicKey(sk),
relays: NOSTRCONNECT_RELAYS,
secret: Math.random().toString(36).substring(2, 10),
secret,
name: location.host,
url: location.origin,
});
const abort = new AbortController();
const session = BunkerSigner.fromURI(
sk,
uri,
signerParams,
abort.signal,
).then(async (signer) => {
const pubkey = await signer.getPublicKey();
return {
signer,
pubkey,
bunkerUrl: stripSecret(toBunkerURL(signer.bp)),
clientSecretKey: bytesToHex(sk),
};
const session = waitForConnect(sk, secret, abort.signal).then(async (bp) => {
onAck?.();
// The signer already accepted this client key; no connect round-trip
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
try {
const pubkey = await signer.getPublicKey();
return {
signer,
pubkey,
bunkerUrl: stripSecret(toBunkerURL(bp)),
clientSecretKey: bytesToHex(sk),
};
} catch (e) {
signer.close().catch(() => {});
throw new Error(`Bunker error: ${describe(e)}`);
}
});
return { uri, session, cancel: () => abort.abort() };
}
@ -96,5 +211,6 @@ export async function restoreBunker(
): Promise<BunkerSigner> {
const bp = await parseBunkerInput(bunkerUrl);
if (!bp) throw new Error("Invalid bunker");
touchRelays(bp.relays);
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
}

View file

@ -38,8 +38,15 @@
(typeof location !== "undefined" ? location.host : ""),
);
// Client-initiated NIP-46 flow, alive only while the bunker view is shown
let connect = $state<ReturnType<typeof loginWithNostrConnect> | null>(null);
// Client-initiated NIP-46 flow, alive only while the bunker view is shown.
// `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(
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
);
@ -57,6 +64,7 @@
function stopConnect() {
connect?.cancel();
connect = null;
finalizing = false;
}
function reset() {
@ -116,7 +124,9 @@
function startConnect() {
stopConnect();
const nc = loginWithNostrConnect();
const nc = loginWithNostrConnect(() => {
if (connect === nc) finalizing = true;
});
connect = nc;
nc.done
.then(() => {
@ -127,6 +137,7 @@
.catch((e) => {
if (connect !== nc) return; // cancelled by leaving the view
connect = null;
finalizing = false;
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
});
}
@ -325,71 +336,89 @@
Sign up
</button>
{:else if view === "bunker"}
{#if connect}
<a
href={connect.uri}
aria-label="Open in your signer app"
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
>
{@html qrSvg}
</a>
<button
type="button"
onclick={copyUri}
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"
>
<span class="min-w-0 flex-1 truncate font-mono">{connect.uri}</span>
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
</button>
<p
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
{#if finalizing}
<div
class="flex flex-col items-center gap-3 py-10"
role="status"
aria-live="polite"
>
Scan or paste this in your signer app, then approve the connection.
<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}
<a
href={connect.uri}
aria-label="Open in your signer app"
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
>
{@html qrSvg}
</a>
<button
type="button"
onclick={copyUri}
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"
>
<span class="min-w-0 flex-1 truncate font-mono"
>{connect.uri}</span
>
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
</button>
<p
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
aria-live="polite"
>
Scan or paste this in your signer app, then approve the
connection.
</p>
{/if}
<div class="my-4 flex items-center gap-3" aria-hidden="true">
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
<span class="text-xs text-neutral-400">or</span>
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
</div>
<label
for="bunker-input"
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
>
Paste a bunker URL
</label>
<input
id="bunker-input"
bind:this={bunkerInput}
type="text"
placeholder="bunker://…"
bind:value={bunkerUrl}
disabled={busy}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleBunker()}
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleBunker}
disabled={busy || !bunkerUrl.trim()}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Connecting…" : "Log in"}
</button>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
A NIP-05 address that points to a bunker works too.
</p>
{/if}
<div class="my-4 flex items-center gap-3" aria-hidden="true">
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
<span class="text-xs text-neutral-400">or</span>
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
</div>
<label
for="bunker-input"
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
>
Paste a bunker URL
</label>
<input
id="bunker-input"
bind:this={bunkerInput}
type="text"
placeholder="bunker://…"
bind:value={bunkerUrl}
disabled={busy}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleBunker()}
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleBunker}
disabled={busy || !bunkerUrl.trim()}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Connecting…" : "Log in"}
</button>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
A NIP-05 address that points to a bunker works too.
</p>
<button
type="button"
onclick={showExtensionView}

26
src/lib/ssr/endpoint.ts Normal file
View 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;
}
};
}

View file

@ -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
// 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:
// fresh entries are returned as they are, stale ones are returned at once and
// 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 CACHE_MAX = 2000;
export class RelayUnavailable extends Error {
constructor() {
super("No relay answered");
this.name = "RelayUnavailable";
}
}
const pool = new SimplePool();
// Open relay sockets keep the process alive after SIGTERM until systemd kills it
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
// closed), or at the deadline, whichever comes first — always with whatever
// 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(
relays: string[],
filter: Filter,
maxWait: number,
): Promise<Event[]> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const events: Event[] = [];
const subs: { close(): void }[] = [];
let done = false;
let answered = false;
let pending = 0;
const finish = () => {
if (done) return;
@ -56,7 +71,8 @@ function boundedQuery(
// Already closed
}
}
resolve(events);
if (answered) resolve(events);
else reject(new RelayUnavailable());
};
const timer = setTimeout(finish, maxWait);
@ -78,10 +94,14 @@ function boundedQuery(
const sub = relay.subscribe([filter], {
onevent: (e) => events.push(e),
oneose: () => {
answered = true;
sub.close();
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);
},
@ -109,20 +129,31 @@ function cachedQuery(
if (age < STALE) {
if (!hit.refreshing) {
hit.refreshing = true;
boundedQuery(relays, filter, maxWait).then((events) => {
cache.set(key, {
at: Date.now(),
result: Promise.resolve(events),
refreshing: false,
});
});
boundedQuery(relays, filter, maxWait).then(
(events) => {
cache.set(key, {
at: Date.now(),
result: Promise.resolve(events),
refreshing: false,
});
},
// Keep serving the stale entry, try again next time
() => {
hit.refreshing = false;
},
);
}
return hit.result;
}
}
if (cache.size >= CACHE_MAX) cache.clear();
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;
}