import { mapNotNullish } from "#fp"; import type { ScriptIo } from "#scripts/script-runner.ts"; import type { FetchText, FetchTextResult } from "./fetch-text.ts"; export const BUNNY_API_BASE = "https://api.bunny.net"; export const BUNDLE_PATH = "bunny-script.ts"; export const USAGE = "Usage: deno task deploy:edge "; export const DEPLOY_BUILT_USAGE = "Usage: deno run scripts/deploy-built-edge.ts [file]"; export const ACCESS_KEY_ERROR = "BUNNY_ACCESS_KEY is required (BUNNY_API_KEY also works)."; const ACCESS_KEY_ENV_KEYS = ["BUNNY_ACCESS_KEY", "BUNNY_API_KEY"] as const; export type BunnyDeployResult = { ok: true } | { error: string; ok: false }; /** Shared shape of the script-code operations (upload, deploy). */ type ScriptCodeFn = ( scriptId: string, code: string, accessKey: string, fetchText: FetchText, ) => Promise; export interface BuildResult { code: number; success: boolean; } export interface DeployEdgeDeps extends ScriptIo { bundlePath: string; cwd: string; fetchText: FetchText; readTextFile: (path: string) => Promise; runBuildEdge: (cwd: string) => Promise; } export interface DeployBuiltEdgeDeps extends ScriptIo { fetchText: FetchText; readTextFile: (path: string) => Promise; } export interface DeployBuiltArgs { bundlePath: string; scriptId: string; } type ParsedScriptId = | { ok: true; scriptId: string } | { error: string; ok: false; }; const parseScriptId = ( value: string | undefined, usage: string, ): ParsedScriptId => { const scriptId = value?.trim(); return scriptId ? { ok: true, scriptId } : { error: usage, ok: false }; }; export const parseScriptIdArg = ( args: string[], ): { ok: true; scriptId: string } | { error: string; ok: false } => { if (args.length !== 1) { return { error: USAGE, ok: false }; } return parseScriptId(args[0], USAGE); }; export const parseDeployBuiltArgs = ( args: string[], ): ({ ok: true } & DeployBuiltArgs) | { error: string; ok: false } => { if (args.length < 1 || args.length > 2) { return { error: DEPLOY_BUILT_USAGE, ok: false }; } const scriptId = parseScriptId(args[0], DEPLOY_BUILT_USAGE); if (!scriptId.ok) return scriptId; const fileArg = args[1]; const bundlePath = fileArg === undefined ? BUNDLE_PATH : fileArg.trim(); return bundlePath ? { bundlePath, ok: true, scriptId: scriptId.scriptId } : { error: DEPLOY_BUILT_USAGE, ok: false }; }; export const getAccessKey = ( getEnv: (key: string) => string | undefined, ): string | undefined => mapNotNullish<(typeof ACCESS_KEY_ENV_KEYS)[number], string>((key) => { const value = getEnv(key)?.trim(); return value ? value : undefined; })([...ACCESS_KEY_ENV_KEYS]).at(0); const parseBunnyMessage = (text: string): string => { const fallback = text.trim() || "empty response"; try { const json = JSON.parse(text) as { Message?: unknown }; if (typeof json.Message === "string" && json.Message.trim()) { return json.Message.trim(); } } catch { /* use raw response text */ } return fallback; }; export const formatBunnyError = ( label: string, response: FetchTextResult, ): string => `${label} failed (${response.status}): ${parseBunnyMessage(response.text)}`; const postScriptAction = async ( scriptId: string, action: "code" | "publish", body: Record, accessKey: string, label: string, fetchText: FetchText, ): Promise => { const response = await fetchText( `${BUNNY_API_BASE}/compute/script/${encodeURIComponent(scriptId)}/${action}`, { body: JSON.stringify(body), headers: { AccessKey: accessKey, "Content-Type": "application/json", }, method: "POST", }, ); return response.ok ? { ok: true } : { error: formatBunnyError(label, response), ok: false, }; }; export const uploadScriptCode: ScriptCodeFn = ( scriptId, code, accessKey, fetchText, ) => postScriptAction( scriptId, "code", { Code: code }, accessKey, "Upload script code", fetchText, ); export const publishScript = ( scriptId: string, accessKey: string, fetchText: FetchText, ): Promise => postScriptAction( scriptId, "publish", {}, accessKey, "Publish script", fetchText, ); export const deployScriptCode: ScriptCodeFn = async ( scriptId, code, accessKey, fetchText, ) => { const upload = await uploadScriptCode(scriptId, code, accessKey, fetchText); if (!upload.ok) return upload; return publishScript(scriptId, accessKey, fetchText); }; interface DeployBuiltBundleDeps extends Pick { accessKey: string; bundlePath: string; fetchText: FetchText; labelPath: string; readTextFile: (path: string) => Promise; scriptId: string; } const deployBuiltBundle = async ( deps: DeployBuiltBundleDeps, ): Promise => { let code: string; try { code = await deps.readTextFile(deps.bundlePath); } catch (error) { deps.stderr(`Failed to read ${deps.bundlePath}: ${String(error)}`); return 1; } deps.stdout( `Uploading ${deps.labelPath} to Bunny script ${deps.scriptId}...`, ); const deploy = await deployScriptCode( deps.scriptId, code, deps.accessKey, deps.fetchText, ); if (!deploy.ok) { deps.stderr(deploy.error); return 1; } deps.stdout(`Published Bunny script ${deps.scriptId}.`); return 0; }; const withAccessKey = async ( deps: Pick, run: (accessKey: string) => Promise, ): Promise => { const accessKey = getAccessKey(deps.getEnv); if (!accessKey) { deps.stderr(ACCESS_KEY_ERROR); return 1; } return run(accessKey); }; export const runDeployBuiltEdge = async ( deps: DeployBuiltEdgeDeps, ): Promise => { const args = parseDeployBuiltArgs(deps.args); if (!args.ok) { deps.stderr(args.error); return 1; } return withAccessKey(deps, (accessKey) => deployBuiltBundle({ ...deps, accessKey, bundlePath: args.bundlePath, labelPath: args.bundlePath, scriptId: args.scriptId, }), ); }; export const runDeployEdge = async (deps: DeployEdgeDeps): Promise => { const scriptId = parseScriptIdArg(deps.args); if (!scriptId.ok) { deps.stderr(scriptId.error); return 1; } return withAccessKey(deps, async (accessKey) => { deps.stdout("Building edge bundle..."); const build = await deps.runBuildEdge(deps.cwd); if (!build.success) { deps.stderr(`build:edge failed with exit code ${build.code}`); return 1; } return deployBuiltBundle({ ...deps, accessKey, labelPath: BUNDLE_PATH, scriptId: scriptId.scriptId, }); }); };