A simple Ruby server using Sinatra that serves Bluesky custom feeds
at master 344 lines 17 kB view raw view rendered
1# BlueFactory 🏭 2 3A Ruby gem for hosting custom feeds for Bluesky. 4 5> [!NOTE] 6> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue) 7 8 9## What does it do 10 11BlueFactory is a Ruby library which helps you build a web service that hosts custom feeds a.k.a. "[feed generators](https://github.com/bluesky-social/feed-generator)" for the Bluesky social network. It implements a simple HTTP server based on [Sinatra](https://sinatrarb.com) which provides the required endpoints for the feed generator interface. You need to provide the content for the feed by making a query to your preferred local database. 12 13A feed server will usually be run together with a second piece of code that streams posts from the Bluesky "firehose" stream, runs them through some kind of filter and saves some or all of them to the database. To build that part, you can use my other Ruby gem [Skyfall](https://tangled.org/mackuba.eu/skyfall). 14 15 16## Installation 17 18BlueFactory should run on any somewhat recent version of Ruby (3.x/4.x), although it's recommended to use one that's still getting maintenance updates, ideally the latest one. In production, it's also recommended to install it with [YJIT support](https://shopify.engineering/ruby-yjit-is-production-ready) and with [jemalloc](https://scalingo.com/blog/improve-ruby-application-memory-jemalloc). A compatible version should be preinstalled on many Linux systems, otherwise you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)). 19 20To use it in your app, add this to your `Gemfile`: 21 22 gem 'blue_factory', '~> 0.2' 23 24 25## Usage 26 27The server is configured through the `BlueFactory` module. The two required settings are: 28 29- `publisher_did` – DID identifier of the account that you will publish the feed on (the string that starts with `did:plc:...`) 30- `hostname` – the hostname on which the feed service will be run 31 32You also need to configure at least one feed by passing a feed key and a feed object. The key is the identifier (rkey) that will appear at the end of the feed URI – it must only contain characters that are valid in URLs (preferably all lowercase) and it should be rather short. The feed object is anything that implements the single required method `get_posts` (could be a class, a module or an instance). 33 34So a simple setup could look like this: 35 36```rb 37require 'blue_factory' 38 39BlueFactory.set :publisher_did, 'did:plc:loremipsumqwerty' 40BlueFactory.set :hostname, 'feeds.example.com' 41 42BlueFactory.add_feed 'starwars', StarWarsFeed.new 43``` 44 45 46## The feed API 47 48The `get_posts` method of the feed object should: 49 50- accept a `params` argument which is a hash with fields: `:feed`, `:cursor` and `:limit` (the last two are optional) 51- optionally, it can accept a second `context` argument with additional info like the authenticated user's DID (see "[Authentication](#authentication)") 52- return a response hash with the posts data, with at least one key `:posts` 53 54 55### Parameters 56 57The `:feed` is the `at://` URI of the feed. 58 59The `:cursor` param, if included, should be a cursor returned earlier by your feed from one of the previous requests, so it should be in the format used by the same function – but anyone can call the endpoint with any params, so you should validate it. The cursor is used for pagination to provide more pages further down in the feed (the first request to load the top of the feed doesn't include a cursor). 60 61The `:limit`, if included, should be a numeric value specifying the number of posts to return, and you should return at most that many posts in response. According to the spec, the maximum allowed value for the limit is 100, but again, you should verify this. The default limit is 50. 62 63### Response 64 65The `:posts` in the response hash that you return should be an array of URIs of posts. You only return the URI of a post to the Bluesky server, not all contents of the post like text and embed data – the server will "hydrate" the posts with all the other data from its own database. 66 67The posts in the `get_posts` response from your feed object can be either: 68 69- strings with the `at://` URI of a post 70- hashes with the URI in the `:post` field and additional metadata 71 72A combination of both is also allowed – some posts can be returned as URI strings, and some as hashes. 73 74A response hash should also include a `:cursor`, which is some kind of string that encodes the offset in the feed, which will be passed back to you in a request for the next page. The structure of the cursor is something for you to decide, and it could possibly be a very long string (the actual length limit is uncertain). See the readme of the official [feed-generator repo](https://github.com/bluesky-social/feed-generator#pagination) for some guidelines on how to construct cursor strings. In practice, it's usually some combination of a timestamp in some form and/or an internal record ID, possibly with some separator like `:` or `-`. 75 76The response can also include a `:req_id`, which is a "request ID" assigned to this specific request (again, the form of which is decided by you), which may be useful for processing [interactions](#handling-feed-interactions). 77 78#### Post metadata: 79 80If the post entry in `:posts` array is a hash, apart from the `:post` field with the URI it can include: 81 82* `:context` – some kind of internal metadata about this specific post in this specific response, e.g. identifying how this post ended up in that response, used for processing [interactions](#handling-feed-interactions) 83* `:reason` – information about why this post is being displayed, which can be shown to the user; currently supported reasons are: 84 - `{ :repost => repost_uri }` – the post is displayed because someone reposted it (the uri points to a `app.bsky.feed.repost` record) 85 - `{ :pin => true }` – the post is pinned at the top of the feed 86 87So the complete structure of your reponse in full form may look something like this: 88 89```rb 90{ 91 posts: [ 92 { 93 post: "at://.../app.bsky.feed.post/...", 94 reason: { pin: true } 95 }, 96 "at://.../app.bsky.feed.post/...", 97 "at://.../app.bsky.feed.post/...", 98 "at://.../app.bsky.feed.post/...", 99 { 100 post: "at://.../app.bsky.feed.post/...", 101 reason: { repost: "at://.../app.bsky.feed.repost/..." }, 102 context: 'qweqweqwe' 103 }, 104 "at://.../app.bsky.feed.post/...", 105 ... 106 ], 107 cursor: "1760639159", 108 req_id: "req2048" 109} 110``` 111 112### Error handling 113 114If you determine that the request is somehow invalid (e.g. the cursor doesn't match what you expect), you can also raise a `BlueFactory::InvalidRequestError` error, which will return a JSON error message with status 400. The `message` of the exception might be shown to the user in an error banner. 115 116### Example code 117 118A simple example implementation could look like this: 119 120```rb 121require 'time' 122 123class StarWarsFeed 124 def get_posts(params) 125 limit = check_query_limit(params) 126 query = Post.select('uri, time').order('time DESC').limit(limit) 127 128 if params[:cursor].to_s != "" 129 time = Time.at(params[:cursor].to_i) 130 query = query.where("time < ?", time) 131 end 132 133 posts = query.to_a 134 last = posts.last 135 cursor = last && last.time.to_i.to_s 136 137 { cursor: cursor, posts: posts.map(&:uri) } 138 end 139 140 def check_query_limit(params) 141 if params[:limit] 142 limit = params[:limit].to_i 143 (limit < 0) ? 0 : (limit > MAX_LIMIT ? MAX_LIMIT : limit) 144 else 145 DEFAULT_LIMIT 146 end 147 end 148end 149``` 150 151## Running the server 152 153The server itself is run using the `BlueFactory::Server` class, which is a subclass of `Sinatra::Base` and is used as described in the [Sinatra documentation](https://sinatrarb.com/intro.html) (as a "modular application"). 154 155In development, you can launch it using: 156 157```rb 158BlueFactory::Server.run! 159``` 160 161In production, you will probably want to create a `config.ru` file that instead runs it from the Rack interface: 162 163```rb 164run BlueFactory::Server 165``` 166 167Then, you would configure your preferred Ruby app server like Passenger, Unicorn or Puma to run the server using that config file and configure the main HTTP server (Nginx, Apache) to route requests on the given hostname to that app server. 168 169As an example, an Nginx configuration for a site that runs the server via Passenger could look something like this: 170 171``` 172server { 173 server_name feeds.example.com; 174 listen 443 ssl; 175 176 passenger_enabled on; 177 root /var/www/feeds/current/public; 178 179 ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; 180 ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; 181 182 access_log /var/log/nginx/feeds-access.log combined buffer=16k flush=10s; 183 error_log /var/log/nginx/feeds-error.log; 184} 185``` 186 187## Authentication 188 189Feeds are authenticated using a technology called [JSON Web Tokens](https://jwt.io). If a user is logged in, when they open, refresh or scroll down a feed in their app, requests are made to the feed service from the Bluesky network's IP address with user's authentication token in the `Authorization` HTTP header. (This is not the same kind of token as the access token that you use to make API calls – it does not let you perform any actions on user's behalf.) 190 191At the moment, Blue Factory handles authentication in a very simplified way – it extracts the user's DID from the authentication header, but it does not verify the signature. This means that anyone with some programming knowledge can trivially prepare a fake token and make requests to the `getFeedSkeleton` endpoint as a different user. 192 193As such, this authentication should not be used for anything critical. It may be used for things like logging, analytics, or as "security by obscurity" to just discourage others from accessing the feed in the app. You can also use this to build personalized feeds, as long as it's not a problem that the user DID may be fake. 194 195To use this simple authentication, make a `get_posts` method that accepts two arguments: the second argument is a `context`, from which you can get user info via `context.user.raw_did`. `context.user.token` returns the whole Base64-encoded JWT token. 196 197So this way you could, for example, return an empty list when the user is not authorized to use it: 198 199```rb 200class HiddenFeed 201 def get_posts(params, context) 202 if AUTHORIZED_USERS.include?(context.user.raw_did) 203 # ... 204 else 205 { posts: [] } 206 end 207 end 208end 209``` 210 211Alternatively, you can raise a `BlueFactory::AuthorizationError` with an optional custom message. This will return a 401 status response to the Bluesky app, which will make it display the pink error banner in the app: 212 213```rb 214class HiddenFeed 215 def get_posts(params, context) 216 if AUTHORIZED_USERS.include?(context.user.raw_did) 217 # ... 218 else 219 raise BlueFactory::AuthorizationError, "You shall not pass!" 220 end 221 end 222end 223``` 224 225<p><img width="400" src="https://github.com/mackuba/blue_factory/assets/28465/9197c0ec-9302-4ca0-b06c-3fce2e0fa4f4"></p> 226 227 228### Unauthenticated access 229 230Please note that there might not be any user information in the context – this will happen if the authentication header is not set at all. Since the [bsky.app](https://bsky.app) website can be accessed while logged out, people can also access your feeds this way. In that case, `context.user` will exist, but `context.user.token` and `context.user.raw_did` will be nil. You can also use the `context.has_auth?` method as a shortcut. 231 232If you want the feed to only be available to logged in users (even if it's a non-personalized feed), simply raise an `AuthorizationError` if there's no authentication info: 233 234```rb 235class RestrictedFeed 236 def get_posts(params, context) 237 if !context.has_auth? 238 raise BlueFactory::AuthorizationError, "Log in to see this feed" 239 end 240 241 # ... 242 end 243end 244``` 245 246 247## Handling feed interactions 248 249If that makes sense in your feed, you can opt in to receiving "feed interaction" events from the users who view it. Interactions are either explicit actions that the user takes – they can press the "Show more like this" or "Show less like this" buttons in the context menu on a post they see in your feed – or implicit events that get sent automatically. 250 251To receive interactions, your feed needs to opt in to that (the "Show more/less" buttons are only displayed in your feed if you do). This is done by setting an `acceptsInteractions` field in the feed generator record – in BlueFactory, you need to add an `accepts_interactions` property or method to your feed object and return `true` from it (and re-publish the feed if it was already live). 252 253The interactions are sent to your feed by making a `POST` request to the `app.bsky.feed.sendInteractions` endpoint. BlueFactory passes these to you using a handler which you configure this way: 254 255```rb 256BlueFactory.on_interactions do |interactions, context| 257 interactions.each do |i| 258 unless i.type == :seen 259 puts "[#{Time.now}] #{context.user.raw_did}: #{i.type} #{i.item}" 260 end 261 end 262end 263``` 264 265or, alternatively: 266 267```rb 268BlueFactory.interactions_handler = proc { ... } 269``` 270 271There is one shared handler for all the feeds you're hosting – to find out what a given interaction is about, you need to add the fields `:req_id` and/or `:context` to the feed response (see "[Feed API – Response](#response)"). 272 273An `Interaction` has such properties: 274 275- `item` – at:// URI of a post the interaction is about 276- `event` – name of the interaction type as specified in the lexicon, e.g. `app.bsky.feed.defs#requestLess` 277- `context` – the context that was assigned in your response to this specific post 278- `req_id` – the request ID that was assigned in your response to the request 279- `type` – a short symbolic code of the interaction type 280 281Currently enabled interaction types are: 282 283- `:request_more` – user asked to see more posts like this 284- `:request_less` – user asked to see fewer posts like this 285- `:like` – user pressed like on the post 286- `:repost` – user reposted the post 287- `:reply` – user replied to the post 288- `:quote` – user quoted the post 289- `:seen` – user has seen the post (scrolled down to it) 290 291 292## Additional configuration & customizing 293 294You can use the [Sinatra API](https://sinatrarb.com/intro.html#configuration) to do any additional configuration, like changing the server port, enabling/disabling logging and so on. 295 296For example, you can change the port used in development with: 297 298```rb 299BlueFactory::Server.set :port, 7777 300``` 301 302You can also add additional routes, e.g. to make a redirect or print something on the root URL: 303 304```rb 305BlueFactory::Server.get '/' do 306 redirect 'https://welcome.example.com' 307end 308``` 309 310 311## Publishing the feed 312 313When your feed server is ready and deployed to the production server, you can use the included `bluesky:publish` Rake task to upload the feed configuration to the Bluesky network. To do that, add this line to your `Rakefile`: 314 315```rb 316require 'blue_factory/rake' 317``` 318 319You also need to load your `BlueFactory` configuration and your feed classes here, so it's recommended that you extract this configuration code to some kind of init file that can be included in the `Rakefile`, `config.ru` and elsewhere if needed. 320 321To publish the feed, you will need to provide some additional info about the feed, like its public name, through a few more methods in the feed object (the same one that responds to `#get_posts`): 322 323- `display_name` (required) – the publicly visible name of your feed, e.g. "Cat Pics" (should be something short) 324- `description` (optional) – a longer (~1-2 lines) description of what the feed does, displayed on the feed page as the "bio" 325- `avatar_file` (optional) – path to an avatar image from the project's root (PNG or JPG) 326- `content_mode` (optional) – return `:video` to create a video feed, which is displayed with a special layout 327- `accepts_interactions` (optional) – return `true` to opt in to receiving [interactions](#handling-feed-interactions) 328 329When you're ready, run the rake task passing the feed key (you will be asked for the uploader's account password or app password): 330 331``` 332bundle exec rake bluesky:publish KEY=wwdc 333``` 334 335You also need to republish the feed by running the same task again any time you make changes to these properties and you want them to take effect. 336 337 338## Credits 339 340Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 341 342The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT). 343 344Bug reports and pull requests are welcome 😎