···11# @atcute/bluesky-threading
2233-create Bluesky threads containing multiple posts with one write.
33+publish Bluesky threads atomically.
44+55+```sh
66+npm install @atcute/bluesky-threading
77+```
88+99+creates multiple posts as a single atomic write using `com.atproto.repo.applyWrites`, so either all
1010+posts succeed or none do.
1111+1212+## usage
1313+1414+### publishing a thread
415516```ts
617import { XRPC } from '@atcute/client';
77-import { AtpAuth } from '@atcute/client/middlewares/auth';
88-918import RichTextBuilder from '@atcute/bluesky-richtext-builder';
1019import { publishThread } from '@atcute/bluesky-threading';
11201212-const rpc = new XRPC({ service: 'https://bsky.social' });
1313-const auth = new AtpAuth(rpc);
1414-1515-await auth.login({ identifier: '...', password: '...' });
2121+const rpc = new XRPC({ handler: agent });
16221723await publishThread(rpc, {
1824 author: 'did:plc:ia76kvnndjutgedggx2ibrem',
···2026 posts: [
2127 {
2228 content: new RichTextBuilder()
2323- .addText('Hello, please visit my website! ')
2929+ .addText('First post of my thread! ')
2430 .addLink('example.com', 'https://example.com'),
2531 },
2632 {
2727- content: {
2828- text: `Here's the second post!`,
3333+ content: { text: 'Second post continues the story...' },
3434+ },
3535+ {
3636+ content: { text: 'And the thrilling conclusion!' },
3737+ },
3838+ ],
3939+});
4040+```
4141+4242+### adding images
4343+4444+```ts
4545+await publishThread(rpc, {
4646+ author: 'did:plc:ia76kvnndjutgedggx2ibrem',
4747+ posts: [
4848+ {
4949+ content: { text: 'Check out this photo!' },
5050+ embed: {
5151+ media: {
5252+ type: 'image',
5353+ images: [
5454+ {
5555+ blob: imageFile, // Web Blob - auto-uploaded
5656+ alt: 'A beautiful sunset',
5757+ aspectRatio: { width: 1200, height: 800 },
5858+ },
5959+ ],
6060+ },
2961 },
3062 },
6363+ ],
6464+});
6565+```
6666+6767+### quote posting
6868+6969+```ts
7070+await publishThread(rpc, {
7171+ author: 'did:plc:ia76kvnndjutgedggx2ibrem',
7272+ posts: [
3173 {
3232- content: {
3333- text: `Third post for good measure.`,
7474+ content: { text: 'This is such a great post!' },
7575+ embed: {
7676+ record: {
7777+ type: 'quote',
7878+ uri: 'at://did:plc:.../app.bsky.feed.post/...',
7979+ },
3480 },
3581 },
3682 ],
3783});
3884```
8585+8686+### replying to a post
8787+8888+```ts
8989+await publishThread(rpc, {
9090+ author: 'did:plc:ia76kvnndjutgedggx2ibrem',
9191+ reply: 'at://did:plc:.../app.bsky.feed.post/...', // AT-URI of post to reply to
9292+ posts: [{ content: { text: 'Great thread! Adding my thoughts...' } }],
9393+});
9494+```
9595+9696+### restricting replies with threadgate
9797+9898+```ts
9999+await publishThread(rpc, {
100100+ author: 'did:plc:ia76kvnndjutgedggx2ibrem',
101101+ gate: {
102102+ follows: true, // only followers can reply
103103+ mentions: true, // or users mentioned in the post
104104+ },
105105+ posts: [{ content: { text: 'Only my followers can reply to this thread' } }],
106106+});
107107+```
108108+109109+### creating without publishing
110110+111111+use `createThread` to get the records without publishing - useful for previews or custom handling:
112112+113113+```ts
114114+import { createThread } from '@atcute/bluesky-threading';
115115+116116+const records = await createThread({
117117+ client: rpc,
118118+ author: 'did:plc:ia76kvnndjutgedggx2ibrem',
119119+ posts: [{ content: { text: 'Preview this first' } }],
120120+});
121121+122122+// inspect records, then publish yourself via com.atproto.repo.applyWrites
123123+```
+68-8
packages/clients/cache/README.md
···11# @atcute/cache
2233-> [!WARNING]
44-> very experimental package
33+> [!WARNING]
44+> experimental package - API may change
55+66+normalized cache store for AT Protocol.
77+88+```sh
99+npm install @atcute/cache
1010+```
1111+1212+stores entities by their unique keys and automatically deduplicates nested references. when an
1313+entity appears in multiple API responses (e.g., a profile in a post author and in followers), they
1414+share the same object reference in memory.
1515+1616+## usage
51766-normalized cache store for AT Protocol
1818+### setting up the cache
719820```ts
921import { NormalizedCache } from '@atcute/cache';
1010-import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedGetTimeline } from '@atcute/bluesky';
2222+import { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/bluesky';
11231224const cache = new NormalizedCache();
1325···2133 schema: AppBskyActorDefs.profileViewBasicSchema,
2234 key: (profile) => profile.did,
2335});
3636+```
24372525-// normalize API responses
3838+### normalizing API responses
3939+4040+```ts
4141+import { AppBskyFeedGetTimeline } from '@atcute/bluesky';
4242+2643const response = await rpc.get('app.bsky.feed.getTimeline', { params: {} });
27444545+// walks the response, extracts entities, and stores them
2846const timeline = cache.normalize(AppBskyFeedGetTimeline.mainSchema.output.schema, response.data);
4747+```
29483030-// read entities from cache
4949+### reading from cache
5050+5151+```ts
3152const post = cache.get(AppBskyFeedDefs.postViewSchema, 'at://did:plc:.../app.bsky.feed.post/...');
3253const profile = cache.get(AppBskyActorDefs.profileViewBasicSchema, 'did:plc:...');
33543434-// optimistic updates
5555+// check if entity exists
5656+if (cache.has(AppBskyFeedDefs.postViewSchema, postUri)) {
5757+ // ...
5858+}
5959+6060+// get all cached entities of a type
6161+const allPosts = cache.getAll(AppBskyFeedDefs.postViewSchema);
6262+```
6363+6464+### optimistic updates
6565+6666+```ts
6767+// update a post's like count immediately, before the API responds
3568cache.update(AppBskyFeedDefs.postViewSchema, postUri, (post) => ({
3669 ...post,
3770 viewer: { ...post.viewer, like: tempLikeUri },
3871 likeCount: (post.likeCount ?? 0) + 1,
3972}));
7373+```
40744141-// subscribe to entity changes
7575+### subscribing to changes
7676+7777+```ts
7878+// subscribe to a specific entity
4279const unsubscribe = cache.subscribe(AppBskyFeedDefs.postViewSchema, postUri, (post) => {
4380 console.log('post changed:', post);
8181+});
8282+8383+// subscribe to all entities of a type
8484+const unsubscribeType = cache.subscribeType(AppBskyActorDefs.profileViewBasicSchema, (key, profile) => {
8585+ console.log(`profile ${key} changed:`, profile);
8686+});
8787+8888+// clean up when done
8989+unsubscribe();
9090+unsubscribeType();
9191+```
9292+9393+### custom merge logic
9494+9595+```ts
9696+cache.define({
9797+ schema: AppBskyActorDefs.profileViewBasicSchema,
9898+ key: (profile) => profile.did,
9999+ // custom merge: prefer existing avatar if new one is missing
100100+ merge: (existing, incoming) => ({
101101+ ...incoming,
102102+ avatar: incoming.avatar ?? existing.avatar,
103103+ }),
44104});
45105```
+297-92
packages/clients/client/README.md
···2233lightweight and cute API client for AT Protocol.
4455-- **small**, the bare minimum is ~1.1 kB gzipped, with validation at ~3 kB gzipped.
66-- **optional runtime validation**, by default there's no runtime validation as the server is assumed
77- to be trusted in returning valid responses, but you can opt-in to validation using the `call()`
88- method with lexicon schemas. validation code is automatically tree-shaken when not used.
55+```sh
66+npm install @atcute/client
77+```
88+99+## definition packages
1010+1111+by default, the client has no type definitions for queries or procedures.
1212+1313+| package | schemas |
1414+| ------------------------------------------------------------------ | --------------------------------------- |
1515+| [`@atcute/atproto`](../../definitions/atproto) | `com.atproto.*` |
1616+| [`@atcute/bluesky`](../../definitions/bluesky) | `app.bsky.*`, `chat.bsky.*` |
1717+| [`@atcute/ozone`](../../definitions/ozone) | `tools.ozone.*` |
1818+| [`@atcute/bluemoji`](../../definitions/bluemoji) | `blue.moji.*` |
1919+| [`@atcute/frontpage`](../../definitions/frontpage) | `fyi.unravel.frontpage.*` |
2020+| [`@atcute/whitewind`](../../definitions/whitewind) | `com.whtwnd.*` |
2121+| [`@atcute/tangled`](../../definitions/tangled) | `sh.tangled.*` |
2222+| [`@atcute/microcosm`](../../definitions/microcosm) | `blue.microcosm.*`, `com.bad-example.*` |
2323+| [`@atcute/pckt`](../../definitions/pckt) | `blog.pckt.*` |
2424+| [`@atcute/lexicon-community`](../../definitions/lexicon-community) | `community.lexicon.*` |
2525+2626+## usage
2727+2828+the client communicates with AT Protocol services using XRPC, a simple RPC framework over HTTP.
2929+queries are GET requests, procedures are POST requests.
9301010-```ts
1111-import { Client, CredentialManager, ok, simpleFetchHandler } from '@atcute/client';
3131+### making requests
12321313-// import lexicons
3333+```ts
3434+import { Client, simpleFetchHandler } from '@atcute/client';
1435import type {} from '@atcute/bluesky';
15361616-// basic usage
1717-{
1818- const handler = simpleFetchHandler({ service: 'https://public.api.bsky.app' });
1919- const rpc = new Client({ handler });
3737+// create a client pointing to the Bluesky public API
3838+const rpc = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) });
3939+```
20402121- // explicit response handling
2222- {
2323- const { ok, data } = await rpc.get('app.bsky.actor.getProfile', {
2424- params: {
2525- actor: 'bsky.app',
2626- },
2727- });
4141+use `get()` for queries and `post()` for procedures. both return a response object with `ok`,
4242+`status`, `headers`, and `data` fields:
28432929- if (!ok) {
3030- switch (data.error) {
3131- case 'InvalidRequest': {
3232- // Account doesn't exist
3333- break;
3434- }
3535- case 'AccountTakedown': {
3636- // Account taken down
3737- break;
3838- }
3939- case 'AccountDeactivated': {
4040- // Account deactivated
4141- break;
4242- }
4343- }
4444- }
4444+```ts
4545+// queries use get()
4646+const response = await rpc.get('app.bsky.actor.getProfile', {
4747+ params: { actor: 'bsky.app' },
4848+});
4949+5050+if (response.ok) {
5151+ console.log(response.data.displayName);
5252+ // -> "Bluesky"
5353+}
5454+```
5555+5656+```ts
5757+// procedures use post()
5858+const response = await rpc.post('com.atproto.repo.createRecord', {
5959+ input: {
6060+ repo: 'did:plc:1234...',
6161+ collection: 'app.bsky.feed.post',
6262+ record: {
6363+ $type: 'app.bsky.feed.post',
6464+ text: 'hello world!',
6565+ createdAt: new Date().toISOString(),
6666+ },
6767+ },
6868+});
6969+```
7070+7171+### handling errors
7272+7373+responses always include an `ok` field indicating success. for failed requests, `data` contains an
7474+error object with `error` (the error name) and optionally `message` (description):
7575+7676+```ts
7777+const response = await rpc.get('app.bsky.actor.getProfile', {
7878+ params: { actor: 'nonexistent.invalid' },
7979+});
8080+8181+if (!response.ok) {
8282+ console.log(response.data.error);
8383+ // -> "InvalidRequest"
8484+ console.log(response.data.message);
8585+ // -> "Unable to resolve handle"
8686+}
8787+```
8888+8989+the error names are defined in the lexicon schema. you can switch on them for typed error handling:
45904646- if (ok) {
4747- console.log(data.displayName);
4848- // -> "Bluesky"
4949- }
9191+```ts
9292+if (!response.ok) {
9393+ switch (response.data.error) {
9494+ case 'InvalidRequest':
9595+ // handle or account doesn't exist
9696+ break;
9797+ case 'AccountTakedown':
9898+ // account was taken down
9999+ break;
100100+ case 'AccountDeactivated':
101101+ // account deactivated by user
102102+ break;
50103 }
104104+}
105105+```
106106+107107+### optimistic requests
108108+109109+if you prefer throwing on errors instead of checking `response.ok`, use the `ok()` helper:
110110+111111+```ts
112112+import { Client, ok, simpleFetchHandler } from '@atcute/client';
113113+114114+const rpc = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) });
115115+116116+// throws ClientResponseError if the request fails
117117+const profile = await ok(rpc.get('app.bsky.actor.getProfile', { params: { actor: 'bsky.app' } }));
118118+119119+console.log(profile.displayName);
120120+// -> "Bluesky"
121121+```
511225252- // optimistic response handling
5353- {
5454- const data = await ok(
5555- rpc.get('app.bsky.actor.getProfile', {
5656- params: {
5757- actor: 'bsky.app',
5858- },
5959- }),
6060- );
123123+catch errors with `ClientResponseError`:
611246262- console.log(data.displayName);
6363- // -> "Bluesky"
125125+```ts
126126+import { ClientResponseError } from '@atcute/client';
127127+128128+try {
129129+ const profile = await ok(rpc.get('app.bsky.actor.getProfile', { params: { actor: 'invalid' } }));
130130+} catch (err) {
131131+ if (err instanceof ClientResponseError) {
132132+ console.log(err.error); // error name from server
133133+ console.log(err.description); // error message from server
134134+ console.log(err.status); // HTTP status code
64135 }
65136}
137137+```
661386767-// with runtime validation
139139+### authenticated requests
140140+141141+use `CredentialManager` to handle authentication. it manages tokens, automatically refreshes expired
142142+access tokens, and can persist sessions:
143143+144144+```ts
145145+import { Client, CredentialManager, ok } from '@atcute/client';
146146+147147+const manager = new CredentialManager({ service: 'https://bsky.social' });
148148+const rpc = new Client({ handler: manager });
149149+150150+// sign in with handle/email and password (or app password)
151151+await manager.login({ identifier: 'you.bsky.social', password: 'your-app-password' });
152152+153153+// requests are now authenticated
154154+const session = await ok(rpc.get('com.atproto.server.getSession'));
155155+console.log(session.did);
156156+// -> "did:plc:..."
157157+```
158158+159159+save `manager.session` to persist login across app restarts:
160160+161161+```ts
162162+// after login, save the session
163163+localStorage.setItem('session', JSON.stringify(manager.session));
164164+```
165165+166166+```ts
167167+// later, restore the session
168168+const saved = localStorage.getItem('session');
169169+if (saved) {
170170+ await manager.resume(JSON.parse(saved));
171171+}
172172+```
173173+174174+use callbacks to keep persisted sessions in sync:
175175+176176+```ts
177177+const manager = new CredentialManager({
178178+ service: 'https://bsky.social',
179179+ onSessionUpdate(session) {
180180+ // called on login, resume, and token refresh
181181+ localStorage.setItem('session', JSON.stringify(session));
182182+ },
183183+ onExpired(session) {
184184+ // called when refresh token expires and can't be renewed
185185+ localStorage.removeItem('session');
186186+ },
187187+});
188188+```
189189+190190+### response formats
191191+192192+by default, responses are parsed as JSON. for endpoints that return binary data, specify the format
193193+with `as`:
194194+195195+```ts
196196+// get response as a Blob
197197+const { data: blob } = await ok(
198198+ rpc.get('com.atproto.sync.getBlob', {
199199+ params: { did: 'did:plc:...', cid: 'bafyrei...' },
200200+ as: 'blob',
201201+ }),
202202+);
203203+204204+// get response as Uint8Array
205205+const { data: bytes } = await ok(
206206+ rpc.get('com.atproto.sync.getBlob', {
207207+ params: { did: 'did:plc:...', cid: 'bafyrei...' },
208208+ as: 'bytes',
209209+ }),
210210+);
211211+212212+// get response as ReadableStream
213213+const { data: stream } = await ok(
214214+ rpc.get('com.atproto.sync.getBlob', {
215215+ params: { did: 'did:plc:...', cid: 'bafyrei...' },
216216+ as: 'stream',
217217+ }),
218218+);
219219+220220+// discard response body
221221+await ok(
222222+ rpc.post('com.atproto.repo.deleteRecord', {
223223+ input: { repo: 'did:plc:...', collection: '...', rkey: '...' },
224224+ as: null,
225225+ }),
226226+);
227227+```
228228+229229+### runtime validation
230230+231231+by default, responses are trusted without validation. for stricter guarantees, use `call()` with the
232232+schema from a definition package:
233233+234234+```ts
235235+import { Client, ok, simpleFetchHandler } from '@atcute/client';
68236import { AppBskyActorGetProfile } from '@atcute/bluesky';
692377070-{
7171- const handler = simpleFetchHandler({ service: 'https://public.api.bsky.app' });
7272- const rpc = new Client({ handler });
238238+const rpc = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) });
732397474- const { ok, data } = await rpc.call(AppBskyActorGetProfile, {
7575- params: {
7676- actor: 'bsky.app',
7777- },
7878- });
240240+// validates params, input, and output against the schema
241241+const response = await rpc.call(AppBskyActorGetProfile, {
242242+ params: { actor: 'bsky.app' },
243243+});
792448080- if (ok) {
8181- console.log(data.displayName);
8282- // -> "Bluesky"
245245+if (response.ok) {
246246+ // response.data is validated
247247+ console.log(response.data.displayName);
248248+}
249249+```
250250+251251+validation errors throw `ClientValidationError`:
252252+253253+```ts
254254+import { ClientValidationError } from '@atcute/client';
255255+256256+try {
257257+ await rpc.call(AppBskyActorGetProfile, { params: { actor: 'invalid!' } });
258258+} catch (err) {
259259+ if (err instanceof ClientValidationError) {
260260+ console.log(err.target); // 'params', 'input', or 'output'
261261+ console.log(err.message); // validation error details
83262 }
84263}
264264+```
852658686-// performing authenticated requests
8787-{
8888- const manager = new CredentialManager({ service: 'https://bsky.social' });
8989- const rpc = new Client({ handler: manager });
266266+### service proxying
267267+268268+service proxying lets you make authenticated requests through your PDS to other services. the PDS
269269+forwards the request with authorization headers proving it's acting on your behalf.
270270+271271+```ts
272272+// must be authenticated via CredentialManager
273273+const manager = new CredentialManager({ service: 'https://bsky.social' });
274274+await manager.login({ identifier: 'you.bsky.social', password: 'your-app-password' });
275275+276276+// create a client that proxies requests through your PDS to the chat service
277277+const chatClient = new Client({
278278+ handler: manager,
279279+ proxy: {
280280+ did: 'did:web:api.bsky.chat',
281281+ serviceId: '#bsky_chat',
282282+ },
283283+});
284284+285285+// request goes to your PDS, which forwards it to api.bsky.chat with auth headers
286286+const convos = await ok(chatClient.get('chat.bsky.convo.listConvos'));
287287+```
288288+289289+common service IDs include:
290290+291291+- `#atproto_pds` - personal data server
292292+- `#atproto_labeler` - labeler service
293293+- `#bsky_chat` - Bluesky chat service
294294+295295+### custom fetch handlers
296296+297297+the `simpleFetchHandler` works for most cases. for advanced scenarios, provide your own handler:
298298+299299+```ts
300300+import type { FetchHandler } from '@atcute/client';
903019191- await manager.login({ identifier: 'example.com', password: 'ofki-yrwl-hmcc-cvau' });
302302+const customHandler: FetchHandler = async (pathname, init) => {
303303+ // pathname is like "/xrpc/app.bsky.actor.getProfile?actor=bsky.app"
304304+ const url = new URL(pathname, 'https://public.api.bsky.app');
923059393- console.log(manager.session);
9494- // -> { refreshJwt: 'eyJhb...', ... }
306306+ // add custom headers, logging, retry logic, etc.
307307+ console.log(`${init.method?.toUpperCase()} ${url}`);
953089696- const data = await ok(
9797- rpc.get('com.atproto.identity.resolveHandle', {
9898- params: {
9999- handle: 'pfrazee.com',
100100- },
101101- }),
102102- );
309309+ return fetch(url, init);
310310+};
103311104104- console.log(data.did);
105105- // -> 'did:plc:ragtjsm2j2vknwkz3zp4oxrd'
106106-}
312312+const rpc = new Client({ handler: customHandler });
107313```
108314109109-by default, the API client ships with no queries or procedures. you can extend the client by
110110-installing one of these definition packages.
315315+or implement `FetchHandlerObject` for stateful handlers (like `CredentialManager` does):
111316112112-- [`@atcute/atproto`](../../definitions/atproto): `com.atproto.*` schema definitions
113113-- [`@atcute/bluemoji`](../../definitions/bluemoji): `blue.moji.*` schema definitions
114114-- [`@atcute/bluesky`](../../definitions/bluesky): `app.bsky.*` and `chat.bsky.*` schema definitions
115115-- [`@atcute/frontpage`](../../definitions/frontpage): `fyi.unravel.frontpage.*` schema definitions
116116-- [`@atcute/lexicon-community`](../../definitions/lexicon-community): `community.lexicon.\*` schema
117117- definitions
118118-- [`@atcute/microcosm`](../../definitions/microcosm): `blue.microcosm.*` and `com.bad-example.*`
119119- schema definitions
120120-- [`@atcute/ozone`](../../definitions/ozone): `tools.ozone.*` schema definitions
121121-- [`@atcute/pckt`](../../definitions/pckt): `blog.pckt.*` schema definitions
122122-- [`@atcute/tangled`](../../definitions/tangled): `sh.tangled.*` schema definitions
123123-- [`@atcute/whitewind`](../../definitions/whitewind): `com.whtwnd.*` schema definitions
317317+```ts
318318+import type { FetchHandlerObject } from '@atcute/client';
319319+320320+class MyHandler implements FetchHandlerObject {
321321+ async handle(pathname: string, init: RequestInit): Promise<Response> {
322322+ // your implementation
323323+ return fetch(new URL(pathname, 'https://...'), init);
324324+ }
325325+}
326326+327327+const rpc = new Client({ handler: new MyHandler() });
328328+```
+192-4
packages/clients/firehose/README.md
···11# @atcute/firehose
2233-lightweight and cute XRPC subscription client for AT Protocol.
33+lightweight XRPC subscription client for AT Protocol.
44+55+```sh
66+npm install @atcute/firehose
77+```
88+99+this package provides a generic client for XRPC subscriptions - the WebSocket-based streaming
1010+protocol used by AT Protocol. it handles CBOR frame decoding, automatic reconnection, and optional
1111+schema validation.
1212+1313+for consuming the Bluesky network firehose specifically, consider using `@atcute/jetstream` instead,
1414+which provides a simpler JSON-based interface.
1515+1616+## usage
1717+1818+### subscribing to the relay firehose
419520```ts
621import { FirehoseSubscription } from '@atcute/firehose';
722import { ComAtprotoSyncSubscribeRepos } from '@atcute/atproto';
8232424+const subscription = new FirehoseSubscription({
2525+ service: 'wss://bsky.network',
2626+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
2727+});
2828+2929+for await (const message of subscription) {
3030+ console.log(message.$type, message.seq);
3131+}
3232+```
3333+3434+the connection opens when you start iterating and closes when you break out of the loop. the
3535+underlying WebSocket automatically reconnects on disconnection.
3636+3737+### handling message types
3838+3939+messages include a `$type` field indicating their type:
4040+4141+```ts
4242+for await (const message of subscription) {
4343+ switch (message.$type) {
4444+ case 'com.atproto.sync.subscribeRepos#commit': {
4545+ // repository commit (record creates, updates, deletes)
4646+ console.log('commit:', message.repo, message.rev);
4747+ break;
4848+ }
4949+5050+ case 'com.atproto.sync.subscribeRepos#handle': {
5151+ // handle change
5252+ console.log('handle:', message.did, message.handle);
5353+ break;
5454+ }
5555+5656+ case 'com.atproto.sync.subscribeRepos#migrate': {
5757+ // account migration
5858+ console.log('migrate:', message.did, message.migrateTo);
5959+ break;
6060+ }
6161+6262+ case 'com.atproto.sync.subscribeRepos#tombstone': {
6363+ // account deletion
6464+ console.log('tombstone:', message.did);
6565+ break;
6666+ }
6767+6868+ case 'com.atproto.sync.subscribeRepos#identity': {
6969+ // identity update
7070+ console.log('identity:', message.did);
7171+ break;
7272+ }
7373+7474+ case 'com.atproto.sync.subscribeRepos#account': {
7575+ // account status change
7676+ console.log('account:', message.did, message.active);
7777+ break;
7878+ }
7979+ }
8080+}
8181+```
8282+8383+### tracking cursor for resumption
8484+8585+use a function for `params` to provide the current cursor on each connection attempt:
8686+8787+```ts
988let cursor: number | undefined;
10899090+// load saved cursor if resuming
9191+const saved = localStorage.getItem('firehose-cursor');
9292+if (saved) {
9393+ cursor = Number(saved);
9494+}
9595+1196const subscription = new FirehoseSubscription({
1212- service: ['wss://bsky.network'],
9797+ service: 'wss://bsky.network',
1398 nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
9999+ // function is called on each connection/reconnection
14100 params: () => ({ cursor }),
15101});
1610217103for await (const message of subscription) {
1818- if (message.$type === 'com.atproto.sync.subscribeRepos#commit') {
104104+ if ('seq' in message) {
19105 cursor = message.seq;
2020- console.log('commit:', message.seq, message.repo);
106106+107107+ // periodically save cursor for recovery
108108+ if (cursor % 1000 === 0) {
109109+ localStorage.setItem('firehose-cursor', String(cursor));
110110+ }
21111 }
22112}
23113```
114114+115115+the params function is called on each connection attempt, so when the WebSocket reconnects after a
116116+disconnection, it automatically uses the latest cursor value.
117117+118118+### using multiple servers
119119+120120+pass an array of URLs for automatic failover. the client randomly selects one on each connection:
121121+122122+```ts
123123+const subscription = new FirehoseSubscription({
124124+ service: ['wss://bsky.network', 'wss://bsky-relay.example.com'],
125125+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
126126+});
127127+```
128128+129129+### handling errors
130130+131131+XRPC subscriptions can send error frames. handle them with the `onError` callback:
132132+133133+```ts
134134+const subscription = new FirehoseSubscription({
135135+ service: 'wss://bsky.network',
136136+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
137137+ onError(error, message) {
138138+ console.error('firehose error:', error, message);
139139+ // common errors:
140140+ // - "FutureCursor": cursor is ahead of the server
141141+ // - "ConsumerTooSlow": client is not consuming messages fast enough
142142+ },
143143+});
144144+```
145145+146146+### connection lifecycle callbacks
147147+148148+handle connection events for logging or UI updates:
149149+150150+```ts
151151+const subscription = new FirehoseSubscription({
152152+ service: 'wss://bsky.network',
153153+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
154154+ onConnectionOpen(event) {
155155+ console.log('connected to firehose');
156156+ },
157157+ onConnectionClose(event) {
158158+ console.log('disconnected:', event.code, event.reason);
159159+ },
160160+ onConnectionError(event) {
161161+ console.error('connection error:', event.error);
162162+ },
163163+});
164164+```
165165+166166+### updating options at runtime
167167+168168+change options using `updateOptions()`. this triggers a reconnection:
169169+170170+```ts
171171+const subscription = new FirehoseSubscription({
172172+ service: 'wss://bsky.network',
173173+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
174174+});
175175+176176+// later, switch to a different service
177177+subscription.updateOptions({
178178+ service: 'wss://different-relay.example.com',
179179+});
180180+```
181181+182182+### disabling message validation
183183+184184+by default, messages are validated against the schema. disable this for better performance if you
185185+trust the server:
186186+187187+```ts
188188+const subscription = new FirehoseSubscription({
189189+ service: 'wss://bsky.network',
190190+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
191191+ validateMessages: false,
192192+});
193193+```
194194+195195+### WebSocket options
196196+197197+pass options to the underlying
198198+[partysocket](https://github.com/partykit/partykit/tree/main/packages/partysocket) WebSocket for
199199+custom reconnection behavior:
200200+201201+```ts
202202+const subscription = new FirehoseSubscription({
203203+ service: 'wss://bsky.network',
204204+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
205205+ ws: {
206206+ maxRetries: 10,
207207+ minReconnectionDelay: 1000,
208208+ maxReconnectionDelay: 30000,
209209+ },
210210+});
211211+```
+222-13
packages/clients/jetstream/README.md
···11# @atcute/jetstream
2233-a simple Jetstream client
33+lightweight Jetstream subscriber for AT Protocol.
44+55+```sh
66+npm install @atcute/jetstream
77+```
88+99+[Jetstream](https://docs.bsky.app/blog/jetstream) is a streaming service that delivers a filtered
1010+firehose of events from the AT Protocol network over WebSocket. this package provides a simple
1111+client to subscribe to these events.
1212+1313+## usage
1414+1515+### subscribing to events
1616+1717+create a subscription and iterate over events with `for await`:
418519```ts
620import { JetstreamSubscription } from '@atcute/jetstream';
77-import { is } from '@atcute/lexicons';
2121+2222+const subscription = new JetstreamSubscription({
2323+ url: 'wss://jetstream2.us-east.bsky.network',
2424+});
2525+2626+for await (const event of subscription) {
2727+ console.log(event.kind, event.did);
2828+}
2929+```
3030+3131+the connection opens when you start iterating and closes when you break out of the loop. the
3232+underlying WebSocket automatically reconnects on disconnection.
3333+3434+### filtering by collection
83599-import { AppBskyFeedPost } from '@atcute/bluesky';
3636+use `wantedCollections` to receive only events for specific record types:
10373838+```ts
1139const subscription = new JetstreamSubscription({
1240 url: 'wss://jetstream2.us-east.bsky.network',
1313- wantedCollections: ['app.bsky.feed.post'],
4141+ wantedCollections: ['app.bsky.feed.post', 'app.bsky.feed.like'],
1442});
15431644for await (const event of subscription) {
1745 if (event.kind === 'commit') {
1818- const commit = event.commit;
4646+ console.log(event.commit.collection, event.commit.operation);
4747+ // -> "app.bsky.feed.post" "create"
4848+ }
4949+}
5050+```
5151+5252+### filtering by account
5353+5454+use `wantedDids` to receive only events from specific accounts:
19552020- if (commit.collection !== 'app.bsky.feed.post') {
2121- continue;
2222- }
5656+```ts
5757+const subscription = new JetstreamSubscription({
5858+ url: 'wss://jetstream2.us-east.bsky.network',
5959+ wantedDids: ['did:plc:z72i7hdynmk6r22z27h6tvur'], // @bsky.app
6060+});
6161+```
23622424- if (commit.operation === 'create') {
2525- const record = commit.record;
2626- if (!is(AppBskyFeedPost.mainSchema, record)) {
2727- continue;
6363+### handling event types
6464+6565+jetstream delivers three kinds of events:
6666+6767+```ts
6868+for await (const event of subscription) {
6969+ switch (event.kind) {
7070+ case 'commit': {
7171+ // record was created, updated, or deleted
7272+ const { collection, operation, rkey, rev } = event.commit;
7373+7474+ if (operation === 'create' || operation === 'update') {
7575+ // record and cid are available on create/update
7676+ console.log(event.commit.record);
2877 }
29783030- console.log(`${record.text}`);
7979+ break;
8080+ }
8181+8282+ case 'identity': {
8383+ // handle or DID document changed
8484+ const { did, handle, seq, time } = event.identity;
8585+ break;
8686+ }
8787+8888+ case 'account': {
8989+ // account status changed (activated, deactivated, etc.)
9090+ const { did, active, seq, time } = event.account;
9191+ break;
3192 }
3293 }
3394}
3495```
9696+9797+### validating records
9898+9999+jetstream events include the raw record data. use `is()` from `@atcute/lexicons` to validate and
100100+narrow the type:
101101+102102+```ts
103103+import { JetstreamSubscription } from '@atcute/jetstream';
104104+import { is } from '@atcute/lexicons';
105105+106106+import { AppBskyFeedPost } from '@atcute/bluesky';
107107+108108+const subscription = new JetstreamSubscription({
109109+ url: 'wss://jetstream2.us-east.bsky.network',
110110+ wantedCollections: ['app.bsky.feed.post'],
111111+});
112112+113113+for await (const event of subscription) {
114114+ if (event.kind !== 'commit') {
115115+ continue;
116116+ }
117117+118118+ const commit = event.commit;
119119+ if (commit.operation !== 'create') {
120120+ continue;
121121+ }
122122+123123+ // validate the record against the schema
124124+ if (!is(AppBskyFeedPost.mainSchema, commit.record)) {
125125+ console.warn('invalid record', commit.record);
126126+ continue;
127127+ }
128128+129129+ // commit.record is now typed as AppBskyFeedPost.$record
130130+ console.log(`@${event.did}: ${commit.record.text}`);
131131+}
132132+```
133133+134134+### resuming from a cursor
135135+136136+jetstream supports cursors for resuming from a specific point. the cursor is a timestamp in
137137+microseconds:
138138+139139+```ts
140140+const subscription = new JetstreamSubscription({
141141+ url: 'wss://jetstream2.us-east.bsky.network',
142142+ // resume from a saved cursor
143143+ cursor: 1699900000000000,
144144+});
145145+146146+// save the cursor periodically to resume later
147147+setInterval(() => {
148148+ localStorage.setItem('jetstream-cursor', String(subscription.cursor));
149149+}, 5_000);
150150+```
151151+152152+when switching between jetstream instances (e.g., when using multiple URLs for failover), the client
153153+automatically rolls back the cursor by 10 seconds to avoid missing events due to clock differences.
154154+155155+### using multiple servers
156156+157157+pass an array of URLs for automatic failover. the client randomly selects one on each connection:
158158+159159+```ts
160160+const subscription = new JetstreamSubscription({
161161+ url: [
162162+ 'wss://jetstream1.us-east.bsky.network',
163163+ 'wss://jetstream2.us-east.bsky.network',
164164+ 'wss://jetstream1.us-west.bsky.network',
165165+ 'wss://jetstream2.us-west.bsky.network',
166166+ ],
167167+});
168168+```
169169+170170+### updating options at runtime
171171+172172+change filters without reconnecting using `updateOptions()`:
173173+174174+```ts
175175+// start with all collections
176176+const subscription = new JetstreamSubscription({
177177+ url: 'wss://jetstream2.us-east.bsky.network',
178178+});
179179+180180+// later, filter to only posts
181181+subscription.updateOptions({
182182+ wantedCollections: ['app.bsky.feed.post'],
183183+});
184184+185185+// add accounts to filter
186186+subscription.updateOptions({
187187+ wantedDids: ['did:plc:...'],
188188+});
189189+```
190190+191191+changes to `wantedCollections` and `wantedDids` are sent to the server without reconnecting. other
192192+option changes trigger a reconnection.
193193+194194+### connection lifecycle callbacks
195195+196196+handle connection events for logging or UI updates:
197197+198198+```ts
199199+const subscription = new JetstreamSubscription({
200200+ url: 'wss://jetstream2.us-east.bsky.network',
201201+ onConnectionOpen(event) {
202202+ console.log('connected to jetstream');
203203+ },
204204+ onConnectionClose(event) {
205205+ console.log('disconnected from jetstream', event.code, event.reason);
206206+ },
207207+ onConnectionError(event) {
208208+ console.error('jetstream error', event.error);
209209+ },
210210+});
211211+```
212212+213213+### disabling event validation
214214+215215+by default, jetstream events are validated. disable this for slightly better performance if you
216216+trust the server:
217217+218218+```ts
219219+const subscription = new JetstreamSubscription({
220220+ url: 'wss://jetstream2.us-east.bsky.network',
221221+ validateEvents: false,
222222+});
223223+```
224224+225225+note: this only disables validation of the event envelope. you should still validate records using
226226+`is()` from `@atcute/lexicons`.
227227+228228+### WebSocket options
229229+230230+pass options to the underlying
231231+[partysocket](https://github.com/partykit/partykit/tree/main/packages/partysocket) WebSocket for
232232+custom reconnection behavior:
233233+234234+```ts
235235+const subscription = new JetstreamSubscription({
236236+ url: 'wss://jetstream2.us-east.bsky.network',
237237+ ws: {
238238+ maxRetries: 10,
239239+ minReconnectionDelay: 1000,
240240+ maxReconnectionDelay: 30000,
241241+ },
242242+});
243243+```
···2233[Microcosm](https://www.microcosm.blue/) (blue.microcosm.\*, com.bad-example.\*) schema definitions
4455+```sh
66+npm install @atcute/microcosm
77+```
88+59Microcosm is a collection of services and independent community-run infrastructure for AT Protocol,
610including:
711···78827983now all the XRPC operations should be visible in the client
80848181-#### with `@atcute/lex-cli`
8585+### with `@atcute/lex-cli`
82868387when building your own lexicons that reference Microcosm types, configure lex-cli to import from
8488this package:
···11# @atcute/did-plc
2233-validations, type definitions and schemas for did:plc operations
33+validate and process did:plc operation logs.
44+55+```sh
66+npm install @atcute/did-plc
77+```
88+99+did:plc is a self-certifying DID method where the audit log serves as the source of truth. this
1010+package validates that operations are properly signed and chained.
1111+1212+## usage
1313+1414+### validating audit logs
415516```ts
66-import { defs, validateIndexedOperationLog } from '@atcute/did-plc';
1717+import { defs, processIndexedEntryLog } from '@atcute/did-plc';
71888-const did = `did:plc:ragtjsm2j2vknwkz3zp4oxrd`;
1919+const did = 'did:plc:ragtjsm2j2vknwkz3zp4oxrd';
9201021const response = await fetch(`https://plc.directory/${did}/log/audit`);
1122const json = await response.json();
12231324const logs = defs.indexedOperationLog.parse(json);
1414-await validateIndexedOperationLog(did, logs);
2525+const { canonical, nullified } = await processIndexedEntryLog(did, logs);
2626+```
2727+2828+### validating new operations
2929+3030+before submitting a new operation to plc.directory:
3131+3232+```ts
3333+import { validateIncomingOp } from '@atcute/did-plc';
3434+3535+// throws if operation exceeds size limits or has invalid structure
3636+validateIncomingOp(operation);
3737+```
3838+3939+### checking dispute windows
4040+4141+```ts
4242+import { isDisputePeriodActive, getDisputeCandidates } from '@atcute/did-plc';
4343+4444+// check if an operation can still be disputed (72-hour window)
4545+if (isDisputePeriodActive(operation)) {
4646+ // operation is still within the recovery window
4747+}
4848+4949+// find operations that a key can dispute
5050+const candidates = getDisputeCandidates(canonicalLog, rotationKey);
1551```
···11# @atcute/identity-resolver-node
2233-additional atproto identity resolvers for Node.js
33+Node.js handle resolver using native DNS.
44+55+```sh
66+npm install @atcute/identity-resolver-node
77+```
88+99+provides `NodeDnsHandleResolver` which resolves handles via DNS TXT records using Node.js's native
1010+`dns` module, avoiding the need for HTTP-based resolution.
1111+1212+## usage
413514```ts
66-// handle resolution
1515+import { CompositeHandleResolver, WellKnownHandleResolver } from '@atcute/identity-resolver';
1616+import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node';
1717+718const handleResolver = new CompositeHandleResolver({
819 strategy: 'race',
920 methods: {
···1223 },
1324});
14251515-try {
1616- const handle = await didResolver.resolve('bsky.app');
1717- // ^? 'did:plc:z72i7hdynmk6r22z27h6tvur'
1818-} catch (err) {
1919- if (err instanceof DidNotFoundError) {
2020- // handle returned no did
2121- }
2222- if (err instanceof InvalidResolvedHandleError) {
2323- // handle returned a did, but isn't a valid atproto did
2424- }
2525- if (err instanceof AmbiguousHandleError) {
2626- // handle returned multiple did values
2727- }
2828- if (err instanceof FailedHandleResolutionError) {
2929- // handle resolution had thrown something unexpected (fetch error)
3030- }
2626+const did = await handleResolver.resolve('bsky.app');
2727+// -> 'did:plc:z72i7hdynmk6r22z27h6tvur'
2828+```
31293232- if (err instanceof HandleResolutionError) {
3333- // the errors above extend this class, so you can do a catch-all.
3434- }
3535-}
3030+### custom nameservers
3131+3232+```ts
3333+const resolver = new NodeDnsHandleResolver({
3434+ nameservers: ['8.8.8.8', '8.8.4.4'],
3535+});
3636```
+213-40
packages/identity/identity-resolver/README.md
···11# @atcute/identity-resolver
2233-atproto handle and DID document resolution
33+handle and DID document resolution for AT Protocol.
44+55+```sh
66+npm install @atcute/identity-resolver
77+```
88+99+in AT Protocol, handles (like `alice.bsky.social`) need to be resolved to DIDs, and DIDs need to be
1010+resolved to DID documents (which contain the user's PDS location and keys). this package provides
1111+resolvers for both.
1212+1313+## usage
1414+1515+### resolving handles
1616+1717+handles can be resolved via DNS TXT records or HTTP well-known endpoints. use the composite resolver
1818+to try both:
419520```ts
66-// handle resolution
2121+import {
2222+ CompositeHandleResolver,
2323+ DohJsonHandleResolver,
2424+ WellKnownHandleResolver,
2525+} from '@atcute/identity-resolver';
2626+727const handleResolver = new CompositeHandleResolver({
88- strategy: 'race',
928 methods: {
1029 dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }),
1130 http: new WellKnownHandleResolver(),
1231 },
1332});
14331515-try {
1616- const handle = await handleResolver.resolve('bsky.app');
1717- // ^? 'did:plc:z72i7hdynmk6r22z27h6tvur'
1818-} catch (err) {
1919- if (err instanceof DidNotFoundError) {
2020- // handle returned no did
2121- }
2222- if (err instanceof InvalidResolvedHandleError) {
2323- // handle returned a did, but isn't a valid atproto did
2424- }
2525- if (err instanceof AmbiguousHandleError) {
2626- // handle returned multiple did values
2727- }
2828- if (err instanceof FailedHandleResolutionError) {
2929- // handle resolution had thrown something unexpected (fetch error)
3030- }
3434+const did = await handleResolver.resolve('bsky.app');
3535+// -> "did:plc:z72i7hdynmk6r22z27h6tvur"
3636+```
3737+3838+### resolution strategies
3939+4040+the composite resolver supports different strategies for combining DNS and HTTP resolution:
4141+4242+```ts
4343+const handleResolver = new CompositeHandleResolver({
4444+ strategy: 'race', // default - first successful response wins
4545+ methods: { dns: dnsResolver, http: httpResolver },
4646+});
4747+```
4848+4949+available strategies:
5050+5151+- `race` - returns whichever method succeeds first (default)
5252+- `dns-first` - try DNS first, fall back to HTTP if it fails
5353+- `http-first` - try HTTP first, fall back to DNS if it fails
5454+- `both` - require both methods to agree (throws `AmbiguousHandleError` if they differ)
5555+5656+### resolving DID documents
5757+5858+DID documents can be resolved for did:plc and did:web methods:
31593232- if (err instanceof HandleResolutionError) {
3333- // the errors above extend this class, so you can do a catch-all.
3434- }
3535-}
6060+```ts
6161+import {
6262+ CompositeDidDocumentResolver,
6363+ PlcDidDocumentResolver,
6464+ WebDidDocumentResolver,
6565+} from '@atcute/identity-resolver';
36663737-// DID document resolution
3838-const docResolver = new CompositeDidDocumentResolver({
6767+const didResolver = new CompositeDidDocumentResolver({
3968 methods: {
4069 plc: new PlcDidDocumentResolver(),
4170 web: new WebDidDocumentResolver(),
4271 },
4372});
44737474+const doc = await didResolver.resolve('did:plc:z72i7hdynmk6r22z27h6tvur');
7575+// -> { '@context': [...], id: 'did:plc:...', service: [...], ... }
7676+```
7777+7878+### resolving actors
7979+8080+the `ActorResolver` interface provides a way to resolve an actor identifier (handle or DID) to the
8181+essential info needed to interact with them: their DID, verified handle, and PDS endpoint.
8282+8383+`LocalActorResolver` implements this by combining handle and DID document resolution locally:
8484+8585+```ts
8686+import { LocalActorResolver } from '@atcute/identity-resolver';
8787+8888+const actorResolver = new LocalActorResolver({
8989+ handleResolver,
9090+ didDocumentResolver: didResolver,
9191+});
9292+9393+// resolve from handle
9494+const actor = await actorResolver.resolve('bsky.app');
9595+// -> { did: "did:plc:...", handle: "bsky.app", pds: "https://..." }
9696+9797+// resolve from DID
9898+const actor2 = await actorResolver.resolve('did:plc:z72i7hdynmk6r22z27h6tvur');
9999+// -> { did: "did:plc:...", handle: "bsky.app", pds: "https://..." }
100100+```
101101+102102+the local resolver performs bidirectional verification: it checks that the handle in the DID
103103+document resolves back to the same DID.
104104+105105+other implementations of `ActorResolver` can get this info from dedicated identity services (like
106106+Slingshot) without needing to fetch and parse full DID documents.
107107+108108+### handling errors
109109+110110+each resolver throws specific error types for different failure cases:
111111+112112+```ts
113113+import {
114114+ DidNotFoundError,
115115+ InvalidResolvedHandleError,
116116+ AmbiguousHandleError,
117117+ FailedHandleResolutionError,
118118+ HandleResolutionError,
119119+} from '@atcute/identity-resolver';
120120+45121try {
4646- const doc = await docResolver.resolve('did:plc:z72i7hdynmk6r22z27h6tvur');
4747- // ^? { '@context': [...], id: 'did:plc:z72i7hdynmk6r22z27h6tvur', ... }
122122+ const did = await handleResolver.resolve('nonexistent.invalid');
48123} catch (err) {
4949- if (err instanceof DocumentNotFoundError) {
5050- // did returned no document
124124+ if (err instanceof DidNotFoundError) {
125125+ // handle has no DID record
126126+ console.log('handle not found');
127127+ } else if (err instanceof InvalidResolvedHandleError) {
128128+ // handle returned an invalid DID format
129129+ console.log('invalid DID:', err.did);
130130+ } else if (err instanceof AmbiguousHandleError) {
131131+ // multiple different DIDs found (with 'both' strategy)
132132+ console.log('ambiguous handle');
133133+ } else if (err instanceof FailedHandleResolutionError) {
134134+ // network or other unexpected error
135135+ console.log('resolution failed:', err.cause);
136136+ } else if (err instanceof HandleResolutionError) {
137137+ // catch-all for any handle resolution error
51138 }
5252- if (err instanceof UnsupportedDidMethodError) {
5353- // resolver doesn't support did method (composite resolver)
5454- }
5555- if (err instanceof ImproperDidError) {
5656- // resolver considers did as invalid (atproto did:web)
5757- }
5858- if (err instanceof FailedDocumentResolutionError) {
5959- // document resolution had thrown something unexpected (fetch error)
6060- }
139139+}
140140+```
141141+142142+DID document resolution errors:
143143+144144+```ts
145145+import {
146146+ DocumentNotFoundError,
147147+ UnsupportedDidMethodError,
148148+ ImproperDidError,
149149+ FailedDocumentResolutionError,
150150+ DidDocumentResolutionError,
151151+} from '@atcute/identity-resolver';
611526262- if (err instanceof HandleResolutionError) {
6363- // the errors above extend this class, so you can do a catch-all.
153153+try {
154154+ const doc = await didResolver.resolve('did:example:123');
155155+} catch (err) {
156156+ if (err instanceof DocumentNotFoundError) {
157157+ // DID document doesn't exist
158158+ } else if (err instanceof UnsupportedDidMethodError) {
159159+ // resolver doesn't support this DID method
160160+ } else if (err instanceof ImproperDidError) {
161161+ // DID format is invalid for this method
162162+ } else if (err instanceof FailedDocumentResolutionError) {
163163+ // network or other unexpected error
164164+ } else if (err instanceof DidDocumentResolutionError) {
165165+ // catch-all for any DID resolution error
64166 }
65167}
66168```
169169+170170+### caching and abort signals
171171+172172+all resolvers accept options for cache control and cancellation:
173173+174174+```ts
175175+// skip cache
176176+const did = await handleResolver.resolve('bsky.app', { noCache: true });
177177+178178+// with abort signal
179179+const controller = new AbortController();
180180+const did = await handleResolver.resolve('bsky.app', { signal: controller.signal });
181181+182182+// cancel the request
183183+controller.abort();
184184+```
185185+186186+### custom fetch function
187187+188188+all resolvers accept a custom fetch implementation:
189189+190190+```ts
191191+const dnsResolver = new DohJsonHandleResolver({
192192+ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query',
193193+ fetch: customFetch,
194194+});
195195+196196+const httpResolver = new WellKnownHandleResolver({
197197+ fetch: customFetch,
198198+});
199199+200200+const plcResolver = new PlcDidDocumentResolver({
201201+ fetch: customFetch,
202202+});
203203+```
204204+205205+### custom PLC directory
206206+207207+by default, did:plc resolution uses `https://plc.directory`. you can specify a different directory:
208208+209209+```ts
210210+const plcResolver = new PlcDidDocumentResolver({
211211+ plcUrl: 'https://plc.wtf', // mirror of plc.directory
212212+});
213213+```
214214+215215+## resolver classes
216216+217217+### handle resolvers
218218+219219+| class | description |
220220+| ------------------------- | --------------------------------------------------------------- |
221221+| `DohJsonHandleResolver` | resolves via DNS-over-HTTPS (TXT record at `_atproto.{handle}`) |
222222+| `WellKnownHandleResolver` | resolves via HTTP (`https://{handle}/.well-known/atproto-did`) |
223223+| `XrpcHandleResolver` | resolves via XRPC (`com.atproto.identity.resolveHandle`) |
224224+| `CompositeHandleResolver` | combines DNS and HTTP resolvers |
225225+226226+### DID document resolvers
227227+228228+| class | description |
229229+| ------------------------------ | ----------------------------------------------------- |
230230+| `PlcDidDocumentResolver` | resolves did:plc from PLC directory |
231231+| `WebDidDocumentResolver` | resolves did:web from domain |
232232+| `XrpcDidDocumentResolver` | resolves via XRPC (`com.atproto.identity.resolveDid`) |
233233+| `CompositeDidDocumentResolver` | routes to resolver by DID method |
234234+235235+### actor resolvers
236236+237237+| class | description |
238238+| -------------------- | ------------------------------------------------------------------ |
239239+| `LocalActorResolver` | combines handle and DID resolution with bidirectional verification |
+184-1
packages/identity/identity/README.md
···11# @atcute/identity
2233-syntax, type definitions and schemas for atproto handles, DIDs and DID documents.
33+types, schemas, and utilities for working with AT Protocol identities.
44+55+```sh
66+npm install @atcute/identity
77+```
88+99+in AT Protocol, users are identified by [DIDs](https://www.w3.org/TR/did-core/) (decentralized
1010+identifiers). this package provides tools for working with DIDs and DID documents - the documents
1111+that describe a user's identity, including their handle, PDS location, and cryptographic keys.
1212+1313+for resolving handles and DIDs (fetching their documents), see `@atcute/identity-resolver`.
1414+1515+## usage
1616+1717+### checking DID types
1818+1919+AT Protocol supports two DID methods: `did:plc` and `did:web`. use the type guards to check which
2020+method a DID uses:
2121+2222+```ts
2323+import { isPlcDid, isWebDid, isAtprotoDid } from '@atcute/identity';
2424+2525+const did = 'did:plc:z72i7hdynmk6r22z27h6tvur';
2626+2727+if (isPlcDid(did)) {
2828+ // did:plc identifier
2929+ console.log('PLC DID');
3030+}
3131+3232+if (isWebDid(did)) {
3333+ // did:web identifier (general, includes custom paths)
3434+ console.log('Web DID');
3535+}
3636+3737+if (isAtprotoDid(did)) {
3838+ // either did:plc or atproto-compatible did:web
3939+ console.log('AT Protocol DID');
4040+}
4141+```
4242+4343+`isAtprotoDid` checks for DIDs that are valid in AT Protocol - this excludes did:web identifiers
4444+with custom paths, which atproto doesn't support.
4545+4646+### extracting the DID method
4747+4848+```ts
4949+import { extractDidMethod } from '@atcute/identity';
5050+5151+const method = extractDidMethod('did:plc:z72i7hdynmk6r22z27h6tvur');
5252+// -> "plc"
5353+```
5454+5555+### working with did:web
5656+5757+convert a did:web identifier to its DID document URL:
5858+5959+```ts
6060+import { webDidToDocumentUrl, normalizeWebDid } from '@atcute/identity';
6161+6262+// simple domain
6363+const url = webDidToDocumentUrl('did:web:example.com');
6464+// -> URL { href: "https://example.com/.well-known/did.json" }
6565+6666+// with path
6767+const url2 = webDidToDocumentUrl('did:web:example.com:users:alice');
6868+// -> URL { href: "https://example.com/users/alice/did.json" }
6969+7070+// normalize for comparison
7171+const normalized = normalizeWebDid('did:web:EXAMPLE.COM');
7272+// -> "did:web:example.com"
7373+```
7474+7575+### reading DID documents
7676+7777+once you have a DID document (from a resolver or API), extract information using the utility
7878+functions:
7979+8080+```ts
8181+import {
8282+ getPdsEndpoint,
8383+ getAtprotoHandle,
8484+ getAtprotoVerificationMaterial,
8585+ getLabelerEndpoint,
8686+} from '@atcute/identity';
8787+import type { DidDocument } from '@atcute/identity';
8888+8989+const doc: DidDocument = {
9090+ '@context': ['https://www.w3.org/ns/did/v1'],
9191+ id: 'did:plc:z72i7hdynmk6r22z27h6tvur',
9292+ alsoKnownAs: ['at://bsky.app'],
9393+ verificationMethod: [
9494+ {
9595+ id: 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto',
9696+ type: 'Multikey',
9797+ controller: 'did:plc:z72i7hdynmk6r22z27h6tvur',
9898+ publicKeyMultibase: 'zDnaek...',
9999+ },
100100+ ],
101101+ service: [
102102+ {
103103+ id: '#atproto_pds',
104104+ type: 'AtprotoPersonalDataServer',
105105+ serviceEndpoint: 'https://morel.us-east.host.bsky.network',
106106+ },
107107+ ],
108108+};
109109+110110+// get the user's PDS URL
111111+const pds = getPdsEndpoint(doc);
112112+// -> "https://morel.us-east.host.bsky.network"
113113+114114+// get the user's handle
115115+const handle = getAtprotoHandle(doc);
116116+// -> "bsky.app"
117117+118118+// get the signing key material
119119+const key = getAtprotoVerificationMaterial(doc);
120120+// -> { type: "Multikey", publicKeyMultibase: "zDnaek..." }
121121+```
122122+123123+### service endpoint helpers
124124+125125+extract specific service endpoints from DID documents:
126126+127127+```ts
128128+import {
129129+ getPdsEndpoint,
130130+ getLabelerEndpoint,
131131+ getBlueskyChatEndpoint,
132132+ getBlueskyFeedgenEndpoint,
133133+ getBlueskyNotificationEndpoint,
134134+ getAtprotoServiceEndpoint,
135135+} from '@atcute/identity';
136136+137137+// standard helpers for common services
138138+const pds = getPdsEndpoint(doc); // #atproto_pds
139139+const labeler = getLabelerEndpoint(doc); // #atproto_labeler
140140+const chat = getBlueskyChatEndpoint(doc); // #bsky_chat
141141+const feedgen = getBlueskyFeedgenEndpoint(doc); // #bsky_fg
142142+const notif = getBlueskyNotificationEndpoint(doc); // #bsky_notif
143143+144144+// or use the generic helper for custom services
145145+const custom = getAtprotoServiceEndpoint(doc, {
146146+ id: '#my_service',
147147+ type: 'MyServiceType', // optional type filter
148148+});
149149+```
150150+151151+### validating DID documents
152152+153153+use the validation schemas to check if a DID document is well-formed:
154154+155155+```ts
156156+import { defs } from '@atcute/identity';
157157+158158+const result = defs.didDocument.try(unknownData);
159159+if (result.ok) {
160160+ // result.value is a validated DidDocument
161161+ console.log(result.value.id);
162162+} else {
163163+ // validation failed
164164+ console.error('invalid DID document');
165165+}
166166+```
167167+168168+the schema validates:
169169+170170+- required fields (`@context`, `id`)
171171+- DID string format
172172+- verification method structure and key formats
173173+- service endpoint URLs
174174+- no duplicate entries in arrays
175175+176176+### type definitions
177177+178178+the package exports TypeScript types for DID document structures:
179179+180180+```ts
181181+import type { DidDocument, VerificationMethod, Service } from '@atcute/identity';
182182+183183+// DidDocument - the full DID document
184184+// VerificationMethod - a verification method entry
185185+// Service - a service endpoint entry
186186+```
+5-1
packages/lexicons/lex-cli/README.md
···11# @atcute/lex-cli
2233-command line tool for generating TypeScript schemas out of lexicon documents
33+generate TypeScript schemas from lexicon documents.
44+55+```sh
66+npm install @atcute/lex-cli
77+```
4859## quick start
610
+9-1
packages/lexicons/lexicon-doc/README.md
···11# @atcute/lexicon-doc
2233-type definitions and schemas for atproto lexicon documents
33+parse and author atproto lexicon documents.
44+55+```sh
66+npm install @atcute/lexicon-doc
77+```
88+99+## usage
1010+1111+### parsing lexicon documents
412513```ts
614import { findExternalReferences, lexiconDoc } from '@atcute/lexicon-doc';
+50-34
packages/lexicons/lexicon-resolver/README.md
···11# @atcute/lexicon-resolver
2233-atproto lexicon authority resolution and schema retrieval
33+resolve lexicon schemas from the AT Protocol network.
44+55+```sh
66+npm install @atcute/lexicon-resolver
77+```
88+99+## usage
1010+1111+### resolving lexicon authority
1212+1313+find which DID is authoritative for an NSID via DNS TXT records:
414515```ts
66-// authority resolution
1616+import { DohJsonLexiconAuthorityResolver } from '@atcute/lexicon-resolver';
1717+718const authorityResolver = new DohJsonLexiconAuthorityResolver({
819 dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query',
920});
10211111-try {
1212- const authority = await authorityResolver.resolve('app.bsky.feed.post');
1313- // ^? 'did:plc:4v4y5r3lwsbtmsxhile2ljac'
1414-} catch (err) {
1515- if (err instanceof AuthorityNotFoundError) {
1616- // nsid returned no did
1717- }
1818- if (err instanceof InvalidResolvedAuthorityError) {
1919- // nsid returned a did, but isn't a valid atproto did
2020- }
2121- if (err instanceof AmbiguousAuthorityError) {
2222- // nsid returned multiple did values
2323- }
2424- if (err instanceof FailedAuthorityResolutionError) {
2525- // nsid resolution had thrown something unexpected (fetch error)
2626- }
2222+const authority = await authorityResolver.resolve('app.bsky.feed.post');
2323+// -> 'did:plc:4v4y5r3lwsbtmsxhile2ljac'
2424+```
2525+2626+### fetching lexicon schemas
27272828- if (err instanceof LexiconAuthorityResolutionError) {
2929- // the errors above extend this class, so you can do a catch-all.
3030- }
3131-}
2828+retrieve the lexicon document from an authority's PDS:
32293333-// schema resolution
3030+```ts
3131+import {
3232+ CompositeDidDocumentResolver,
3333+ PlcDidDocumentResolver,
3434+ WebDidDocumentResolver,
3535+} from '@atcute/identity-resolver';
3636+import { LexiconSchemaResolver } from '@atcute/lexicon-resolver';
3737+3438const schemaResolver = new LexiconSchemaResolver({
3539 didDocumentResolver: new CompositeDidDocumentResolver({
3640 methods: {
···4044 }),
4145});
42464747+const resolved = await schemaResolver.resolve(authority, 'app.bsky.feed.post');
4848+// -> { uri: string, cid: string, schema: LexiconDoc }
4949+```
5050+5151+### error handling
5252+5353+```ts
5454+import {
5555+ AuthorityNotFoundError,
5656+ InvalidResolvedAuthorityError,
5757+ LexiconAuthorityResolutionError,
5858+ InvalidLexiconSchemaError,
5959+ LexiconResolutionError,
6060+} from '@atcute/lexicon-resolver';
6161+4362try {
4444- const resolved = await schemaResolver.resolve(authority, 'app.bsky.feed.post');
4545- // ^? { uri: string, cid: string, schema: LexiconDoc }
6363+ await authorityResolver.resolve(nsid);
4664} catch (err) {
4747- if (err instanceof InvalidLexiconSchemaError) {
4848- // lexicon schema is malformed
6565+ if (err instanceof LexiconAuthorityResolutionError) {
6666+ // authority resolution failed
4967 }
5050- if (err instanceof InvalidLexiconProofError) {
5151- // lexicon record proof verification failed
5252- }
5353- if (err instanceof FailedLexiconResolutionError) {
5454- // lexicon resolution had thrown something unexpected (fetch error)
5555- }
6868+}
56697070+try {
7171+ await schemaResolver.resolve(authority, nsid);
7272+} catch (err) {
5773 if (err instanceof LexiconResolutionError) {
5858- // the errors above extend this class, so you can do a catch-all.
7474+ // schema resolution failed
5975 }
6076}
6177```
+145-4
packages/lexicons/lexicons/README.md
···11# @atcute/lexicons
2233-AT Protocol core lexicon types, interfaces, and schema validations
33+core types and syntax validators for AT Protocol.
44+55+```sh
66+npm install @atcute/lexicons
77+```
88+99+this package provides syntax validators for AT Protocol's string formats (handles, DIDs, NSIDs,
1010+etc.) and validation functions for use with lexicon schemas from definition packages.
1111+1212+## usage
1313+1414+### validating syntax
1515+1616+use the syntax validators to check AT Protocol string formats:
1717+1818+```ts
1919+import {
2020+ isHandle,
2121+ isDid,
2222+ isNsid,
2323+ isCid,
2424+ isTid,
2525+ isRecordKey,
2626+ isDatetime,
2727+ isResourceUri,
2828+ isActorIdentifier,
2929+} from '@atcute/lexicons/syntax';
3030+3131+// handle format (domain names)
3232+isHandle('alice.bsky.social'); // true
3333+isHandle('invalid'); // false (no TLD)
3434+3535+// DID format
3636+isDid('did:plc:z72i7hdynmk6r22z27h6tvur'); // true
3737+isDid('did:web:example.com'); // true
3838+isDid('not-a-did'); // false
3939+4040+// NSID format (namespaced identifiers)
4141+isNsid('app.bsky.feed.post'); // true
4242+isNsid('com.atproto.repo.createRecord'); // true
4343+4444+// CID format (content identifiers)
4545+isCid('bafyreib2rxk3rybk3aobmv5cjuql3setrnhyfnxq...'); // true
4646+4747+// TID format (timestamp identifiers)
4848+isTid('3jzfcijpj2z2a'); // true
4949+5050+// record key format
5151+isRecordKey('3jzfcijpj2z2a'); // true (TID)
5252+isRecordKey('self'); // true (literal "self")
5353+5454+// datetime format (ISO 8601)
5555+isDatetime('2024-01-15T12:00:00.000Z'); // true
5656+5757+// AT URI format
5858+isResourceUri('at://did:plc:123/app.bsky.feed.post/abc'); // true
5959+6060+// actor identifier (handle or DID)
6161+isActorIdentifier('alice.bsky.social'); // true
6262+isActorIdentifier('did:plc:123'); // true
6363+```
6464+6565+### parsing AT URIs
6666+6767+parse AT URIs to extract their components:
6868+6969+```ts
7070+import { parseResourceUri, parseCanonicalResourceUri } from '@atcute/lexicons/syntax';
7171+7272+// parse any AT URI (handle or DID authority)
7373+const uri = parseResourceUri('at://alice.bsky.social/app.bsky.feed.post/123');
7474+// -> { repo: 'alice.bsky.social', collection: 'app.bsky.feed.post', rkey: '123' }
7575+7676+// parse canonical AT URI (DID authority only)
7777+const canonical = parseCanonicalResourceUri('at://did:plc:123/app.bsky.feed.post/abc');
7878+// -> { repo: 'did:plc:123', collection: 'app.bsky.feed.post', rkey: 'abc' }
7979+```
8080+8181+### branded types
8282+8383+the syntax module exports branded types for type-safe string handling:
8484+8585+```ts
8686+import type { Handle, Did, Nsid, Cid, Tid, RecordKey, Datetime } from '@atcute/lexicons/syntax';
8787+import { isHandle } from '@atcute/lexicons/syntax';
8888+8989+function getProfile(handle: Handle): Promise<Profile> {
9090+ // handle is guaranteed to be a valid handle format
9191+}
9292+9393+const input = 'alice.bsky.social';
9494+if (isHandle(input)) {
9595+ // input is narrowed to Handle type
9696+ getProfile(input);
9797+}
9898+```
9999+100100+### validating records
101101+102102+validate data against lexicon schemas using `is()`, `safeParse()`, or `parse()`. schemas come from
103103+definition packages like `@atcute/bluesky`:
41045105```ts
66-import { isHandle, isDid } from '@atcute/lexicons/syntax';
106106+import { is, safeParse, parse, ValidationError } from '@atcute/lexicons';
107107+import { AppBskyFeedPost } from '@atcute/bluesky';
710888-isHandle('example.com');
109109+const data: unknown = {
110110+ $type: 'app.bsky.feed.post',
111111+ text: 'hello world',
112112+ createdAt: '2024-01-15T12:00:00.000Z',
113113+};
91141010-isDid('did:web:example.com');
115115+// type guard - returns boolean
116116+if (is(AppBskyFeedPost.mainSchema, data)) {
117117+ // data is typed as AppBskyFeedPost.$record
118118+ console.log(data.text);
119119+}
120120+121121+// safe parse - returns result object
122122+const result = safeParse(AppBskyFeedPost.mainSchema, data);
123123+if (result.ok) {
124124+ console.log(result.value.text);
125125+} else {
126126+ console.log(result.message);
127127+ console.log(result.issues);
128128+}
129129+130130+// parse - throws on failure
131131+try {
132132+ const post = parse(AppBskyFeedPost.mainSchema, data);
133133+ console.log(post.text);
134134+} catch (err) {
135135+ if (err instanceof ValidationError) {
136136+ console.log(err.message);
137137+ console.log(err.issues);
138138+ }
139139+}
140140+```
141141+142142+### IPLD types
143143+144144+the package exports types for IPLD data structures used in AT Protocol:
145145+146146+```ts
147147+import type { Blob, Bytes, CidLink } from '@atcute/lexicons';
148148+149149+// Blob - reference to uploaded media (images, videos)
150150+// Bytes - raw binary data (base64 encoded in JSON)
151151+// CidLink - reference to content by CID
11152```
+7-1
packages/misc/uint8array/README.md
···11# @atcute/uint8array
2233-uint8array utilities
33+Uint8Array utilities used internally by atcute packages.
44+55+```sh
66+npm install @atcute/uint8array
77+```
88+99+this library provides common byte array operations.
···11# @atcute/oauth-browser-client
2233-minimal OAuth browser client implementation for AT Protocol.
33+minimal OAuth browser client for AT Protocol.
44+55+```sh
66+npm install @atcute/oauth-browser-client
77+```
88+99+## client metadata
1010+1111+your app needs an OAuth client metadata document hosted at a public URL. this tells authorization
1212+servers about your app:
41355-- **only the bare minimum**: enough code to get authentication reasonably working, with only one
66- happy path is supported (only ES256 keys for DPoP. PKCE and DPoP-bound PAR is required.)
77-- **does not use IndexedDB**: makes the library work under Safari's lockdown mode, and has less
88- maintenance headache overall, but it also means this is "less secure" (it won't be able to use
99- non-exportable keys as recommended by [DPoP specification][idb-dpop-spec].)
1010-- **not well-tested**: it has been used in personal projects and by friends for quite some time, but
1111- hasn't seen any use outside of that. using the [reference implementation][oauth-atproto-lib] is
1212- recommended if you are unsure about the implications presented here.
1414+```json
1515+{
1616+ "client_id": "https://example.com/oauth-client-metadata.json",
1717+ "client_name": "My App",
1818+ "client_uri": "https://example.com",
1919+ "redirect_uris": ["https://example.com/oauth/callback"],
2020+ "scope": "atproto transition:generic",
2121+ "grant_types": ["authorization_code", "refresh_token"],
2222+ "response_types": ["code"],
2323+ "token_endpoint_auth_method": "none",
2424+ "application_type": "web",
2525+ "dpop_bound_access_tokens": true
2626+}
2727+```
13281414-[idb-dpop-spec]: https://datatracker.ietf.org/doc/html/rfc9449#section-2-4
1515-[oauth-atproto-lib]: https://npm.im/@atproto/oauth-client-browser
2929+the `client_id` must be the URL where this document is hosted. see the
3030+[OAuth client metadata spec](https://docs.bsky.app/docs/advanced-guides/oauth-client#client-metadata)
3131+for all available fields.
16321733## usage
18341919-### setup
3535+### configuration
20362121-initialize the client by importing and calling `configureOAuth` with the client ID and redirect URL,
2222-along with the resolvers that will be used to resolve and verify account details. this call should
2323-be placed before any other calls you make with this library.
3737+call `configureOAuth` before using any other functions from this library:
24382539```ts
2626-import { configureOAuth, defaultIdentityResolver } from '@atcute/oauth-browser-client';
4040+import { configureOAuth } from '@atcute/oauth-browser-client';
27412842import {
2943 CompositeDidDocumentResolver,
4444+ LocalActorResolver,
3045 PlcDidDocumentResolver,
3146 WebDidDocumentResolver,
3247 XrpcHandleResolver,
···3752 client_id: 'https://example.com/oauth-client-metadata.json',
3853 redirect_uri: 'https://example.com/oauth/callback',
3954 },
4040- identityResolver: defaultIdentityResolver({
4141- // AT Protocol handles resolve via DNS TXT record or HTTP well-known endpoints.
4242- // since web apps lack direct DNS access and face CORS restrictions, we're using
4343- // Bluesky's AppView for this example.
4444- //
4545- // NOTE: Bluesky may log handle resolutions and requester info per their privacy
4646- // policy. consider the privacy implications of this arrangement and change this
4747- // setup if unsuitable for your use case.
5555+ identityResolver: new LocalActorResolver({
4856 handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }),
4949-5057 didDocumentResolver: new CompositeDidDocumentResolver({
5158 methods: {
5259 plc: new PlcDidDocumentResolver(),
···5764});
5865```
59666060-### starting an authorization flow
6767+> [!NOTE]
6868+> this example uses Bluesky's AppView for handle resolution since web apps lack direct DNS access.
6969+> Bluesky may log handle resolutions per their privacy policy - consider the implications for your
7070+> use case.
61716262-we can start authorization by calling `createAuthorizationUrl` with the intended account's
6363-identifier or service along with the scope of the authorization, which should either match the one
6464-in your client metadata, or a reduced set of it.
7272+### starting authorization
65736674```ts
6775import { createAuthorizationUrl } from '@atcute/oauth-browser-client';
68766977const authUrl = await createAuthorizationUrl({
7078 target: { type: 'account', identifier: 'mary.my.id' },
7171- // or { type: 'pds', serviceUrl: 'https://bsky.social' }
7279 scope: 'atproto transition:generic transition:chat.bsky',
7380});
74817575-// recommended to wait for the browser to persist local storage before proceeding
7676-await sleep(200);
7777-7878-// redirect the user to sign in and authorize the app
8282+await sleep(200); // let browser persist local storage
7983window.location.assign(authUrl);
8080-8181-// if this is on an async function, ideally the function should never ever resolve.
8282-// the only way it should resolve at this point is if the user aborted the authorization
8383-// by returning back to this page (thanks to back-forward page caching)
8484-await new Promise((_resolve, reject) => {
8585- const listener = () => {
8686- reject(new Error(`user aborted the login request`));
8787- };
8888-8989- window.addEventListener('pageshow', listener, { once: true });
9090-});
9184```
92859386### finalizing authorization
94879595-once the user has been redirected to your redirect URL, we can call `finalizeAuthorization` with the
9696-parameters that have been provided.
8888+on your redirect URL, extract the parameters and finalize:
97899890```ts
9991import { XRPC } from '@atcute/client';
10092import { OAuthUserAgent, finalizeAuthorization } from '@atcute/oauth-browser-client';
10193102102-// `createAuthorizationUrl` asks for the server to redirect here with the
103103-// parameters assigned in the hash, not the search string.
9494+// server redirects with params in hash, not search string
10495const params = new URLSearchParams(location.hash.slice(1));
10596106106-// this is optional, but after retrieving the parameters, we should ideally
107107-// scrub it from history to prevent this authorization state to be replayed,
108108-// just for good measure.
9797+// scrub params from URL to prevent replay
10998history.replaceState(null, '', location.pathname + location.search);
11099111111-// you'd be given a session object that you can then pass to OAuthUserAgent!
112112-const session = await finalizeAuthorization(params);
113113-114114-// now you can start making requests!
100100+const { session } = await finalizeAuthorization(params);
115101const agent = new OAuthUserAgent(session);
116116-117117-// pass it onto the XRPC so you can make RPC calls with the PDS.
118118-{
119119- const rpc = new XRPC({ handler: agent });
120120-121121- const { data } = await rpc.get('com.atproto.identity.resolveHandle', {
122122- params: {
123123- handle: 'mary.my.id',
124124- },
125125- });
126126-}
102102+const rpc = new XRPC({ handler: agent });
127103128128-// or, use it directly!
129129-{
130130- const response = await agent.handle('/xrpc/com.atproto.identity.resolveHandle?handle=mary.my.id');
131131-}
104104+const { data } = await rpc.get('com.atproto.identity.resolveHandle', {
105105+ params: { handle: 'mary.my.id' },
106106+});
132107```
133108134134-the `session` object returned by `finalizeAuthorization` should not be stored anywhere else, as it
135135-is already persisted in the internal database. you are expected to keep track of who's signed in and
136136-who was last signed in for your own UI, as the sessions stored by the database is not guaranteed to
137137-be permanent (mostly if they don't come with a refresh token.)
109109+the session is persisted internally - don't store it elsewhere. track signed-in DIDs yourself for
110110+your UI, as sessions without refresh tokens may expire.
138111139139-### resuming existing sessions
140140-141141-you can resume existing sessions by calling `getSession` with the DID identifier you intend to
142142-resume.
112112+### resuming sessions
143113144114```ts
145145-import { XRPC } from '@atcute/client';
146115import { OAuthUserAgent, getSession } from '@atcute/oauth-browser-client';
147116148117const session = await getSession('did:plc:ia76kvnndjutgedggx2ibrem', { allowStale: true });
149149-150118const agent = new OAuthUserAgent(session);
151151-const rpc = new XRPC({ handler: agent });
152119```
153120154154-### removing sessions
155155-156156-you can manually remove sessions via `deleteStoredSession`, but ideally, you should revoke the token
157157-first before doing so.
121121+### signing out
158122159123```ts
160124import { OAuthUserAgent, deleteStoredSession, getSession } from '@atcute/oauth-browser-client';
···164128try {
165129 const session = await getSession(did, { allowStale: true });
166130 const agent = new OAuthUserAgent(session);
167167-168131 await agent.signOut();
169169-} catch (err) {
170170- // `signOut` also deletes the session, we only serve as fallback if it fails.
171171- deleteStoredSession(did);
132132+} catch {
133133+ deleteStoredSession(did); // fallback if signOut fails
172134}
173135```
174136175175-## confidential client mode (optional)
176176-177177-by default, `@atcute/oauth-browser-client` operates as a **public client**, resulting in shorter
178178-session lifetimes by authorization servers as it's deemed to be unable to securely store
179179-credentials.
180180-181181-if you want longer-lived sessions and better security controls, you can enable **confidential client
182182-mode** by setting up a [client assertion backend](client-assertion-backend).
137137+## confidential client mode
183138184184-[client-assertion-backend]: https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend
139139+by default, this library operates as a **public client** with shorter session lifetimes. for
140140+longer-lived sessions, set up a [client assertion backend][client-assertion-backend] to enable
141141+**confidential client mode**.
185142186186-### setup
143143+[client-assertion-backend]:
144144+ https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend
187145188188-configure the client with a function to fetch client assertions from your backend:
146146+add `fetchClientAssertion` to your config. the backend API is entirely up to you - this is just one
147147+example:
189148190149```ts
191191-import { configureOAuth } from '@atcute/oauth-browser-client';
192192-193150configureOAuth({
194151 // ... existing config
195152···198155199156 const response = await fetch('https://example.com/api/client-assertion', {
200157 method: 'POST',
201201- headers: {
202202- dpop: dpop,
203203- 'content-type': 'application/json',
204204- },
158158+ headers: { dpop, 'content-type': 'application/json' },
205159 body: JSON.stringify({ jkt, aud }),
206160 });
207161208162 const data = await response.json();
209209-210163 return {
211164 client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
212165 client_assertion: data.assertion,
···215168});
216169```
217170218218-the backend API is completely up to you—there's no standardized spec. design it however works best
219219-for your infrastructure (authentication, request format, error handling, etc.)
220220-221221-your backend needs to validate the incoming DPoP proof and sign a client assertion JWT with the
222222-following interface:
223223-224224-```ts
225225-interface ClientAssertionJwt {
226226- /** your client ID */
227227- iss: string;
228228- /** also your client ID */
229229- sub: string;
230230- /** the authorization server receiving this token */
231231- aud: string;
232232- /** when this token expires */
233233- exp: number;
234234- /** unique nonce */
235235- jti: string;
236236- /** asserts that this jkt is allowed */
237237- cnf: { jkt: string };
238238-}
239239-```
240240-241241-you're able to use the `jkt` to refuse assertions when necessary (suspicious activity, compromised
242242-code, etc.)
243243-244244-### client metadata updates
245245-246246-your OAuth client metadata document must also be updated for confidential clients:
247247-248248-```json
249249-{
250250- "client_id": "https://example.com/oauth-client-metadata.json",
251251- "client_name": "My App",
252252- "redirect_uris": ["https://example.com/oauth/callback"],
253253- "scope": "atproto transition:generic",
254254- "token_endpoint_auth_method": "private_key_jwt",
255255- "token_endpoint_auth_signing_alg": "ES256",
256256- "jwks_uri": "https://example.com/oauth-jwks.json"
257257-}
258258-```
259259-260260-the `jwks_uri` should expose the public keys used to sign client assertions. it should return a JSON
261261-Web Key Set (JWKS) document:
262262-263263-```json
264264-{
265265- "keys": [
266266- {
267267- "kty": "EC",
268268- "crv": "P-256",
269269- "x": "base64url-encoded-x-coordinate",
270270- "y": "base64url-encoded-y-coordinate",
271271- "use": "sig",
272272- "kid": "key-identifier",
273273- "alg": "ES256"
274274- }
275275- ]
276276-}
277277-```
278278-279279-the public keys in the JWKS must correspond to the private keys your backend uses to sign client
280280-assertions. multiple keys can be listed to support key rotation.
171171+your backend validates the DPoP proof and signs a client assertion JWT containing `iss`, `sub` (both
172172+your client ID), `aud` (authorization server), `exp`, `jti` (unique nonce), and `cnf: { jkt }` (the
173173+allowed key thumbprint).
281174282282-## additional guide
175175+update your client metadata for confidential mode - replace `token_endpoint_auth_method` with
176176+`private_key_jwt`, add `token_endpoint_auth_signing_alg: "ES256"`, and add a `jwks_uri` pointing to
177177+your public keys.
283178284284-### configuring your Vite project
179179+## local development with Vite
285180286286-you might want to configure the server options in your Vite config so you'll never end up visiting
287287-your app in `localhost`, which is specifically forbidden by AT Protocol's OAuth, let's change it so
288288-it'll always use `127.0.0.1`:
181181+AT Protocol OAuth forbids `localhost` - use `127.0.0.1` instead:
289182290183```ts
291291-/// vite.config.ts
184184+// vite.config.ts
292185import { defineConfig } from 'vite';
186186+import metadata from './public/oauth-client-metadata.json' with { type: 'json' };
293187294188const SERVER_HOST = '127.0.0.1';
295189const SERVER_PORT = 12520;
296190297191export default defineConfig({
298298- server: {
299299- host: SERVER_HOST,
300300- port: SERVER_PORT,
301301- },
302302-});
303303-```
304304-305305-additionally, to make it easier to develop locally and deploy to production, you should consider
306306-adding a plugin that'll inject the necessary values for you through environment variables:
307307-308308-```ts
309309-/// vite.config.ts
310310-import metadata from './public/oauth-client-metadata.json' with { type: 'json' };
311311-312312-export default defineConfig({
313313- // ...
314314-192192+ server: { host: SERVER_HOST, port: SERVER_PORT },
315193 plugins: [
316316- // injects OAuth-related environment variables
317194 {
318195 config(_conf, { command }) {
319196 if (command === 'build') {
320197 process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id;
321198 process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0];
322199 } else {
323323- const redirectUri = (() => {
324324- const url = new URL(metadata.redirect_uris[0]);
325325- return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`;
326326- })();
327327-328328- const clientId =
329329- `http://localhost` +
330330- `?redirect_uri=${encodeURIComponent(redirectUri)}` +
200200+ const redirectUri = `http://${SERVER_HOST}:${SERVER_PORT}${new URL(metadata.redirect_uris[0]).pathname}`;
201201+ process.env.VITE_OAUTH_CLIENT_ID =
202202+ `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` +
331203 `&scope=${encodeURIComponent(metadata.scope)}`;
332332-333333- process.env.VITE_DEV_SERVER_PORT = '' + SERVER_PORT;
334334- process.env.VITE_OAUTH_CLIENT_ID = clientId;
335204 process.env.VITE_OAUTH_REDIRECT_URI = redirectUri;
336205 }
337337-338338- process.env.VITE_CLIENT_URI = metadata.client_uri;
339206 process.env.VITE_OAUTH_SCOPE = metadata.scope;
340207 },
341208 },
···343210});
344211```
345212346346-we'll augment the type declarations to get type-checking on it:
347347-348348-```ts
349349-/// src/vite-env.d.ts
350350-351351-interface ImportMetaEnv {
352352- readonly VITE_DEV_SERVER_PORT?: string;
353353- readonly VITE_CLIENT_URI: string;
354354- readonly VITE_OAUTH_CLIENT_ID: string;
355355- readonly VITE_OAUTH_REDIRECT_URI: string;
356356- readonly VITE_OAUTH_SCOPE: string;
357357-}
358358-359359-interface ImportMeta {
360360- readonly env: ImportMetaEnv;
361361-}
362362-```
363363-364364-et voilà! you can now use this to configure the client.
213213+then use environment variables in your code:
365214366215```ts
367216configureOAuth({
···371220 },
372221 // ...
373222});
374374-375375-// ... later during sign-in process
376376-const authUrl = await createAuthorizationUrl({
377377- // ...
378378- scope: import.meta.env.VITE_OAUTH_SCOPE,
379379-});
380223```
381224382382-adjust the code here as necessary, the plugin adds more environment variables than what is actually
383383-needed, you can remove them if you don't think you'd need it.
225225+## caveats
226226+227227+- **minimal implementation**: only ES256 DPoP keys, requires PKCE and DPoP-bound PAR
228228+- **no IndexedDB**: works in Safari lockdown mode but can't use non-exportable keys as [recommended
229229+ by DPoP spec][dpop-spec]
230230+- **limited testing**: works in personal projects but consider the [reference
231231+ implementation][oauth-atproto-lib] for production
232232+233233+[dpop-spec]: https://datatracker.ietf.org/doc/html/rfc9449#section-2-4
234234+[oauth-atproto-lib]: https://npm.im/@atproto/oauth-client-browser
+4
packages/servers/xrpc-server-bun/README.md
···2233Bun WebSocket adapter for `@atcute/xrpc-server`.
4455+```sh
66+npm install @atcute/xrpc-server-bun
77+```
88+59```ts
610import { XRPCRouter } from '@atcute/xrpc-server';
711import { createBunWebSocket } from '@atcute/xrpc-server-bun';
+4
packages/servers/xrpc-server-cloudflare/README.md
···2233Cloudflare Workers WebSocket adapter for `@atcute/xrpc-server`.
4455+```sh
66+npm install @atcute/xrpc-server-cloudflare
77+```
88+59```ts
610import { XRPCRouter } from '@atcute/xrpc-server';
711import { createCloudflareWebSocket } from '@atcute/xrpc-server-cloudflare';
+4
packages/servers/xrpc-server-deno/README.md
···2233Deno WebSocket adapter for `@atcute/xrpc-server`.
4455+```sh
66+npm install @atcute/xrpc-server-deno
77+```
88+59```ts
610import { XRPCRouter } from '@atcute/xrpc-server';
711import { createDenoWebSocket } from '@atcute/xrpc-server-deno';
+4
packages/servers/xrpc-server-node/README.md
···2233Node.js WebSocket adapter for `@atcute/xrpc-server`.
4455+```sh
66+npm install @atcute/xrpc-server-node
77+```
88+59```ts
610import { serve } from '@hono/node-server';
711import { XRPCRouter } from '@atcute/xrpc-server';
+6-2
packages/servers/xrpc-server/README.md
···11# @atcute/xrpc-server
2233-a small web framework for handling XRPC operations.
33+web framework for XRPC servers.
44+55+```sh
66+npm install @atcute/xrpc-server
77+```
4859## quick start
610···4953```ts
5054// file: src/index.js
5155import { XRPCRouter, json } from '@atcute/xrpc-server';
5252-import { cors } from '@atucte/xrpc-server/middlewares/cors';
5656+import { cors } from '@atcute/xrpc-server/middlewares/cors';
53575458import { ComExampleGreet } from './lexicons/index.js';
5559
+8-1
packages/utilities/car/README.md
···11# @atcute/car
2233-lightweight [DASL CAR (content-addressable archives)][dasl-car] codec library for AT Protocol.
33+content-addressable archive (CAR) reader for AT Protocol.
44+55+```sh
66+npm install @atcute/car
77+```
88+99+this library implements DASL's [CAR][dasl-car] format used by AT Protocol to store and transfer
1010+repository data.
411512[dasl-car]: https://dasl.ing/car.html
613
+32-19
packages/utilities/cbor/README.md
···11# @atcute/cbor
2233-lightweight [DASL dCBOR42 (deterministic CBOR with tag 42)][dasl-dcbor42] codec library for AT
44-Protocol.
33+deterministic CBOR codec for AT Protocol.
5466-the specific profile being implemented is [IPLD DAG-CBOR][ipld-dag-cbor], with some additional notes
77-to keep in mind:
55+```sh
66+npm install @atcute/cbor
77+```
8899-- `undefined` types are still forbidden, except for when they are in a `map` type, where fields will
1010- be omitted instead, which makes it easier to construct objects to then pass to the encoder.
1111-- `byte` and `link` types are represented by atproto's [lex-json][atproto-data-model] interfaces,
1212- but because these involve string codec and parsing, they are done lazily by `BytesWrapper` and
1313- `CidLinkWrapper` instances.
1414- - use `fromBytes` and `fromCidLink` to convert them to Uint8Array or CID interface respectively,
1515- without hitting the string conversion path.
1616- - use `toBytes` and `toCidLink` for the other direction.
1717-- integers can't exceed JavaScript's safe integer range, no bigint conversions will occur as they
1818- will be thrown instead if encountered.
99+this library implements DASL's [DRISL][dasl-drisl] format used by AT Protocol for encoding records
1010+and repository data.
1111+1212+[dasl-drisl]: https://dasl.ing/drisl.html
1313+1414+## usage
19152020-[atproto-data-model]: https://atproto.com/specs/data-model
2121-[dasl-dcbor42]: https://dasl.ing/dcbor42.html
2222-[ipld-dag-cbor]: https://ipld.io/specs/codecs/dag-cbor/spec
1616+### encoding
23172418```ts
2519import { encode } from '@atcute/cbor';
···3226};
33273428const cbor = encode(record);
3535-// ^? Uint8Array(90) [ ... ]
2929+// -> Uint8Array(90)
3030+```
3131+3232+### decoding
3333+3434+```ts
3535+import { decode } from '@atcute/cbor';
3636+3737+const record = decode(cborBytes);
3838+// -> { $type: 'app.bsky.feed.post', ... }
3639```
37403838-Implementation based on the excellent [`microcbor` library](https://github.com/joeltg/microcbor).
4141+## notes
4242+4343+- `undefined` values are omitted from maps (making it easier to construct objects)
4444+- bytes and CID links use lazy wrappers (`BytesWrapper`, `CidLinkWrapper`) compatible with atproto's
4545+ [lex-json][atproto-data-model] format
4646+- use `toBytes`/`fromBytes` and `toCidLink`/`fromCidLink` to convert between lex-json and raw types
4747+- integers must be within JavaScript's safe integer range (no bigint support)
4848+4949+[atproto-data-model]: https://atproto.com/specs/data-model
5050+5151+based on [`microcbor`](https://github.com/joeltg/microcbor).
+52-6
packages/utilities/cid/README.md
···11# @atcute/cid
2233-lightweight [DASL CID][dasl-cid] codec library for AT Protocol.
33+content identifier (CID) codec for AT Protocol.
44+55+```sh
66+npm install @atcute/cid
77+```
88+99+this library implements DASL's [CID][dasl-cid] format used by AT Protocol to address resources by
1010+their contents.
411512[dasl-cid]: https://dasl.ing/cid.html
6131414+## usage
1515+1616+### creating CIDs
1717+718```ts
819import * as CID from '@atcute/cid';
9202121+// create a CID from DAG-CBOR data
2222+const cid = await CID.create(0x71, cborBytes);
2323+// -> { version: 1, codec: 113, digest: { ... }, bytes: Uint8Array(36) }
2424+2525+// create from raw data
2626+const rawCid = await CID.create(0x55, rawBytes);
2727+```
2828+2929+### parsing CIDs
3030+3131+```ts
3232+import * as CID from '@atcute/cid';
3333+3434+// parse from base32 string
1035const cid = CID.fromString('bafyreihffx5a2e7k5uwrmmgofbvzujc5cmw5h4espouwuxt3liqoflx3ee');
1111-// ^? { version: 1, codec: 113, digest: { ... }, bytes: Uint8Array(36) }
12361313-// Creating a CID containing CBOR data
1414-const cid = await CID.create(0x71, buffer);
3737+// parse from binary (with 0x00 prefix)
3838+const cid = CID.fromBinary(bytes);
3939+4040+// parse from raw CID bytes
4141+const cid = CID.decode(cidBytes);
4242+```
4343+4444+### serializing CIDs
4545+4646+```ts
4747+import * as CID from '@atcute/cid';
4848+4949+// to base32 string
5050+CID.toString(cid);
5151+// -> "bafyreihffx5a2e7k5uwrmmgofbvzujc5cmw5h4espouwuxt3liqoflx3ee"
15521616-// Serializing CID into string
1717-CID.toString(cid); // -> bafyrei...
5353+// to binary (with 0x00 prefix)
5454+CID.toBinary(cid);
5555+// -> Uint8Array(37)
5656+```
5757+5858+### comparing CIDs
5959+6060+```ts
6161+import * as CID from '@atcute/cid';
6262+6363+CID.equals(cidA, cidB); // true if same content hash
1864```
+41-14
packages/utilities/crypto/README.md
···11# @atcute/crypto
2233-lightweight atproto cryptographic library, supporting its two "blessed" elliptic curve cryptography
44-systems:
33+cryptographic utilities for AT Protocol.
5466-- `p256`: uses WebCrypto API.
77-- `secp256k1`: uses `node:crypto` on Node.js, [`@noble/secp256k1`][noble-secp256k1] everywhere else
88- (browsers, Bun, Deno).
55+```sh
66+npm install @atcute/crypto
77+```
88+99+this package provides key generation, signing, and verification for the two elliptic curve systems
1010+used by AT Protocol to certify identity and repository data:
1111+1212+- `p256`: uses WebCrypto API
1313+- `secp256k1`: uses `node:crypto` on Node.js, [`@noble/secp256k1`][noble-secp256k1] elsewhere
9141015[noble-secp256k1]: https://github.com/paulmillr/noble-secp256k1
11161717+## usage
1818+1919+### creating keypairs
2020+1221```ts
1313-import { Secp256k1PrivateKeyExportable, verifySigWithDidKey } from './index.js';
2222+import { Secp256k1PrivateKeyExportable, P256PrivateKeyExportable } from '@atcute/crypto';
14232424+// secp256k1 keypair
1525const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
16261717-// `.sign()` hashes the data and signs it
2727+// p256 keypair
2828+const p256Keypair = await P256PrivateKeyExportable.createKeypair();
2929+```
3030+3131+### signing data
3232+3333+```ts
3434+// sign() hashes the data and signs it
1835const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
1936const sig = await keypair.sign(data);
3737+```
20382121-// `.exportPublicKey()` exports the public key in various formats
2222-// e.g. `did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38`
2323-const didPublicKey = await keypair.exportPublicKey('did');
3939+### exporting public keys
4040+4141+```ts
4242+// export as did:key format
4343+const didKey = await keypair.exportPublicKey('did');
4444+// -> "did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38"
4545+4646+// export as multibase
4747+const multibase = await keypair.exportPublicKey('multibase');
4848+```
4949+5050+### verifying signatures
24512525-// `.verify()` can be used to check if the signature is valid, but to save the
2626-// hassle of figuring out the key type, we can use `verifySigWithDidKey()`
2727-const ok = await verifySigWithDidKey(didPublicKey, sig, data);
5252+```ts
5353+import { verifySigWithDidKey } from '@atcute/crypto';
28542929-expect(ok).toBe(true);
5555+// verify using did:key (automatically detects curve type)
5656+const ok = await verifySigWithDidKey(didKey, sig, data);
3057```
+8-1
packages/utilities/mst/README.md
···11# @atcute/mst
2233-atproto MST (Merkle Search Tree) manipulation utilities
33+Merkle Search Tree (MST) manipulation utilities for AT Protocol.
44+55+```sh
66+npm install @atcute/mst
77+```
4859MST is a key-value tree structure used in atproto repositories to store collections of records. keys
610are sorted lexicographically and organized into nodes based on the leading zero bits in their
711SHA-256 hash. each node uses prefix compression for efficient storage and has a CID (content
812identifier) for content-addressable access.
1313+1414+this is a low-level utility for building tools that need to work directly with repository
1515+structures. for reading repository exports, see `@atcute/repo` instead.
9161017see the [atproto repository specification](https://atproto.com/specs/repository) for more details.
+7-1
packages/utilities/multibase/README.md
···11# @atcute/multibase
2233-provides various base codecs used in atproto ecosystem
33+base encoding utilities for AT Protocol.
44+55+```sh
66+npm install @atcute/multibase
77+```
88+99+provides various base codecs used in the atproto ecosystem:
410511- base16
612- base32
+9-1
packages/utilities/repo/README.md
···11# @atcute/repo
2233-read AT Protocol repository exports
33+read AT Protocol repository exports.
44+55+```sh
66+npm install @atcute/repo
77+```
88+99+AT Protocol stores user data in repositories - Merkle tree structures containing records organized
1010+by collection. this package reads repository CAR exports (from `com.atproto.sync.getRepo` or
1111+account exports) and iterates over the records.
412513## usage
614
+37-5
packages/utilities/tid/README.md
···11# @atcute/tid
2233-atproto timestamp identifier codec library
33+timestamp identifier (TID) codec for AT Protocol.
44+55+```sh
66+npm install @atcute/tid
77+```
88+99+this library implements atproto's TID codec used to generate compact and unique record keys that can
1010+be sorted chronologically.
1111+1212+## usage
1313+1414+### generating TIDs
415516```ts
617import * as TID from '@atcute/tid';
71888-const tidString = TID.now();
99-// ^? "3l25zusnsfctk"
1919+// generate a TID for the current time
2020+const tid = TID.now();
2121+// -> "3l25zusnsfctk"
10221111-const result = TID.parse(tidString);
1212-// ^? { timestamp: 1724171495793000, clockid: 816 }
2323+// create from specific timestamp (microseconds) and clock ID
2424+const custom = TID.create(1724171495793000, 512);
2525+// -> "3l25zusnsfcta"
2626+```
2727+2828+### parsing TIDs
2929+3030+```ts
3131+import * as TID from '@atcute/tid';
3232+3333+const { timestamp, clockid } = TID.parse('3l25zusnsfctk');
3434+// timestamp: 1724171495793000 (microseconds since epoch)
3535+// clockid: 816
3636+```
3737+3838+### validating TIDs
3939+4040+```ts
4141+import * as TID from '@atcute/tid';
4242+4343+TID.validate('3l25zusnsfctk'); // true
4444+TID.validate('invalid'); // false
1345```