A Bsky-like frontend using the atprotocol natively.
README.md

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 (for did:plc) or /.well-known/did.json (for did:web).
  • Direct blob URLs — Post/image embeds link directly to the PDS com.atproto.sync.getBlob endpoint; no proxy needed.
  • Profile image compositing at /profile-img/:actor renders 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>