Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scripts/export-tools-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import fs from "node:fs";
import path from "node:path";
import { page, keysPage, poolsPage, analyticsPage, historyPage, playgroundPage, providerPage } from "../src/admin/pages.js";
import { robotsTxt, sitemapXml } from "../src/admin/seo.js";

const ROOT = process.cwd();
const OUT = path.join(ROOT, "tools-site");
Expand Down Expand Up @@ -52,14 +53,18 @@ write("playground/index.html", withShim(playgroundPage()));
// _redirects stays as the catch-all for unknown ids.
const providerShell = withShim(providerPage());
write("provider/index.html", providerShell);
let providerIds: string[] = [];
try {
const registry = JSON.parse(fs.readFileSync(path.join(ROOT, "data", "providers.json"), "utf8")) as { id?: string }[];
const ids = [...new Set(registry.map((p) => p.id).filter((id): id is string => typeof id === "string" && /^[a-z0-9-]+$/.test(id)))];
providerIds = ids;
for (const id of ids) write(`provider/${id}/index.html`, providerShell);
console.log(`provider routes: ${ids.length}`);
} catch (e) {
console.warn("provider pre-render skipped:", (e as Error).message);
}
write("robots.txt", robotsTxt());
write("sitemap.xml", sitemapXml(providerIds));

