@atproto/api
Version:
Client library for atproto and Bluesky
377 lines (343 loc) • 10.2 kB
text/typescript
import { ErrorResponseBody, errorResponseBody } from '@atproto/xrpc'
import { defaultFetchHandler } from '@atproto/xrpc'
import { isValidDidDoc, getPdsEndpoint } from '@atproto/common-web'
import {
AtpBaseClient,
AtpServiceClient,
ComAtprotoServerCreateAccount,
ComAtprotoServerCreateSession,
ComAtprotoServerGetSession,
ComAtprotoServerRefreshSession,
} from './client'
import {
AtpSessionData,
AtpAgentCreateAccountOpts,
AtpAgentLoginOpts,
AtpAgentFetchHandler,
AtpAgentFetchHandlerResponse,
AtpAgentGlobalOpts,
AtpPersistSessionHandler,
AtpAgentOpts,
} from './types'
const REFRESH_SESSION = 'com.atproto.server.refreshSession'
/**
* An ATP "Agent"
* Manages session token lifecycles and provides convenience methods.
*/
export class AtpAgent {
service: URL
api: AtpServiceClient
session?: AtpSessionData
/**
* The PDS URL, driven by the did doc. May be undefined.
*/
pdsUrl: URL | undefined
private _baseClient: AtpBaseClient
private _persistSession?: AtpPersistSessionHandler
private _refreshSessionPromise: Promise<void> | undefined
get com() {
return this.api.com
}
/**
* The `fetch` implementation; must be implemented for your platform.
*/
static fetch: AtpAgentFetchHandler | undefined = defaultFetchHandler
/**
* Configures the API globally.
*/
static configure(opts: AtpAgentGlobalOpts) {
AtpAgent.fetch = opts.fetch
}
constructor(opts: AtpAgentOpts) {
this.service =
opts.service instanceof URL ? opts.service : new URL(opts.service)
this._persistSession = opts.persistSession
// create an ATP client instance for this agent
this._baseClient = new AtpBaseClient()
this._baseClient.xrpc.fetch = this._fetch.bind(this) // patch its fetch implementation
this.api = this._baseClient.service(opts.service)
}
/**
* Is there any active session?
*/
get hasSession() {
return !!this.session
}
/**
* Sets the "Persist Session" method which can be used to store access tokens
* as they change.
*/
setPersistSessionHandler(handler?: AtpPersistSessionHandler) {
this._persistSession = handler
}
/**
* Create a new account and hydrate its session in this agent.
*/
async createAccount(
opts: AtpAgentCreateAccountOpts,
): Promise<ComAtprotoServerCreateAccount.Response> {
try {
const res = await this.api.com.atproto.server.createAccount({
handle: opts.handle,
password: opts.password,
email: opts.email,
inviteCode: opts.inviteCode,
})
this.session = {
accessJwt: res.data.accessJwt,
refreshJwt: res.data.refreshJwt,
handle: res.data.handle,
did: res.data.did,
email: opts.email,
emailConfirmed: false,
}
this._updateApiEndpoint(res.data.didDoc)
return res
} catch (e) {
this.session = undefined
throw e
} finally {
if (this.session) {
this._persistSession?.('create', this.session)
} else {
this._persistSession?.('create-failed', undefined)
}
}
}
/**
* Start a new session with this agent.
*/
async login(
opts: AtpAgentLoginOpts,
): Promise<ComAtprotoServerCreateSession.Response> {
try {
const res = await this.api.com.atproto.server.createSession({
identifier: opts.identifier,
password: opts.password,
})
this.session = {
accessJwt: res.data.accessJwt,
refreshJwt: res.data.refreshJwt,
handle: res.data.handle,
did: res.data.did,
email: res.data.email,
emailConfirmed: res.data.emailConfirmed,
}
this._updateApiEndpoint(res.data.didDoc)
return res
} catch (e) {
this.session = undefined
throw e
} finally {
if (this.session) {
this._persistSession?.('create', this.session)
} else {
this._persistSession?.('create-failed', undefined)
}
}
}
/**
* Resume a pre-existing session with this agent.
*/
async resumeSession(
session: AtpSessionData,
): Promise<ComAtprotoServerGetSession.Response> {
try {
this.session = session
const res = await this.api.com.atproto.server.getSession()
if (!res.success || res.data.did !== this.session.did) {
throw new Error('Invalid session')
}
this.session.email = res.data.email
this.session.handle = res.data.handle
this.session.emailConfirmed = res.data.emailConfirmed
this._updateApiEndpoint(res.data.didDoc)
return res
} catch (e) {
this.session = undefined
throw e
} finally {
if (this.session) {
this._persistSession?.('create', this.session)
} else {
this._persistSession?.('create-failed', undefined)
}
}
}
/**
* Internal helper to add authorization headers to requests.
*/
private _addAuthHeader(reqHeaders: Record<string, string>) {
if (!reqHeaders.authorization && this.session?.accessJwt) {
return {
...reqHeaders,
authorization: `Bearer ${this.session.accessJwt}`,
}
}
return reqHeaders
}
/**
* Internal fetch handler which adds access-token management
*/
private async _fetch(
reqUri: string,
reqMethod: string,
reqHeaders: Record<string, string>,
reqBody: any,
): Promise<AtpAgentFetchHandlerResponse> {
if (!AtpAgent.fetch) {
throw new Error('AtpAgent fetch() method not configured')
}
// wait for any active session-refreshes to finish
await this._refreshSessionPromise
// send the request
let res = await AtpAgent.fetch(
reqUri,
reqMethod,
this._addAuthHeader(reqHeaders),
reqBody,
)
// handle session-refreshes as needed
if (isErrorResponse(res, ['ExpiredToken']) && this.session?.refreshJwt) {
// attempt refresh
await this._refreshSession()
// resend the request with the new access token
res = await AtpAgent.fetch(
reqUri,
reqMethod,
this._addAuthHeader(reqHeaders),
reqBody,
)
}
return res
}
/**
* Internal helper to refresh sessions
* - Wraps the actual implementation in a promise-guard to ensure only
* one refresh is attempted at a time.
*/
private async _refreshSession() {
if (this._refreshSessionPromise) {
return this._refreshSessionPromise
}
this._refreshSessionPromise = this._refreshSessionInner()
try {
await this._refreshSessionPromise
} finally {
this._refreshSessionPromise = undefined
}
}
/**
* Internal helper to refresh sessions (actual behavior)
*/
private async _refreshSessionInner() {
if (!AtpAgent.fetch) {
throw new Error('AtpAgent fetch() method not configured')
}
if (!this.session?.refreshJwt) {
return
}
// send the refresh request
const url = new URL((this.pdsUrl || this.service).origin)
url.pathname = `/xrpc/${REFRESH_SESSION}`
const res = await AtpAgent.fetch(
url.toString(),
'POST',
{
authorization: `Bearer ${this.session.refreshJwt}`,
},
undefined,
)
if (isErrorResponse(res, ['ExpiredToken', 'InvalidToken'])) {
// failed due to a bad refresh token
this.session = undefined
this._persistSession?.('expired', undefined)
} else if (isNewSessionObject(this._baseClient, res.body)) {
// succeeded, update the session
this.session = {
...(this.session || {}),
accessJwt: res.body.accessJwt,
refreshJwt: res.body.refreshJwt,
handle: res.body.handle,
did: res.body.did,
}
this._updateApiEndpoint(res.body.didDoc)
this._persistSession?.('update', this.session)
}
// else: other failures should be ignored - the issue will
// propagate in the _fetch() handler's second attempt to run
// the request
}
/**
* Upload a binary blob to the server
*/
uploadBlob: typeof this.api.com.atproto.repo.uploadBlob = (data, opts) =>
this.api.com.atproto.repo.uploadBlob(data, opts)
/**
* Resolve a handle to a DID
*/
resolveHandle: typeof this.api.com.atproto.identity.resolveHandle = (
params,
opts,
) => this.api.com.atproto.identity.resolveHandle(params, opts)
/**
* Change the user's handle
*/
updateHandle: typeof this.api.com.atproto.identity.updateHandle = (
data,
opts,
) => this.api.com.atproto.identity.updateHandle(data, opts)
/**
* Create a moderation report
*/
createModerationReport: typeof this.api.com.atproto.moderation.createReport =
(data, opts) => this.api.com.atproto.moderation.createReport(data, opts)
/**
* Helper to update the pds endpoint dynamically.
*
* The session methods (create, resume, refresh) may respond with the user's
* did document which contains the user's canonical PDS endpoint. That endpoint
* may differ from the endpoint used to contact the server. We capture that
* PDS endpoint and update the client to use that given endpoint for future
* requests. (This helps ensure smooth migrations between PDSes, especially
* when the PDSes are operated by a single org.)
*/
private _updateApiEndpoint(didDoc: unknown) {
if (isValidDidDoc(didDoc)) {
const endpoint = getPdsEndpoint(didDoc)
this.pdsUrl = endpoint ? new URL(endpoint) : undefined
}
this.api.xrpc.uri = this.pdsUrl || this.service
}
}
function isErrorObject(v: unknown): v is ErrorResponseBody {
return errorResponseBody.safeParse(v).success
}
function isErrorResponse(
res: AtpAgentFetchHandlerResponse,
errorNames: string[],
): boolean {
if (res.status !== 400) {
return false
}
if (!isErrorObject(res.body)) {
return false
}
return (
typeof res.body.error === 'string' && errorNames.includes(res.body.error)
)
}
function isNewSessionObject(
client: AtpBaseClient,
v: unknown,
): v is ComAtprotoServerRefreshSession.OutputSchema {
try {
client.xrpc.lex.assertValidXrpcOutput(
'com.atproto.server.refreshSession',
v,
)
return true
} catch {
return false
}
}