Initial commit: Goostr static site

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-09-27 08:37:10 -06:00
commit 21093eb800
11 changed files with 1178 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.DS_Store

6
.stignore Normal file
View File

@ -0,0 +1,6 @@
/node_modules
node_modules
/.yarn
.yarn
/.pnp.*
.pnp.*

94
README.md Normal file
View File

@ -0,0 +1,94 @@
# Goostr (static)
Your Google account is your nostr identity. No server, no key management.
**Pure static site.** No build step. No backend. No install required to run.
## How it works
1. User clicks **Sign in with Google**. We request three OAuth scopes: `profile`, `email`, and `drive.appdata`.
2. We look in the user's **Google Drive App Data folder** for a file `goostr-key-v1.json`. (App Data is a per-OAuth-client hidden folder in the user's Drive that only this app's client ID can read. WhatsApp uses it for backups; Enpass uses it for vault sync.)
- **If found** → download it. The 32 bytes inside are the nostr private key. User is now signed into their existing nostr identity.
- **If missing** → generate 32 fresh random bytes (`crypto.getRandomValues`), upload to App Data. User now has a brand-new nostr identity.
3. We fetch the user's Google name + picture. For a brand-new identity we publish a kind-0 metadata event with them so it has a profile. For an existing one, if no relay returns a kind-0 within ~6s, we publish it then.
4. SessionStorage caches the key inside the tab (max 12h) so reloads don't require re-auth. Closing the tab logs out.
Note: the key is **stored**, not derived. It's random bytes that live in the Drive file. Delete that file (or revoke/delete the OAuth client, which makes App Data unreadable) and the identity is gone for good.
### The demo app
Once signed in, [`app.js`](public/app.js) shows your name, avatar and npub, a dot per relay (connecting / open / closed / timeout), your last 50 notes (kind 1), and a box to publish a new note.
### Relays
[`relay.js`](public/relay.js) opens every relay in [`config.js`](public/config.js) in parallel (4s timeout each) and lets the UI continue as soon as **one** is open. Relays that connect later are sent every active subscription plus anything published in the last 60s, so nothing is lost to a slow relay.
### Why this is good
- Works across devices: sign in on phone, laptop, anything — same Google account → same nostr identity.
- No nsec to lose, no seed phrase to back up. Recovery == "remember your Google password."
- The seed key never touches *this* device's disk. It lives in Google Drive (encrypted at rest by Google) and in tab-only sessionStorage.
### What this is *not*
- Not as secure as `keystr` (passkey-based). Anyone with your Google password + 2FA can read your nostr key. With keystr/passkeys, the user must physically be at the authenticator device.
- Not for situations where you want to be invisible to a single big platform — Google obviously knows you own this Drive App Data file.
- Not safe against script injection. While signed in, the raw key sits in `sessionStorage`, readable by any script on the page. That includes `nostr-tools`, loaded from `esm.sh` via the import map in `index.html` with no integrity check. Pin/self-host it before trusting this with a real identity.
- Every sign-in shows Google's consent screen (`prompt: "consent"`) unless `signIn({ silent: true })` is used.
## One-time Google setup (~2 minutes)
1. Open <https://console.cloud.google.com/apis/credentials>.
2. Create or select a project.
3. Enable the **Google Drive API** under "Library."
4. Under "OAuth consent screen", configure a minimal app (External + Testing is fine for prototyping; add yourself as a Test User).
5. Back in "Credentials" → **Create Credentials** → **OAuth client ID** → **Web application**.
6. Under **Authorized JavaScript origins** add:
- `http://localhost:5174` (for local dev)
- your deploy URL (e.g. `https://you.github.io`)
7. Copy the **Client ID** — looks like `1234567890-abcdef.apps.googleusercontent.com`.
8. Paste it into [`public/config.js`](public/config.js) as the value of `CLIENT_ID`.
## Run it
```bash
python3 -m http.server -d public 5174
# or
npx serve public -l 5174
```
Then open <http://localhost:5174>. Google requires HTTPS for OAuth in production, but `localhost` is exempt for dev.
## Deploy it
Static folder. Drop `public/` into any host. GitHub Pages, Netlify, Vercel, Cloudflare Pages, S3 — all fine. Make sure HTTPS is on, and add your deployed URL to the Google Cloud OAuth client's authorized origins.
## Layout
```
public/
index.html — entry point + setup banner
app.js — UI glue
goostr.js — library: signIn(), restoreSession(), clearSession()
drive.js — Google Drive App Data helpers
relay.js — minimal nostr WebSocket pool with replay-on-late-connect
style.css — dark UI
config.js — CLIENT_ID + relay list (edit this)
```
## Library use
```js
import { signIn, restoreSession, clearSession } from "./goostr.js";
// On boot, try restoring first (sessionStorage):
let session = restoreSession();
if (!session) {
session = await signIn({
clientId: "1234.apps.googleusercontent.com",
onStatus: (msg) => console.log(msg),
});
}
const { signer, profile } = session;
// signer.sign({ kind: 1, content: "hello nostr", tags: [] })
```

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "goostr-static",
"version": "0.1.0",
"description": "Sign in with Google → derive a nostr identity from a key stored in your Drive App Data. Pure static site, no server.",
"type": "module",
"main": "public/goostr.js",
"exports": {
".": "./public/goostr.js"
},
"files": ["public/goostr.js", "public/drive.js"],
"peerDependencies": {
"nostr-tools": "^2.7.0"
},
"scripts": {
"serve": "npx --yes serve public -l 5174"
},
"keywords": ["nostr", "google", "oauth", "drive-appdata", "static"],
"license": "MIT"
}

