Files
memind/mindspace-remote-server-adapter.mjs
T

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,
};