How to build a web component based application that integrates the BlueSky Social public API? Covered topics: TDD, BlueSky API, BlueSky bot (automation), AtProto SDK, Streaming, Web components.
Table of Contents
Introduction
BlueSky is a (relatively) new Social network nowadays. It looks like a Twitter clone, only open source with much more control over the content one consumes. It also has a public API. In this article, we will walk through the building of an A11y love meter bot. We will gamify adding Alt Text to your posts so you will get better at remembering it.
What are we going to build? This
You can view the complete repo here
What’s so hard with Alt Text in BlueSky?
One challenge on BlueSky is that posts are immutable—you can’t edit them once they’re published. Your only options are to publish a new one or delete the old one. There’s no middle ground (at least for now!).
When creating posts, many of us forget to include Alt Text. The excitement of sharing a fresh idea or the perfect image often takes priority, and accessibility features can slip our minds.
So, how do we get better at remembering Alt Text? Practice! Let’s turn this into a fun and practical learning experience with a BlueSky Alt Text training game designed to help you build this habit effortlessly.
How to Build a BlueSky bot?
We will use the AtpAgent SDK to build a BlueSky bot. This typescript library allows you to perform operations on the BlueSky network.
The Project Setup
We’ll work with the minimal infrastructure that will make our work comfortable. I assume you already have nodejs installed so will skip right ahead to installing packages.
- Create a new folder (let’s assume you called it BlueSkyBot)
- cd into it: cd BlueSkyBot
- Run npm init -y to initialize our project as an npm one (with a package.json file).
- Run npm i vite vitest -D && npm i @atproto/api (this will add a package.lock.json file to the project).
- Our package.json file has an empty scripts property. We will replace its contents with the following scripts:
"scripts": {
"test": "vitest",
"start": "vite"
}
For the full package.json, see this gist.
/// <reference types="vitest/globals" />
import { defineConfig } from 'vitest/config'
export default defineConfig({
root: './src',
server: {
open: true
},
test: {
globals: true,
environment: 'jsdom',
coverage: {
enabled: true,
provider: 'v8',
reporter: ['text', 'json', 'html'],
reportsDirectory: '../coverage',
},
},
});
We define the root as the src
folder, tell our development server to open the browser with the local URL and setup the tests to ru on jsdom
with some coverage options.
- Now we can create the src folder. All our files will be there. Our folder structure should look like this:
.
├── src/
│ ├── agent
│ ├── components
│ └── index.html
├── package.lock.json
├── package.json
└── vite.config.ts
Vite by default is looking for an index.html
file, so this will be the app’s source. Once we have that, we can run npm start
and see the contents of the index.html
file (currently, a blank page).
The Agent
The Agent is going to be our data fetcher from BlueSky.
Inside the agent folder, we will create two files:
- find-altless-posts.spec.ts
- find-altless-posts.ts
We will start by creating some basic tests to get us going. Inside find-altless-posts.spec.ts we will add the following:
When we run npm test, it will ask us to install a few packages. Answer yes.
Once everything’s installed, running npm test will fail. That’s because we didn’t implement the class yet. We will create it in find-altless-posts.ts:
And now the tests have passed! Hooray!
Let’s parse the contents of a single BlueSky post.
Check a Single Post
Our first exposed method is going to be: checkSinglePost.
We will build it step-by-step:
Handle Data Fetch Error
Looking at the atproto api docs we can see there’s an agent.getPost method. We’ll use it. First, we will create a mock:
let mockAtpAgent;
const resetAtpAgentMock = () => {
mockAtpAgent = {
getPost: vi.fn(),
};
}
vi.mock('@atproto/api', () => ({
AtpAgent: vi.fn(() => mockAtpAgent),
}));
and use this mock in our test:
The test follows the Arrange-Act-Assert (AAA) pattern. We first arrange
the scenario to fail the fetch post:
- Reset the agent’s mock
- Set some consts and initialize the bot
- Mock a rejection as getPost response
We then commence the action
:
const result = await bot.checkSinglePost(postUri);
Eventually, we assert the result is the error message resolved from getPost:
expect(result).toEqual(error);
This, of course, fails, so we implement the minimal code to make it pass:
We’ve added a private #agent property, which initializes an AtpAgent
.
We then use it in checkSinglePost
inside a try/catch phrase and return the error in case of a caught error.
Note that this fails with strict TypeScript enforcement, which we do not have in our project. We will take care of it later.
Refactor
Our tests have some boilerplate we can extract. For instance, we see that instantiating a new bot repeats itself. Let’s make our tests leaner:
Notice that our test case (it
) is now three lines of code, and we don’t have to repeat resetting the mock and instantiating the bot again.
Verify Correct Usage of getPost
As mentioned before, TypeScript does not like that we send the postUri
as a string to getPost. That’s because it expects something else:
Argument of type ‘string’ is not assignable to parameter of type ‘Omit<QueryParams, “collection”>’.ts(2345)
Because AtProto is written in TypeScript, we can search for the QueryParams
interface in their code (simply cmd/ctrl+click on the method will send us there):
export interface QueryParams {
/** The handle or DID of the repo. */
repo: string
/** The NSID of the record collection. */
collection: string
/** The Record Key. */
rkey: string
/** The CID of the version of the record. If not specified, then return the most recent version. */
cid?: string
}
According to the above, we need an object with repo
, collection
, and rkey
. The last two can be inferred from the postUri
. The first will require the use of the API.
Let’s see what we expect to happen:
Notice we are now using a real postUri
. We’d expect our method to use getPost
with a QueryParams
object derived from our postUri
. We also expect it to run only once (expect(mockAtpAgent.getPost).toHaveBeenCalledTimes(1);)
.
One “GOTCHA” here is the repo, also called the DID. As mentioned before, we need to use the API to get it. For this, we will mock the AtpAgent
‘s resolveHandle
method, which return the DID:
const resolvedHandle = {
did: 'handle-did'
};
const resetAtpAgentMock = () => {
mockAtpAgent = {
getPost: vi.fn(),
resolveHandle: vi.fn().mockResolvedValue({
data: resolvedHandle
})
};
}
Now that we have a failing test let’s make it pass. We’ll write a small utility function that converts our Uri to the params needed:
And use it in our method:
Great! We validated that we are using the AtpAgent correctly.
Now, we would like to return the post with a list of Alt-less images. We’d expect the output of the method to be like this:
Arrange: we generate a mock post response and tell our agent’s mock to resolve it. We know the value holds the embed according to the AtProto types, so we set it with three images: 1 with alt text and two without.
Act: call bot.checkSinglePost(postUri)
Assert: We expect the value returned from checkSinglePost
to be an object with the post as well as an array of the two images without alt text.
The implementation is straightforward:
checkSinglePost
uses the agent to get the post (line 8). It then returns an object with the post
and imagesWithoutAlt
which is the array of alt-less images we expected.
Our code is ready! Let’s check it out in the browser!
Our Bot in the Browser
Remember our index.html file? It’s time to put it to work. We will use our Bot inside the HTML file like this:
The secret sauce is lines 5 to 10. It should look familiar because it’s just like the tests. Our tests were written as if we were consumers of the interface. It’s fun like that 🙂
Now we can run npm start
. It will open a browser serving our app. In the console, you should see the result:
Try to replace the URL we send to our method in the HTML file and see that it also works for your posts.
Now we can go ahead and move to more complex stuff that requires login – so that’d be our next stop.
Login
Some actions require authentication. Actions like posting, replying or even getting all your posts. While we don’t need login to get posts, we will use it in later features, so let’s quickly implement it.
For simplicity’s sake, we will use the simple username (a.k.a. handle) and password login method.
Essentially, it’s supposed to do something straightforward: use the SDK’s login
method. Here’s the test and implementation:
In the spec file, we add the login method to the AtpAgent
mock. We then make sure that when we call bot.login
, it will call the agent’s login with the handle
and password
. The implementation in the `login.ts` file would be just to call login with the handle and password parameters.
For the complete solution with the login
and checkSinglePost
, click here.
Notice that Bluesky also has an OAuth API which we will not discuss in this (already-very-long) article. Now we can get our posts.
Stream Your Posts
So far, we fetched a single post. When we fetch a user’s posts, the number of posts can be massive. Some people post once a week, some a few times each day. So, if you have been in the system for two years, you might get quite a few posts. When fetching lots of data, it’s always good to “stream” it, which is a fancy name for saying we’ll get it in chunks.
How to Stream BlueSky Posts to the Client?
Part of the AtpAgent
SDK is the getAuthorFeed
method. It must receive an actor (the user handle). There are also some more properties. We will go over some of them in this example. We can call it like this:
const result: AppBskyFeedGetAuthorFeed.Response = await this.#agent.getAuthorFeed({
actor: handle,
limit: 20,
cursor: cursor,
filter: 'posts_with_media'
});
In this example, aside from the actor, we also used cursor. The cursor is either undefined
, which returns the first batch of posts or a value we receive from a subsequent call. This value signifies that we want to fetch the next batch. I also added a limit of 20 responses in each batch and asked for only posts with media (because we only want to see if we have alt text in media).
Our first step will be to add getAuthorFeed
to our AtpAgent mock:
const resetAtpAgentMock = () => {
mockAtpAgent = {
getPost: vi.fn(),
resolveHandle: vi.fn().mockResolvedValue({
data: resolvedHandle
}),
login: vi.fn(),
getAuthorFeed: vi.fn(),
};
}
Let’s make sure we call it right in our code:
it('should call getAuthorFeed with the correct parameters', async () => {
queueAgentFeedResponse(emptyFeedResponse);
await bot.streamPosts(handle);
expect(mockAtpAgent.getAuthorFeed.mock.calls[0][0]).toEqual({
actor: handle,
limit: 20,
cursor: undefined,
filter: 'posts_with_media'
});
});
In the code snippet above, we enqueue an empty feed response from getAuthorFeed
mock and call our method. We expect the mock to be called with the expected parameters.
Simple implementation again:
async streamPosts(handle: string) {
const result = await this.#agent.getAuthorFeed({
actor: handle,
limit: 20,
cursor: undefined,
filter: 'posts_with_media'
});
}
Our next stop is… well… to stop.
Stop Conditions
Because streaming is a kind of an infinite loop, we need “stop” conditions.
We have two ways of knowing if we have reached the end of the stream.
The first is when we get an empty feed array:
it('should log when there are no more posts and break', async () => {
queueAgentFeedResponse({
data: {
feed: [],
cursor: ‘somthing’,
},
});
await bot.streamPosts(handle);
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(1);
});
As the test description implies, we enqueue an empty response for the getAuthorFeed
mock. We then stream the posts. We then assert that getAuthorFeed
was called only once, which means we broke out of the loop.
The second stop condition is when we get a response without a new cursor for the next batch:
it('should break if no cursor is provided in the feed response', async () => {
queueAgentFeedResponse({
data: {
feed: [
{ post: { uri: 'postUri1', cid: 'postCid1', embed: {} } },
{ post: { uri: 'postUri2', cid: 'postCid2', embed: {} } },
],
cursor: undefined
}
});
await bot.streamPosts(handle);
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(1);
});
In this test, we enqueue a response with a feed – only this time, the cursor
is undefined. This simulates the last batch use case.
Implementing those is a breeze. We wrap our request with an endless while loop and add our stop conditions:
async streamPosts(handle: string) {
while(true) {
const result = await this.#agent.getAuthorFeed({
actor: handle,
limit: 20,
cursor: undefined,
filter: 'posts_with_media'
});
if (!result.data?.feed?.length) {
console.log('No more posts');
break;
}
if (!result.data.cursor) {
break;
}
}
}
Our endless while loop calls getAuthorFeed
and checks the feed’s length and the data’s cursor. In the case of an empty feed or undefined cursor, we break from the loop.
Error Handling
We shouldn’t forget the error handling whenever we make a server call. In our case, we decided that if the fetch fails for some reason, we’d like to retry every 5000ms:
it('should retry after 5 seconds if author feed rejected', async () => {
vi.useFakeTimers();
const fetchError = new Error('Fetch error');
mockAtpAgent.getAuthorFeed.mockRejectedValueOnce(fetchError);
await bot.streamPosts(handle);
await vi.advanceTimersByTimeAsync(4999);
const callsBefore5Seconds = mockAtpAgent.getAuthorFeed.mock.calls.length;
await vi.advanceTimersByTimeAsync(1);
expect(callsBefore30Seconds).toBe(1);
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
A retry means we need to use timers. In order to control timers in vitest
, we use fake timers (vi.useFakeTimers()
on line 2). We make the mock getAuthorFeed
reject the request with an error and then call our function. The interesting thing here is that we tell vitest
to advance time by 4999ms. We would expect to have only one call to getAuthorFeed
. We then advance the time by 1ms, completing the 5000ms cycle. We expect a single call to getAuthorFeed
after 4999ms and 2 calls after another 1ms (a total of 5000ms).
Let’s implement this:
async streamPosts(handle: string) {
while (true) {
try {
const result = await this.#agent.getAuthorFeed({
actor: handle,
limit: 20,
cursor: undefined,
filter: 'posts_with_media'
});
if (!result.data?.feed?.length) {
console.log('No more posts');
break;
}
if (!result.data.cursor) {
break;
}
} catch (e) {
await new Promise(res => setTimeout(res, 5000));
}
}
}
Notice the addition of try/catch
and the promise that resolves after 5000ms. Next step – notify the consumer that a chunk has arrived.
The Streaming Callback
To notify the consumer that a new chunk arrived from our fetch request, we will allow the consumer to pass a callback to streamPosts
. We will fire this callback on every update and notify the user that the messages have stopped. So, we have three use cases here:
Let’s go over the test cases:
should stream posts with done set to false when cursor is truthy
Arrange: This is the actual streaming. Our first line creates a spy
. A spy
is a method we can track. We used it when we mocked the AtpAgent
. Here we create a spy
that will be the callback
we send to streamPosts
.
We enqueue three responses – two full responses with a cursor and one last response with and undefined cursor
. We must send the last response because this is our stop condition.
Act: When we call streamPosts
, we also send our spy
as the callback.
Assert: We’d expect to receive an object with result
(which will hold the feed) and done
(which will be false until we reach the last response and then it should be true).
The other tests should look familiar – they test the stop conditions. See that a final call to our callback receives an object with { done: true }
.
Here’s the implementation:
Notice the calls to onUpdate
with the results and in our stop conditions.
We have one more thing to do here. Remember the cursor
? We need to make sure to send the last result’s cursor in the next request. It looks something like this:
it('should send the last response cursor in the next request', async () => {
const spy = vi.fn();
queueAgentFeedResponse({ data: { ...fullFeedResponse.data, cursor: 'next-0' } });
queueAgentFeedResponse({ data: { ...fullFeedResponse.data, cursor: 'next-1' } });
queueAgentFeedResponse(fullFeedLastResponse);
await bot.streamPosts(handle, spy);
expect(mockAtpAgent.getAuthorFeed.mock.calls[1][0].cursor).toBe('next-0');
expect(mockAtpAgent.getAuthorFeed.mock.calls[2][0].cursor).toBe('next-1');
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(3);
});
We implement it by creating a cursor
variable outside the loop. It will be initiated as undefined, but we will populate it with the cursor we receive from the getAuthorFeed
response:
async streamPosts(handle: string, onUpdate: (results: any) => any) {
let cursor: string | undefined = undefined;
while (true) {
try {
const result = await this.#agent.getAuthorFeed({
actor: handle,
limit: 20,
cursor,
filter: 'posts_with_media'
});
if (!result.data?.feed?.length) {
console.log('No more posts');
onUpdate({ done: true });
break;
}
onUpdate({ result: result.data.feed, done: !result.data.cursor });
if (!result.data.cursor) {
break;
}
cursor = result.data.cursor;
} catch (e) {
await new Promise(res => setTimeout(res, 5000));
}
}
}
For the full solution, click here.
There’s more streaming…
While our streaming solution works, we can keep on enhancing it.
One way of doing that would be to cache results locally. If we already fetched something, we can keep it in local storage and skip the request.
Another enhancement can be a timed delay of our request. Much like we did with the retry, we can hold the next request by a few hundreds of milliseconds if the request rate is too fast.
One final suggestion would be to add a limit to the number of retries, or gradually increase the retry timeout.
We will not implement these ideas in this tutorial, but it’s important to note this is not a complete streaming solution.
Running the Bot
We can now stream the posts’ data to our client. The way our bot will work will be as follows:
- Stream the posts
- For every bunch, process the data
The Run Method
The bot’s entry point will be a run
method. If we change our BlueSky service to the public API URL (https://public.api.bsky.app) we will not need to login to get the author feed. We will change it in our AtpAgent
initiation:
#agent: AtpAgent = new AtpAgent({ service: 'https://public.api.bsky.app' });
All we need now is a BlueSky handle and a callback for streaming:
We use a similar callback mechanism to notify the consumer of newly streamed parsed data.
While this is nice, we want simplify the data structure for the consumer. Our data should look like this:
export interface BotPost {
imagesWithoutAlt: [];
text: string;
createdAt: string;
}
export interface AltlessPosts {
results: BotPost[],
done: Boolean;
}
So, we’ll make sure it does:
As usual, we enqueue the messages, call on our bot. This time we expect the callback to receive the expected messages for each chunk of our data.
Implementing it is as easy as:
async run(handle, callback: (results: BotPosts) => { }) {
await this.streamPosts(handle, ({ result, done }) => {
const parsedData = {
results: this.#parseStreamData(result),
done
};
callback(parsedData);
});
}
Note that #parseStreamData is a private implementation detail. You can view it in the full repository.
If you followed so far, you can be proud. A 100% covered bot:
We are ready to use our BlueSky bot for our Alt Text Game of Life!
Summary (of part 1)
We have a well-tested “bot” capable of analyzing BlueSky posts for missing alt text. By leveraging the AtProto SDK and implementing efficient streaming techniques, the bot can handle large amounts of data.
We wrote it in a way that can be used both in a nodejs backend as well as run in the browser. This sets the stage for the next phase of the project: developing a user interface to make this functionality accessible and engaging for BlueSky users. In the upcoming part, we will focus on creating a web component-based UI to gamify the process of adding alt text and encourage wider adoption of accessibility best practices.
If you are eager to see the full code, the complete repository is available on github: https://github.com/YonatanKra/bluesky-alttext-game