216
public/app.js Normal file
View File

@ -0,0 +1,216 @@
import { CLIENT_ID } from "./config.js";
import {
signIn,
restoreSession,
clearSession,
isConfigured,
} from "./goostr.js";
import { pool } from "./relay.js";
const $ = (id) => document.getElementById(id);
const views = ["loading", "signin", "home"];
function show(view) {
for (const v of views) $(`view-${v}`).classList.toggle("hidden", v !== view);
}
function setStatus(id, msg, kind = "") {
const el = $(id);
el.textContent = msg;
el.className = "status" + (kind ? " " + kind : "");
}
let signer = null;
let currentProfile = null;
let publishedProfileTs = 0; // most-recent kind-0 we've published or rendered
const DEFAULT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<rect width='64' height='64' rx='32' fill='#1e2128'/>
<text x='32' y='42' font-size='32' text-anchor='middle' font-family='sans-serif'>👤</text>
</svg>`
)}`;
// ---------- sign-in ----------
$("signin-go").addEventListener("click", async () => {
$("signin-go").disabled = true;
try {
const { signer: s, profile, isNewIdentity } = await signIn({
clientId: CLIENT_ID,
onStatus: (msg) => setStatus("signin-status", msg),
});
signer = s;
currentProfile = profile;
setStatus("signin-status", "Connecting to relays…");
const unsubStatus = pool.onStatus((snap) => {
const open = Object.values(snap).filter((s) => s === "open").length;
setStatus("signin-status", `Connecting to relays… ${open}/${Object.keys(snap).length}`);
});
await pool.connectAll({ minRelays: 1 });
unsubStatus();
if (isNewIdentity) {
// First time: publish a kind-0 with the Google name + picture so this
// user shows up properly on nostr.
publishProfileEvent(profile);
}
enterHome(profile);
} catch (err) {
console.error(err);
setStatus("signin-status", err.message || String(err), "error");
$("signin-go").disabled = false;
}
});
$("sign-out").addEventListener("click", () => {
clearSession();
signer = null;
location.reload();
});
// ---------- home / live profile / notes ----------
function publishProfileEvent(profile) {
const event = signer.sign({
kind: 0,
content: JSON.stringify({
name: profile.name,
picture: profile.picture,
// `nip05` would be nice here but we don't host one
}),
tags: [],
});
pool.publish(event);
publishedProfileTs = event.created_at;
}
function enterHome(profile) {
$("me-name").textContent = profile.name || "anonymous";
$("me-pic").src = profile.picture || DEFAULT_AVATAR;
$("me-npub").textContent = signer.npub;
show("home");
pool.onStatus(renderRelayDots);
startLiveProfile(signer.pubkey, profile);
loadNotes();
}
function renderRelayDots(snap) {
const host = $("relay-dots");
host.innerHTML = "";
for (const [url, state] of Object.entries(snap)) {
const dot = document.createElement("span");
dot.className = `relay-dot ${state}`;
dot.title = `${url} — ${state}`;
host.appendChild(dot);
}
}
function safeJSON(s) { try { return JSON.parse(s); } catch { return {}; } }
function startLiveProfile(pubkey, googleProfile) {
// Long-lived sub for the latest kind-0. If a relay has a newer event than
// what's on screen, upgrade. If no relay returns anything within a few
// seconds, our google-side profile may not have been published anywhere —
// republish to make sure it lands.
let sawOne = false;
pool.subscribe(
[{ kinds: [0], authors: [pubkey] }],
(ev) => {
sawOne = true;
if (ev.created_at <= publishedProfileTs) return;
const p = safeJSON(ev.content);
publishedProfileTs = ev.created_at;
if (p.name) $("me-name").textContent = p.name;
if (p.picture) $("me-pic").src = p.picture;
},
null
);
setTimeout(() => {
if (!sawOne && googleProfile?.name) {
// No relay had our profile. Publish it now.
publishProfileEvent(googleProfile);
}
}, 6000);
}
let notesById = new Map();
function loadNotes() {
const tbody = $("notes-table").querySelector("tbody");
tbody.innerHTML = "";
setStatus("notes-status", "Loading…");
notesById = new Map();
pool.subscribe(
[{ kinds: [1], authors: [signer.pubkey], limit: 50 }],
(ev) => {
if (notesById.has(ev.id)) return;
notesById.set(ev.id, ev);
renderRows([...notesById.values()], tbody);
setStatus("notes-status", "");
},
null
);
setTimeout(() => {
if (notesById.size === 0) {
setStatus("notes-status", "No notes yet. Publish one above.");
}
}, 8000);
}
function renderRows(rows, tbody) {
const sorted = [...rows].sort((a, b) => b.created_at - a.created_at);
tbody.innerHTML = "";
for (const ev of sorted) {
const tr = document.createElement("tr");
const t = document.createElement("td");
t.textContent = new Date(ev.created_at * 1000).toLocaleString();
const c = document.createElement("td");
c.textContent = ev.content;
tr.appendChild(t);
tr.appendChild(c);
tbody.appendChild(tr);
}
}
$("note-send").addEventListener("click", () => {
const text = $("note-text").value.trim();
if (!text) return;
$("note-send").disabled = true;
const event = signer.sign({ kind: 1, content: text, tags: [] });
pool.publish(event);
$("note-text").value = "";
$("note-send").disabled = false;
notesById.set(event.id, event);
renderRows([...notesById.values()], $("notes-table").querySelector("tbody"));
setStatus("notes-status", "");
});
// ---------- boot ----------
(async function boot() {
if (!isConfigured(CLIENT_ID)) {
$("setup-banner").classList.remove("hidden");
show("signin");
setStatus("signin-status", "Configure CLIENT_ID in public/config.js first.", "error");
$("signin-go").disabled = true;
return;
}
const restored = restoreSession();
if (restored) {
signer = restored.signer;
currentProfile = restored.profile;
show("loading");
$("loading-text").textContent = "Welcome back. Connecting to relays…";
pool.onStatus(renderRelayDots);
await pool.connectAll({ minRelays: 1 });
enterHome(restored.profile);
return;
}
show("signin");
})();

