← Public packages
@kentcdodds/x
X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.
src/sidecar.ts
136 lines · 4.1 KB · TypeScriptimport { packageStorage } from 'kody:runtime'
import type { JsonRecord } from './types.ts'
import {
CHAT_PIN_SECRET,
DEFAULT_SIDECAR_URL,
SIDECAR_HOST,
SIDECAR_TOKEN_SECRET,
SIDECAR_URL_VALUE,
} from './owner-config.ts'
export {
CHAT_PIN_SECRET,
DEFAULT_SIDECAR_URL,
SIDECAR_HOST,
SIDECAR_TOKEN_SECRET,
SIDECAR_URL_VALUE,
} from './owner-config.ts'
function normalizeOrigin(raw: string, fallback = DEFAULT_SIDECAR_URL) {
const origin = raw.trim().replace(/\/+$/, '')
return origin || fallback
}
export function sidecarOrigin(sidecarUrl?: string): string {
return normalizeOrigin(sidecarUrl || DEFAULT_SIDECAR_URL)
}
/**
* Resolve the Fly sidecar origin.
* Per-call `sidecarUrl` wins, then package storage `xChatSidecarUrl`, then
* then owner-config default.
*/
export async function resolveSidecarUrl(sidecarUrl?: string): Promise<string> {
if (sidecarUrl?.trim()) return normalizeOrigin(sidecarUrl)
const storage = packageStorage()
const stored = await storage.get(SIDECAR_URL_VALUE)
if (typeof stored === 'string' && stored.trim()) {
return normalizeOrigin(stored)
}
return DEFAULT_SIDECAR_URL
}
export async function persistSidecarUrl(sidecarUrl: string): Promise<string> {
const origin = normalizeOrigin(sidecarUrl)
await packageStorage().set(SIDECAR_URL_VALUE, origin)
return origin
}
export async function migrateSidecarUrlFromValues(
input: { sidecarUrl?: string } = {},
) {
if (input.sidecarUrl?.trim()) {
const origin = await persistSidecarUrl(input.sidecarUrl)
return { copied: [SIDECAR_URL_VALUE], origin, source: 'param' as const }
}
const origin = await resolveSidecarUrl()
return { copied: [SIDECAR_URL_VALUE], origin, source: 'resolved' as const }
}
export function sidecarHost(sidecarUrl?: string): string {
try {
return new URL(sidecarOrigin(sidecarUrl)).host
} catch {
return SIDECAR_HOST
}
}
export function secretSetupUrls(sidecarUrl?: string) {
const host = encodeURIComponent(sidecarHost(sidecarUrl))
return {
xChatPin: `https://kody.codes/account/secrets/new?name=${CHAT_PIN_SECRET}&description=${encodeURIComponent('Existing X Chat PIN from x.com. Approve only the Fly sidecar host.')}&allowedHosts=${host}&scope=user`,
xChatSidecarToken: `https://kody.codes/account/secrets/new?name=${SIDECAR_TOKEN_SECRET}&description=${encodeURIComponent('Shared bearer token for your X Chat Fly sidecar.')}&allowedHosts=${host}&scope=user`,
}
}
export class XChatSidecarError extends Error {
readonly status: number
readonly details: unknown
readonly setup?: ReturnType<typeof secretSetupUrls>
constructor(
message: string,
options: { status: number; details?: unknown; sidecarUrl?: string },
) {
super(message)
this.name = 'XChatSidecarError'
this.status = options.status
this.details = options.details
this.setup = secretSetupUrls(options.sidecarUrl)
}
}
async function parseSidecarBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
export async function sidecarRequest(
path: string,
body: JsonRecord,
sidecarUrl?: string,
): Promise<JsonRecord> {
const origin = await resolveSidecarUrl(sidecarUrl)
const response = await fetch(`${origin}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer {{secret:${SIDECAR_TOKEN_SECRET}|scope=user}}`,
'X-Chat-Pin': `{{secret:${CHAT_PIN_SECRET}|scope=user}}`,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(body),
})
const payload = await parseSidecarBody(response)
if (!response.ok) {
const record = payload && typeof payload === 'object' ? (payload as JsonRecord) : null
const message =
(typeof record?.message === 'string' && record.message) ||
(typeof record?.error === 'string' && record.error) ||
`X Chat sidecar request failed: ${response.status}`
throw new XChatSidecarError(message, {
status: response.status,
details: payload,
sidecarUrl: origin,
})
}
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload as JsonRecord
}
return { data: payload }
}