@atproto/lex 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1287 -0
- package/bin/lex +165 -0
- package/index.d.ts +6 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
# @atproto/lex
|
|
2
|
+
|
|
3
|
+
Type-safe Lexicon tooling for creating great API clients.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @atproto/lex
|
|
7
|
+
lex --help
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- Install and manage Lexicon schemas
|
|
11
|
+
- Generate TypeScript client and data validators
|
|
12
|
+
- Handle common tasks like OAuth
|
|
13
|
+
|
|
14
|
+
**What is this?**
|
|
15
|
+
|
|
16
|
+
Working directly with XRPC endpoints requires manually tracking schema definitions, validation data structures, and managing authentication. `@atproto/lex` automates this by:
|
|
17
|
+
|
|
18
|
+
1. Fetching lexicons from the network and generating TypeScript types
|
|
19
|
+
2. Providing runtime validation to ensure data matches schemas
|
|
20
|
+
3. Offering a type-safe client that knows which parameters each endpoint expects
|
|
21
|
+
4. Support modern patterns like tree-shaking and composition
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
const profile = await client.call(app.bsky.actor.getProfile, {
|
|
25
|
+
actor: 'atproto.com',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
await client.create(app.bsky.feed.post, {
|
|
29
|
+
text: 'Hello, world!',
|
|
30
|
+
createdAt: new Date().toISOString(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const posts = await client.list(app.bsky.feed.post, {
|
|
34
|
+
limit: 10,
|
|
35
|
+
repo: 'atproto.com',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
app.bsky.actor.profile.$validate({
|
|
39
|
+
$type: 'app.bsky.actor.profile',
|
|
40
|
+
displayName: 'Ha'.repeat(32) + '!',
|
|
41
|
+
}) // { success: false, error: Error: grapheme too big (maximum 64) at $.displayName (got 65) }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
|
45
|
+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
|
46
|
+
|
|
47
|
+
- [Quick Start](#quick-start)
|
|
48
|
+
- [Lexicon Schemas](#lexicon-schemas)
|
|
49
|
+
- [TypeScript Schemas](#typescript-schemas)
|
|
50
|
+
- [Generated Schema Structure](#generated-schema-structure)
|
|
51
|
+
- [Type definitions](#type-definitions)
|
|
52
|
+
- [Validation Helpers](#validation-helpers)
|
|
53
|
+
- [Client API](#client-api)
|
|
54
|
+
- [Creating a Client](#creating-a-client)
|
|
55
|
+
- [Core Methods](#core-methods)
|
|
56
|
+
- [Error Handling](#error-handling)
|
|
57
|
+
- [Authentication Methods](#authentication-methods)
|
|
58
|
+
- [Labeler Configuration](#labeler-configuration)
|
|
59
|
+
- [Low-Level XRPC](#low-level-xrpc)
|
|
60
|
+
- [Advanced Usage](#advanced-usage)
|
|
61
|
+
- [Workflow Integration](#workflow-integration)
|
|
62
|
+
- [Tree-Shaking](#tree-shaking)
|
|
63
|
+
- [Custom Headers](#custom-headers)
|
|
64
|
+
- [Request Options](#request-options)
|
|
65
|
+
- [Actions](#actions)
|
|
66
|
+
- [Building Library-Style APIs with Actions](#building-library-style-apis-with-actions)
|
|
67
|
+
- [License](#license)
|
|
68
|
+
|
|
69
|
+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
**1. Install Lexicons**
|
|
74
|
+
|
|
75
|
+
Install the Lexicon schemas you need for your application:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
lex install app.bsky.feed.post app.bsky.feed.like
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This creates:
|
|
82
|
+
|
|
83
|
+
- `lexicons.json` - manifest tracking installed Lexicons and their versions (CIDs)
|
|
84
|
+
- `lexicons/` - directory containing the Lexicon JSON files
|
|
85
|
+
|
|
86
|
+
> [!NOTE]
|
|
87
|
+
>
|
|
88
|
+
> The `lex` command might conflict with other binaries intalled on your system.
|
|
89
|
+
> If that happens, you can also run the CLI using `ts-lex`, `pnpm exec lex` or
|
|
90
|
+
> `npx @atproto/lex`.
|
|
91
|
+
|
|
92
|
+
**2. Verify and commit installed Lexicons**
|
|
93
|
+
|
|
94
|
+
Make sure to commit the `lexicons.json` manifest and the `lexicons/` directory containing the JSON files to version control.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
echo "./src/lexicons" >> .gitignore
|
|
98
|
+
git add lexicons.json lexicons/
|
|
99
|
+
git commit -m "Install Lexicons"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
> [!NOTE]
|
|
103
|
+
>
|
|
104
|
+
> The generated TypeScript files don't need to be committed to version control. Instead, they can be pre-built during your project's build step or post-install step. See [Workflow Integration](#workflow-integration) for details.
|
|
105
|
+
|
|
106
|
+
**3. Use in your code**
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { Client } from '@atproto/lex'
|
|
110
|
+
import * as app from './lexicons/app.js'
|
|
111
|
+
|
|
112
|
+
// Create a client instance
|
|
113
|
+
const client = new Client('https://public.api.bsky.app')
|
|
114
|
+
|
|
115
|
+
// Start making requests using generated schemas
|
|
116
|
+
const response = await client.call(app.bsky.actor.getProfile, {
|
|
117
|
+
actor: 'pfrazee.com',
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
> [!TIP]
|
|
122
|
+
>
|
|
123
|
+
> If you wish to customize the output location, or any other options, you can run the `lex build` command separately. For that purpose, make sure to use the `--no-build` flag when installing lexicons to skip the automatic build step.
|
|
124
|
+
|
|
125
|
+
## Lexicon Schemas
|
|
126
|
+
|
|
127
|
+
The `lex install` command fetches Lexicon schemas from the Atmosphere network and manages them locally (in the `lexicons/` directory by default). It also updates the `lexicons.json` manifest file to track installed Lexicons and their versions.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Install Lexicons and update lexicons.json (default behavior)
|
|
131
|
+
lex install app.bsky.feed.post
|
|
132
|
+
|
|
133
|
+
# Install all Lexicons from lexicons.json manifest
|
|
134
|
+
lex install
|
|
135
|
+
|
|
136
|
+
# Install specific Lexicons without updating manifest
|
|
137
|
+
lex install --no-save app.bsky.feed.post app.bsky.actor.profile
|
|
138
|
+
|
|
139
|
+
# Update (re-fetch) all installed Lexicons to latest versions
|
|
140
|
+
lex install --update
|
|
141
|
+
|
|
142
|
+
# Fetch any missing Lexicons and verify against manifest
|
|
143
|
+
lex install --ci
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Options:
|
|
147
|
+
|
|
148
|
+
- `--manifest <path>` - Path to lexicons.json manifest file (default: `./lexicons.json`)
|
|
149
|
+
- `--no-save` - Don't update lexicons.json with installed lexicons (save is enabled by default)
|
|
150
|
+
- `--no-build` - Skip building TypeScript lexicon schema files after installation (build is enabled by default)
|
|
151
|
+
- `--update` - Update all installed lexicons to their latest versions by re-resolving and re-installing them
|
|
152
|
+
- `--ci` - Error if the installed lexicons do not match the CIDs in the lexicons.json manifest
|
|
153
|
+
- `--lexicons <dir>` - Directory containing lexicon JSON files (default: `./lexicons`)
|
|
154
|
+
- `--out <dir>` - Output directory for generated TS files (default: `./src/lexicons`)
|
|
155
|
+
|
|
156
|
+
## TypeScript Schemas
|
|
157
|
+
|
|
158
|
+
The `lex install` command automatically builds TypeScript schemas after installing Lexicon JSON files. You can also run the build step separately using the `lex build` command. These generated schemas provide type-safe validation, type guards, and builder utilities for working with AT Protocol data structures.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
lex build --lexicons ./lexicons --out ./src/lexicons
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Options:
|
|
165
|
+
|
|
166
|
+
- `--lexicons <dir>` - Directory containing lexicon JSON files (default: `./lexicons`)
|
|
167
|
+
- `--out <dir>` - Output directory for generated TypeScript (default: `./src/lexicons`)
|
|
168
|
+
- `--clear` - Clear output directory before generating
|
|
169
|
+
- `--override` - Override existing files (has no effect with --clear)
|
|
170
|
+
- `--no-pretty` - Don't run prettier on generated files (prettier is enabled by default)
|
|
171
|
+
- `--ignore-errors` - How to handle errors when processing input files
|
|
172
|
+
- `--pure-annotations` - Add `/*#__PURE__*/` annotations for tree-shaking tools. Set this to true if you are using generated lexicons in a library
|
|
173
|
+
- `--exclude <patterns...>` - List of strings or regex patterns to exclude lexicon documents by their IDs
|
|
174
|
+
- `--include <patterns...>` - List of strings or regex patterns to include lexicon documents by their IDs
|
|
175
|
+
- `--lib <package>` - Package name of the library to import the lex schema utility "l" from (default: `@atproto/lex`)
|
|
176
|
+
- `--allowLegacyBlobs` - Allow generating schemas that accept legacy blob references (disabled by default; enabling this might cause compatibility issues with records created a long time ago)
|
|
177
|
+
|
|
178
|
+
### Generated Schema Structure
|
|
179
|
+
|
|
180
|
+
Each Lexicon generates a TypeScript module with:
|
|
181
|
+
|
|
182
|
+
- **Type definitions** - TypeScript types extracted from the schema
|
|
183
|
+
- **Schema instances** - Runtime validation objects with methods
|
|
184
|
+
- **Exported utilities** - Convenience functions for common operations
|
|
185
|
+
|
|
186
|
+
### Type definitions
|
|
187
|
+
|
|
188
|
+
You can extract TypeScript types from the generated schemas for use in you application:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import * as app from './lexicons/app.js'
|
|
192
|
+
|
|
193
|
+
// Extract the type for a post record
|
|
194
|
+
type Post = app.bsky.feed.post.Main
|
|
195
|
+
|
|
196
|
+
// Use the extracted types
|
|
197
|
+
const post: Post = {
|
|
198
|
+
$type: 'app.bsky.feed.post',
|
|
199
|
+
text: 'Hello, AT Protocol!',
|
|
200
|
+
createdAt: new Date().toISOString(),
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Validation Helpers
|
|
205
|
+
|
|
206
|
+
Each schema provides multiple validation methods:
|
|
207
|
+
|
|
208
|
+
#### `$nsid` - Namespace Identifier
|
|
209
|
+
|
|
210
|
+
Returns the NSID of the schema:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import * as app from './lexicons/app.js'
|
|
214
|
+
|
|
215
|
+
console.log(app.bsky.feed.defs.$nsid) // 'app.bsky.feed.defs'
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
#### `$type` - Type Identifier
|
|
219
|
+
|
|
220
|
+
Returns the `$type` string of the schema (for record and object schemas):
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
import * as app from './lexicons/app.js'
|
|
224
|
+
|
|
225
|
+
console.log(app.bsky.feed.post.$type) // 'app.bsky.feed.post'
|
|
226
|
+
console.log(app.bsky.actor.defs.profileViewBasic.$type) // 'app.bsky.actor.defs#profileViewBasic'
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### `$check(data)` - Type Guard
|
|
230
|
+
|
|
231
|
+
Returns `true` if data matches the schema, `false` otherwise. Acts as a TypeScript type guard:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import * as app from './lexicons/app.js'
|
|
235
|
+
|
|
236
|
+
const data = {
|
|
237
|
+
$type: 'app.bsky.feed.post',
|
|
238
|
+
text: 'Hello!',
|
|
239
|
+
createdAt: new Date().toISOString(),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (app.bsky.feed.post.$check(data)) {
|
|
243
|
+
// TypeScript knows data is a Post here
|
|
244
|
+
console.log(data.text)
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
#### `$parse(data)` - Parse and Validate
|
|
249
|
+
|
|
250
|
+
Validates and returns typed data, throwing an error if validation fails:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import * as app from './lexicons/app.js'
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const post = app.bsky.feed.post.$main.$parse({
|
|
257
|
+
$type: 'app.bsky.feed.post',
|
|
258
|
+
text: 'Hello!',
|
|
259
|
+
createdAt: new Date().toISOString(),
|
|
260
|
+
})
|
|
261
|
+
// post is now typed and validated
|
|
262
|
+
console.log(post.text)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.error('Validation failed:', error)
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
#### `$validate(data)` - Get Validation Result
|
|
269
|
+
|
|
270
|
+
Returns a detailed validation result object without throwing:
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
import * as app from './lexicons/app.js'
|
|
274
|
+
|
|
275
|
+
const result = app.bsky.feed.post.$validate({
|
|
276
|
+
$type: 'app.bsky.feed.post',
|
|
277
|
+
text: 'Hello!',
|
|
278
|
+
createdAt: new Date().toISOString(),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (result.success) {
|
|
282
|
+
console.log('Valid post:', result.value)
|
|
283
|
+
} else {
|
|
284
|
+
console.error('Validation failed:', result.error)
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### `$build(data)` - Build with Defaults
|
|
289
|
+
|
|
290
|
+
Creates a valid object by applying defaults for optional fields:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
import * as app from './lexicons/app.js'
|
|
294
|
+
|
|
295
|
+
// Build a like record with defaults (and without needing to specify $type)
|
|
296
|
+
const like = app.bsky.feed.like.$build({
|
|
297
|
+
subject: {
|
|
298
|
+
uri: 'at://did:plc:abc/app.bsky.feed.post/123',
|
|
299
|
+
cid: 'bafyrei...',
|
|
300
|
+
},
|
|
301
|
+
createdAt: new Date().toISOString(),
|
|
302
|
+
})
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
#### `$isTypeOf(data)` - Type Discriminator
|
|
306
|
+
|
|
307
|
+
Discriminates (already validated) data by `$type`, without re-validating. This is especially useful when working with union types:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
import { l } from '@atproto/lex'
|
|
311
|
+
import * as app from './lexicons/app.js'
|
|
312
|
+
|
|
313
|
+
declare const data:
|
|
314
|
+
| app.bsky.feed.post.Main
|
|
315
|
+
| app.bsky.feed.like.Main
|
|
316
|
+
| l.TypedObject
|
|
317
|
+
|
|
318
|
+
// Discriminate by $type without re-validating
|
|
319
|
+
if (app.bsky.feed.post.$isTypeOf(data)) {
|
|
320
|
+
// data is a post
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Client API
|
|
325
|
+
|
|
326
|
+
### Creating a Client
|
|
327
|
+
|
|
328
|
+
#### Unauthenticated Client
|
|
329
|
+
|
|
330
|
+
Just provide the service URL:
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
import { Client } from '@atproto/lex'
|
|
334
|
+
|
|
335
|
+
const client = new Client('https://public.api.bsky.app')
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
#### Authenticated Client with OAuth
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
import { Client } from '@atproto/lex'
|
|
342
|
+
import { OAuthClient } from '@atproto/oauth-client-node'
|
|
343
|
+
|
|
344
|
+
// Setup OAuth client (see @atproto/oauth-client documentation)
|
|
345
|
+
const oauthClient = new OAuthClient({
|
|
346
|
+
/* ... */
|
|
347
|
+
})
|
|
348
|
+
const session = await oauthClient.restore(userDid)
|
|
349
|
+
|
|
350
|
+
// Create authenticated client
|
|
351
|
+
const client = new Client(session)
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
For detailed OAuth setup, see the [@atproto/oauth-client](../../../oauth/oauth-client) documentation.
|
|
355
|
+
|
|
356
|
+
#### Creating a Client from Another Client
|
|
357
|
+
|
|
358
|
+
You can create a new `Client` instance from an existing client. The new client will share the same underlying configuration (authentication, headers, labelers, service proxy), with the ability to override specific settings.
|
|
359
|
+
|
|
360
|
+
> [!NOTE]
|
|
361
|
+
>
|
|
362
|
+
> When you create a client from another client, the child client inherits the base client's configuration. On every request, the child client merges its own configuration with the base client's current configuration, with the child's settings taking precedence. Changes to the base client's configuration (like `baseClient.setLabelers()`) will be reflected in child client requests, but changes to child clients do not affect the base client.
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import { Client } from '@atproto/lex'
|
|
366
|
+
|
|
367
|
+
// Base client with authentication
|
|
368
|
+
const baseClient = new Client(session)
|
|
369
|
+
|
|
370
|
+
baseClient.setLabelers(['did:plc:labelerA', 'did:plc:labelerB'])
|
|
371
|
+
baseClient.headers.set('x-app-version', '1.0.0')
|
|
372
|
+
|
|
373
|
+
// Create a new client with additional configuration that will get merged with
|
|
374
|
+
// baseClient's settings on every request.
|
|
375
|
+
const configuredClient = new Client(baseClient, {
|
|
376
|
+
labelers: ['did:plc:labelerC'],
|
|
377
|
+
headers: { 'x-trace-id': 'abc123' },
|
|
378
|
+
})
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
This pattern is particularly useful when you need to:
|
|
382
|
+
|
|
383
|
+
- Configure labelers after authentication
|
|
384
|
+
- Add application-specific headers
|
|
385
|
+
- Create multiple clients with different configurations from the same session
|
|
386
|
+
|
|
387
|
+
**Example: Configuring labelers after sign-in**
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
import { Client } from '@atproto/lex'
|
|
391
|
+
import * as app from './lexicons/app.js'
|
|
392
|
+
|
|
393
|
+
async function createBaseClient(session: OAuthSession) {
|
|
394
|
+
// Create base client
|
|
395
|
+
const client = new Client(session, {
|
|
396
|
+
service: 'did:web:api.bsky.app#bsky_appview',
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Fetch user preferences
|
|
400
|
+
const { preferences } = await client.call(app.bsky.actor.getPreferences)
|
|
401
|
+
|
|
402
|
+
// Extract labeler preferences
|
|
403
|
+
const labelerPref = preferences.findLast((p) =>
|
|
404
|
+
app.bsky.actor.defs.labelersPref.check(p),
|
|
405
|
+
)
|
|
406
|
+
const labelers = labelerPref?.labelers.map((l) => l.did) ?? []
|
|
407
|
+
|
|
408
|
+
// Configure the client with the user's preferred labelers
|
|
409
|
+
client.setLabelers(labelers)
|
|
410
|
+
|
|
411
|
+
return client
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Usage
|
|
415
|
+
const baseClient = await createBaseClient(session)
|
|
416
|
+
|
|
417
|
+
// Create a new client with a different service, but reusing the labelers
|
|
418
|
+
// from the base client.
|
|
419
|
+
const otherClient = new Client(baseClient, {
|
|
420
|
+
service: 'did:web:com.example.other#other_service',
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Whenever you update labelers on the base client, the other client will automatically
|
|
424
|
+
// receive the same updates, since they share the same labeler set.
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
#### Client with Service Proxy (authenticated only)
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
import { Client } from '@atproto/lex'
|
|
431
|
+
|
|
432
|
+
// Route requests through a specific service
|
|
433
|
+
const client = new Client(session, {
|
|
434
|
+
service: 'did:web:api.bsky.app#bsky_appview',
|
|
435
|
+
})
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Core Methods
|
|
439
|
+
|
|
440
|
+
#### `client.call()`
|
|
441
|
+
|
|
442
|
+
Call procedures or queries defined in Lexicons.
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
import * as app from './lexicons/app.js'
|
|
446
|
+
|
|
447
|
+
// Query (GET request)
|
|
448
|
+
const profile = await client.call(app.bsky.actor.getProfile, {
|
|
449
|
+
actor: 'pfrazee.com',
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
// Procedure (POST request)
|
|
453
|
+
const result = await client.call(app.bsky.feed.sendInteractions, {
|
|
454
|
+
interactions: [
|
|
455
|
+
/* ... */
|
|
456
|
+
],
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// With options
|
|
460
|
+
const timeline = await client.call(
|
|
461
|
+
app.bsky.feed.getTimeline,
|
|
462
|
+
{
|
|
463
|
+
limit: 50,
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
signal: abortSignal,
|
|
467
|
+
headers: { 'custom-header': 'value' },
|
|
468
|
+
},
|
|
469
|
+
)
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### `client.create()`
|
|
473
|
+
|
|
474
|
+
Create a new record.
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
import * as app from './lexicons/app.js'
|
|
478
|
+
|
|
479
|
+
const result = await client.create(app.bsky.feed.post, {
|
|
480
|
+
text: 'Hello, world!',
|
|
481
|
+
createdAt: new Date().toISOString(),
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
console.log(result.uri) // at://did:plc:...
|
|
485
|
+
console.log(result.cid)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Options:
|
|
489
|
+
|
|
490
|
+
- `rkey` - Custom record key (auto-generated if not provided)
|
|
491
|
+
- `validate` - Validate record against schema before creating
|
|
492
|
+
- `swapCommit` - CID for optimistic concurrency control
|
|
493
|
+
|
|
494
|
+
#### `client.get()`
|
|
495
|
+
|
|
496
|
+
Retrieve a record.
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
import * as app from './lexicons/app.js'
|
|
500
|
+
|
|
501
|
+
const profile = await client.get(app.bsky.actor.profile)
|
|
502
|
+
|
|
503
|
+
console.log(profile.displayName)
|
|
504
|
+
console.log(profile.description)
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
For records with non-literal keys:
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
const post = await client.get(app.bsky.feed.post, {
|
|
511
|
+
rkey: '3jxf7z2k3q2',
|
|
512
|
+
})
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
#### `client.put()`
|
|
516
|
+
|
|
517
|
+
Update an existing record.
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
import * as app from './lexicons/app.js'
|
|
521
|
+
|
|
522
|
+
await client.put(app.bsky.actor.profile, {
|
|
523
|
+
displayName: 'New Name',
|
|
524
|
+
description: 'Updated bio',
|
|
525
|
+
})
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Options:
|
|
529
|
+
|
|
530
|
+
- `rkey` - Record key (required for non-literal keys)
|
|
531
|
+
- `swapCommit` - Expected repo commit CID
|
|
532
|
+
- `swapRecord` - Expected record CID
|
|
533
|
+
|
|
534
|
+
#### `client.delete()`
|
|
535
|
+
|
|
536
|
+
Delete a record.
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
import * as app from './lexicons/app.js'
|
|
540
|
+
|
|
541
|
+
await client.delete(app.bsky.feed.post, {
|
|
542
|
+
rkey: '3jxf7z2k3q2',
|
|
543
|
+
})
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
#### `client.list()`
|
|
547
|
+
|
|
548
|
+
List records in a collection.
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
import * as app from './lexicons/app.js'
|
|
552
|
+
|
|
553
|
+
const result = await client.list(app.bsky.feed.post, {
|
|
554
|
+
limit: 50,
|
|
555
|
+
reverse: true,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
for (const record of result.records) {
|
|
559
|
+
console.log(record.uri, record.value.text)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Pagination
|
|
563
|
+
if (result.cursor) {
|
|
564
|
+
const nextPage = await client.list(app.bsky.feed.post, {
|
|
565
|
+
cursor: result.cursor,
|
|
566
|
+
limit: 50,
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### Error Handling
|
|
572
|
+
|
|
573
|
+
By default, all client methods throw errors when requests fail. For more ergonomic error handling, the client provides "Safe" variants that return errors instead of throwing them.
|
|
574
|
+
|
|
575
|
+
#### Safe Methods
|
|
576
|
+
|
|
577
|
+
Each client method has a corresponding "Safe" variant that catches errors and returns them as part of the result type:
|
|
578
|
+
|
|
579
|
+
- `xrpcSafe()` - Safe version of `xrpc()`
|
|
580
|
+
- `createRecordsSafe()` - Safe version of `createRecord()`
|
|
581
|
+
- `deleteRecordsSafe()` - Safe version of `deleteRecord()`
|
|
582
|
+
- `getRecordsSafe()` - Safe version of `getRecord()`
|
|
583
|
+
- `putRecordsSafe()` - Safe version of `putRecord()`
|
|
584
|
+
|
|
585
|
+
#### ResponseFailure Type
|
|
586
|
+
|
|
587
|
+
Safe methods return a union type that includes the success case and all possible failure cases:
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
import { Client, ResponseFailure } from '@atproto/lex'
|
|
591
|
+
import * as app from './lexicons/app.js'
|
|
592
|
+
|
|
593
|
+
const client = new Client(session)
|
|
594
|
+
|
|
595
|
+
// Using a safe method
|
|
596
|
+
const result = await client.xrpcSafe(com.atproto.identity.resolveHandle, {
|
|
597
|
+
params: { limit: 50 },
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
if (result.success) {
|
|
601
|
+
// Success - result is an XrpcResponse
|
|
602
|
+
console.log(result.body)
|
|
603
|
+
} else {
|
|
604
|
+
// Failure - result is a ResponseFailure, the type depends on the method's error definitions
|
|
605
|
+
|
|
606
|
+
result // ResponseFailure<"HandleNotFound">
|
|
607
|
+
|
|
608
|
+
// Handle error based on type
|
|
609
|
+
if (result.name === 'UnexpectedError') {
|
|
610
|
+
// Network error, invalid response, etc.
|
|
611
|
+
result.error // "unknown" type
|
|
612
|
+
} else if (result.name === 'Unknown') {
|
|
613
|
+
// Server returned a valid XRPC error response with an unknown error type
|
|
614
|
+
result.error // XrpcResponseError<string>
|
|
615
|
+
} else {
|
|
616
|
+
// Declared error from the method's errors list
|
|
617
|
+
result.error // XrpcResponseError<"HandleNotFound">
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
The `ResponseFailure<M>` type is a union with three possible error types:
|
|
623
|
+
|
|
624
|
+
1. **Declared errors** - Errors explicitly listed in the method's Lexicon schema will be represented as an `XrpcResponseError<N>` instance:
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
// XrpcResponseError<N>
|
|
628
|
+
type KnownXrpcResponseFailure<N extends string> = {
|
|
629
|
+
success: false
|
|
630
|
+
name: N
|
|
631
|
+
error: XrpcResponseError<N>
|
|
632
|
+
|
|
633
|
+
// Additional response details
|
|
634
|
+
status: number
|
|
635
|
+
headers: Headers
|
|
636
|
+
encoding: undefined | string
|
|
637
|
+
body: XrpcErrorBody<N>
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
2. **Unknown errors** - Server errors not declared in the method's schema:
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
// XrpcResponseFailure<'Unknown', XrpcResponseError>
|
|
645
|
+
type UnknownXrpcResponseFailure = {
|
|
646
|
+
success: false
|
|
647
|
+
name: 'Unknown'
|
|
648
|
+
error: XrpcResponseError<string>
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
3. **Unexpected errors** - Network errors, invalid responses, or other client-side errors:
|
|
653
|
+
```typescript
|
|
654
|
+
// XrpcResponseFailure<'UnexpectedError', unknown>
|
|
655
|
+
type UnexpectedXrpcResponseFailure = {
|
|
656
|
+
success: false
|
|
657
|
+
name: 'UnexpectedError'
|
|
658
|
+
error: unknown // Could be anything (network error, parsing error, etc.)
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Authentication Methods
|
|
663
|
+
|
|
664
|
+
#### `client.did`
|
|
665
|
+
|
|
666
|
+
Get the authenticated user's DID.
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
const did = client.did // Returns Did | undefined
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
#### `client.assertAuthenticated()`
|
|
673
|
+
|
|
674
|
+
Assert that the client is authenticated (throws if not).
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
client.assertAuthenticated()
|
|
678
|
+
// After this call, TypeScript knows client.did is defined
|
|
679
|
+
const did = client.did // Type: Did (not undefined)
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
#### `client.assertDid`
|
|
683
|
+
|
|
684
|
+
Get the authenticated user's DID, asserting that the client is authenticated.
|
|
685
|
+
|
|
686
|
+
```typescript
|
|
687
|
+
const did = client.assertDid // Type: Did (throws if not authenticated)
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
This is equivalent to calling `client.assertAuthenticated()` followed by accessing `client.did`, but provides a more concise way to get the DID when you know authentication is required.
|
|
691
|
+
|
|
692
|
+
### Labeler Configuration
|
|
693
|
+
|
|
694
|
+
Configure content labelers for moderation.
|
|
695
|
+
|
|
696
|
+
```typescript
|
|
697
|
+
import { Client } from '@atproto/lex'
|
|
698
|
+
|
|
699
|
+
// Global app-level labelers
|
|
700
|
+
Client.configure({
|
|
701
|
+
appLabelers: ['did:plc:labeler1', 'did:plc:labeler2'],
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
// Client-specific labelers
|
|
705
|
+
const client = new Client(session, {
|
|
706
|
+
labelers: ['did:plc:labeler3'],
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
// Add labelers dynamically
|
|
710
|
+
client.addLabelers(['did:plc:labeler4'])
|
|
711
|
+
|
|
712
|
+
// Replace all labelers
|
|
713
|
+
client.setLabelers(['did:plc:labeler5'])
|
|
714
|
+
|
|
715
|
+
// Clear labelers
|
|
716
|
+
client.clearLabelers()
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Low-Level XRPC
|
|
720
|
+
|
|
721
|
+
For advanced use cases, use `client.xrpc()` to get the full response (headers, status, body):
|
|
722
|
+
|
|
723
|
+
```typescript
|
|
724
|
+
import * as app from './lexicons/app.js'
|
|
725
|
+
|
|
726
|
+
const response = await client.xrpc(app.bsky.feed.getTimeline, {
|
|
727
|
+
params: { limit: 50 },
|
|
728
|
+
signal: abortSignal,
|
|
729
|
+
headers: { 'custom-header': 'value' },
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
console.log(response.status)
|
|
733
|
+
console.log(response.headers)
|
|
734
|
+
console.log(response.body)
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
## Advanced Usage
|
|
738
|
+
|
|
739
|
+
### Workflow Integration
|
|
740
|
+
|
|
741
|
+
#### Development Workflow
|
|
742
|
+
|
|
743
|
+
Add these scripts to your `package.json`:
|
|
744
|
+
|
|
745
|
+
```json
|
|
746
|
+
{
|
|
747
|
+
"scripts": {
|
|
748
|
+
"lex:update": "lex install --update --save",
|
|
749
|
+
"prebuild": "lex install --ci"
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
This ensures that:
|
|
755
|
+
|
|
756
|
+
1. Lexicons are installed/verified before every build.
|
|
757
|
+
2. You can easily update lexicons with `npm run lex:update` or `pnpm lex:update`.
|
|
758
|
+
|
|
759
|
+
### Tree-Shaking
|
|
760
|
+
|
|
761
|
+
The generated TypeScript is optimized for tree-shaking. Import only what you need:
|
|
762
|
+
|
|
763
|
+
```typescript
|
|
764
|
+
// Import specific methods
|
|
765
|
+
import { post } from './lexicons/app/bsky/feed/post.js'
|
|
766
|
+
import { getProfile } from './lexicons/app/bsky/actor/getProfile.js'
|
|
767
|
+
|
|
768
|
+
// Or use namespace imports (still tree-shakeable)
|
|
769
|
+
import * as app from './lexicons/app.js'
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
For library authors, use `--pure-annotations` when building:
|
|
773
|
+
|
|
774
|
+
```bash
|
|
775
|
+
lex build --pure-annotations
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
This will make the generated code more tree-shakeable from places that import your library.
|
|
779
|
+
|
|
780
|
+
### Custom Headers
|
|
781
|
+
|
|
782
|
+
Add custom headers to all requests:
|
|
783
|
+
|
|
784
|
+
```typescript
|
|
785
|
+
const client = new Client(session, {
|
|
786
|
+
headers: {
|
|
787
|
+
'x-custom-header': 'value',
|
|
788
|
+
},
|
|
789
|
+
})
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### Request Options
|
|
793
|
+
|
|
794
|
+
All client methods accept options for controlling request behavior. The available options depend on the type of operation.
|
|
795
|
+
|
|
796
|
+
#### Base Call Options
|
|
797
|
+
|
|
798
|
+
All methods support these base options:
|
|
799
|
+
|
|
800
|
+
```typescript
|
|
801
|
+
type CallOptions = {
|
|
802
|
+
signal?: AbortSignal // Abort the request
|
|
803
|
+
headers?: HeadersInit // Custom request headers
|
|
804
|
+
service?: Service // Override service proxy for this request
|
|
805
|
+
}
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
#### Query and Procedure Calls
|
|
809
|
+
|
|
810
|
+
When using `.call()` with Query or Procedure schemas:
|
|
811
|
+
|
|
812
|
+
```typescript
|
|
813
|
+
import * as app from './lexicons/app.js'
|
|
814
|
+
|
|
815
|
+
// Query with parameters
|
|
816
|
+
const timeline = await client.call(
|
|
817
|
+
app.bsky.feed.getTimeline,
|
|
818
|
+
{ limit: 50 },
|
|
819
|
+
{
|
|
820
|
+
signal: abortController.signal,
|
|
821
|
+
headers: { 'x-custom': 'value' },
|
|
822
|
+
},
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
// Procedure with body
|
|
826
|
+
const result = await client.call(
|
|
827
|
+
app.bsky.actor.putPreferences,
|
|
828
|
+
{ preferences: [...] },
|
|
829
|
+
{
|
|
830
|
+
signal: abortController.signal,
|
|
831
|
+
},
|
|
832
|
+
)
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
For low-level access with full response data, use `.xrpc()`:
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
const response = await client.xrpc(app.bsky.feed.getTimeline, {
|
|
839
|
+
params: { limit: 50 },
|
|
840
|
+
signal: abortController.signal,
|
|
841
|
+
headers: { 'x-custom': 'value' },
|
|
842
|
+
skipVerification: false, // Whether to skip response schema validation
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
console.log(response.status) // 200
|
|
846
|
+
console.log(response.headers) // Headers object
|
|
847
|
+
console.log(response.body) // Parsed response body
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
#### Record Operations (CRUD)
|
|
851
|
+
|
|
852
|
+
Record operations support additional options beyond base `CallOptions`:
|
|
853
|
+
|
|
854
|
+
**Creating Records**
|
|
855
|
+
|
|
856
|
+
```typescript
|
|
857
|
+
import * as app from './lexicons/app.js'
|
|
858
|
+
|
|
859
|
+
await client.create(
|
|
860
|
+
app.bsky.feed.post,
|
|
861
|
+
{
|
|
862
|
+
text: 'Hello!',
|
|
863
|
+
createdAt: new Date().toISOString(),
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
// Base options
|
|
867
|
+
signal: abortController.signal,
|
|
868
|
+
headers: { 'x-custom': 'value' },
|
|
869
|
+
|
|
870
|
+
// Create-specific options
|
|
871
|
+
rkey: 'custom-key', // Custom record key (optional, auto-generated if omitted)
|
|
872
|
+
validate: true, // Validate before creating
|
|
873
|
+
swapCommit: 'bafyrei...', // CID for optimistic concurrency
|
|
874
|
+
},
|
|
875
|
+
)
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
**Reading Records**
|
|
879
|
+
|
|
880
|
+
```typescript
|
|
881
|
+
await client.get(app.bsky.actor.profile, {
|
|
882
|
+
// Base options
|
|
883
|
+
signal: abortController.signal,
|
|
884
|
+
|
|
885
|
+
// Get-specific options
|
|
886
|
+
rkey: 'self', // Record key (required for non-literal keys)
|
|
887
|
+
})
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
**Updating Records**
|
|
891
|
+
|
|
892
|
+
```typescript
|
|
893
|
+
await client.put(
|
|
894
|
+
app.bsky.actor.profile,
|
|
895
|
+
{
|
|
896
|
+
displayName: 'New Name',
|
|
897
|
+
description: 'Updated bio',
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
// Base options
|
|
901
|
+
signal: abortController.signal,
|
|
902
|
+
|
|
903
|
+
// Put-specific options
|
|
904
|
+
rkey: 'self', // Record key
|
|
905
|
+
validate: true, // Validate before updating
|
|
906
|
+
swapCommit: 'bafyrei...', // Expected repo commit CID
|
|
907
|
+
swapRecord: 'bafyrei...', // Expected record CID (for CAS)
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
**Deleting Records**
|
|
913
|
+
|
|
914
|
+
```typescript
|
|
915
|
+
await client.delete(app.bsky.feed.post, {
|
|
916
|
+
// Base options
|
|
917
|
+
signal: abortController.signal,
|
|
918
|
+
|
|
919
|
+
// Delete-specific options
|
|
920
|
+
rkey: '3jxf7z2k3q2', // Record key
|
|
921
|
+
swapCommit: 'bafyrei...', // Expected repo commit CID
|
|
922
|
+
swapRecord: 'bafyrei...', // Expected record CID
|
|
923
|
+
})
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
**Listing Records**
|
|
927
|
+
|
|
928
|
+
```typescript
|
|
929
|
+
await client.list(app.bsky.feed.post, {
|
|
930
|
+
// Base options
|
|
931
|
+
signal: abortController.signal,
|
|
932
|
+
|
|
933
|
+
// List-specific options
|
|
934
|
+
limit: 50, // Maximum records to return
|
|
935
|
+
cursor: 'abc123', // Pagination cursor
|
|
936
|
+
reverse: true, // Reverse chronological order
|
|
937
|
+
})
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
### Actions
|
|
941
|
+
|
|
942
|
+
Actions are composable functions that combine multiple XRPC calls into higher-level operations. They can be invoked using `client.call()` just like Lexicon methods, making them a powerful tool for building library-style APIs on top of the low-level client.
|
|
943
|
+
|
|
944
|
+
#### What are Actions?
|
|
945
|
+
|
|
946
|
+
An `Action` is a function with this signature:
|
|
947
|
+
|
|
948
|
+
```typescript
|
|
949
|
+
type Action<Input, Output> = (
|
|
950
|
+
client: Client,
|
|
951
|
+
input: Input,
|
|
952
|
+
options: CallOptions,
|
|
953
|
+
) => Output | Promise<Output>
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
Actions receive:
|
|
957
|
+
|
|
958
|
+
- `client` - The Client instance (to make XRPC calls)
|
|
959
|
+
- `input` - The input data for the action
|
|
960
|
+
- `options` - Call options (signal, headers)
|
|
961
|
+
|
|
962
|
+
#### Using Actions
|
|
963
|
+
|
|
964
|
+
Actions are called using `client.call()`, the same method used for XRPC queries and procedures:
|
|
965
|
+
|
|
966
|
+
```typescript
|
|
967
|
+
import { Action, Client } from '@atproto/lex'
|
|
968
|
+
import * as app from './lexicons/app.js'
|
|
969
|
+
|
|
970
|
+
// Define an action
|
|
971
|
+
export const likePost: Action<
|
|
972
|
+
{ uri: string; cid: string },
|
|
973
|
+
{ uri: string; cid: string }
|
|
974
|
+
> = async (client, { uri, cid }, options) => {
|
|
975
|
+
client.assertAuthenticated()
|
|
976
|
+
|
|
977
|
+
const result = await client.create(
|
|
978
|
+
app.bsky.feed.like,
|
|
979
|
+
{
|
|
980
|
+
subject: { uri, cid },
|
|
981
|
+
createdAt: new Date().toISOString(),
|
|
982
|
+
},
|
|
983
|
+
options,
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
return result
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Use the action
|
|
990
|
+
const client = new Client(session)
|
|
991
|
+
const like = await client.call(likePost, {
|
|
992
|
+
uri: 'at://did:plc:abc/app.bsky.feed.post/123',
|
|
993
|
+
cid: 'bafyreiabc...',
|
|
994
|
+
})
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
#### Composing Multiple Operations
|
|
998
|
+
|
|
999
|
+
Actions excel at combining multiple XRPC calls:
|
|
1000
|
+
|
|
1001
|
+
```typescript
|
|
1002
|
+
import { Action, Client } from '@atproto/lex'
|
|
1003
|
+
import * as app from './lexicons/app.js'
|
|
1004
|
+
|
|
1005
|
+
type Preference = app.bsky.actor.defs.Preferences[number]
|
|
1006
|
+
|
|
1007
|
+
// Action that reads, modifies, and writes preferences
|
|
1008
|
+
const upsertPreference: Action<Preference, Preference[]> = async (
|
|
1009
|
+
client,
|
|
1010
|
+
newPref,
|
|
1011
|
+
options,
|
|
1012
|
+
) => {
|
|
1013
|
+
// Read current preferences
|
|
1014
|
+
const { preferences } = await client.call(
|
|
1015
|
+
app.bsky.actor.getPreferences,
|
|
1016
|
+
options,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
// Update the preference list
|
|
1020
|
+
const updated = [
|
|
1021
|
+
...preferences.filter((p) => p.$type !== newPref.$type),
|
|
1022
|
+
newPref,
|
|
1023
|
+
]
|
|
1024
|
+
|
|
1025
|
+
// Save updated preferences
|
|
1026
|
+
await client.call(
|
|
1027
|
+
app.bsky.actor.putPreferences,
|
|
1028
|
+
{ preferences: updated },
|
|
1029
|
+
options,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
return updated
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Use it
|
|
1036
|
+
await client.call(
|
|
1037
|
+
upsertPreference,
|
|
1038
|
+
app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
|
|
1039
|
+
)
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
#### Higher-Order Actions
|
|
1043
|
+
|
|
1044
|
+
Actions can call other actions, enabling powerful composition:
|
|
1045
|
+
|
|
1046
|
+
```typescript
|
|
1047
|
+
import { Action } from '@atproto/lex'
|
|
1048
|
+
import * as app from './lexicons/app.js'
|
|
1049
|
+
|
|
1050
|
+
type Preference = app.bsky.actor.defs.Preferences[number]
|
|
1051
|
+
|
|
1052
|
+
// Low-level action: update preferences with a function
|
|
1053
|
+
const updatePreferences: Action<
|
|
1054
|
+
(prefs: Preference[]) => Preference[] | false,
|
|
1055
|
+
Preference[]
|
|
1056
|
+
> = async (client, updateFn, options) => {
|
|
1057
|
+
const { preferences } = await client.call(
|
|
1058
|
+
app.bsky.actor.getPreferences,
|
|
1059
|
+
options,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
const updated = updateFn(preferences)
|
|
1063
|
+
if (updated === false) return preferences
|
|
1064
|
+
|
|
1065
|
+
await client.call(
|
|
1066
|
+
app.bsky.actor.putPreferences,
|
|
1067
|
+
{ preferences: updated },
|
|
1068
|
+
options,
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
return updated
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Higher-level action: upsert a specific preference
|
|
1075
|
+
const upsertPreference: Action<Preference, Preference[]> = async (
|
|
1076
|
+
client,
|
|
1077
|
+
pref,
|
|
1078
|
+
options,
|
|
1079
|
+
) => {
|
|
1080
|
+
return updatePreferences(
|
|
1081
|
+
client,
|
|
1082
|
+
(prefs) => [...prefs.filter((p) => p.$type !== pref.$type), pref],
|
|
1083
|
+
options,
|
|
1084
|
+
)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Even higher-level: enable adult content
|
|
1088
|
+
const enableAdultContent: Action<void, Preference[]> = async (
|
|
1089
|
+
client,
|
|
1090
|
+
_,
|
|
1091
|
+
options,
|
|
1092
|
+
) => {
|
|
1093
|
+
return upsertPreference(
|
|
1094
|
+
client,
|
|
1095
|
+
app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
|
|
1096
|
+
options,
|
|
1097
|
+
)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Use the high-level action
|
|
1101
|
+
await client.call(enableAdultContent)
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
### Building Library-Style APIs with Actions
|
|
1105
|
+
|
|
1106
|
+
Actions enable you to create high-level, convenience APIs similar to [@atproto/api](https://www.npmjs.com/package/@atproto/api)'s `Agent` class. Here are patterns for common operations:
|
|
1107
|
+
|
|
1108
|
+
#### Creating Posts
|
|
1109
|
+
|
|
1110
|
+
```typescript
|
|
1111
|
+
import { Action } from '@atproto/lex'
|
|
1112
|
+
import * as app from './lexicons/app.js'
|
|
1113
|
+
|
|
1114
|
+
type PostInput = Partial<app.bsky.feed.post.Main> &
|
|
1115
|
+
Omit<app.bsky.feed.post.Main, 'createdAt'>
|
|
1116
|
+
|
|
1117
|
+
export const post: Action<PostInput, { uri: string; cid: string }> = async (
|
|
1118
|
+
client,
|
|
1119
|
+
record,
|
|
1120
|
+
options,
|
|
1121
|
+
) => {
|
|
1122
|
+
return client.create(
|
|
1123
|
+
app.bsky.feed.post,
|
|
1124
|
+
{
|
|
1125
|
+
...record,
|
|
1126
|
+
createdAt: record.createdAt || new Date().toISOString(),
|
|
1127
|
+
},
|
|
1128
|
+
options,
|
|
1129
|
+
)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Usage
|
|
1133
|
+
await client.call(post, {
|
|
1134
|
+
text: 'Hello, AT Protocol!',
|
|
1135
|
+
langs: ['en'],
|
|
1136
|
+
})
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
#### Following Users
|
|
1140
|
+
|
|
1141
|
+
```typescript
|
|
1142
|
+
import { Action } from '@atproto/lex'
|
|
1143
|
+
import { AtUri } from '@atproto/syntax'
|
|
1144
|
+
import * as app from './lexicons/app.js'
|
|
1145
|
+
|
|
1146
|
+
export const follow: Action<
|
|
1147
|
+
{ did: string },
|
|
1148
|
+
{ uri: string; cid: string }
|
|
1149
|
+
> = async (client, { did }, options) => {
|
|
1150
|
+
return client.create(
|
|
1151
|
+
app.bsky.graph.follow,
|
|
1152
|
+
{
|
|
1153
|
+
subject: did,
|
|
1154
|
+
createdAt: new Date().toISOString(),
|
|
1155
|
+
},
|
|
1156
|
+
options,
|
|
1157
|
+
)
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export const unfollow: Action<{ followUri: string }, void> = async (
|
|
1161
|
+
client,
|
|
1162
|
+
{ followUri },
|
|
1163
|
+
options,
|
|
1164
|
+
) => {
|
|
1165
|
+
const uri = new AtUri(followUri)
|
|
1166
|
+
await client.delete(app.bsky.graph.follow, {
|
|
1167
|
+
...options,
|
|
1168
|
+
rkey: uri.rkey,
|
|
1169
|
+
})
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Usage
|
|
1173
|
+
const { uri } = await client.call(follow, { did: 'did:plc:abc123' })
|
|
1174
|
+
await client.call(unfollow, { followUri: uri })
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
#### Updating Profile with Retry Logic
|
|
1178
|
+
|
|
1179
|
+
```typescript
|
|
1180
|
+
import { Action } from '@atproto/lex'
|
|
1181
|
+
import * as app from './lexicons/app.js'
|
|
1182
|
+
import * as com from './lexicons/com.js'
|
|
1183
|
+
|
|
1184
|
+
type ProfileUpdate = Partial<Omit<app.bsky.actor.profile.Main, '$type'>>
|
|
1185
|
+
|
|
1186
|
+
export const updateProfile: Action<ProfileUpdate, void> = async (
|
|
1187
|
+
client,
|
|
1188
|
+
updates,
|
|
1189
|
+
options,
|
|
1190
|
+
) => {
|
|
1191
|
+
const maxRetries = 5
|
|
1192
|
+
for (let attempt = 0; ; attempt++) {
|
|
1193
|
+
try {
|
|
1194
|
+
// Get current profile and its CID
|
|
1195
|
+
const res = await client.xrpc(com.atproto.repo.getRecord, {
|
|
1196
|
+
...options,
|
|
1197
|
+
params: {
|
|
1198
|
+
repo: client.assertDid,
|
|
1199
|
+
collection: 'app.bsky.actor.profile',
|
|
1200
|
+
rkey: 'self',
|
|
1201
|
+
},
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
const current = app.bsky.actor.profile.main.validate(res.body.record)
|
|
1205
|
+
|
|
1206
|
+
// Merge updates with current profile (if valid)
|
|
1207
|
+
const updated = app.bsky.actor.profile.main.build({
|
|
1208
|
+
...(current.success ? current.value : undefined),
|
|
1209
|
+
...updates,
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
// Save with optimistic concurrency control
|
|
1213
|
+
await client.put(app.bsky.actor.profile, updated, {
|
|
1214
|
+
...options,
|
|
1215
|
+
swapRecord: res?.body.cid ?? null,
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
return
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
// Retry on swap/concurrent modification errors
|
|
1221
|
+
if (
|
|
1222
|
+
error instanceof XrpcRequestFailure &&
|
|
1223
|
+
error.name === 'SwapError' &&
|
|
1224
|
+
attempt < maxRetries - 1
|
|
1225
|
+
) {
|
|
1226
|
+
continue
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
throw error
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Usage
|
|
1235
|
+
await client.call(updateProfile, {
|
|
1236
|
+
displayName: 'Alice',
|
|
1237
|
+
description: 'Software engineer',
|
|
1238
|
+
})
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
#### Packaging Actions as a Library
|
|
1242
|
+
|
|
1243
|
+
Create a collection of actions for your application:
|
|
1244
|
+
|
|
1245
|
+
```typescript
|
|
1246
|
+
// actions.ts
|
|
1247
|
+
import { Action, Client } from '@atproto/lex'
|
|
1248
|
+
import * as app from './lexicons/app.js'
|
|
1249
|
+
|
|
1250
|
+
export const post: Action</* ... */> = async (client, input, options) => {
|
|
1251
|
+
/* ... */
|
|
1252
|
+
}
|
|
1253
|
+
export const like: Action</* ... */> = async (client, input, options) => {
|
|
1254
|
+
/* ... */
|
|
1255
|
+
}
|
|
1256
|
+
export const follow: Action</* ... */> = async (client, input, options) => {
|
|
1257
|
+
/* ... */
|
|
1258
|
+
}
|
|
1259
|
+
export const updateProfile: Action</* ... */> = async (
|
|
1260
|
+
client,
|
|
1261
|
+
input,
|
|
1262
|
+
options,
|
|
1263
|
+
) => {
|
|
1264
|
+
/* ... */
|
|
1265
|
+
}
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
Usage:
|
|
1269
|
+
|
|
1270
|
+
```typescript
|
|
1271
|
+
import * as actions from './actions.js'
|
|
1272
|
+
|
|
1273
|
+
await client.call(actions.post, { text: 'Hello!' })
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
#### Best Practices for Actions
|
|
1277
|
+
|
|
1278
|
+
1. **Type Safety**: Always provide explicit type parameters for `Action<Input, Output>`
|
|
1279
|
+
2. **Authentication**: Use `client.assertAuthenticated()` when auth is required
|
|
1280
|
+
3. **Abort Signals**: Check `options.signal?.throwIfAborted()` between long operations
|
|
1281
|
+
4. **Composition**: Build complex actions from simpler ones
|
|
1282
|
+
5. **Retries**: Implement retry logic for operations with optimistic concurrency control
|
|
1283
|
+
6. **Tree-shaking**: Export actions individually to allow tree-shaking (instead of bundling them in a single class)
|
|
1284
|
+
|
|
1285
|
+
## License
|
|
1286
|
+
|
|
1287
|
+
MIT or Apache2
|