Skip to content

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

Package listing

@kody/shopify

src/lib/shop.ts

150 lines · 4.2 KB · TypeScript
export const DEFAULT_API_VERSION = '2026-07'
export const MYSHOPIFY_HOST_SUFFIX = '.myshopify.com'

const API_VERSION_RE = /^\d{4}-(0[1-9]|1[0-2])$/
const SHOP_HANDLE_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/

export function trimString(value: unknown): string {
	return typeof value === 'string' ? value.trim() : ''
}

export function clampInt(
	value: unknown,
	min: number,
	max: number,
	fallback: number,
): number {
	const number = Number(value)
	if (!Number.isFinite(number)) return fallback
	return Math.min(max, Math.max(min, Math.floor(number)))
}

/**
 * Normalize a shop identifier to the myshopify.com handle (no host suffix).
 * Accepts `acme`, `acme.myshopify.com`, or `https://acme.myshopify.com/admin`.
 */
export function normalizeShop(value: unknown): string {
	let raw = trimString(value).toLowerCase()
	if (!raw) {
		throw new Error(
			'Shop is required. Pass shop: "your-store" (the *.myshopify.com handle).',
		)
	}
	if (raw.includes('://')) {
		try {
			raw = new URL(raw).hostname
		} catch {
			throw new Error(`Shop URL is invalid: ${String(value)}`)
		}
	}
	raw = raw.replace(/\/.*$/, '')
	if (raw.endsWith(MYSHOPIFY_HOST_SUFFIX)) {
		raw = raw.slice(0, -MYSHOPIFY_HOST_SUFFIX.length)
	}
	if (raw.includes('.') || !SHOP_HANDLE_RE.test(raw)) {
		throw new Error(
			`Shop must be a myshopify.com handle such as "your-store", not ${JSON.stringify(value)}.`,
		)
	}
	return raw
}

export function shopHostname(shop: string): string {
	return `${normalizeShop(shop)}${MYSHOPIFY_HOST_SUFFIX}`
}

export function shopOrigin(shop: string): string {
	return `https://${shopHostname(shop)}`
}

export function resolveApiVersion(value?: unknown): string {
	const version = trimString(value) || DEFAULT_API_VERSION
	if (!API_VERSION_RE.test(version)) {
		throw new Error(
			`API version must look like "2026-07" (got ${JSON.stringify(value)}).`,
		)
	}
	return version
}

/** Accept a GID or numeric/string id and return `gid://shopify/{type}/{id}`. */
export function toGid(type: string, id: unknown): string {
	const raw = trimString(id)
	if (!raw) throw new Error(`${type} id is required.`)
	if (raw.startsWith('gid://')) {
		if (!raw.startsWith(`gid://shopify/${type}/`)) {
			throw new Error(`Expected a gid://shopify/${type}/… id, got ${raw}`)
		}
		return raw
	}
	if (!/^\d+$/.test(raw)) {
		throw new Error(`${type} id must be a GID or numeric id, got ${JSON.stringify(id)}`)
	}
	return `gid://shopify/${type}/${raw}`
}

export function gidId(gid: string): string {
	const parts = gid.split('/')
	return parts[parts.length - 1] || gid
}

export type SelfCheckResult = {
	ok: true
	checks: number
}

/** Local, network-free assertions for shop/id helpers. Safe for publish smoke. */
export function runShopHelperSelfCheck(): SelfCheckResult {
	const shops: Array<[unknown, string]> = [
		['Acme-Store', 'acme-store'],
		['acme-store.myshopify.com', 'acme-store'],
		['https://acme-store.myshopify.com/admin', 'acme-store'],
		['x', 'x'],
	]
	for (const [input, expected] of shops) {
		const got = normalizeShop(input)
		if (got !== expected) {
			throw new Error(`normalizeShop(${JSON.stringify(input)}) => ${got}, expected ${expected}`)
		}
	}

	const rejected = ['https://evil.example', 'store.example.com', '../nope', '']
	for (const input of rejected) {
		let threw = false
		try {
			normalizeShop(input)
		} catch {
			threw = true
		}
		if (!threw) throw new Error(`normalizeShop should reject ${JSON.stringify(input)}`)
	}

	if (shopHostname('acme') !== 'acme.myshopify.com') {
		throw new Error('shopHostname failed')
	}
	if (resolveApiVersion() !== DEFAULT_API_VERSION) {
		throw new Error('default API version drifted')
	}
	if (resolveApiVersion('2026-04') !== '2026-04') {
		throw new Error('resolveApiVersion failed')
	}
	let versionThrew = false
	try {
		resolveApiVersion('v1')
	} catch {
		versionThrew = true
	}
	if (!versionThrew) throw new Error('resolveApiVersion should reject v1')

	if (toGid('Product', '123') !== 'gid://shopify/Product/123') {
		throw new Error('toGid numeric failed')
	}
	if (toGid('Product', 'gid://shopify/Product/9') !== 'gid://shopify/Product/9') {
		throw new Error('toGid passthrough failed')
	}
	if (gidId('gid://shopify/Order/55') !== '55') {
		throw new Error('gidId failed')
	}

	return { ok: true, checks: shops.length + rejected.length + 7 }
}