Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/supabase

src/client.ts

282 lines · 7.4 KB · TypeScript
import { accounts } from './accounts.ts'
import {
	projectBaseUrl,
	secretPlaceholder,
	setupUrls,
} from './setup.ts'
import {
	SupabaseRequestError,
	isRecord,
	optionalString,
	requireString,
	type DryRunResult,
	type HttpMethod,
	type Json,
	type JsonRecord,
	type SupabaseAuthInput,
} from './types.ts'

const MANAGEMENT_BASE = 'https://api.supabase.com'
const PROJECT_REF_PATTERN = /^[a-z0-9]{8,40}$/i

export type RequestPreview = DryRunResult<JsonRecord>

export function resolveProjectUrl(input: SupabaseAuthInput): {
	baseUrl: string
	host: string
	projectRef?: string
} {
	const projectUrl = optionalString(input.projectUrl, 'projectUrl')
	if (projectUrl) {
		const url = new URL(projectUrl)
		if (url.protocol !== 'https:') {
			throw new Error('projectUrl must use https.')
		}
		return {
			baseUrl: url.origin,
			host: url.host,
			projectRef: optionalString(input.projectRef, 'projectRef'),
		}
	}
	const projectRef = requireString(input.projectRef, 'projectRef')
	if (!PROJECT_REF_PATTERN.test(projectRef)) {
		throw new Error(
			'projectRef must be the Supabase project ref (8–40 letters or digits).',
		)
	}
	return {
		baseUrl: projectBaseUrl(projectRef),
		host: `${projectRef}.supabase.co`,
		projectRef,
	}
}

export function joinUrl(baseUrl: string, path: string): string {
	const normalized = path.startsWith('/') ? path : `/${path}`
	return new URL(normalized, `${baseUrl.replace(/\/$/, '')}/`).toString()
}

export function withQuery(
	url: string,
	query?: Record<string, string | number | boolean | undefined>,
): string {
	if (!query) return url
	const target = new URL(url)
	for (const [key, value] of Object.entries(query)) {
		if (value === undefined) continue
		target.searchParams.set(key, String(value))
	}
	return target.toString()
}

function slimErrorBody(text: string): { message: string; code?: string } {
	try {
		const parsed: unknown = JSON.parse(text)
		if (isRecord(parsed)) {
			const message =
				optionalString(parsed.message, 'message') ??
				optionalString(parsed.error, 'error') ??
				optionalString(parsed.msg, 'msg') ??
				text.slice(0, 400)
			const code =
				optionalString(parsed.code, 'code') ??
				optionalString(parsed.error_code, 'error_code')
			return { message, code }
		}
	} catch {
		// Use the raw body when it is not JSON.
	}
	return { message: text.slice(0, 400) || 'Request failed' }
}

function authSetupHint(input: {
	status: number
	lane: 'pat' | 'serviceRole'
	auth: SupabaseAuthInput
}): string | undefined {
	if (input.status !== 401 && input.status !== 403) return undefined
	const setup = setupUrls(input.auth)
	if (input.lane === 'pat') {
		return ` Save a personal access token at ${setup.patSetupUrl} and approve host api.supabase.com. Create the token at ${setup.patGenerateUrl}.`
	}
	const host = setup.projectHost ?? '{projectRef}.supabase.co'
	return ` Save the project service role / secret key at ${setup.serviceRoleSetupUrl} and approve host ${host}. Copy the key at ${setup.apiKeysUrl}.`
}

