Skip to content

Commit 8bd3453

Browse files
Fixes to atproto-loader
1 parent b664712 commit 8bd3453

30 files changed

Lines changed: 5852 additions & 118 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@fujocoded/astro-atproto-loader": patch
3+
---
4+
5+
Fixes `0.2.0`, which was broken: `defineAtProtoCollection` and `defineAtProtoLiveCollection` didn't return the shape Astro expected, so callers had to wrap them in `defineCollection` / `defineLiveCollection` themselves. They now return the real Astro collection shape and work as documented.
6+
7+
**Breaking:** `fetchRecord({ atUri })` now resolves to `{ value, repo }` instead of just the record value. Existing callers need to read `.value`. The new `repo` field carries the fetched record's `{ did, pds }` so it can be passed directly to `toHostedBlob` without re-resolving identity. This is shipped as a patch (and not a minor bump) because `0.2.0` was only on npm for ~8 hours and is being deprecated alongside this release, so realistically nobody is depending on the old `fetchRecord` shape.
8+
9+
Also in this release:
10+
11+
- New `toHostedBlob({ repo, blob })` for building `com.atproto.sync.getBlob` URLs, plus the `isAtBlob` guard and the `AtBlob` type.
12+
- `AtProtoRecordRepo` now includes `pds` alongside `did`, so `args.repo` works directly with `toHostedBlob`.
13+
- `getPds(repo)` is exported and shares the identity cache with `getClient`.
14+
- `BlobRef` and `CID` instances are flattened to `{ $link }` before being stored, so records with blobs don't break Astro's devalue store.
15+
- Added the `04-single-entry` example.

astro-atproto-loader/README.md

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ In this package, you'll find:
5757
`defineLiveCollection()`
5858
- `defineAtProtoCollection()`, which reads public AtProto records at build
5959
time. Use it where you'd otherwise call Astro's `defineCollection()`
60+
- `toHostedBlob()` and `isAtBlob()`, helpers for turning a record's blob
61+
ref (a profile avatar, a sprite sheet, a stream thumbnail) into a URL you
62+
can drop into `<img src>`. Use them inside `transform`
6063

6164
> [!WARNING]
6265
>
@@ -81,6 +84,9 @@ In this package, you'll find:
8184
- **Hydrate linked records** like `strongRef`s and `subject` URIs from inside
8285
your `transform`, so a post's quoted record or a label's subject is already
8386
resolved by the time your page renders
87+
- **Display blob-backed media** like profile avatars, sprite sheets, or
88+
stream thumbnails by handing the blob ref to `toHostedBlob()` inside
89+
`transform`
8490

8591
> [!TIP]
8692
>
@@ -101,9 +107,27 @@ Before you start, you'll need:
101107
1. Run the following command:
102108

103109
```bash
104-
npm add @fujocoded/astro-atproto-loader
110+
npm add @fujocoded/astro-atproto-loader@latest
105111
```
106112