fs.copyFileSync(SHIM, path.join(OUT, "api-base.js"));
fs.copyFileSync(path.join(ROOT, "src", "logo.png"), path.join(OUT, "logo.png"));
Expand Down
29 changes: 28 additions & 1 deletion src/admin/pages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LOGO_V, DOCS_URL, ICONS } from "./branding.js";
import { HOME_HEAD_EXTRA, SEO_TITLE, subPageHead } from "./seo.js";
import { ADAPTERS } from "../playground/adapters.js";
import { shellCss, shellNav, shellJs, sharedHeader, sharedLayoutCss } from "./shell.js";
import { tokensCss } from "./tokens.js";
Expand Down Expand Up @@ -130,7 +131,8 @@ export const page = `<!doctype html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>pool-anything</title>
<title>${SEO_TITLE}</title>
${HOME_HEAD_EXTRA}
<link rel="icon" type="image/png" href="/logo.png" />
<style>
${tokensCss}
Expand Down Expand Up @@ -236,6 +238,12 @@ export const page = `<!doctype html>
.hero-row, .search-card { max-width:400px; }
.hero-row h1, .hero-row { gap:2px; }
h1 { font-family:var(--font-serif); font-size:clamp(20px, 4vw, 26px); font-weight:500; }
.hero-sub { width:100%; max-width:640px; margin:8px auto 0; text-align:center; font-size:14px; line-height:1.55; color:var(--ink-soft); }
.seo-foot { width:100%; max-width:640px; margin:28px auto 0; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px 20px; font-size:13.5px; line-height:1.6; color:var(--body-copy); }
.seo-foot h2 { font-size:14px; margin:14px 0 6px; color:var(--ink); }
.seo-foot h2:first-child { margin-top:0; }
.seo-foot ul { margin:6px 0; padding-left:18px; display:flex; flex-direction:column; gap:4px; }
.seo-foot a { color:var(--ink); }
.sec-label { font-size:11px; font-weight:600; letter-spacing:.08em; text-transform:uppercase; color:var(--ink-soft); margin:0 0 8px; }
.conn-grid { width:100%; display:flex; flex-direction:column; gap:8px; }
.prov-lg { display:flex; align-items:center; gap:10px; width:100%; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:10px 12px; cursor:pointer; font-size:13px; font-weight:600; text-align:left; animation:fadeSlide .28s cubic-bezier(.2,.7,.3,1) both; }
Expand Down Expand Up @@ -384,6 +392,7 @@ ${docsComponentsCss}
<main>
<div class="wrap">
<div class="hero-row"><img class="hero-logo" src="/logo.png" alt="pool-anything logo" width="40" height="40" /><h1>What do you want to pool</h1></div>
<p class="hero-sub">Pool free-tier API keys and rotate through them — one endpoint, round-robin key rotation, usage &amp; quota tracking for Groq, OpenRouter, Gemini and 30+ providers.</p>
<div class="search-card">
<div class="search-box">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"></path></svg>
Expand Down Expand Up @@ -432,6 +441,18 @@ ${docsComponentsCss}
</div>
</div>
</div>
<section class="seo-foot" aria-label="About pool-anything">
<h2>Pool free-tier API keys into one endpoint</h2>
<p>Most AI and API providers hand you a small free tier — a rate limit, a daily quota, a handful of trial tokens. <strong>pool-anything</strong> gathers N keys for the same provider into a pool, then serves them through a single drop-in HTTP proxy that round-robins across keys and tracks usage per key in SQLite.</p>
<h2>Why pool keys?</h2>
<ul>
<li>Round-robin rotation evens out rate limits across all keys.</li>
<li>Per-key usage, quota windows, cooldown and failover keep pools healthy.</li>
<li>36 preconfigured providers: Groq, OpenRouter, Gemini, OpenAI, Anthropic, ElevenLabs, Tavily, Resend and more — plus any custom provider.</li>
<li>Self-hosted TypeScript + SQLite core with zero-dependency server, web UI, analytics dashboard and playground.</li>
</ul>
<p>Start in <a href="/pools">Pools</a>, gather keys in the <a href="/keys">API key manager</a>, try a pooled key in the <a href="/playground">Playground</a>, or read the <a href="https://pool-anything.pages.dev/docs">docs</a>.</p>
</section>
</div>
</main>
</div>
Expand Down Expand Up @@ -716,6 +737,7 @@ return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>API key manager — pool-anything</title>
${subPageHead("/keys/", "Add, view, edit and rotate pooled API keys per provider with masked storage and per-key usage.")}
<link rel="icon" type="image/png" href="/logo.png" />
<style>
*{box-sizing:border-box}body{margin:0;font-family:var(--font-sans);background:var(--paper-2);color:var(--ink)}
Expand Down Expand Up @@ -852,6 +874,7 @@ return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Analytics — pool-anything</title>
${subPageHead("/analytics/", "Analytics dashboard: total requests, tokens tracked, pools, keys pooled and 14-day usage charts.")}
<link rel="icon" type="image/png" href="/logo.png" />
<style>
*{box-sizing:border-box}body{margin:0;font-family:var(--font-sans);background:var(--paper-2);color:var(--ink)}
Expand Down Expand Up @@ -1000,6 +1023,7 @@ return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Pools — pool-anything</title>
${subPageHead("/pools/", "Pools holding API keys with per-pool usage, key counts and quota status.")}
<link rel="icon" type="image/png" href="/logo.png" />
<style>
*{box-sizing:border-box}body{margin:0;font-family:var(--font-sans);background:var(--paper-2);color:var(--ink)}
Expand Down Expand Up @@ -1062,6 +1086,7 @@ return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>History — pool-anything</title>
${subPageHead("/history/", "History of proxied API calls per pool and key: status, tokens, errors and retained responses.")}
<link rel="icon" type="image/png" href="/logo.png" />
<style>
*{box-sizing:border-box}body{margin:0;font-family:var(--font-sans);background:var(--paper-2);color:var(--ink)}
Expand Down Expand Up @@ -1195,6 +1220,7 @@ return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Playground — pool-anything</title>
${subPageHead("/playground/", "Chat, image, audio and video playground to try a pooled API key across providers.")}
<link rel="icon" type="image/png" href="/logo.png" />
<style>
*{box-sizing:border-box}body{margin:0;font-family:var(--font-sans);background:var(--paper-2);color:var(--ink)}
Expand Down Expand Up @@ -2252,6 +2278,7 @@ return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Provider — pool-anything</title>
${subPageHead("/provider/", "Gather API keys for one provider, see per-key usage and copy a pooled proxy snippet.")}
<link rel="icon" type="image/png" href="/logo.png" />
<style>
*{box-sizing:border-box}body{margin:0;font-family:var(--font-sans);background:var(--paper-2);color:var(--ink)}
Expand Down
67 changes: 67 additions & 0 deletions src/admin/seo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/** Shared SEO constants for the public landing (tools-site) + local app.
* Canonical host is the hosted tools UI; local / just reuses the same tags
* so exported static HTML is already optimized with no per-env branching. */

export const SITE_URL = "https://pool-anything-tools.pages.dev";
export const SITE_NAME = "pool-anything";
export const SEO_TITLE = "pool-anything — Pool Free-Tier API Keys, Rotate & Track Usage";
export const SEO_DESCRIPTION =
"Gather free-tier API keys into one pool and rotate them. Round-robin proxy, per-key usage & quota tracking, 36 providers, self-hosted TypeScript + SQLite.";
export const SEO_OG_IMAGE = `${SITE_URL}/logo.png`;

