import https from 'bare-https'
/**
* Minimal AT Protocol client using raw XRPC HTTP calls.
* No SDK dependency — just fetch + JSON + JWT.
*/
class AtprotoClient {
constructor (opts = {}) {
this.service = opts.service || 'https://bsky.social'
this.identifier = opts.identifier // handle or DID
this.password = opts.password // app password
// Session state
this.accessJwt = null
this.refreshJwt = null
this.did = null
this.handle = null
// Parse hostname from service URL
const url = new URL(this.service)
this.hostname = url.hostname
this.basePath = url.pathname === '/' ? '' : url.pathname
}
_request (method, nsid, params, body) {
return new Promise((resolve, reject) => {
let path = `${this.basePath}/xrpc/${nsid}`
// Query params for GET
if (params && method === 'GET') {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) qs.set(k, String(v))
}
const str = qs.toString()
if (str) path += '?' + str
}
const opts = {
hostname: this.hostname,
port: 443,
path,
method,
headers: { Accept: 'application/json' }
}
if (this.accessJwt) {
opts.headers.Authorization = `Bearer ${this.accessJwt}`
}
let payload = ''
if (body && method === 'POST') {
payload = JSON.stringify(body)
opts.headers['Content-Type'] = 'application/json'
opts.headers['Content-Length'] = Buffer.byteLength(payload)
}
const req = https.request(opts, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk.toString() })
res.on('end', () => {
if (res.statusCode >= 400) {
// Check if token expired
if (res.statusCode === 401 && this.refreshJwt) {
return this._refreshSession()
.then(() => this._request(method, nsid, params, body))
.then(resolve)
.catch(reject)
}
return reject(new Error(`XRPC ${res.statusCode}: ${data.slice(0, 300)}`))
}
try { resolve(JSON.parse(data)) } catch { resolve(data) }
})
})
req.on('error', reject)
req.setTimeout(15000, () => req.destroy(new Error('Request timeout')))
if (payload) req.write(payload)
req.end()
})
}
// --- Session management ---
async login () {
const res = await this._requestNoAuth('POST', 'com.atproto.server.createSession', null, {
identifier: this.identifier,
password: this.password
})
this.accessJwt = res.accessJwt
this.refreshJwt = res.refreshJwt
this.did = res.did
this.handle = res.handle
return res
}
async _refreshSession () {
const oldRefresh = this.refreshJwt
this.accessJwt = null
const res = await this._requestWithToken('POST', 'com.atproto.server.refreshSession', null, null, oldRefresh)
this.accessJwt = res.accessJwt
this.refreshJwt = res.refreshJwt
return res
}
_requestNoAuth (method, nsid, params, body) {
const saved = this.accessJwt
this.accessJwt = null
return this._request(method, nsid, params, body).finally(() => {
this.accessJwt = saved
})
}
_requestWithToken (method, nsid, params, body, token) {
const saved = this.accessJwt
this.accessJwt = token
return this._request(method, nsid, params, body).finally(() => {
this.accessJwt = saved
})
}
// --- Feed reading ---
async getTimeline (limit = 30, cursor) {
const res = await this._request('GET', 'app.bsky.feed.getTimeline', { limit, cursor })
return res.feed || []
}
async getAuthorFeed (actor, limit = 30, cursor) {
const res = await this._request('GET', 'app.bsky.feed.getAuthorFeed', {
actor: actor || this.did,
limit,
cursor
})
return res.feed || []
}
async getProfile (actor) {
return this._request('GET', 'app.bsky.actor.getProfile', {
actor: actor || this.did
})
}
// --- Blob upload ---
async uploadBlob (buffer, mimeType) {
return new Promise((resolve, reject) => {
const req = https.request({
hostname: this.hostname,
port: 443,
path: `${this.basePath}/xrpc/com.atproto.repo.uploadBlob`,
method: 'POST',
headers: {
Authorization: `Bearer ${this.accessJwt}`,
'Content-Type': mimeType,
'Content-Length': buffer.length
}
}, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk.toString() })
res.on('end', () => {
if (res.statusCode >= 400) {
return reject(new Error(`Upload ${res.statusCode}: ${data.slice(0, 300)}`))
}
try { resolve(JSON.parse(data)) } catch { reject(new Error('Invalid JSON')) }
})
})
req.on('error', reject)
req.setTimeout(60000, () => req.destroy(new Error('Upload timeout')))
req.write(buffer)
req.end()
})
}
// --- Posting ---
async post (text, opts = {}) {
const now = new Date().toISOString()
const record = {
$type: 'app.bsky.feed.post',
text,
createdAt: now
}
// Facets (mentions, links, hashtags) — auto-detect
const facets = detectFacets(text)
if (facets.length > 0) record.facets = facets
// Reply
if (opts.replyTo) {
record.reply = opts.replyTo
}
// Image embed
if (opts.images && opts.images.length > 0) {
record.embed = {
$type: 'app.bsky.embed.images',
images: opts.images.map(img => ({
alt: img.alt || '',
image: img.blob, // { $type: 'blob', ref: { $link: cid }, mimeType, size }
aspectRatio: img.width && img.height ? { width: img.width, height: img.height } : undefined
}))
}
}
return this._request('POST', 'com.atproto.repo.createRecord', null, {
repo: this.did,
collection: 'app.bsky.feed.post',
record
})
}
// --- Notifications ---
async getNotifications (limit = 20, cursor) {
const res = await this._request('GET', 'app.bsky.notification.listNotifications', { limit, cursor })
return res.notifications || []
}
// --- Search ---
async searchPosts (query, limit = 25) {
const res = await this._request('GET', 'app.bsky.feed.searchPosts', { q: query, limit })
return res.posts || []
}
}
/**
* Auto-detect facets (links, mentions, hashtags) in text.
* Returns AT Proto facet objects with byte offsets.
*/
function detectFacets (text) {
const facets = []
const encoder = new TextEncoder()
const bytes = encoder.encode(text)
// URLs
const urlRegex = /https?:\/\/[^\s<>)"']+/g
let match
while ((match = urlRegex.exec(text)) !== null) {
const before = encoder.encode(text.slice(0, match.index))
const target = encoder.encode(match[0])
facets.push({
index: { byteStart: before.length, byteEnd: before.length + target.length },
features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }]
})
}
// Mentions (@handle.domain)
const mentionRegex = /@([a-zA-Z0-9._-]+\.[a-zA-Z]{2,})/g
while ((match = mentionRegex.exec(text)) !== null) {
const before = encoder.encode(text.slice(0, match.index))
const target = encoder.encode(match[0])
facets.push({
index: { byteStart: before.length, byteEnd: before.length + target.length },
features: [{ $type: 'app.bsky.richtext.facet#mention', did: '' }] // DID resolved by AppView
})
}
// Hashtags (#tag)
const tagRegex = /#([a-zA-Z0-9_]+)/g
while ((match = tagRegex.exec(text)) !== null) {
const before = encoder.encode(text.slice(0, match.index))
const target = encoder.encode(match[0])
facets.push({
index: { byteStart: before.length, byteEnd: before.length + target.length },
features: [{ $type: 'app.bsky.richtext.facet#tag', tag: match[1] }]
})
}
return facets
}
export { AtprotoClient, detectFacets }