Files
ST-Bionic-Memory-Ecology/runtime/authority-http-client.js

195 lines
6.5 KiB
JavaScript

import { normalizeAuthorityBaseUrl } from "./authority-capabilities.js";
export const AUTHORITY_PROTOCOL_AUTO = "auto";
export const AUTHORITY_PROTOCOL_SERVER_PLUGIN_V06 = "server-plugin-v06";
export const AUTHORITY_SESSION_HEADER = "x-authority-session-token";
function normalizeProtocol(value = AUTHORITY_PROTOCOL_AUTO) {
const normalized = String(value || AUTHORITY_PROTOCOL_AUTO).trim().toLowerCase();
if (["v06", "v0.6", "server-plugin", AUTHORITY_PROTOCOL_SERVER_PLUGIN_V06].includes(normalized)) {
return AUTHORITY_PROTOCOL_SERVER_PLUGIN_V06;
}
return AUTHORITY_PROTOCOL_SERVER_PLUGIN_V06;
}
function clonePlain(value, fallbackValue = null) {
if (value == null) return fallbackValue;
if (typeof globalThis.structuredClone === "function") {
try {
return globalThis.structuredClone(value);
} catch {
}
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return fallbackValue;
}
}
function normalizeHeaderName(name = "") {
return String(name || "").trim().toLowerCase();
}
function hasSessionHeader(headers = {}) {
return Object.keys(headers || {}).some((name) => normalizeHeaderName(name) === AUTHORITY_SESSION_HEADER);
}
function buildDefaultSessionInitConfig(source = {}) {
const config = source && typeof source === "object" && !Array.isArray(source) ? source : {};
return {
extensionId: String(config.extensionId || "third-party/st-bme"),
displayName: String(config.displayName || "ST-BME"),
version: String(config.version || "0.0.0"),
installType: String(config.installType || "local"),
declaredPermissions: clonePlain(config.declaredPermissions, null) || {
storage: { kv: true, blob: true },
fs: { private: true },
sql: { private: true },
trivium: { private: true },
jobs: { background: true },
events: { channels: true },
},
...(config.uiLabel ? { uiLabel: String(config.uiLabel) } : {}),
};
}
function readPayloadErrorMessage(payload = null, fallback = "") {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return fallback;
return String(payload.error || payload.message || payload.reason || fallback || "");
}
async function readResponsePayload(response = null) {
if (!response) return {};
const contentType = String(response.headers?.get?.("content-type") || "").toLowerCase();
if (contentType.includes("application/json") && typeof response.json === "function") {
try {
return await response.json();
} catch {
return {};
}
}
if (typeof response.json === "function") {
try {
return await response.json();
} catch {
}
}
if (typeof response.text === "function") {
try {
return { error: await response.text() };
} catch {
return {};
}
}
return {};
}
export class AuthorityHttpError extends Error {
constructor(message, options = {}) {
super(message);
this.name = "AuthorityHttpError";
this.status = Number(options.status || 0);
this.code = String(options.code || "");
this.category = String(options.category || "");
this.payload = clonePlain(options.payload, null);
this.path = String(options.path || "");
this.protocol = String(options.protocol || "");
}
}
export class AuthorityHttpClient {
constructor(options = {}) {
this.baseUrl = normalizeAuthorityBaseUrl(options.baseUrl);
this.fetchImpl = options.fetchImpl || (typeof fetch === "function" ? fetch.bind(globalThis) : null);
this.headerProvider = typeof options.headerProvider === "function" ? options.headerProvider : null;
this.protocol = normalizeProtocol(options.protocol || options.authorityProtocol);
this.sessionToken = String(options.sessionToken || options.authoritySessionToken || "");
this.sessionInitConfig = buildDefaultSessionInitConfig(options.sessionInitConfig || options.initConfig || options);
this.sessionPromise = null;
}
async buildHeaders({ session = false } = {}) {
let provided = {};
if (this.headerProvider) {
provided = await this.headerProvider() || {};
}
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
...provided,
};
if (session && this.sessionToken && !hasSessionHeader(headers)) {
headers[AUTHORITY_SESSION_HEADER] = this.sessionToken;
}
return headers;
}
async ensureSession() {
if (this.sessionToken) return this.sessionToken;
if (!this.sessionPromise) {
this.sessionPromise = this.requestJson("/session/init", {
method: "POST",
body: this.sessionInitConfig,
session: false,
protocol: AUTHORITY_PROTOCOL_SERVER_PLUGIN_V06,
}).then((payload) => {
const token = String(payload?.sessionToken || payload?.token || "");
if (!token) {
throw new AuthorityHttpError("Authority session init did not return a session token", {
status: 0,
path: "/session/init",
protocol: AUTHORITY_PROTOCOL_SERVER_PLUGIN_V06,
payload,
});
}
this.sessionToken = token;
return token;
}).catch((error) => {
this.sessionPromise = null;
throw error;
});
}
return await this.sessionPromise;
}
async requestJson(path, options = {}) {
if (typeof this.fetchImpl !== "function") {
throw new AuthorityHttpError("Authority fetch unavailable", {
path,
protocol: options.protocol || this.protocol,
});
}
const method = String(options.method || "POST").toUpperCase();
const session = Boolean(options.session);
if (session && !this.sessionToken) {
await this.ensureSession();
}
const headers = await this.buildHeaders({ session });
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method,
headers,
...(method === "GET" || options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
...(options.signal ? { signal: options.signal } : {}),
});
const status = Number(response?.status || 0);
const payload = await readResponsePayload(response);
if (!response?.ok) {
const message = readPayloadErrorMessage(payload, `Authority HTTP ${status || "unknown"}`);
throw new AuthorityHttpError(message || `Authority HTTP ${status || "unknown"}`, {
status,
code: payload?.code,
category: payload?.category,
payload,
path,
protocol: options.protocol || this.protocol,
});
}
return payload;
}
}
export function createAuthorityHttpClient(options = {}) {
return new AuthorityHttpClient(options);
}