import {RuntimeUnavailableError, httpStatusToCloudError} from './cloud-spi/errors' /** Floci-GCP's health endpoint; deliberately not the `/_floci/health` core uses. */ const GCP_HEALTH_PATH = '/_floci-gcp/health' export interface GcpRuntimeFetchOptions { emptyOnNotFound?: boolean } /** * Transport seam for the Floci-GCP runtime, mirroring `AzureRuntimeClient`. * * Both GCP adapters previously carried their own copy of this and threw away the * response body, so a failure surfaced as a bare "HTTP 500" with no explanation. */ export interface GcpRuntimeClient { readonly endpoint: string readonly project: string readonly location: string fetch(path: string, init?: RequestInit, options?: GcpRuntimeFetchOptions): Promise json(path: string, init?: RequestInit, options?: GcpRuntimeFetchOptions): Promise health(): Promise } /** Google's REST error envelope. */ interface GcpErrorEnvelope { error?: {code?: number; message?: string; status?: string} } export class GcpRestRuntimeClient implements GcpRuntimeClient { constructor( readonly endpoint: string = gcpEndpoint(), readonly project: string = gcpProject(), readonly location: string = gcpLocation(), ) {} async fetch(path: string, init: RequestInit = {}, options: GcpRuntimeFetchOptions = {}): Promise { let res: Response try { res = await globalThis.fetch(`${this.endpoint}${path}`, init) } catch (error) { throw new RuntimeUnavailableError( `Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`, {cause: error}, ) } if (options.emptyOnNotFound && res.status === 404) return null if (!res.ok) { const detail = await readErrorDetail(res) throw httpStatusToCloudError( res.status, `GCP runtime request failed: HTTP ${res.status} ${path}${detail ? ` - ${detail}` : ''}`, ) } return res } async json(path: string, init: RequestInit = {}, options: GcpRuntimeFetchOptions = {}): Promise { const res = await this.fetch(path, init, options) if (!res) return null return res.json() as Promise } /** * Liveness probe against the runtime's own health endpoint. * * Note the path is `/_floci-gcp/health`, not the `/_floci/health` that Floci * core uses — `/` and `/_floci/health` both 404 on this runtime. */ async health(): Promise { const url = `${this.endpoint}${GCP_HEALTH_PATH}` let res: Response try { res = await globalThis.fetch(url, {method: 'GET'}) } catch (error) { throw new RuntimeUnavailableError( `Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`, {cause: error}, ) } if (res.status >= 500) { throw new RuntimeUnavailableError(`TheCloud's GCP runtime at ${this.endpoint} returned HTTP ${res.status}`) } } } export function gcpEndpoint(): string { return process.env.FLOCI_GCP_ENDPOINT ?? process.env.FLOCI_GP_ENDPOINT ?? 'http://localhost:4588' } export function gcpProject(): string { return process.env.FLOCI_GCP_PROJECT ?? 'floci-local' } export function gcpLocation(): string { return process.env.FLOCI_GCP_LOCATION ?? 'us-central1' } export const gcp = new GcpRestRuntimeClient() export async function checkGcpRuntime(endpoint: string = gcpEndpoint()): Promise { await new GcpRestRuntimeClient(endpoint).health() } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } /** Prefer Google's structured error message over the raw body. */ async function readErrorDetail(res: Response): Promise { const text = await safeResponseText(res) if (!text) return '' try { const parsed = JSON.parse(text) as GcpErrorEnvelope return parsed.error?.message ?? text } catch { return text } } async function safeResponseText(res: Response): Promise { try { return (await res.text()).trim().slice(0, 500) } catch { return '' } }