Skip to content
← 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 · TypeScript
import { 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 }
}