Foxsky OG Server #
A standalone Node.js server that generates OpenGraph / Twitter Card meta tags for Foxsky links, enabling rich embeds in Telegram, Discord, Twitter, and other platforms.
Why? #
Social platform crawlers (Telegram, Discord, Twitter/X, Facebook, etc.) do not execute JavaScript. Foxsky is a client-side SPA, so when a crawler visits a /profile/x or /post/x/y URL, it sees an empty page with no metadata.
This server sits at those same URL paths and returns pre-rendered HTML with the correct <meta og:*> and <meta twitter:*> tags, plus a tiny <script> that redirects real browsers to the SPA.
Architecture #
- No bsky services / no CDN — All data is fetched directly from the user's PDS via XRPC.
- DID resolution via
plc.directory(fordid:plc) or/.well-known/did.json(fordid:web). - Direct blob URLs — Post/image embeds link directly to the PDS
com.atproto.sync.getBlobendpoint; no proxy needed. - Profile image compositing at
/profile-img/:actorrenders a banner + circular avatar composite using Sharp. - In-memory caches for DID→PDS, handle→DID, and composite images.
Routes #
| Route | Description |
|---|---|
GET /profile/:actor |
OG tags for a profile (composite image, display name, bio) |
GET /profile-img/:actor |
Rendered composite profile image (banner + circular PFP) |
GET /post/:actor/:rkey |
OG tags for a post (text content, images) |
GET /profile/:actor/post/:rkey |
Alias for /post/:actor/:rkey |
GET /feed/:did/:feedname |
OG tags for a custom feed (name, description, avatar, recent posts) |
:actor can be a handle (e.g., alice.bsky.social) or a DID (e.g., did:plc:xyz).
Setup #
cd og-server
npm install
Running #
# Development (auto-reload)
npm run dev
# Production
npm start
Environment Variables #
| Variable | Default | Description |
|---|---|---|
PORT |
5122 |
Server port |
BASE_URL |
https://foxsky.app |
Public base URL used for og:url and blob proxy URLs |
DISABLE_CACHE |
false |
Set to true to disable internal data caching |
CACHE_TTL |
3600 |
Cache expiration time in seconds |
MAX_CACHE_SIZE |
10000 |
Maximum number of entries in each internal cache |
Deployment #
In production, set BASE_URL to your public domain (e.g., https://foxsky.app). Profile composite images will be served from https://foxsky.app/profile-img/:actor and post embeds link directly to the user's PDS blob endpoint.
nginx #
The key idea: only route requests from social-platform crawlers to the OG server. All other traffic should be served by the SPA as normal.
map $http_user_agent $is_embed_bot {
default 0;
~*discordbot 1;
~*twitterbot 1;
~*slackbot 1;
~*facebookexternalhit 1;
~*linkedinbot 1;
~*embedly 1;
~*pinterest 1;
~*telegrambot 1;
~*bsky 1;
~*bluesky 1;
}
upstream embed-backend {
server localhost:5122;
keepalive 16;
}
server {
# ... your server config ...
location / {
if ($is_embed_bot) {
proxy_pass http://embed-backend;
break;
}
# your SPA config
}
}
This way:
- Crawlers (Discord, Telegram, Twitter, etc.) are proxied to the OG server and get static HTML with
<meta>tags. - Real browsers are served the SPA as usual and never hit the OG server.
Example Response #
GET /profile/alice.bsky.social
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta property="og:type" content="profile" />
<meta property="og:title" content="Alice (@alice.bsky.social)" />
<meta property="og:description" content="Building cool stuff on AT Protocol" />
<meta property="og:image" content="https://og.foxsky.app/profile-img/alice.bsky.social" />
<meta name="twitter:card" content="summary_large_image" />
...
</head>
<body>
<script>window.location.replace("...")</script>
</body>
</html>