PSA/packages/product-ext-proxy/ee/runner-backend.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

281 lines
8.0 KiB
TypeScript

import { filterResponseHeaders, Method } from '../shared/gateway-utils';
export type RunnerBackendKind = 'knative' | 'docker';
export interface RunnerExecutePayload {
context: Record<string, unknown>;
http: {
method: Method;
path: string;
query: Record<string, string>;
headers: Record<string, string>;
body_b64?: string;
};
limits: { timeout_ms: number };
endpoint?: string;
providers?: unknown;
secret_envelope?: unknown;
[key: string]: unknown;
}
export interface RunnerExecuteOptions {
requestId: string;
timeoutMs: number;
headers?: Record<string, string>;
}
export interface RunnerExecuteResult {
status: number;
headers: Record<string, string>;
body?: Buffer;
}
export interface RunnerFetchAssetOptions {
path: string;
search: string;
method: string;
headers: Headers;
}
export interface RunnerBackend {
readonly kind: RunnerBackendKind;
execute(payload: RunnerExecutePayload, options: RunnerExecuteOptions): Promise<RunnerExecuteResult>;
fetchStaticAsset(options: RunnerFetchAssetOptions): Promise<Response>;
getPublicBase(): string | null;
}
export class RunnerConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'RunnerConfigError';
}
}
export class RunnerRequestError extends Error {
public readonly status?: number;
public readonly backend: RunnerBackendKind;
constructor(message: string, backend: RunnerBackendKind, status?: number) {
super(message);
this.name = 'RunnerRequestError';
this.backend = backend;
this.status = status;
}
}
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const STATIC_HEADER_ALLOWLIST = new Set([
'accept',
'accept-encoding',
'accept-language',
'if-modified-since',
'if-none-match',
'range',
'user-agent',
]);
class HttpRunnerBackend implements RunnerBackend {
public readonly kind: RunnerBackendKind;
private readonly baseUrl: string;
private readonly publicBase: string | null;
private readonly serviceToken: string | null;
constructor(kind: RunnerBackendKind, baseUrl: string, publicBase: string | null, serviceToken: string | null) {
this.kind = kind;
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.publicBase = publicBase ? trimTrailingSlashes(publicBase) : null;
this.serviceToken = serviceToken;
}
getPublicBase(): string | null {
return this.publicBase;
}
async execute(payload: RunnerExecutePayload, options: RunnerExecuteOptions): Promise<RunnerExecuteResult> {
const endpoint = `${this.baseUrl}/v1/execute`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-request-id': options.requestId,
};
if (this.serviceToken) {
headers['x-runner-service-token'] = this.serviceToken;
}
if (options.headers) {
for (const [key, value] of Object.entries(options.headers)) {
if (value !== undefined && value !== null) {
headers[key.toLowerCase()] = value;
}
}
}
let response: Response;
try {
console.log('[runner-backend] Fetching:', endpoint);
response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: controller.signal,
cache: 'no-store', // Bypass Next.js fetch caching
} as RequestInit);
console.log('[runner-backend] Fetch succeeded:', response.status);
} catch (error: any) {
console.error('[runner-backend] Fetch error:', {
name: error?.name,
message: error?.message,
code: error?.code,
cause: error?.cause,
causeName: error?.cause?.name,
causeMessage: error?.cause?.message,
causeCode: error?.cause?.code,
causeErrno: error?.cause?.errno,
causeHostname: error?.cause?.hostname,
stack: error?.stack,
});
throw wrapFetchError(error, this.kind);
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
const text = await response.text().catch(() => undefined);
throw new RunnerRequestError(
`Runner responded with non-success status ${response.status}${text ? `: ${text}` : ''}`,
this.kind,
response.status,
);
}
const payloadJson: any = await response.json().catch((error) => {
throw new RunnerRequestError(`Runner returned invalid JSON: ${(error as Error).message}`, this.kind);
});
const status = typeof payloadJson?.status === 'number' ? payloadJson.status : 200;
const headersOut = filterResponseHeaders(payloadJson?.headers as Record<string, string | string[] | undefined> | undefined);
const body = typeof payloadJson?.body_b64 === 'string'
? Buffer.from(payloadJson.body_b64, 'base64')
: undefined;
return { status, headers: headersOut, body };
}
async fetchStaticAsset(options: RunnerFetchAssetOptions): Promise<Response> {
const url = buildUrlWithSearch(`${this.baseUrl}/${options.path}`.replace(/\/+$/, ''), options.search);
const headers = new Headers();
for (const [key, value] of options.headers.entries()) {
const lower = key.toLowerCase();
if (STATIC_HEADER_ALLOWLIST.has(lower)) {
headers.set(lower, value);
}
}
let response: Response;
try {
response = await fetch(url, {
method: options.method,
headers,
});
} catch (error) {
throw wrapFetchError(error, this.kind);
}
return response;
}
}
function wrapFetchError(error: unknown, backend: RunnerBackendKind): Error {
if (error instanceof RunnerRequestError || error instanceof RunnerConfigError) {
return error;
}
if ((error as any)?.name === 'AbortError') {
const err = new RunnerRequestError('Runner request timed out', backend);
err.name = 'AbortError';
return err;
}
return new RunnerRequestError((error as Error)?.message ?? 'Runner request failed', backend);
}
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
function buildUrlWithSearch(base: string, search: string): string {
if (!search) return base;
if (search.startsWith('?') || search.startsWith('&')) {
return `${base}${search}`;
}
return `${base}?${search}`;
}
let cachedBackend: RunnerBackend | null = null;
function resolveBackendKind(): RunnerBackendKind {
const raw = (process.env.RUNNER_BACKEND || 'knative').trim().toLowerCase();
return raw === 'docker' ? 'docker' : 'knative';
}
function resolveBaseUrl(kind: RunnerBackendKind): string {
const base = kind === 'docker'
? process.env.RUNNER_DOCKER_HOST || process.env.RUNNER_BASE_URL
: process.env.RUNNER_BASE_URL;
if (!base) {
throw new RunnerConfigError(`Runner base URL not configured for backend "${kind}"`);
}
return base.replace(/\/+$/, '');
}
function resolvePublicBase(): string | null {
const raw = process.env.RUNNER_PUBLIC_BASE;
if (!raw) return null;
return trimTrailingSlashes(raw);
}
function resolveServiceToken(): string | null {
return process.env.RUNNER_SERVICE_TOKEN || null;
}
function buildBackend(): RunnerBackend {
const kind = resolveBackendKind();
const base = resolveBaseUrl(kind);
const publicBase = resolvePublicBase();
const serviceToken = resolveServiceToken();
return new HttpRunnerBackend(kind, base, publicBase, serviceToken);
}
export function getRunnerBackend(): RunnerBackend {
if (process.env.NODE_ENV === 'development') {
return buildBackend();
}
if (!cachedBackend) {
cachedBackend = buildBackend();
}
return cachedBackend;
}
export function filterHopByHopHeaders(headers: Headers): Record<string, string> {
const out: Record<string, string> = {};
headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower)) {
out[lower] = value;
}
});
return out;
}