332 lines
10 KiB
JavaScript
332 lines
10 KiB
JavaScript
import {
|
|
MINDSPACE_SERVER_ADAPTER_BINDINGS,
|
|
MINDSPACE_SERVER_ADAPTER_BINDING_KEYS,
|
|
MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
|
|
MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS,
|
|
MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES,
|
|
} from './mindspace-server-adapter-contract.mjs';
|
|
|
|
function trimTrailingSlash(value) {
|
|
return String(value ?? '').trim().replace(/\/+$/, '');
|
|
}
|
|
|
|
function resolveRemoteAdapterEndpoint(endpoint) {
|
|
const base = trimTrailingSlash(endpoint);
|
|
return base || '';
|
|
}
|
|
|
|
function normalizeOperationBasePath(value) {
|
|
const raw = String(value ?? '').trim();
|
|
if (!raw) return '/mindspace/v1/adapter';
|
|
return `/${raw.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
}
|
|
|
|
function buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath = '/mindspace/v1/adapter') {
|
|
const base = resolveRemoteAdapterEndpoint(endpoint);
|
|
if (!base) {
|
|
throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL');
|
|
}
|
|
const normalizedBasePath = normalizeOperationBasePath(operationBasePath);
|
|
return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`;
|
|
}
|
|
|
|
function buildRemoteContractUrl(endpoint, contractPath = '/mindspace/v1/contract') {
|
|
const base = resolveRemoteAdapterEndpoint(endpoint);
|
|
if (!base) {
|
|
throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL');
|
|
}
|
|
const normalizedPath = normalizeOperationBasePath(contractPath);
|
|
return `${base}${normalizedPath}`;
|
|
}
|
|
|
|
function throwRemoteOperationError(response, bindingKey, method, bodyText, payload) {
|
|
const error = new Error(
|
|
payload?.message ??
|
|
`MindSpace remote adapter ${bindingKey}.${method}() failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
|
|
);
|
|
if (payload?.code) error.code = payload.code;
|
|
if (payload?.details !== undefined) error.details = payload.details;
|
|
throw error;
|
|
}
|
|
|
|
async function parseRemoteOperationResponse(response, bindingKey, method) {
|
|
const bodyText = await response.text().catch(() => '');
|
|
let payload = null;
|
|
if (bodyText) {
|
|
try {
|
|
payload = JSON.parse(bodyText);
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
throwRemoteOperationError(response, bindingKey, method, bodyText, payload);
|
|
}
|
|
if (response.status === 204) return null;
|
|
if (!bodyText) return null;
|
|
return payload ?? JSON.parse(bodyText);
|
|
}
|
|
|
|
async function invokeRemoteOperation({
|
|
endpoint,
|
|
operationBasePath,
|
|
authToken,
|
|
timeoutMs,
|
|
bindingKey,
|
|
method,
|
|
args,
|
|
fetchFn,
|
|
}) {
|
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
const timeoutId =
|
|
controller && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
? setTimeout(() => controller.abort(new Error('MindSpace remote adapter request timed out')), timeoutMs)
|
|
: null;
|
|
try {
|
|
const response = await fetchFn(buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
args,
|
|
}),
|
|
signal: controller?.signal,
|
|
});
|
|
return parseRemoteOperationResponse(response, bindingKey, method);
|
|
} finally {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
function formatContractIssue(issue) {
|
|
if (typeof issue === 'string') return issue;
|
|
return JSON.stringify(issue);
|
|
}
|
|
|
|
export function validateMindSpaceRemoteContract(
|
|
contract,
|
|
{
|
|
minContractVersion = MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
|
|
requiredBindings = MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS,
|
|
requiredCapabilities = MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES,
|
|
} = {},
|
|
) {
|
|
const issues = [];
|
|
const version = Number(contract?.contractVersion ?? 0);
|
|
if (!Number.isFinite(version) || version < minContractVersion) {
|
|
issues.push(
|
|
`contractVersion ${contract?.contractVersion ?? '(missing)'} is below required ${minContractVersion}`,
|
|
);
|
|
}
|
|
const bindings = contract?.bindings ?? {};
|
|
for (const [bindingKey, methods] of Object.entries(requiredBindings ?? {})) {
|
|
const exposed = bindings[bindingKey];
|
|
if (!Array.isArray(exposed)) {
|
|
issues.push(`missing binding ${bindingKey}`);
|
|
continue;
|
|
}
|
|
for (const method of methods) {
|
|
if (!exposed.includes(method)) {
|
|
issues.push(`missing method ${bindingKey}.${method}`);
|
|
}
|
|
}
|
|
}
|
|
const capabilities = new Set(
|
|
Array.isArray(contract?.requiredCapabilities)
|
|
? contract.requiredCapabilities
|
|
: [],
|
|
);
|
|
for (const capability of requiredCapabilities ?? []) {
|
|
if (!capabilities.has(capability)) {
|
|
issues.push(`missing capability ${capability}`);
|
|
}
|
|
}
|
|
if (issues.length > 0) {
|
|
const error = new Error(
|
|
`MindSpace remote contract is incompatible: ${issues
|
|
.map(formatContractIssue)
|
|
.join('; ')}`,
|
|
);
|
|
error.code = 'mindspace_contract_incompatible';
|
|
error.details = {
|
|
issues,
|
|
contractVersion: contract?.contractVersion ?? null,
|
|
buildId: contract?.buildId ?? null,
|
|
gitSha: contract?.gitSha ?? null,
|
|
};
|
|
throw error;
|
|
}
|
|
return contract;
|
|
}
|
|
|
|
async function fetchRemoteContract({
|
|
endpoint,
|
|
contractPath,
|
|
fetchFn,
|
|
authToken,
|
|
timeoutMs,
|
|
}) {
|
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
const timeoutId =
|
|
controller && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
? setTimeout(() => controller.abort(new Error('MindSpace remote contract request timed out')), timeoutMs)
|
|
: null;
|
|
try {
|
|
const response = await fetchFn(buildRemoteContractUrl(endpoint, contractPath), {
|
|
method: 'GET',
|
|
headers: {
|
|
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
},
|
|
signal: controller?.signal,
|
|
});
|
|
const bodyText = await response.text().catch(() => '');
|
|
let payload = null;
|
|
if (bodyText) {
|
|
try {
|
|
payload = JSON.parse(bodyText);
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
const error = new Error(
|
|
`MindSpace remote contract check failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
|
|
);
|
|
error.code = 'mindspace_contract_unavailable';
|
|
throw error;
|
|
}
|
|
if (!payload) {
|
|
const error = new Error('MindSpace remote contract response must be valid JSON');
|
|
error.code = 'mindspace_contract_invalid_json';
|
|
throw error;
|
|
}
|
|
return payload;
|
|
} finally {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
function createRemoteBindingProxy({
|
|
endpoint,
|
|
bindingKey,
|
|
fetchFn,
|
|
authToken,
|
|
timeoutMs,
|
|
operationBasePath,
|
|
}) {
|
|
const methods = MINDSPACE_SERVER_ADAPTER_BINDINGS[bindingKey] ?? [];
|
|
const methodSet = new Set(methods);
|
|
const proxyTarget = {};
|
|
for (const method of methods) {
|
|
proxyTarget[method] = async (...args) =>
|
|
invokeRemoteOperation({
|
|
endpoint,
|
|
operationBasePath,
|
|
authToken,
|
|
timeoutMs,
|
|
bindingKey,
|
|
method,
|
|
args,
|
|
fetchFn,
|
|
});
|
|
}
|
|
return new Proxy(proxyTarget, {
|
|
get(target, prop) {
|
|
if (prop === Symbol.toStringTag) return 'MindSpaceRemoteBindingProxy';
|
|
if (typeof prop === 'string' && !methodSet.has(prop)) {
|
|
throw new Error(`MindSpace remote adapter binding "${bindingKey}" does not expose ${bindingKey}.${prop}()`);
|
|
}
|
|
return target[prop];
|
|
},
|
|
});
|
|
}
|
|
|
|
export function createMindSpaceRemoteServerAdapter({
|
|
logger = console,
|
|
endpoint = '',
|
|
authToken = '',
|
|
operationBasePath = '/mindspace/v1/adapter',
|
|
contractPath = '/mindspace/v1/contract',
|
|
minContractVersion = MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
|
|
requiredBindings = MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS,
|
|
requiredCapabilities = MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES,
|
|
timeoutMs = 15_000,
|
|
fetchFn = globalThis.fetch?.bind(globalThis),
|
|
} = {}) {
|
|
const endpointUrl = resolveRemoteAdapterEndpoint(endpoint);
|
|
const normalizedOperationBasePath = normalizeOperationBasePath(operationBasePath);
|
|
const invoke = fetchFn
|
|
? fetchFn
|
|
: async () => {
|
|
throw new Error('MindSpace remote server adapter requires a fetch implementation');
|
|
};
|
|
const bindings = Object.fromEntries(
|
|
MINDSPACE_SERVER_ADAPTER_BINDING_KEYS.map((bindingKey) => [
|
|
bindingKey,
|
|
createRemoteBindingProxy({
|
|
endpoint: endpointUrl,
|
|
bindingKey,
|
|
fetchFn: invoke,
|
|
authToken,
|
|
timeoutMs,
|
|
operationBasePath: normalizedOperationBasePath,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
return {
|
|
kind: 'remote',
|
|
implementationStatus: 'scaffold',
|
|
endpoint: endpointUrl,
|
|
operationBasePath: normalizedOperationBasePath,
|
|
contractPath: normalizeOperationBasePath(contractPath),
|
|
timeoutMs,
|
|
storageRoot: null,
|
|
publicBaseUrl: null,
|
|
storageAdapter: null,
|
|
...bindings,
|
|
async assertReady() {
|
|
if (!endpointUrl) {
|
|
throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL');
|
|
}
|
|
const contract = await fetchRemoteContract({
|
|
endpoint: endpointUrl,
|
|
contractPath,
|
|
fetchFn: invoke,
|
|
authToken,
|
|
timeoutMs,
|
|
});
|
|
validateMindSpaceRemoteContract(contract, {
|
|
minContractVersion,
|
|
requiredBindings,
|
|
requiredCapabilities,
|
|
});
|
|
logger.log?.(
|
|
`MindSpace remote contract ok (version=${contract.contractVersion}, build=${contract.buildId ?? 'unknown'}, git=${contract.gitSha ?? 'unknown'})`,
|
|
);
|
|
return true;
|
|
},
|
|
startBackgroundJobs() {
|
|
logger.log?.(
|
|
`MindSpace remote server adapter enabled${endpointUrl ? ` (${endpointUrl})` : ''}; background jobs stay on the remote runtime`,
|
|
);
|
|
return {
|
|
publicationCleanup: null,
|
|
agentWorker: null,
|
|
workspaceMaintenance: null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export const mindspaceRemoteServerAdapterInternals = {
|
|
buildRemoteContractUrl,
|
|
buildRemoteOperationUrl,
|
|
fetchRemoteContract,
|
|
invokeRemoteOperation,
|
|
normalizeOperationBasePath,
|
|
validateMindSpaceRemoteContract,
|
|
};
|