Files
makelore/electron/services/game-resource-client.ts

335 lines
14 KiB
TypeScript

import { Buffer } from 'node:buffer';
import type { CapabilityBillingReceiptV1 } from '../../shared/data-service';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { getValidWorksSquareAccessToken } from './works-square-session';
const MAX_JSON_BYTES = 1_048_576;
const MAX_REQUEST_BYTES = 24 * 1024 * 1024;
const MAX_CONTENT_BYTES = 32 * 1024 * 1024;
const EXECUTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u;
const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'pending_review']);
type FetchImplementation = typeof fetch;
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
type JsonRecord = Record<string, unknown>;
export interface GameResourceGeneration {
readonly executionId: string;
readonly releaseId: string;
readonly projectId: string;
readonly logicalOperationId: string;
readonly kind: 'pixel' | 'hd';
readonly templateName: string;
readonly status: 'reserved' | 'accepted' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'submission_unknown' | 'pending_review';
readonly outputCount: number;
readonly pollIntervalSeconds?: number;
readonly errorCode?: string;
readonly billing: Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>;
}
export interface GameResourceContent {
readonly bytes: Uint8Array;
readonly fileName: string | null;
readonly contentType: string;
}
export interface GameResourceGenerateInput {
readonly releaseAdmissionId: string;
readonly releaseId: string;
readonly projectId: string;
readonly logicalOperationId: string;
readonly kind: 'pixel' | 'hd';
readonly templateName: string;
readonly templateConfig: Readonly<Record<string, unknown>>;
readonly requirement: string;
readonly aspectRatio?: string;
readonly temperature?: number;
readonly jobName?: string;
readonly modelName?: string;
readonly resolution?: string;
readonly hdRemoveBgMode?: string;
readonly threadId?: string;
readonly referenceFiles?: readonly {
name: string;
mimeType: string;
dataBase64: string;
}[];
}
export class GameResourceClientError extends Error {
constructor(
readonly code: string,
readonly status: number,
readonly retryable: boolean,
message: string,
) {
super(message);
this.name = 'GameResourceClientError';
}
}
export interface GameResourceClientOptions {
readonly fetchImpl?: FetchImplementation;
readonly getAccessToken?: AccessTokenGetter;
readonly apiBaseUrl?: string;
}
function isRecord(value: unknown): value is JsonRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function text(value: unknown, maximum: number): string | null {
return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null;
}
function integer(value: unknown, minimum: number, maximum: number): number | null {
return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum
? value as number
: null;
}
function billing(value: unknown): Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }> {
if (!isRecord(value) || value.mode !== 'platform_metered') throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid');
const status = text(value.status, 32);
const reservedPoints = text(value.reserved_points, 32);
const actualPoints = value.actual_points === null || value.actual_points === undefined
? undefined
: text(value.actual_points, 32) ?? undefined;
const usageAmount = value.usage_amount === null || value.usage_amount === undefined
? undefined
: integer(value.usage_amount, 0, Number.MAX_SAFE_INTEGER) ?? undefined;
if (!status || !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status)
|| !reservedPoints || !DECIMAL.test(reservedPoints)
|| (actualPoints !== undefined && !DECIMAL.test(actualPoints))
|| (['settled', 'refunded'].includes(status) && actualPoints === undefined)
|| value.unit !== 'generation') {
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid');
}
return {
mode: 'platform_metered',
status: status as Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>['status'],
reserved_points: reservedPoints,
...(actualPoints === undefined ? {} : { actual_points: actualPoints }),
...(usageAmount === undefined ? {} : { usage_amount: usageAmount }),
unit: 'generation',
} as Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>;
}
function generation(value: unknown): GameResourceGeneration {
if (!isRecord(value) || value.schema_version !== 1 || value.plugin_id !== 'makelore.game-resource') {
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid');
}
const executionId = text(value.execution_id, 36);
const releaseId = text(value.release_id, 36);
const projectId = text(value.project_id, 36);
const logicalOperationId = text(value.logical_operation_id, 128);
const templateName = text(value.template_name, 200);
const status = text(value.status, 32);
const outputCount = integer(value.output_count, 0, 100);
const poll = value.poll_interval_seconds === null || value.poll_interval_seconds === undefined
? undefined
: integer(value.poll_interval_seconds, 1, 300) ?? undefined;
const errorCode = value.error_code === null || value.error_code === undefined
? undefined
: text(value.error_code, 128) ?? undefined;
if (!executionId || !EXECUTION_ID.test(executionId) || !releaseId || !projectId || !logicalOperationId
|| (value.kind !== 'pixel' && value.kind !== 'hd') || !templateName
|| !status || !['reserved', 'accepted', 'running', 'succeeded', 'failed', 'cancelled', 'submission_unknown', 'pending_review'].includes(status)
|| outputCount === null) {
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid');
}
return {
executionId,
releaseId,
projectId,
logicalOperationId,
kind: value.kind,
templateName,
status: status as GameResourceGeneration['status'],
outputCount,
...(poll === undefined ? {} : { pollIntervalSeconds: poll }),
...(errorCode === undefined ? {} : { errorCode }),
billing: billing(value.billing),
};
}
async function readBounded(response: Response, maximum: number): Promise<Uint8Array> {
const declared = response.headers.get('content-length');
if (declared && /^\d+$/u.test(declared) && Number(declared) > maximum) {
await response.body?.cancel().catch(() => undefined);
throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound');
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maximum) throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound');
return bytes;
}
function errorFrom(status: number, payload: unknown): GameResourceClientError {
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
const code = text(detail?.error_code, 128) ?? 'plugin_backend_unavailable';
const retryable = status >= 500 || status === 429;
const message = status === 401
? 'Works Square sign-in is required'
: status === 402
? 'Token Point balance is insufficient'
: status === 409
? 'Hosted game-resource operation conflicts with current state'
: status === 422
? 'Hosted game-resource request is invalid'
: 'Hosted game-resource service is unavailable';
return new GameResourceClientError(code, status, retryable, message);
}
function encodedBody(value: unknown): string {
const result = JSON.stringify(value);
if (Buffer.byteLength(result, 'utf8') > MAX_REQUEST_BYTES) {
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Hosted game-resource request is too large');
}
return result;
}
export class GameResourceClient {
private readonly fetchImpl: FetchImplementation;
private readonly getAccessToken: AccessTokenGetter;
private readonly apiBaseUrl: string;
constructor(options: GameResourceClientOptions = {}) {
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken;
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, '');
}
async templates(input: { releaseId: string; releaseAdmissionId: string; kind: 'pixel' | 'hd' }): Promise<readonly string[]> {
const query = new URLSearchParams({
release_id: input.releaseId,
release_admission_id: input.releaseAdmissionId,
kind: input.kind,
});
const payload = await this.json('GET', `/api/plugins/v1/hosted/game-resource/templates?${query}`);
if (!isRecord(payload) || payload.schema_version !== 1 || payload.kind !== input.kind
|| !Array.isArray(payload.templates) || payload.templates.length > 100
|| payload.templates.some((item) => !text(item, 200))) {
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted template response is invalid');
}
return payload.templates as string[];
}
async generate(input: GameResourceGenerateInput): Promise<GameResourceGeneration> {
return generation(await this.json('POST', '/api/plugins/v1/hosted/game-resource/generations', {
release_admission_id: input.releaseAdmissionId,
release_id: input.releaseId,
project_id: input.projectId,
logical_operation_id: input.logicalOperationId,
kind: input.kind,
template_name: input.templateName,
template_config: input.templateConfig,
requirement: input.requirement,
aspect_ratio: input.aspectRatio ?? '1:1',
temperature: input.temperature ?? 0,
...(input.jobName ? { job_name: input.jobName } : {}),
...(input.modelName ? { model_name: input.modelName } : {}),
...(input.resolution ? { resolution: input.resolution } : {}),
...(input.hdRemoveBgMode ? { hd_remove_bg_mode: input.hdRemoveBgMode } : {}),
...(input.threadId ? { thread_id: input.threadId } : {}),
reference_files: input.referenceFiles?.map((item) => ({
name: item.name,
mime_type: item.mimeType,
data_base64: item.dataBase64,
})) ?? [],
}));
}
async get(executionId: string): Promise<GameResourceGeneration> {
return generation(await this.json('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}`));
}
async cancel(executionId: string): Promise<GameResourceGeneration> {
return generation(await this.json('POST', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/cancel`));
}
async download(executionId: string, outputIndex?: number): Promise<GameResourceContent> {
const suffix = outputIndex === undefined ? '' : `?output_index=${outputIndex}`;
const response = await this.send('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/content${suffix}`);
if (!response.ok) {
const bytes = await readBounded(response, MAX_JSON_BYTES).catch(() => new Uint8Array());
let payload: unknown = null;
try { payload = JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; } catch { /* bounded generic error */ }
throw errorFrom(response.status, payload);
}
const bytes = await readBounded(response, MAX_CONTENT_BYTES);
const disposition = response.headers.get('content-disposition') ?? '';
const encoded = /filename\*=UTF-8''([^;]+)/iu.exec(disposition)?.[1];
return {
bytes,
fileName: encoded ? decodeURIComponent(encoded).slice(0, 200) : null,
contentType: (response.headers.get('content-type') ?? 'application/octet-stream').slice(0, 128),
};
}
private async json(method: 'GET' | 'POST', path: string, body?: unknown): Promise<unknown> {
const response = await this.send(method, path, body);
const bytes = await readBounded(response, MAX_JSON_BYTES);
let payload: unknown;
try {
payload = bytes.byteLength ? JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown : null;
} catch {
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted response is invalid');
}
if (!response.ok) throw errorFrom(response.status, payload);
return payload;
}
private async send(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Response> {
let token: string | null;
try {
token = await this.getAccessToken({ fetchImpl: this.fetchImpl });
} catch {
token = null;
}
if (!token) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required');
const encoded = body === undefined ? undefined : encodedBody(body);
const request = async (accessToken: string) => await this.fetchImpl(`${this.apiBaseUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
...(encoded === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(encoded === undefined ? {} : { body: encoded }),
redirect: 'manual',
signal: AbortSignal.timeout(35_000),
});
let response: Response;
try {
response = await request(token);
} catch {
throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable');
}
if (response.status !== 401) return response;
await response.body?.cancel().catch(() => undefined);
let refreshed: string | null;
try {
refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true });
} catch {
refreshed = null;
}
if (!refreshed) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required');
try {
response = await request(refreshed);
} catch {
throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable');
}
if (response.status === 401) {
await response.body?.cancel().catch(() => undefined);
throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required');
}
return response;
}
}
export function isTerminalGameResourceStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}