export const HOME_HEAD_EXTRA = `<meta name="description" content="${SEO_DESCRIPTION}" />
<link rel="canonical" href="${SITE_URL}/" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="author" content="Parithosh Varma" />
<meta name="theme-color" content="#ffffff" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="${SITE_NAME}" />
<meta property="og:title" content="${SEO_TITLE}" />
<meta property="og:description" content="${SEO_DESCRIPTION}" />
<meta property="og:url" content="${SITE_URL}/" />
<meta property="og:image" content="${SEO_OG_IMAGE}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="${SEO_TITLE}" />
<meta name="twitter:description" content="${SEO_DESCRIPTION}" />
<meta name="twitter:image" content="${SEO_OG_IMAGE}" />
<script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"pool-anything","applicationCategory":"DeveloperApplication","operatingSystem":"Any","url":"${SITE_URL}/","description":"${SEO_DESCRIPTION}","license":"https://www.apache.org/licenses/LICENSE-2.0","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"author":{"@type":"Person","name":"Parithosh Varma"}}</script>`;

/** Minimal per-page head (description + canonical) for app sub-pages. */
export function subPageHead(path: string, description: string): string {
const url = `${SITE_URL}${path}`;
return `<meta name="description" content="${description}" />
<link rel="canonical" href="${url}" />
<meta name="robots" content="index, follow" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="${SITE_NAME}" />
<meta property="og:url" content="${url}" />
<meta property="og:image" content="${SEO_OG_IMAGE}" />
<meta name="twitter:card" content="summary" />`;
}

export function robotsTxt(): string {
return `User-agent: *
Allow: /
Sitemap: ${SITE_URL}/sitemap.xml
`;
}

const STATIC_ROUTES = ["/", "/pools", "/keys", "/playground", "/analytics", "/history"];

export function sitemapXml(providerIds: string[]): string {
const urls = new Set<string>(STATIC_ROUTES);
for (const id of providerIds) {
if (/^[a-z0-9-]+$/.test(id)) urls.add(`/provider/${id}/`);
}
const withSlash = [...urls].map((p) => (p === "/" ? "/" : p.endsWith("/") ? p : `${p}/`));
const today = new Date().toISOString().slice(0, 10);
const entries = withSlash
.map(
(p) =>
` <url><loc>${SITE_URL}${p}</loc><lastmod>${today}</lastmod><changefreq>${p === "/" ? "weekly" : "monthly"}</changefreq><priority>${p === "/" ? "1.0" : p.startsWith("/provider/") ? "0.6" : "0.8"}</priority></url>`
)
.join("\n");
// Note: provider routes are exported as /provider/:id/index.html so the
// trailing-slash form is canonical on Pages.
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>\n`;
}
12 changes: 12 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { sdb, PROVIDERS, mask, maskCredentials, normalizeCredentials, parseCrede
import { nextKey, recordUsage } from "./pool/rotation.js";
import { forward, MAX_TOKENS_PER_REQUEST, checkTarget } from "./proxy/forward.js";
import { DOCS_URL } from "./admin/branding.js";
import { robotsTxt, sitemapXml } from "./admin/seo.js";
import { page, keysPage, poolsPage, analyticsPage, historyPage, playgroundPage, providerPage } from "./admin/pages.js";
import { MAX_POOLS, MAX_KEYS_PER_POOL, MAX_API_KEY_CHARS, matchPoolRoute, matchKeyRoute } from "./router/api.js";
import { health, readiness, analyticsSnapshot } from "./observability/health.js";
Expand Down Expand Up @@ -390,6 +391,17 @@ const server = http.createServer(async (req, res) => {
res.end(h.body);
return;
}
if (req.method === "GET" && url.pathname === "/robots.txt") {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600" });
res.end(robotsTxt());
return;
}
if (req.method === "GET" && url.pathname === "/sitemap.xml") {
const ids = PROVIDERS.map((p) => (p as { id?: string }).id).filter((id): id is string => typeof id === "string");
res.writeHead(200, { "content-type": "application/xml; charset=utf-8", "cache-control": "public, max-age=3600" });
res.end(sitemapXml(ids));
return;
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found\n");
} catch (e) {
Expand Down
Loading