335 lines
14 KiB
TypeScript
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);
|
|
}
|