Skip to content
← Public packages

@kentcdodds/fly

Manage Fly.io apps, Machines, logs, and metrics.

src/core.ts

226 lines · 7.0 KB · TypeScript
import {
	appsList,
	appsShow,
	currenttokenShow,
	machinesList,
	machinesOrgList,
	machinesShow,
} from './lib/openapi-client.ts'

const flyApiToken = '{{secret:flyApiToken}}'
const flyGraphqlUrl = 'https://api.fly.io/graphql'
const flyApiUrl = 'https://api.fly.io'

export function clean(value) {
	return String(value ?? '').trim()
}

export function positiveInt(value, fallback, max = 100) {
	const number = Math.floor(Number(value))
	if (!Number.isFinite(number) || number <= 0) return fallback
	return Math.min(number, max)
}

export async function parseJsonResponse(response, label = 'Fly API') {
	const text = await response.text()
	let body = null
	try {
		body = text ? JSON.parse(text) : null
	} catch {
		body = { raw: text }
	}
	if (!response.ok) {
		throw new Error(label + ' HTTP ' + response.status + ': ' + JSON.stringify(body).slice(0, 800))
	}
	return body
}

export async function requestGraphql({ query, variables = {} } = {}) {
	if (!clean(query)) throw new Error('query is required.')
	const response = await fetch(flyGraphqlUrl, {
		method: 'POST',
		headers: {
			Authorization: 'Bearer ' + flyApiToken,
			'Content-Type': 'application/json',
		},
		body: JSON.stringify({ query, variables }),
	})
	const body = await parseJsonResponse(response, 'Fly GraphQL')
	if (Array.isArray(body?.errors) && body.errors.length > 0) {
		const message = body.errors.map((error) => error.message || JSON.stringify(error)).join('; ')
		throw new Error('Fly GraphQL error: ' + message)
	}
	return body?.data ?? body
}

/** Prometheus / app-logs helper against api.fly.io (not Machines OpenAPI). */
export async function requestFlyApi(path, { method = 'GET', search = {}, headers = {}, body } = {}) {
	const url = new URL(path, flyApiUrl)
	for (const [key, value] of Object.entries(search)) {
		if (value !== undefined && value !== null && clean(value)) {
			url.searchParams.set(key, String(value))
		}
	}
	const response = await fetch(url, {
		method,
		headers: {
			Authorization: 'FlyV1 ' + flyApiToken,
			Accept: 'application/json',
			...headers,
		},
		body,
	})
	return await parseJsonResponse(response, 'Fly API')
}

export function pageInfo(connection) {
	return connection && typeof connection === 'object' ? connection.pageInfo || {} : {}
}

export function nodes(connection) {
	return Array.isArray(connection?.nodes) ? connection.nodes : []
}

export async function collectConnection(fetchPage, { maxPages = 20 } = {}) {
	const all = []
	let after = null
	for (let page = 0; page < positiveInt(maxPages, 20, 100); page += 1) {
		const connection = await fetchPage(after)
		all.push(...nodes(connection))
		const info = pageInfo(connection)
		if (!info.hasNextPage || !info.endCursor) break
		after = info.endCursor
	}
	return all
}

export function resolveOrgSlug(params = {}) {
	return (
		clean(params.organizationSlug) ||
		clean(params.orgSlug) ||
		clean(params.slug) ||
		clean(params.org_slug) ||
		'personal'
	)
}

export function summarizeMachine(machine, appHint = null) {
	const appName = machine?.app_name || machine?.app?.name || appHint?.name || null
	const appId = machine?.app?.id || appHint?.id || null
	return {
		id: machine?.id ?? null,
		name: machine?.name ?? null,
		state: machine?.state ?? null,
		region: machine?.region ?? null,
		app: appName || appId ? { id: appId, name: appName } : null,
		image: machine?.config?.image ?? machine?.image ?? null,
		createdAt: machine?.created_at ?? machine?.createdAt ?? null,
		updatedAt: machine?.updated_at ?? machine?.updatedAt ?? null,
	}
}

export function summarizeApp(app) {
	return {
		id: app?.id ?? null,
		name: app?.name ?? null,
		status: app?.status ?? null,
		deployed: app?.status === 'deployed' ? true : app?.deployed ?? null,
		organization: app?.organization
			? {
					id: app.organization.id ?? null,
					slug: app.organization.slug ?? null,
					name: app.organization.name ?? null,
				}
			: null,
		machineCount: app?.machine_count ?? null,
		volumeCount: app?.volume_count ?? null,
	}
}

export async function machinesAppsList(params = {}) {
	const orgSlug = resolveOrgSlug(params)
	const query = { org_slug: orgSlug }
	if (clean(params.appRole) || clean(params.app_role)) {
		query.app_role = clean(params.appRole || params.app_role)
	}
	const response = await appsList({ query })
	const body = await parseJsonResponse(response, 'Fly Machines apps_list')
	const apps = Array.isArray(body?.apps) ? body.apps : Array.isArray(body) ? body : []
	return {
		organization: { slug: orgSlug },
		totalApps: body?.total_apps ?? apps.length,
		apps,
	}
}

export async function machinesAppShow(appName) {
	const name = clean(appName)
	if (!name) throw new Error('appName is required.')
	const response = await appsShow({ params: { app_name: name } })
	return await parseJsonResponse(response, 'Fly Machines apps_show')
}

export async function machinesListForApp(appName, params = {}) {
	const name = clean(appName)
	if (!name) throw new Error('appName is required.')
	const query = {}
	if (clean(params.state)) query.state = clean(params.state)
	if (clean(params.region)) query.region = clean(params.region)
	if (params.includeDeleted === true || params.include_deleted === true) query.include_deleted = true
	if (params.summary !== false) query.summary = true
	const response = await machinesList({
		params: { app_name: name },
		query,
	})
	const body = await parseJsonResponse(response, 'Fly Machines machines_list')
	return Array.isArray(body) ? body : Array.isArray(body?.machines) ? body.machines : []
}

export async function machinesGet(appName, machineId) {
	const name = clean(appName)
	const id = clean(machineId)
	if (!name) throw new Error('appName is required.')
	if (!id) throw new Error('machineId is required.')
	const response = await machinesShow({
		params: { app_name: name, machine_id: id },
	})
	return await parseJsonResponse(response, 'Fly Machines machines_show')
}

export async function machinesListForOrg(params = {}) {
	const orgSlug = resolveOrgSlug(params)
	const query = {}
	if (clean(params.state)) query.state = clean(params.state)
	if (clean(params.region)) query.region = clean(params.region)
	if (params.includeDeleted === true || params.include_deleted === true) query.include_deleted = true
	if (params.summary !== false) query.summary = true
	if (clean(params.updatedAfter) || clean(params.updated_after)) {
		query.updated_after = clean(params.updatedAfter || params.updated_after)
	}

	// Org machines endpoint returns one page; optional cursor continues when present.
	const response = await machinesOrgList({
		params: { org_slug: orgSlug },
		query: {
			...query,
			...(clean(params.cursor) ? { cursor: clean(params.cursor) } : {}),
		},
	})
	const body = await parseJsonResponse(response, 'Fly Machines machines_org_list')
	const machines = Array.isArray(body?.machines)
		? body.machines
		: Array.isArray(body)
			? body
			: []
	return {
		organization: { slug: orgSlug },
		machines,
		lastMachineId: body?.last_machine_id ?? null,
		lastUpdatedAt: body?.last_updated_at ?? null,
	}
}

export async function machinesCurrentToken() {
	const response = await currenttokenShow()
	return await parseJsonResponse(response, 'Fly Machines currenttoken_show')
}