openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
225 lines (224 loc) • 12 kB
JavaScript
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs";
import { a as resolveGlobalSingleton } from "./global-singleton-0m-JziJW.mjs";
import { i as pluginInstanceInvocation } from "./plugin-instance-invocation-CH4zOUb4.mjs";
import { c as pluginInstanceState, m as wrapCurrentPluginInstance, r as getPluginInstanceOwner, u as resolvePluginInstanceOwner } from "./plugin-instance-scope-DEzoxqBj.mjs";
import { b as isPluginRegistryRetired } from "./registry-lifecycle-CZIcWb1R.mjs";
import { A as isPluginHttpRouteVisible, F as respondPluginHttpRouteHandoff, P as replacePluginHttpRoutes, j as notifyPluginHttpRoutesChanged, k as getPluginHttpRouteViews, v as requireActivePluginRegistry } from "./runtime-NsVZTERq.mjs";
import { n as normalizePluginHttpPath } from "./http-path-utbm8n9N.mjs";
import { t as findPluginHttpRouteRegistrationConflicts } from "./http-route-overlap-BwLyACPn.mjs";
import { AsyncLocalStorage } from "node:async_hooks";
//#region src/plugins/http-registry.ts
const pluginHttpRouteRegistryScope = resolveGlobalSingleton(Symbol.for("openclaw.pluginHttpRouteRegistryScope"), () => new AsyncLocalStorage());
const routeOwners = resolveGlobalSingleton(Symbol.for("openclaw.pluginHttpRouteRetentionOwners"), () => /* @__PURE__ */ new WeakMap());
const leasedRoutes = resolveGlobalSingleton(Symbol.for("openclaw.pluginHttpRouteLeaseRetentions"), () => /* @__PURE__ */ new WeakMap());
const noopUnregister = () => {};
function removeOwnedRoute(owner, successor) {
owner.removeRoute();
for (const [handoff, endpoints] of owner.handoffs) {
handoff.delete(owner);
if (successor) {
handoff.add(successor);
successor.handoffs.set(handoff, endpoints);
}
}
owner.handoffs.clear();
routeOwners.delete(owner.entry);
if (successor) updateLegacyListeners(successor);
}
function retireUnheldRoute(owner) {
if (owner.holders.size > 0) return;
if (owner.handoffs.size === 0 || !isPluginHttpRouteVisible(owner.entry)) removeOwnedRoute(owner);
else if (!owner.entry.handoff) {
const previous = owner.entry;
owner.entry = {
...previous,
handoff: true,
handleUpgrade: void 0,
handler: respondPluginHttpRouteHandoff
};
owner.removeRoute = replacePluginHttpRoutes(owner.registry, owner.entry, [previous], true);
routeOwners.delete(previous);
routeOwners.set(owner.entry, owner);
}
}
/** Keep retired ingress retryable until a successor claims it or every replacement ends. */
function createPluginHttpRouteHandoff() {
const routes = /* @__PURE__ */ new Set();
return {
park(lease) {
for (const { owner, legacyListener } of leasedRoutes.get(lease) ?? []) if (isPluginHttpRouteVisible(owner.entry)) {
routes.add(owner);
const endpoints = owner.handoffs.get(routes) ?? [];
owner.handoffs.set(routes, legacyListener && !endpoints.includes(legacyListener) ? [...endpoints, legacyListener] : endpoints);
}
},
release() {
for (const owner of routes) {
owner.handoffs.delete(routes);
updateLegacyListeners(owner);
retireUnheldRoute(owner);
}
routes.clear();
}
};
}
function updateLegacyListeners(owner) {
const handoffs = new Map([...owner.handoffs.values()].flat().map((endpoint) => [`${endpoint.host ?? "<unspecified>"}:${endpoint.port}`, endpoint]));
const listeners = new Map(handoffs);
for (const endpoint of owner.holders.values()) if (endpoint) {
const key = `${endpoint.host ?? "<unspecified>"}:${endpoint.port}`;
listeners.set(key, endpoint);
handoffs.delete(key);
}
if (listeners.size) owner.entry.legacyListeners = [...listeners.values()];
else delete owner.entry.legacyListeners;
if (handoffs.size) owner.entry.legacyListenerHandoffs = [...handoffs.values()];
else delete owner.entry.legacyListenerHandoffs;
notifyPluginHttpRoutesChanged();
}
function hasSameRouteOwner(left, right) {
return left.auth === right.auth && normalizeOptionalString(left.pluginId) === normalizeOptionalString(right.pluginId) && normalizeOptionalString(left.source) === normalizeOptionalString(right.source);
}
function adoptPluginHttpRouteHandoffs(previous, next) {
if (previous === next) return;
const transfers = previous.httpRoutes.flatMap((entry) => {
const owner = routeOwners.get(entry);
if (!owner || !entry.handoff) return [];
const conflicts = findPluginHttpRouteRegistrationConflicts(next.httpRoutes, entry);
if (conflicts.authOverlap || conflicts.canonicalMatches.some((route) => !hasSameRouteOwner(route, entry))) throw new Error(`plugin reload cannot replace HTTP route ownership at ${entry.path}`);
return [{
owner,
replacement: conflicts.canonicalMatches[0]
}];
});
for (const { owner, replacement } of transfers) {
if (replacement === owner.entry) continue;
if (replacement) removeOwnedRoute(owner, routeOwners.get(replacement));
else {
owner.removeRoute();
owner.registry = next;
owner.removeRoute = replacePluginHttpRoutes(next, owner.entry);
}
}
}
function retainPluginHttpRoute(params) {
const owner = routeOwners.get(params.entry);
if (!owner) return noopUnregister;
const retention = {
owner,
legacyListener: params.legacyListener
};
const leaseReleases = [];
const release = () => {
if (!owner.holders.delete(release)) return;
for (const lease of params.leases) leasedRoutes.get(lease)?.delete(retention);
for (const releaseLease of leaseReleases.splice(0)) releaseLease();
updateLegacyListeners(owner);
retireUnheldRoute(owner);
};
owner.holders.set(release, params.legacyListener);
updateLegacyListeners(owner);
for (const lease of params.leases) {
let retentions = leasedRoutes.get(lease);
if (!retentions) {
retentions = /* @__PURE__ */ new Set();
leasedRoutes.set(lease, retentions);
}
retentions.add(retention);
leaseReleases.push(lease.retain(release));
}
return release;
}
function withPluginHttpRouteRegistry(registry, run, lease) {
const inherited = pluginHttpRouteRegistryScope.getStore()?.leases ?? [];
const leases = lease && !inherited.includes(lease) ? [...inherited, lease] : inherited;
return pluginHttpRouteRegistryScope.run({
registry,
leases
}, run);
}
function registerPluginHttpRoute(params) {
const scope = pluginHttpRouteRegistryScope.getStore();
let registry = params.registry ?? scope?.registry ?? requireActivePluginRegistry();
const instance = pluginInstanceInvocation.getStore()?.instance ?? pluginInstanceState.values.get(params.handler);
const record = instance ? getPluginInstanceOwner(instance)?.record : registry.plugins.find((entry) => entry.id === params.pluginId);
const instanceOwner = record ? resolvePluginInstanceOwner(record, registry) : void 0;
if (instanceOwner) registry = instanceOwner.registry;
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
const rejectRegistration = (message) => {
params.log?.(message);
if (params.throwOnFailure) throw new Error(message);
return noopUnregister;
};
if (params.legacyListener && params.auth !== "plugin") return rejectRegistration("legacy webhook listeners require plugin authentication");
if (scope?.leases.some((lease) => !lease.isActive())) return rejectRegistration("plugin runtime HTTP route lease is no longer active");
if (instanceOwner?.revoked || isPluginRegistryRetired(registry)) return rejectRegistration("plugin HTTP route owner is no longer active");
const routes = [...new Set(getPluginHttpRouteViews(registry, params.pluginId, instance).flatMap((view) => view.httpRoutes))];
const normalizedPath = normalizePluginHttpPath(params.path, params.fallbackPath);
if (!normalizedPath) return rejectRegistration(`plugin: webhook path missing${suffix}`);
const routeMatch = params.match ?? "exact";
const candidate = {
path: normalizedPath,
match: routeMatch,
auth: params.auth
};
const { authOverlap, canonicalMatches } = findPluginHttpRouteRegistrationConflicts(routes, candidate);
if (authOverlap) return rejectRegistration(`plugin: route overlap denied at ${normalizedPath} (${routeMatch}, ${params.auth})${suffix}; overlaps ${authOverlap.path} (${authOverlap.match}, ${authOverlap.auth}) owned by ${authOverlap.pluginId ?? "unknown-plugin"} (${authOverlap.source ?? "unknown-source"})`);
const listener = params.legacyListener;
if (listener) for (const route of routes) {
if (params.replaceExisting && canonicalMatches.includes(route)) continue;
if (route.legacyListeners?.find((existing) => existing.port === listener.port && existing.host === listener.host && !route.legacyListenerHandoffs?.includes(existing) && (existing.health?.path !== listener.health?.path || existing.health?.contentType !== listener.health?.contentType || existing.timeouts?.headers !== listener.timeouts?.headers || existing.timeouts?.request !== listener.timeouts?.request || existing.timeouts?.socket !== listener.timeouts?.socket))) return rejectRegistration(`plugin: conflicting legacy webhook health or timeout profile at ${listener.host ?? "<unspecified>"}:${listener.port}${suffix}; registrations sharing a port must use the same profile`);
}
const entry = {
path: normalizedPath,
handler: wrapCurrentPluginInstance(params.handler),
auth: params.auth,
match: routeMatch,
...params.gatewayRuntimeScopeSurface ? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface } : {},
pluginId: params.pluginId,
source: params.source
};
const existing = canonicalMatches[0];
if (existing) {
const requestedOwner = normalizeOptionalString(params.pluginId);
const requestedSource = normalizeOptionalString(params.source);
const mismatchedOwner = canonicalMatches.find((route) => !hasSameRouteOwner(route, params));
const replaceExisting = params.replaceExisting || !mismatchedOwner && canonicalMatches.every((route) => route.handoff);
if (!replaceExisting && params.reuseExistingSameOwner) {
if (requestedOwner !== void 0 && requestedSource !== void 0 && !mismatchedOwner) {
params.log?.(`plugin: reusing existing webhook path ${normalizedPath} (${routeMatch}) (${requestedOwner}/${requestedSource})`);
return retainPluginHttpRoute({
entry: existing,
leases: scope?.leases ?? [],
legacyListener: params.legacyListener
});
}
const conflictingOwner = mismatchedOwner ?? existing;
return rejectRegistration(`plugin: route reuse denied for ${normalizedPath} (${routeMatch})${suffix}; owned by ${conflictingOwner.pluginId ?? "unknown-plugin"} (${conflictingOwner.source ?? "unknown-source"})`);
}
if (!replaceExisting) return rejectRegistration(`plugin: route conflict at ${normalizedPath} (${routeMatch})${suffix}; owned by ${existing.pluginId ?? "unknown-plugin"} (${existing.source ?? "unknown-source"})`);
const incompatibleReplacement = canonicalMatches.find((route) => normalizeOptionalString(route.pluginId) !== requestedOwner || requestedOwner !== void 0 && normalizeOptionalString(route.source) !== requestedSource);
if (incompatibleReplacement) return rejectRegistration(`plugin: route replacement denied for ${normalizedPath} (${routeMatch})${suffix}; owned by ${incompatibleReplacement.pluginId ?? "unknown-plugin"} (${incompatibleReplacement.source ?? "unknown-source"})`);
const pluginHint = params.pluginId ? ` (${params.pluginId})` : "";
params.log?.(`plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`);
}
const successor = {
entry,
registry,
removeRoute: replacePluginHttpRoutes(registry, entry, canonicalMatches),
holders: /* @__PURE__ */ new Map(),
handoffs: /* @__PURE__ */ new Map()
};
for (const route of canonicalMatches.toReversed()) {
const owner = routeOwners.get(route);
if (owner) removeOwnedRoute(owner, successor);
}
routeOwners.set(entry, successor);
return retainPluginHttpRoute({
entry,
leases: scope?.leases ?? [],
legacyListener: params.legacyListener
});
}
//#endregion
export { withPluginHttpRouteRegistry as i, createPluginHttpRouteHandoff as n, registerPluginHttpRoute as r, adoptPluginHttpRouteHandoffs as t };