17
public/config.js Normal file
View File

@ -0,0 +1,17 @@
// Goostr config. Edit this file with your Google OAuth client ID before
// running the app. See README.md for how to get one.
//
// Format: "1234567890-abcdef.apps.googleusercontent.com"
export const CLIENT_ID = "114166903886-pd1lb3k10o66qacn10smdnd63k64vont.apps.googleusercontent.com";
// Public nostr relays to publish to and read from.
export const RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://offchain.pub",
"wss://relay.nostr.band",
"wss://nostr.mom",
];

77
public/drive.js Normal file
View File

@ -0,0 +1,77 @@
// Google Drive AppData helpers. Files written to the appDataFolder space are
// invisible to the user in the regular Drive UI and only readable by the
// OAuth client (i.e. *this* app's client_id) that created them.
//
// Docs: https://developers.google.com/drive/api/guides/appdata
const DRIVE = "https://www.googleapis.com/drive/v3";
const UPLOAD = "https://www.googleapis.com/upload/drive/v3";
async function driveFetch(token, url, init = {}) {
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init.headers || {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Drive API ${res.status}: ${text.slice(0, 300)}`);
}
return res;
}
// Find a file by exact name inside the appDataFolder. Returns the file id, or
// null if not found.
export async function findAppDataFile(token, name) {
const url =
`${DRIVE}/files?spaces=appDataFolder` +
`&q=${encodeURIComponent(`name = '${name.replace(/'/g, "\\'")}'`)}` +
`&fields=files(id,name,modifiedTime)`;
const res = await driveFetch(token, url);
const { files } = await res.json();
if (!files || files.length === 0) return null;
// If somehow multiple, prefer the most recent.
files.sort((a, b) => (b.modifiedTime || "").localeCompare(a.modifiedTime || ""));
return files[0].id;
}
export async function downloadFile(token, fileId) {
const res = await driveFetch(token, `${DRIVE}/files/${fileId}?alt=media`);
return res.text();
}
// Multipart upload of a small JSON blob to appDataFolder. If fileId is given,
// updates that file (preserving parents); otherwise creates a new file.
export async function uploadAppDataJson(token, name, content, fileId = null) {
const boundary = "goostr-" + Math.random().toString(36).slice(2);
const metadata = fileId
? { name }
: { name, parents: ["appDataFolder"] };
const body =
`--${boundary}\r\n` +
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
`${JSON.stringify(metadata)}\r\n` +
`--${boundary}\r\n` +
`Content-Type: application/json\r\n\r\n` +
`${content}\r\n` +
`--${boundary}--`;
const url = fileId
? `${UPLOAD}/files/${fileId}?uploadType=multipart`
: `${UPLOAD}/files?uploadType=multipart`;
const res = await driveFetch(token, url, {
method: fileId ? "PATCH" : "POST",
headers: { "Content-Type": `multipart/related; boundary=${boundary}` },
body,
});
return res.json();
}
export async function fetchUserInfo(token) {
const res = await driveFetch(token, "https://www.googleapis.com/oauth2/v3/userinfo");
return res.json(); // { sub, name, picture, email, ... }
}

194
public/goostr.js Normal file
View File

@ -0,0 +1,194 @@
// Goostr — Google-account-to-nostr login library.
//
// Flow:
// signIn({ clientId })
// → opens Google sign-in popup
// → requests OAuth scopes: profile, email, drive.appdata
// → looks in Drive App Data for goostr-key-v1.json
// · if found, downloads the 32-byte seed
// · if missing, generates 32 fresh bytes and uploads them
// → returns { signer, profile } where profile is the Google name/picture/email
//
// The 32-byte seed is the nostr private key. It lives in:
// 1. The user's Drive App Data — survives device loss, syncs across devices.
// 2. sessionStorage for the current tab — survives reload, dies on tab close.
// It is never written to localStorage, IndexedDB, or any other disk-persistent
// store on the local device.
import { getPublicKey, finalizeEvent, nip19 } from "nostr-tools";
import {
findAppDataFile,
downloadFile,
uploadAppDataJson,
fetchUserInfo,
} from "./drive.js";
const KEY_FILE = "goostr-key-v1.json";
const SCOPES = [
"https://www.googleapis.com/auth/drive.appdata",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"openid",
].join(" ");
// ---------- Google Identity Services token client ----------
let gisReady = null;
function ensureGisLoaded() {
if (gisReady) return gisReady;
gisReady = new Promise((resolve, reject) => {
if (window.google?.accounts?.oauth2) return resolve();
const s = document.createElement("script");
s.src = "https://accounts.google.com/gsi/client";
s.async = true;
s.defer = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error("Failed to load Google Identity Services script."));
document.head.appendChild(s);
});
return gisReady;
}
async function requestAccessToken(clientId, { silent = false } = {}) {
await ensureGisLoaded();
return new Promise((resolve, reject) => {
const client = google.accounts.oauth2.initTokenClient({
client_id: clientId,
scope: SCOPES,
prompt: silent ? "" : "consent",
callback: (resp) => {
if (resp.error) return reject(new Error(`Google auth: ${resp.error}`));
if (!resp.access_token) return reject(new Error("Google auth: no access token returned."));
resolve(resp.access_token);
},
error_callback: (err) => reject(new Error(`Google auth: ${err.type || "error"}`)),
});
client.requestAccessToken();
});
}
// ---------- key bytes / signer ----------
function randomBytes(len) {
const buf = new Uint8Array(len);
crypto.getRandomValues(buf);
return buf;
}
function b64encBytes(bytes) {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
function b64decBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function makeSigner(privKeyBytes) {
const pubkey = getPublicKey(privKeyBytes);
return {
pubkey,
npub: nip19.npubEncode(pubkey),
sign(unsignedEvent) {
return finalizeEvent(
{
...unsignedEvent,
pubkey,
created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000),
},
privKeyBytes
);
},
};
}
// ---------- session persistence (tab-scoped) ----------
const SESSION_KEY = "goostr.session.v1";
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling
function saveSession(privKeyBytes, profile) {
try {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({ k: b64encBytes(privKeyBytes), p: profile, t: Date.now() })
);
} catch {}
}
export function restoreSession() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
const { k, p, t } = JSON.parse(raw);
if (!k || !t || Date.now() - t > SESSION_MAX_AGE_MS) {
sessionStorage.removeItem(SESSION_KEY);
return null;
}
return { signer: makeSigner(b64decBytes(k)), profile: p || {} };
} catch {
return null;
}
}
export function clearSession() {
try { sessionStorage.removeItem(SESSION_KEY); } catch {}
}
// ---------- key file on Drive ----------
async function loadOrCreateSeed(accessToken, onStatus) {
onStatus?.("Looking for your key in Google Drive…");
const fileId = await findAppDataFile(accessToken, KEY_FILE);
if (fileId) {
onStatus?.("Found existing key. Unlocking your nostr identity…");
const text = await downloadFile(accessToken, fileId);
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = null; }
if (!parsed?.k) throw new Error("Stored key file is malformed.");
return { bytes: b64decBytes(parsed.k), created: false };
}
onStatus?.("First time on this Google account — generating a new identity…");
const bytes = randomBytes(32);
const payload = JSON.stringify({
v: 1,
k: b64encBytes(bytes),
created_at: new Date().toISOString(),
});
await uploadAppDataJson(accessToken, KEY_FILE, payload, null);
return { bytes, created: true };
}
// ---------- public API ----------
export async function signIn({ clientId, silent = false, onStatus } = {}) {
if (!clientId || clientId.startsWith("PASTE-")) {
throw new Error("CLIENT_ID is not configured. Edit public/config.js with your Google OAuth client ID. See README.md.");
}
onStatus?.("Signing in with Google…");
const accessToken = await requestAccessToken(clientId, { silent });
onStatus?.("Reading your Google profile…");
const userInfo = await fetchUserInfo(accessToken);
const profile = {
sub: userInfo.sub,
name: userInfo.name || userInfo.email || "Anonymous",
picture: userInfo.picture || null,
email: userInfo.email || null,
};
const { bytes, created } = await loadOrCreateSeed(accessToken, onStatus);
saveSession(bytes, profile);
return { signer: makeSigner(bytes), profile, isNewIdentity: created };
}
export function isConfigured(clientId) {
return !!clientId && !clientId.startsWith("PASTE-") && clientId.endsWith(".apps.googleusercontent.com");
}

85
public/index.html Normal file
View File

@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Goostr — Google to nostr</title>
<link rel="stylesheet" href="./style.css" />
<script type="importmap">
{
"imports": {
"nostr-tools": "https://esm.sh/nostr-tools@2.7.2"
}
}
</script>
</head>
<body>
<main>
<header>
<h1>Goostr</h1>
<p class="tag">Your Google account is your nostr identity.</p>
</header>
<div id="setup-banner" class="banner-warn hidden">
<strong>Setup required.</strong> Edit <code>public/config.js</code> and
paste your Google OAuth client ID into <code>CLIENT_ID</code>. See
<a href="./README.md">README</a> for the 2-minute setup.
</div>
<section id="view-loading" class="view">
<p id="loading-text">Checking session…</p>
</section>
<!-- Sign in -->
<section id="view-signin" class="view hidden">
<h2>Create or unlock your nostr identity</h2>
<p style="color: var(--muted); font-size: 14px; margin-top: -8px;">
We'll save a secret key in your Google Drive's app data — a private
folder only this app can read. The key is the seed for your nostr
identity. Sign in on any device to use the same identity.
</p>
<button id="signin-go" class="google">
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Sign in with Google
</button>
<p id="signin-status" class="status"></p>
</section>
<!-- Logged in -->
<section id="view-home" class="view hidden">
<div class="profile">
<img id="me-pic" alt="" />
<div>
<h2 id="me-name"></h2>
<p class="npub" id="me-npub"></p>
</div>
<button id="sign-out" class="ghost">Sign out</button>
</div>
<div class="composer">
<textarea id="note-text" placeholder="Write something. It will be signed by your nostr key and published." maxlength="500"></textarea>
<button id="note-send" class="primary">Publish</button>
</div>
<div class="relays-bar">
<span class="label">Relays</span>
<span id="relay-dots"></span>
</div>
<h3>Your logs</h3>
<table id="notes-table">
<thead><tr><th>When</th><th>Note</th></tr></thead>
<tbody></tbody>
</table>
<p id="notes-status" class="status">Loading…</p>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>

210
public/relay.js Normal file
View File

@ -0,0 +1,210 @@
// Minimal nostr relay pool — fast-start, fault-tolerant.
//
// Design:
// * Open all relays in parallel, each with a 4s connect timeout.
// * connectAll() resolves as soon as the FIRST relay opens (or after the
// overall timeout). The rest keep opening in the background.
// * Late-arriving relays automatically replay anything we've already sent
// (active subscriptions + recent publishes), so the user never waits.
// * Per-relay status is exposed via onStatus() so the UI can show progress.
import { RELAYS } from "./config.js";
const PER_RELAY_CONNECT_TIMEOUT_MS = 4000;
const OVERALL_CONNECT_TIMEOUT_MS = 4500; // hard ceiling for connectAll()
const PUBLISH_REPLAY_WINDOW_MS = 60_000; // re-send recent publishes to late relays
class RelayPool {
constructor(urls = RELAYS) {
this.urls = urls;
this.sockets = new Map(); // url -> WebSocket (only OPEN ones)
this.status = new Map(); // url -> "connecting"|"open"|"closed"|"timeout"
this.subs = new Map(); // subId -> { req, handler }
this.recentPublishes = []; // { msg, t }
this.subId = 0;
this._listeners = new Set(); // status change listeners
}
onStatus(fn) {
this._listeners.add(fn);
fn(this.statusSnapshot());
return () => this._listeners.delete(fn);
}
statusSnapshot() {
const out = {};
for (const u of this.urls) out[u] = this.status.get(u) || "idle";
return out;
}
_notify() {
const snap = this.statusSnapshot();
for (const fn of this._listeners) fn(snap);
}
_open(url) {
return new Promise((resolve) => {
let settled = false;
const finish = (ok) => {
if (settled) return;
settled = true;
resolve(ok);
};
let ws;
try {
ws = new WebSocket(url);
} catch {
this.status.set(url, "closed");
this._notify();
return finish(false);
}
this.status.set(url, "connecting");
this._notify();
const timer = setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
this.status.set(url, "timeout");
this._notify();
try { ws.close(); } catch {}
finish(false);
}
}, PER_RELAY_CONNECT_TIMEOUT_MS);
ws.addEventListener("open", () => {
clearTimeout(timer);
const t0 = performance.now();
this.sockets.set(url, ws);
this.status.set(url, "open");
this._notify();
console.log(`[relay] open ${url} (${Math.round(t0)}ms since page)`);
// Replay active subscriptions and recent publishes to this new relay.
for (const { req } of this.subs.values()) {
try { ws.send(req); } catch {}
}
const cutoff = Date.now() - PUBLISH_REPLAY_WINDOW_MS;
for (const { msg, t } of this.recentPublishes) {
if (t >= cutoff) {
try { ws.send(msg); } catch {}
}
}
finish(true);
});
ws.addEventListener("error", () => {
clearTimeout(timer);
if (this.status.get(url) !== "open") this.status.set(url, "closed");
this._notify();
finish(false);
});
ws.addEventListener("close", () => {
clearTimeout(timer);
this.sockets.delete(url);
if (this.status.get(url) !== "timeout") this.status.set(url, "closed");
this._notify();
finish(false);
});
// Wire messages to active subscription handlers.
ws.addEventListener("message", (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
const subId = msg[1];
const sub = this.subs.get(subId);
if (sub) sub.handler(msg, url);
});
});
}
// Resolves as soon as we have at least `minRelays` open, or after the
// overall timeout. Always returns the number of relays that opened.
async connectAll({ minRelays = 1 } = {}) {
const attempts = this.urls.map((u) => this._open(u));
let opened = 0;
return new Promise((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
resolve(this.sockets.size);
};
const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS);
attempts.forEach((p) =>
p.then((ok) => {
if (ok) opened++;
if (opened >= minRelays) {
clearTimeout(ceiling);
// Resolve quickly once we hit the threshold; other relays keep
// opening in the background and self-register via _open().
finish();
}
if (opened === 0 && attempts.every((_) => false)) {
/* unreachable; here for clarity */
}
})
);
// If every attempt fully settled with zero opens, resolve too.
Promise.allSettled(attempts).then(() => finish());
});
}
publish(event) {
const msg = JSON.stringify(["EVENT", event]);
this.recentPublishes.push({ msg, t: Date.now() });
// trim
if (this.recentPublishes.length > 200) this.recentPublishes.shift();
for (const ws of this.sockets.values()) {
if (ws.readyState === WebSocket.OPEN) {
try { ws.send(msg); } catch {}
}
}
}
// filters: array of REQ filters. onEvent(event) per match. onEose() once
// every currently-open relay has signaled EOSE.
subscribe(filters, onEvent, onEose) {
const id = `s${++this.subId}`;
const req = JSON.stringify(["REQ", id, ...filters]);
const seen = new Set();
const eoseSet = new Set();
const handler = (msg, url) => {
if (msg[0] === "EVENT" && msg[1] === id) {
const e = msg[2];
if (seen.has(e.id)) return;
seen.add(e.id);
onEvent(e);
} else if (msg[0] === "EOSE" && msg[1] === id) {
eoseSet.add(url);
if (eoseSet.size >= this.sockets.size && onEose) onEose();
}
};
this.subs.set(id, { req, handler });
for (const ws of this.sockets.values()) {
if (ws.readyState === WebSocket.OPEN) {
try { ws.send(req); } catch {}
}
}
return () => {
const close = JSON.stringify(["CLOSE", id]);
for (const ws of this.sockets.values()) {
if (ws.readyState === WebSocket.OPEN) {
try { ws.send(close); } catch {}
}
}
this.subs.delete(id);
};
}
}
export const pool = new RelayPool();

258
public/style.css Normal file
View File

@ -0,0 +1,258 @@
:root {
--bg: #0e0f12;
--panel: #16181d;
--panel-2: #1e2128;
--text: #e8e8ea;
--muted: #8b8f99;
--accent: #8b5cf6;
--accent-2: #6d28d9;
--border: #262932;
--danger: #ef4444;
--ok: #10b981;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
}
main {
max-width: 620px;
margin: 0 auto;
padding: 48px 20px 80px;
}
header { margin-bottom: 32px; }
h1 {
font-size: 32px;
font-weight: 700;
margin: 0 0 4px;
letter-spacing: -0.02em;
background: linear-gradient(90deg, #4285f4, #34a853, #fbbc04, #ea4335);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tag { color: var(--muted); margin: 0; }
.view {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px;
}
.view + .view { margin-top: 16px; }
.hidden { display: none !important; }
h2 { margin: 0 0 16px; font-size: 20px; font-weight: 600; }
h3 { margin: 24px 0 8px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.field { display: block; margin-bottom: 20px; }
.field > span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
input[type="text"], textarea {
width: 100%;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
font-size: 15px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
input[type="text"]:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 84px; }
.picker {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
margin-bottom: 10px;
}
.picker button {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 0;
font-size: 22px;
cursor: pointer;
transition: transform 0.1s, border-color 0.1s;
}
.picker button:hover { transform: scale(1.08); }
.picker button.selected { border-color: var(--accent); background: rgba(139, 92, 246, 0.12); }
.upload {
display: inline-block;
font-size: 13px;
color: var(--accent);
cursor: pointer;
margin-bottom: 10px;
}
.upload:hover { text-decoration: underline; }
.preview img {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--panel-2);
object-fit: cover;
display: block;
}
.preview img:empty, .preview img[src=""] { display: none; }
button {
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
button.primary {
background: linear-gradient(180deg, var(--accent), var(--accent-2));
color: white;
border: none;
border-radius: 10px;
padding: 12px 18px;
font-weight: 600;
width: 100%;
transition: filter 0.15s, transform 0.05s;
}
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button.primary:active:not(:disabled) { transform: translateY(1px); }
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
button.google {
background: white;
color: #1f1f1f;
border: 1px solid #dadce0;
border-radius: 10px;
padding: 12px 18px;
font-weight: 500;
font-size: 15px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
button.google:hover:not(:disabled) { background: #f6f9ff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
button.google:active:not(:disabled) { background: #ebf2ff; }
button.google:disabled { opacity: 0.6; cursor: not-allowed; }
button.google svg { width: 18px; height: 18px; flex-shrink: 0; }
.banner-warn {
background: rgba(234, 67, 53, 0.1);
border: 1px solid rgba(234, 67, 53, 0.4);
color: #ffd2cd;
border-radius: 10px;
padding: 14px 16px;
font-size: 13px;
margin-bottom: 16px;
line-height: 1.55;
}
.banner-warn code {
background: rgba(0,0,0,0.35);
border-radius: 4px;
padding: 1px 6px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
}
.banner-warn a { color: #ffe; }
button.ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 12px;
}
button.ghost:hover { color: var(--text); border-color: var(--muted); }
.status {
font-size: 13px;
color: var(--muted);
margin: 12px 0 0;
min-height: 18px;
}
.status.error { color: var(--danger); }
.status.ok { color: var(--ok); }
.secondary { font-size: 13px; color: var(--muted); margin-top: 18px; }
.secondary a { color: var(--accent); text-decoration: none; }
.secondary a:hover { text-decoration: underline; }
.profile {
display: grid;
grid-template-columns: 64px 1fr auto;
gap: 14px;
align-items: center;
margin-bottom: 24px;
}
.profile img {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--panel-2);
object-fit: cover;
}
.profile h2 { margin: 0; }
.profile .npub {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--muted);
margin: 2px 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 360px;
}
.composer { display: grid; gap: 10px; margin-bottom: 8px; }
.relays-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 18px;
font-size: 12px;
color: var(--muted);
}
.relays-bar .label { text-transform: uppercase; letter-spacing: 0.05em; }
.relay-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
background: var(--border);
cursor: help;
transition: background 0.2s;
}
.relay-dot.open { background: var(--ok); }
.relay-dot.connecting { background: #facc15; animation: pulse 1s infinite; }
.relay-dot.timeout, .relay-dot.closed { background: var(--danger); }
@keyframes pulse { 50% { opacity: 0.4; } }
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
td:first-child { color: var(--muted); white-space: nowrap; width: 140px; font-variant-numeric: tabular-nums; }
tbody tr:last-child td { border-bottom: none; }