async function send(input: {
	url: string
	method: HttpMethod
	headers: Record<string, string>
	body?: Json
	path: string
	lane: 'pat' | 'serviceRole'
	auth: SupabaseAuthInput
}): Promise<{ status: number; headers: Record<string, string>; data: unknown }> {
	const init: RequestInit = {
		method: input.method,
		headers: input.headers,
	}
	if (input.body !== undefined && input.method !== 'GET' && input.method !== 'HEAD') {
		init.body = JSON.stringify(input.body)
	}
	const response = await fetch(input.url, init)
	const text = await response.text()
	let data: unknown = null
	if (text) {
		try {
			data = JSON.parse(text)
		} catch {
			data = text
		}
	}
	if (!response.ok) {
		const slim = slimErrorBody(typeof data === 'string' ? data : text)
		throw new SupabaseRequestError({
			message: `${input.method} ${input.path} failed (${response.status}): ${slim.message}.${authSetupHint({
				status: response.status,
				lane: input.lane,
				auth: input.auth,
			}) ?? ''}`,
			status: response.status,
			method: input.method,
			path: input.path,
			code: slim.code,
		})
	}
	const headers: Record<string, string> = {}
	for (const [key, value] of response.headers.entries()) {
		if (
			key === 'content-range' ||
			key === 'content-type' ||
			key === 'x-total-count' ||
			key.startsWith('x-ratelimit-')
		) {
			headers[key] = value
		}
	}
	return { status: response.status, headers, data }
}

export async function managementRequest(input: {
	auth?: SupabaseAuthInput
	method?: HttpMethod
	path: string
	query?: Record<string, string | number | boolean | undefined>
	body?: Json
	dryRun?: boolean
}): Promise<{ status: number; headers: Record<string, string>; data: unknown } | RequestPreview> {
	const auth = input.auth ?? {}
	const account = await accounts(auth)
	const method = input.method ?? 'GET'
	const path = input.path.startsWith('/') ? input.path : `/${input.path}`
	const url = withQuery(joinUrl(MANAGEMENT_BASE, path), input.query)
	if (input.dryRun) {
		return {
			dryRun: true,
			method,
			path,
			url,
			body: input.body,
		}
	}
	const token = secretPlaceholder(account.patSecretName)
	return await send({
		url,
		method,
		path,
		body: input.body,
		lane: 'pat',
		auth,
		headers: {
			Authorization: `Bearer ${token}`,
			Accept: 'application/json',
			...(input.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
		},
	})
}

export async function projectRequest(input: {
	auth: SupabaseAuthInput
	method?: HttpMethod
	path: string
	query?: Record<string, string | number | boolean | undefined>
	headers?: Record<string, string>
	body?: Json
	rawBody?: BodyInit
	dryRun?: boolean
}): Promise<{ status: number; headers: Record<string, string>; data: unknown } | RequestPreview> {
	const project = resolveProjectUrl(input.auth)
	const account = await accounts(input.auth)
	const method = input.method ?? 'GET'
	const path = input.path.startsWith('/') ? input.path : `/${input.path}`
	const url = withQuery(joinUrl(project.baseUrl, path), input.query)
	if (input.dryRun) {
		return {
			dryRun: true,
			method,
			path,
			url,
			headers: input.headers,
			body: input.body,
		}
	}
	const token = secretPlaceholder(account.serviceRoleSecretName)
	const headers: Record<string, string> = {
		apikey: token,
		Authorization: `Bearer ${token}`,
		Accept: 'application/json',
		...input.headers,
	}
	if (input.rawBody !== undefined) {
		const response = await fetch(url, {
			method,
			headers,
			body: input.rawBody,
		})
		const text = await response.text()
		let data: unknown = null
		if (text) {
			try {
				data = JSON.parse(text)
			} catch {
				data = text
			}
		}
		if (!response.ok) {
			const slim = slimErrorBody(typeof data === 'string' ? data : text)
			throw new SupabaseRequestError({
				message: `${method} ${path} failed (${response.status}): ${slim.message}.${authSetupHint({
					status: response.status,
					lane: 'serviceRole',
					auth: input.auth,
				}) ?? ''}`,
				status: response.status,
				method,
				path,
				code: slim.code,
			})
		}
		return { status: response.status, headers: {}, data }
	}
	if (input.body !== undefined && !headers['Content-Type'] && !headers['content-type']) {
		headers['Content-Type'] = 'application/json'
	}
	return await send({
		url,
		method,
		path,
		body: input.body,
		lane: 'serviceRole',
		auth: input.auth,
		headers,
	})
}

export function asLive<T>(
	result: { status: number; headers: Record<string, string>; data: unknown } | RequestPreview,
): T {
	if ('dryRun' in result && result.dryRun) {
		throw new Error('Expected a live response, received a dry-run preview.')
	}
	return result as T
}