113+
> [!CAUTION]
114+
>
115+
> **`0.2.0` is broken. Upgrade to `0.2.1` or later.**
116+
>
117+
> If you're stuck on `0.2.0`, the correct code requires wrapping the call in "defineCollection":
118+
>
119+
> ```ts
120+
> import { defineCollection, z } from "astro:content";
121+
> import { defineAtProtoCollection } from "@fujocoded/astro-atproto-loader";
122+
>
123+
> const sprites = defineCollection(
124+
> defineAtProtoCollection({
125+
> source: { repo: "bmann.ca", collection: "actor.rpg.sprite" },
126+
> outputSchema: z.any(),
127+
> }),
128+
> );
129+
> ```
130+
107131
2. Define a static collection (for build time magic)...
108132
109133
```ts
@@ -160,6 +184,9 @@ You can start with any of these:
160184
- [`03-grouped-reposts`](./__examples__/03-grouped-reposts/) for reading from
161185
multiple repos at once with `sources: [...]` and merging records with
162186
`groupBy`
187+
- [`04-single-entry`](./__examples__/04-single-entry/) for fetching one
188+
record by `rkey` with `getEntry()` and `getLiveEntry()`, and turning the
189+
record's blob into a URL with `toHostedBlob()`
163190

164191
The first two examples show off two patterns:
165192

@@ -240,7 +267,7 @@ defineAtProtoLiveCollection({
240267
// value is already the parsed lexicon type
241268
const quoted =
242269
value.embed?.$type === "app.bsky.embed.record"
243-
? await fetchRecord({ atUri: value.embed.record.uri })
270+
? (await fetchRecord({ atUri: value.embed.record.uri }))?.value
244271
: null;
245272
return { id: uri, data: { text: value.text, quoted } };
246273
},
@@ -256,16 +283,22 @@ Every `filter` and `transform` callback receives `fetchRecord({ atUri, parse?
256283
than one callback asks for the _same_ URI in the same cycle (for example a
257284
`subject` URI shared across many records), they share a single network call.
258285

286+
A successful call resolves to `{ value, repo }`. `value` is the record body
287+
(or whatever your `parse` callback returned). `repo` is the fetched record's
288+
owning DID and PDS, already resolved. So you can hand it straight to
289+
`toHostedBlob({ repo, blob })` for any blob inside that hydrated record
290+
without re-resolving identity.
291+
259292
```ts
260293
import { $parse, lexicons } from "@atproto/lex";
261294

262295
transform: async ({ value, uri, fetchRecord }) => {
263-
const subject = await fetchRecord({
296+
const result = await fetchRecord({
264297
atUri: value.subject.uri,
265298
parse: (v) => $parse(lexicons, "app.bsky.actor.profile", v),
266299
});
267-
if (!subject) return null; // record was missing, unparseable, or unreachable
268-
return { id: uri, data: { label: value.val, subject } };
300+
if (!result) return null; // record was missing, unparseable, or unreachable
301+
return { id: uri, data: { label: value.val, subject: result.value } };
269302
};
270303
```
271304

@@ -274,6 +307,55 @@ PDS that can't be reached, a 404, a record whose value isn't an object, or a
274307
`parse` callback that threw. Each of these logs a distinct warning to your
275308
console, so when something is missing you can tell which thing went wrong.
276309

310+
## Blob helpers: turning record blobs (and images) into URLs
311+
312+
When looking to display images or other files associated with records,
313+
you won't (unfortunately) find a simple address you can drop into
314+
`<img src>`: AtProto records don't store this content themselves, but instead
315+
only hold a "pointer" (called a _blob ref_) to the actual file on a user PDS.
316+
317+
To show that profile avatar, sprite sheet, or video thumbnail on
318+
your page, we must turn the pointer into a real URL the browser
319+
can load. That's what `toHostedBlob()` is for.
320+
321+
`toHostedBlob()` needs 2 things:
322+
323+
- the `blob`ref itself
324+
- the `repo` that owns the file, that is the DID + PDS url wher the record
325+
is hosted
326+
327+
For records you own, `repo` will likely out of `args.repo`. While for records
328+
you've hydrated via `fetchRecord`, use the `repo` field on the result.
329+
330+
```ts
331+
import {
332+
defineAtProtoLiveCollection,
333+
isAtBlob,
334+
toHostedBlob,
335+
} from "@fujocoded/astro-atproto-loader";
336+
337+
defineAtProtoLiveCollection({
338+
source: { repo: "boba-tan.bsky.social", collection: "actor.rpg.sprite" },
339+
outputSchema: z.object({
340+
spriteSheet: z.object({
341+
url: z.url(),
342+
mimeType: z.string(),
343+
size: z.number(),
344+
}),
345+
}),
346+
transform: ({ repo, rkey, value }) => {
347+
const v = value as { spriteSheet: unknown };
348+
if (!isAtBlob(v.spriteSheet)) return undefined;
349+
return {
350+
id: rkey,
351+
data: { spriteSheet: toHostedBlob({ repo, blob: v.spriteSheet }) },
352+
};
353+
},
354+
});
355+
```
356+
357+
You can find a working example at [`__examples__/04-single-entry`](./__examples__/04-single-entry/).
358+
277359
## Multi-source reads and `onSourceError`
278360

279361
When you're reading from `sources: [...]`, `onSourceError` decides what

astro-atproto-loader/__examples__/03-grouped-reposts/src/live.config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ const sharedReposts = defineAtProtoLiveCollection({
140140
// Hydrate the post, the author's profile, and every reposter's
141141
// profile in parallel. fetchRecord coalesces same-URI requests within
142142
// one load, so duplicate lookups across groups hit the network once.
143-
const [post, authorProfile] = await Promise.all([
143+
const [postResult, authorProfileResult] = await Promise.all([
144144
fetchRecord({
145145
atUri: key,
146146
parse: (value: unknown) => PostSchema.parse(value),
@@ -150,18 +150,21 @@ const sharedReposts = defineAtProtoLiveCollection({
150150
parse: (value: unknown) => ProfileSchema.parse(value),
151151
}),
152152
]);
153-
if (!post) return null;
153+
if (!postResult) return null;
154+
const post = postResult.value;
155+
const authorProfile = authorProfileResult?.value;
154156

155157
// Hydrate each reposter's profile. `record.repo.did` is the resolved
156158
// DID; `record.repo.handle` is set when the source config gave us a
157159
// handle (true for all three of ours), so we use it for `@handle`-style
158160
// display and fall back to the DID otherwise.
159161
const repostedBy = await Promise.all(
160162
records.map(async (record) => {
161-
const profile = await fetchRecord({
163+
const profileResult = await fetchRecord({
162164
atUri: getProfileAtUri({ did: record.repo.did }),
163165
parse: (value: unknown) => ProfileSchema.parse(value),
164166
});
167+
const profile = profileResult?.value;
165168
const avatarCid = profile?.avatar?.cid;
166169
const displayHandle = record.repo.handle ?? record.repo.did;
167170
return {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# build output
2+
dist/
3+
# generated types
4+
.astro/
5+
6+
# dependencies
7+
node_modules/
8+
9+
# logs
10+
npm-debug.log*
11+
yarn-debug.log*
12+
yarn-error.log*
13+
pnpm-debug.log*
14+
15+
16+
# environment variables
17+
.env
18+
.env.production
19+
20+
# macOS-specific files
21+
.DS_Store
22+
23+
# jetbrains setting folder
24+
.idea/
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Single entry example
2+
3+
This example shows how to pull a single AtProto record by `rkey` instead of
4+
loading the whole collection, using `@fujocoded/astro-atproto-loader` with both
5+
`defineAtProtoCollection()` (static) and `defineAtProtoLiveCollection()`
6+
(live).
7+
8+
It reads one `actor.rpg.sprite` record from `bmann.ca` at the `rkey` `self`,
9+
turns the sprite sheet's blob ref into a hosted URL, and renders it on a page
10+
with `<img src>`.
11+
12+
The example uses the loader two ways:
13+
14+
- The static page (`/`) calls `getEntry("sprites-static", "self")` against the
15+
collection from `content.config.ts`. The record is fetched at build time
16+
- The live page (`/live`) calls `getLiveEntry("sprites-live", "self")` against
17+
the collection from `live.config.ts`. The record is fetched on each request
18+
19+
Both configs share the same `transform`. It uses `isAtBlob()` to drop the
20+
record if the `spriteSheet` field isn't a blob ref, then `toHostedBlob()` to
21+
turn the blob ref into `{ url, mimeType, size }` ready for `<img src>`.
22+
23+
## Run it
24+
25+
```bash
26+
npm install
27+
npm run dev
28+
```
29+
30+
Then open `http://127.0.0.1:4321` for the static page and
31+
`http://127.0.0.1:4321/live` for the live page.
32+
33+
If you want to inspect the built static output instead, run `npm run build`
34+
and then `npm run preview`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// @ts-check
2+
import { defineConfig } from "astro/config";
3+
import node from "@astrojs/node";
4+
5+
export default defineConfig({
6+
output: "server",
7+
adapter: node({
8+
mode: "standalone",
9+
}),
10+
});

0 commit comments

Comments
 (0)