mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
810 lines
28 KiB
TypeScript
810 lines
28 KiB
TypeScript
import { mkdirSync } from "node:fs";
|
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import type {
|
|
AppConfig,
|
|
GatewayPluginAppConfig,
|
|
GatewayPluginConfig,
|
|
GatewayPluginProxyRouteConfig,
|
|
GatewayProviderConfig,
|
|
InstalledBrowserApp,
|
|
ProviderAccountMeter,
|
|
ProviderAccountPluginConnectorConfig,
|
|
ProviderAccountSnapshot
|
|
} from "@ccr/core/contracts/app";
|
|
import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "@ccr/core/plugins/backend-service";
|
|
import { CONFIGDIR, DATADIR } from "@ccr/core/config/constants";
|
|
|
|
type MaybePromise<T> = T | Promise<T>;
|
|
type PluginLogger = {
|
|
debug: (...args: unknown[]) => void;
|
|
error: (...args: unknown[]) => void;
|
|
info: (...args: unknown[]) => void;
|
|
warn: (...args: unknown[]) => void;
|
|
};
|
|
|
|
export type GatewayPluginRouteHandler = (
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
context: GatewayPluginRouteContext
|
|
) => MaybePromise<void>;
|
|
|
|
export type GatewayPluginRouteRegistration = {
|
|
auth?: "gateway" | "none";
|
|
handler: GatewayPluginRouteHandler;
|
|
id?: string;
|
|
method?: string;
|
|
methods?: string[];
|
|
path?: string;
|
|
pathPrefix?: string;
|
|
};
|
|
|
|
export type GatewayPluginProxyRouteRegistration = Omit<GatewayPluginProxyRouteConfig, "upstream"> & {
|
|
upstream: string | URL | (() => string | URL);
|
|
};
|
|
|
|
export type GatewayPluginHttpBackendRegistration = {
|
|
handler: GatewayPluginRouteHandler;
|
|
host?: string;
|
|
id?: string;
|
|
port?: number;
|
|
};
|
|
|
|
export type GatewayPluginProviderAccountRequest = {
|
|
config: AppConfig;
|
|
connector: ProviderAccountPluginConnectorConfig;
|
|
now: string;
|
|
provider: GatewayProviderConfig;
|
|
};
|
|
|
|
export type GatewayPluginProviderAccountConnector = {
|
|
id: string;
|
|
resolve: (request: GatewayPluginProviderAccountRequest) => MaybePromise<ProviderAccountMeter[] | ProviderAccountSnapshot | undefined>;
|
|
};
|
|
|
|
export type GatewayPluginRegistration = {
|
|
apps?: GatewayPluginAppConfig[];
|
|
coreGateway?: {
|
|
config?: Record<string, unknown>;
|
|
providerPlugins?: unknown[];
|
|
virtualModelProfiles?: unknown[];
|
|
};
|
|
gatewayRoutes?: GatewayPluginRouteRegistration[];
|
|
onStop?: () => MaybePromise<void>;
|
|
providerAccountConnectors?: GatewayPluginProviderAccountConnector[];
|
|
proxyRoutes?: GatewayPluginProxyRouteRegistration[];
|
|
stop?: () => MaybePromise<void>;
|
|
virtualModelProfiles?: unknown[];
|
|
};
|
|
|
|
export type GatewayPluginContext = {
|
|
config: AppConfig;
|
|
logger: PluginLogger;
|
|
paths: {
|
|
configDir: string;
|
|
dataDir: string;
|
|
pluginDataDir: string;
|
|
};
|
|
pluginConfig: unknown;
|
|
pluginId: string;
|
|
openSqliteStore: (options?: PluginSqliteStoreOptions) => Promise<PluginSqliteStore>;
|
|
registerCoreGatewayProviderPlugin: (providerPlugin: unknown) => void;
|
|
registerCoreGatewayVirtualModelProfile: (profile: unknown) => void;
|
|
registerApp: (app: GatewayPluginAppConfig) => void;
|
|
registerGatewayRoute: (route: GatewayPluginRouteRegistration) => void;
|
|
registerHttpBackend: (backend: GatewayPluginHttpBackendRegistration) => Promise<RegisteredHttpBackend>;
|
|
registerProviderAccountConnector: (connector: GatewayPluginProviderAccountConnector) => void;
|
|
registerProxyRoute: (route: GatewayPluginProxyRouteRegistration) => void;
|
|
};
|
|
|
|
export type GatewayPluginRouteContext = Pick<
|
|
GatewayPluginContext,
|
|
"config" | "logger" | "openSqliteStore" | "paths" | "pluginConfig" | "pluginId"
|
|
> & {
|
|
readBody: (request: IncomingMessage) => Promise<Buffer>;
|
|
readJson: (request: IncomingMessage) => Promise<unknown>;
|
|
sendJson: (response: ServerResponse, statusCode: number, body: unknown) => void;
|
|
};
|
|
|
|
export type PluginSqliteStoreOptions = SqliteStoreOptions;
|
|
export type PluginSqliteStore = SqliteStore;
|
|
|
|
export type GatewayPluginRouteMatch = RegisteredGatewayRoute;
|
|
|
|
export type GatewayPluginProxyRouteMatch = {
|
|
headers?: Record<string, string>;
|
|
id: string;
|
|
preserveHost: boolean;
|
|
pluginId: string;
|
|
targetUrl: URL;
|
|
upstreamUrl: URL;
|
|
};
|
|
|
|
type RegisteredGatewayRoute = Required<Pick<GatewayPluginRouteRegistration, "handler" | "id">> & {
|
|
auth: "gateway" | "none";
|
|
methods?: string[];
|
|
path?: string;
|
|
pathPrefix?: string;
|
|
pluginId: string;
|
|
};
|
|
|
|
type RegisteredProxyRoute = Omit<GatewayPluginProxyRouteRegistration, "host" | "id" | "paths"> & {
|
|
host: string;
|
|
id: string;
|
|
paths?: string[];
|
|
pluginId: string;
|
|
};
|
|
|
|
type LoadedPlugin = {
|
|
activate?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
|
setup?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
|
stop?: () => MaybePromise<void>;
|
|
};
|
|
|
|
type PluginServiceStateSnapshot = {
|
|
apps: InstalledBrowserApp[];
|
|
coreGatewayConfig: Record<string, unknown>;
|
|
coreProviderPlugins: unknown[];
|
|
gatewayRoutes: RegisteredGatewayRoute[];
|
|
providerAccountConnectors: Map<string, GatewayPluginProviderAccountConnector>;
|
|
proxyRoutes: RegisteredProxyRoute[];
|
|
resourceOwnerIds: Set<string>;
|
|
stopHooks: Array<() => MaybePromise<void>>;
|
|
virtualModelProfiles: unknown[];
|
|
};
|
|
|
|
const requireFromHere = createRequire(__filename);
|
|
const builtInMarketplacePluginModules = new Map<string, string>([
|
|
["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")],
|
|
["cursor-proxy", path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs")]
|
|
]);
|
|
|
|
class GatewayPluginService {
|
|
private config?: AppConfig;
|
|
private coreGatewayConfig: Record<string, unknown> = {};
|
|
private coreProviderPlugins: unknown[] = [];
|
|
private apps: InstalledBrowserApp[] = [];
|
|
private gatewayRoutes: RegisteredGatewayRoute[] = [];
|
|
private proxyRoutes: RegisteredProxyRoute[] = [];
|
|
private providerAccountConnectors = new Map<string, GatewayPluginProviderAccountConnector>();
|
|
private resourceOwnerIds = new Set<string>();
|
|
private running = false;
|
|
private stopHooks: Array<() => MaybePromise<void>> = [];
|
|
private virtualModelProfiles: unknown[] = [];
|
|
|
|
async start(config: AppConfig): Promise<void> {
|
|
await this.stop();
|
|
this.config = config;
|
|
this.running = true;
|
|
|
|
for (const pluginConfig of config.plugins ?? []) {
|
|
if (pluginConfig.enabled === false) {
|
|
continue;
|
|
}
|
|
const snapshot = this.createStateSnapshot();
|
|
this.resourceOwnerIds.add(pluginConfig.id);
|
|
try {
|
|
await this.loadConfiguredPlugin(pluginConfig);
|
|
} catch (error) {
|
|
await this.rollbackConfiguredPluginLoad(pluginConfig.id, snapshot);
|
|
console.warn(`[plugin:${pluginConfig.id}] Disabled after startup failure: ${formatError(error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
const stopHooks = [...this.stopHooks].reverse();
|
|
this.stopHooks = [];
|
|
|
|
for (const stopHook of stopHooks) {
|
|
try {
|
|
await stopHook();
|
|
} catch (error) {
|
|
console.warn(`[plugin] Stop hook failed: ${formatError(error)}`);
|
|
}
|
|
}
|
|
|
|
const resourceOwnerIds = [...this.resourceOwnerIds].reverse();
|
|
this.resourceOwnerIds.clear();
|
|
for (const ownerId of resourceOwnerIds) {
|
|
await backendService.stopOwner(ownerId);
|
|
}
|
|
|
|
this.config = undefined;
|
|
this.apps = [];
|
|
this.coreGatewayConfig = {};
|
|
this.coreProviderPlugins = [];
|
|
this.gatewayRoutes = [];
|
|
this.proxyRoutes = [];
|
|
this.providerAccountConnectors.clear();
|
|
this.running = false;
|
|
this.virtualModelProfiles = [];
|
|
}
|
|
|
|
hasGatewayRoutes(): boolean {
|
|
return this.gatewayRoutes.length > 0;
|
|
}
|
|
|
|
getCoreGatewayConfig(): Record<string, unknown> {
|
|
return { ...this.coreGatewayConfig };
|
|
}
|
|
|
|
getCoreProviderPlugins(): unknown[] {
|
|
return [...this.coreProviderPlugins];
|
|
}
|
|
|
|
getVirtualModelProfiles(): unknown[] {
|
|
return [...this.virtualModelProfiles];
|
|
}
|
|
|
|
getApps(): InstalledBrowserApp[] {
|
|
return this.apps.map((app) => ({ ...app }));
|
|
}
|
|
|
|
getProviderAccountConnector(pluginId: string, connectorId: string): GatewayPluginProviderAccountConnector | undefined {
|
|
return this.providerAccountConnectors.get(providerAccountConnectorKey(pluginId, connectorId));
|
|
}
|
|
|
|
getProxyRouteHosts(): string[] {
|
|
return [...new Set(this.proxyRoutes.map((route) => route.host))];
|
|
}
|
|
|
|
getProxyRouteTargets(): Array<{ host: string; paths?: string[] }> {
|
|
return this.proxyRoutes.map((route) => ({
|
|
host: route.host,
|
|
paths: route.paths ? [...route.paths] : undefined
|
|
}));
|
|
}
|
|
|
|
matchGatewayRoute(method: string | undefined, requestPath: string): GatewayPluginRouteMatch | undefined {
|
|
const normalizedMethod = (method || "GET").toUpperCase();
|
|
return this.gatewayRoutes.find((route) => {
|
|
if (route.methods?.length && !route.methods.includes(normalizedMethod)) {
|
|
return false;
|
|
}
|
|
if (route.path && requestPath === route.path) {
|
|
return true;
|
|
}
|
|
if (route.pathPrefix && matchesPathPrefix(route.pathPrefix, requestPath)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
async handleGatewayRoute(route: GatewayPluginRouteMatch, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
if (!this.config) {
|
|
throw new Error("Gateway plugin service is not configured.");
|
|
}
|
|
await route.handler(request, response, this.createRouteContext(route.pluginId));
|
|
}
|
|
|
|
resolveProxyRoute(targetUrl: URL): GatewayPluginProxyRouteMatch | undefined {
|
|
let bestMatch: { matchedPathPrefix: string; route: RegisteredProxyRoute } | undefined;
|
|
for (const route of this.proxyRoutes) {
|
|
const matchedPathPrefix = matchProxyRoute(route, targetUrl);
|
|
if (matchedPathPrefix === undefined) {
|
|
continue;
|
|
}
|
|
|
|
if (!bestMatch || matchedPathPrefix.length > bestMatch.matchedPathPrefix.length) {
|
|
bestMatch = { matchedPathPrefix, route };
|
|
}
|
|
}
|
|
if (!bestMatch) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
headers: bestMatch.route.headers,
|
|
id: bestMatch.route.id,
|
|
pluginId: bestMatch.route.pluginId,
|
|
preserveHost: bestMatch.route.preserveHost === true,
|
|
targetUrl,
|
|
upstreamUrl: buildPluginProxyUpstreamUrl(bestMatch.route, targetUrl, bestMatch.matchedPathPrefix)
|
|
};
|
|
}
|
|
|
|
private async loadConfiguredPlugin(pluginConfig: GatewayPluginConfig): Promise<void> {
|
|
this.registerConfiguredCoreGateway(pluginConfig);
|
|
this.registerConfiguredApps(pluginConfig);
|
|
for (const route of pluginConfig.proxy?.routes ?? []) {
|
|
this.registerProxyRoute(pluginConfig.id, route);
|
|
}
|
|
|
|
const modulePath = pluginConfig.module || builtInMarketplacePluginModules.get(pluginConfig.id);
|
|
if (!modulePath) {
|
|
return;
|
|
}
|
|
|
|
const loadedPlugin = await loadPluginModule(modulePath);
|
|
const plugin = normalizeLoadedPlugin(loadedPlugin);
|
|
const context = this.createPluginContext(pluginConfig);
|
|
const registration = plugin.setup
|
|
? await plugin.setup(context)
|
|
: plugin.activate
|
|
? await plugin.activate(context)
|
|
: undefined;
|
|
|
|
if (registration) {
|
|
this.applyPluginRegistration(pluginConfig.id, registration);
|
|
}
|
|
if (plugin.stop) {
|
|
this.stopHooks.push(() => plugin.stop?.());
|
|
}
|
|
}
|
|
|
|
private applyPluginRegistration(pluginId: string, registration: GatewayPluginRegistration): void {
|
|
for (const app of registration.apps ?? []) {
|
|
this.registerApp(pluginId, app);
|
|
}
|
|
for (const route of registration.gatewayRoutes ?? []) {
|
|
this.registerGatewayRoute(pluginId, route);
|
|
}
|
|
for (const route of registration.proxyRoutes ?? []) {
|
|
this.registerProxyRoute(pluginId, route);
|
|
}
|
|
for (const connector of registration.providerAccountConnectors ?? []) {
|
|
this.registerProviderAccountConnector(pluginId, connector);
|
|
}
|
|
for (const providerPlugin of registration.coreGateway?.providerPlugins ?? []) {
|
|
this.coreProviderPlugins.push(providerPlugin);
|
|
}
|
|
for (const profile of [
|
|
...(registration.coreGateway?.virtualModelProfiles ?? []),
|
|
...(registration.virtualModelProfiles ?? [])
|
|
]) {
|
|
this.virtualModelProfiles.push(profile);
|
|
}
|
|
if (registration.coreGateway?.config) {
|
|
this.coreGatewayConfig = {
|
|
...this.coreGatewayConfig,
|
|
...registration.coreGateway.config
|
|
};
|
|
}
|
|
if (registration.stop) {
|
|
this.stopHooks.push(registration.stop);
|
|
}
|
|
if (registration.onStop) {
|
|
this.stopHooks.push(registration.onStop);
|
|
}
|
|
}
|
|
|
|
private registerConfiguredApps(pluginConfig: GatewayPluginConfig): void {
|
|
for (const app of pluginConfig.apps ?? []) {
|
|
this.registerApp(pluginConfig.id, app);
|
|
}
|
|
}
|
|
|
|
private registerApp(pluginId: string, app: GatewayPluginAppConfig): void {
|
|
const normalized = normalizePluginApp(pluginId, app, this.apps.length + 1);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
this.apps = this.apps.filter((item) => !(item.pluginId === pluginId && item.id === normalized.id));
|
|
this.apps.push(normalized);
|
|
}
|
|
|
|
private registerConfiguredCoreGateway(pluginConfig: GatewayPluginConfig): void {
|
|
for (const providerPlugin of pluginConfig.coreGateway?.providerPlugins ?? []) {
|
|
this.coreProviderPlugins.push(providerPlugin);
|
|
}
|
|
for (const profile of pluginConfig.coreGateway?.virtualModelProfiles ?? []) {
|
|
this.virtualModelProfiles.push(profile);
|
|
}
|
|
if (pluginConfig.coreGateway?.config) {
|
|
this.coreGatewayConfig = {
|
|
...this.coreGatewayConfig,
|
|
...pluginConfig.coreGateway.config
|
|
};
|
|
}
|
|
}
|
|
|
|
private registerGatewayRoute(pluginId: string, route: GatewayPluginRouteRegistration): void {
|
|
if (!route.path && !route.pathPrefix) {
|
|
throw new Error(`Plugin ${pluginId} registered a gateway route without path or pathPrefix.`);
|
|
}
|
|
|
|
this.gatewayRoutes.push({
|
|
auth: route.auth ?? "gateway",
|
|
handler: route.handler,
|
|
id: route.id || `${pluginId}:gateway:${this.gatewayRoutes.length + 1}`,
|
|
methods: normalizeMethods(route),
|
|
path: normalizeRoutePath(route.path),
|
|
pathPrefix: normalizeRoutePath(route.pathPrefix),
|
|
pluginId
|
|
});
|
|
}
|
|
|
|
private registerProxyRoute(pluginId: string, route: GatewayPluginProxyRouteRegistration): void {
|
|
const host = route.host.trim().toLowerCase();
|
|
if (!host) {
|
|
throw new Error(`Plugin ${pluginId} registered a proxy route without host.`);
|
|
}
|
|
|
|
this.proxyRoutes.push({
|
|
...route,
|
|
host,
|
|
id: route.id || `${pluginId}:proxy:${this.proxyRoutes.length + 1}`,
|
|
paths: route.paths?.map(normalizeRoutePath).filter((path): path is string => Boolean(path)),
|
|
pluginId
|
|
});
|
|
}
|
|
|
|
private createPluginContext(pluginConfig: GatewayPluginConfig): GatewayPluginContext {
|
|
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginConfig.id));
|
|
mkdirSync(pluginDataDir, { recursive: true });
|
|
const logger = createPluginLogger(pluginConfig.id);
|
|
|
|
return {
|
|
config: this.config ?? ({} as AppConfig),
|
|
logger,
|
|
paths: {
|
|
configDir: CONFIGDIR,
|
|
dataDir: DATADIR,
|
|
pluginDataDir
|
|
},
|
|
pluginConfig: pluginConfig.config,
|
|
pluginId: pluginConfig.id,
|
|
openSqliteStore: (options) => this.openSqliteStore(pluginConfig.id, pluginDataDir, options),
|
|
registerCoreGatewayProviderPlugin: (providerPlugin) => {
|
|
this.coreProviderPlugins.push(providerPlugin);
|
|
},
|
|
registerCoreGatewayVirtualModelProfile: (profile) => {
|
|
this.virtualModelProfiles.push(profile);
|
|
},
|
|
registerApp: (app) => this.registerApp(pluginConfig.id, app),
|
|
registerGatewayRoute: (route) => this.registerGatewayRoute(pluginConfig.id, route),
|
|
registerHttpBackend: (backend) => this.registerHttpBackend(pluginConfig.id, pluginDataDir, logger, backend),
|
|
registerProviderAccountConnector: (connector) => this.registerProviderAccountConnector(pluginConfig.id, connector),
|
|
registerProxyRoute: (route) => this.registerProxyRoute(pluginConfig.id, route)
|
|
};
|
|
}
|
|
|
|
private registerProviderAccountConnector(pluginId: string, connector: GatewayPluginProviderAccountConnector): void {
|
|
const id = connector.id.trim();
|
|
if (!id || typeof connector.resolve !== "function") {
|
|
throw new Error(`Plugin ${pluginId} registered an invalid provider account connector.`);
|
|
}
|
|
this.providerAccountConnectors.set(providerAccountConnectorKey(pluginId, id), {
|
|
...connector,
|
|
id
|
|
});
|
|
}
|
|
|
|
private createRouteContext(pluginId: string): GatewayPluginRouteContext {
|
|
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginId));
|
|
const logger = createPluginLogger(pluginId);
|
|
return {
|
|
config: this.config ?? ({} as AppConfig),
|
|
logger,
|
|
paths: {
|
|
configDir: CONFIGDIR,
|
|
dataDir: DATADIR,
|
|
pluginDataDir
|
|
},
|
|
pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config,
|
|
pluginId,
|
|
openSqliteStore: (options) => this.openSqliteStore(pluginId, pluginDataDir, options),
|
|
readBody,
|
|
readJson,
|
|
sendJson
|
|
};
|
|
}
|
|
|
|
private async registerHttpBackend(
|
|
pluginId: string,
|
|
pluginDataDir: string,
|
|
logger: PluginLogger,
|
|
backend: GatewayPluginHttpBackendRegistration
|
|
): Promise<RegisteredHttpBackend> {
|
|
return backendService.registerHttpBackend(pluginId, {
|
|
host: backend.host,
|
|
id: backend.id,
|
|
port: backend.port,
|
|
handler: (request, response) =>
|
|
backend.handler(request, response, {
|
|
config: this.config ?? ({} as AppConfig),
|
|
logger,
|
|
paths: {
|
|
configDir: CONFIGDIR,
|
|
dataDir: DATADIR,
|
|
pluginDataDir
|
|
},
|
|
pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config,
|
|
pluginId,
|
|
openSqliteStore: (options) => this.openSqliteStore(pluginId, pluginDataDir, options),
|
|
readBody,
|
|
readJson,
|
|
sendJson
|
|
})
|
|
});
|
|
}
|
|
|
|
private async openSqliteStore(
|
|
pluginId: string,
|
|
pluginDataDir: string,
|
|
options: PluginSqliteStoreOptions = {}
|
|
): Promise<PluginSqliteStore> {
|
|
return backendService.openSqliteStore(pluginId, pluginDataDir, options);
|
|
}
|
|
|
|
private createStateSnapshot(): PluginServiceStateSnapshot {
|
|
return {
|
|
apps: [...this.apps],
|
|
coreGatewayConfig: { ...this.coreGatewayConfig },
|
|
coreProviderPlugins: [...this.coreProviderPlugins],
|
|
gatewayRoutes: [...this.gatewayRoutes],
|
|
providerAccountConnectors: new Map(this.providerAccountConnectors),
|
|
proxyRoutes: [...this.proxyRoutes],
|
|
resourceOwnerIds: new Set(this.resourceOwnerIds),
|
|
stopHooks: [...this.stopHooks],
|
|
virtualModelProfiles: [...this.virtualModelProfiles]
|
|
};
|
|
}
|
|
|
|
private async rollbackConfiguredPluginLoad(pluginId: string, snapshot: PluginServiceStateSnapshot): Promise<void> {
|
|
const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse();
|
|
this.apps = snapshot.apps;
|
|
this.coreGatewayConfig = snapshot.coreGatewayConfig;
|
|
this.coreProviderPlugins = snapshot.coreProviderPlugins;
|
|
this.gatewayRoutes = snapshot.gatewayRoutes;
|
|
this.providerAccountConnectors = snapshot.providerAccountConnectors;
|
|
this.proxyRoutes = snapshot.proxyRoutes;
|
|
this.resourceOwnerIds = snapshot.resourceOwnerIds;
|
|
this.stopHooks = snapshot.stopHooks;
|
|
this.virtualModelProfiles = snapshot.virtualModelProfiles;
|
|
|
|
for (const stopHook of newStopHooks) {
|
|
try {
|
|
await stopHook();
|
|
} catch (error) {
|
|
console.warn(`[plugin:${pluginId}] Rollback stop hook failed: ${formatError(error)}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await backendService.stopOwner(pluginId);
|
|
} catch (error) {
|
|
console.warn(`[plugin:${pluginId}] Rollback resource cleanup failed: ${formatError(error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const pluginService = new GatewayPluginService();
|
|
|
|
async function loadPluginModule(modulePath: string): Promise<unknown> {
|
|
const resolved = resolvePluginModule(modulePath);
|
|
return import(pathToFileURL(resolved).href);
|
|
}
|
|
|
|
function resolvePluginModule(modulePath: string): string {
|
|
const resolved = requireFromHere.resolve(resolveLocalModulePath(modulePath, "Plugin module"));
|
|
assertJavaScriptModulePath(resolved, "Plugin module");
|
|
return resolved;
|
|
}
|
|
|
|
function resolveLocalModulePath(value: string, label: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
throw new Error(`${label} path is required.`);
|
|
}
|
|
|
|
const expanded = expandHome(trimmed);
|
|
if (path.isAbsolute(expanded)) {
|
|
return expanded;
|
|
}
|
|
if (isProtocolSpecifier(expanded)) {
|
|
throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`);
|
|
}
|
|
if (!expanded.startsWith(".")) {
|
|
throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`);
|
|
}
|
|
|
|
const resolved = path.resolve(CONFIGDIR, expanded);
|
|
if (!isPathInside(resolved, CONFIGDIR)) {
|
|
throw new Error(`${label} relative paths must stay inside the CCR config directory.`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function assertJavaScriptModulePath(resolved: string, label: string): void {
|
|
const extension = path.extname(resolved).toLowerCase();
|
|
if (![".cjs", ".js", ".mjs"].includes(extension)) {
|
|
throw new Error(`${label} must resolve to a JavaScript module file.`);
|
|
}
|
|
}
|
|
|
|
function isProtocolSpecifier(value: string): boolean {
|
|
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
|
|
}
|
|
|
|
function isPathInside(file: string, root: string): boolean {
|
|
const relative = path.relative(root, file);
|
|
return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function normalizeLoadedPlugin(moduleValue: unknown): LoadedPlugin {
|
|
const record = isRecord(moduleValue) ? moduleValue : {};
|
|
const candidate = record.default ?? record.plugin ?? moduleValue;
|
|
if (typeof candidate === "function") {
|
|
return { setup: candidate as LoadedPlugin["setup"] };
|
|
}
|
|
if (isRecord(candidate)) {
|
|
return candidate as LoadedPlugin;
|
|
}
|
|
throw new Error("Plugin module must export a function, default plugin, or plugin object.");
|
|
}
|
|
|
|
function matchProxyRoute(route: RegisteredProxyRoute, targetUrl: URL): string | undefined {
|
|
if (!matchesHost(route.host, targetUrl.hostname)) {
|
|
return undefined;
|
|
}
|
|
if (!route.paths?.length) {
|
|
return "";
|
|
}
|
|
let matchedPathPrefix: string | undefined;
|
|
for (const pathPrefix of route.paths) {
|
|
const normalizedPathPrefix = normalizeRoutePath(pathPrefix) ?? "/";
|
|
if (!matchesPathPrefix(normalizedPathPrefix, targetUrl.pathname)) {
|
|
continue;
|
|
}
|
|
if (!matchedPathPrefix || normalizedPathPrefix.length > matchedPathPrefix.length) {
|
|
matchedPathPrefix = normalizedPathPrefix;
|
|
}
|
|
}
|
|
return matchedPathPrefix;
|
|
}
|
|
|
|
function buildPluginProxyUpstreamUrl(route: RegisteredProxyRoute, targetUrl: URL, matchedPathPrefix: string): URL {
|
|
const upstreamValue = typeof route.upstream === "function" ? route.upstream() : route.upstream;
|
|
const upstreamUrl = new URL(upstreamValue.toString());
|
|
const basePath = upstreamUrl.pathname === "/" ? "" : upstreamUrl.pathname.replace(/\/+$/, "");
|
|
let forwardedPath = targetUrl.pathname;
|
|
const stripPrefix = resolveStripPathPrefix(route.stripPathPrefix, matchedPathPrefix);
|
|
|
|
if (stripPrefix && matchesPathPrefix(stripPrefix, forwardedPath)) {
|
|
forwardedPath = forwardedPath.slice(stripPrefix.length) || "/";
|
|
if (!forwardedPath.startsWith("/")) {
|
|
forwardedPath = `/${forwardedPath}`;
|
|
}
|
|
}
|
|
if (route.rewritePathPrefix !== undefined) {
|
|
const rewritePrefix = normalizeRoutePath(route.rewritePathPrefix) ?? "/";
|
|
const suffix = matchedPathPrefix && matchesPathPrefix(matchedPathPrefix, targetUrl.pathname)
|
|
? targetUrl.pathname.slice(matchedPathPrefix.length)
|
|
: targetUrl.pathname;
|
|
forwardedPath = joinUrlPaths(rewritePrefix, suffix || "/");
|
|
}
|
|
|
|
upstreamUrl.pathname = joinUrlPaths(basePath, forwardedPath);
|
|
upstreamUrl.search = targetUrl.search;
|
|
return upstreamUrl;
|
|
}
|
|
|
|
function resolveStripPathPrefix(value: boolean | string | undefined, matchedPathPrefix: string): string | undefined {
|
|
if (value === true) {
|
|
return matchedPathPrefix || undefined;
|
|
}
|
|
if (typeof value === "string") {
|
|
return normalizeRoutePath(value);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizePluginApp(pluginId: string, app: GatewayPluginAppConfig, index: number): InstalledBrowserApp | undefined {
|
|
const name = app.name?.trim();
|
|
const url = app.url?.trim();
|
|
if (!name || !url) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
...(app.description?.trim() ? { description: app.description.trim() } : {}),
|
|
...(app.icon?.trim() ? { icon: app.icon.trim() } : {}),
|
|
id: app.id?.trim() || sanitizeFileSegment(`${name}-${url}`) || `app-${index}`,
|
|
name,
|
|
pluginId,
|
|
url
|
|
};
|
|
}
|
|
|
|
function normalizeMethods(route: GatewayPluginRouteRegistration): string[] | undefined {
|
|
const methods = [...(route.methods ?? []), ...(route.method ? [route.method] : [])]
|
|
.map((method) => method.trim().toUpperCase())
|
|
.filter(Boolean);
|
|
return methods.length ? [...new Set(methods)] : undefined;
|
|
}
|
|
|
|
function normalizeRoutePath(value: string | undefined): string | undefined {
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
}
|
|
|
|
function matchesHost(pattern: string, hostname: string): boolean {
|
|
const normalizedPattern = pattern.toLowerCase();
|
|
const normalizedHost = hostname.toLowerCase();
|
|
if (normalizedPattern === normalizedHost) {
|
|
return true;
|
|
}
|
|
if (normalizedPattern.startsWith("*.")) {
|
|
const suffix = normalizedPattern.slice(1);
|
|
return normalizedHost.endsWith(suffix) && normalizedHost !== suffix.slice(1);
|
|
}
|
|
if (normalizedPattern.startsWith(".")) {
|
|
return normalizedHost.endsWith(normalizedPattern);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function matchesPathPrefix(prefix: string, requestPath: string): boolean {
|
|
const normalizedPrefix = normalizeRoutePath(prefix) ?? "/";
|
|
const normalizedPath = normalizeRoutePath(requestPath) ?? "/";
|
|
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix.replace(/\/+$/, "")}/`);
|
|
}
|
|
|
|
function joinUrlPaths(prefix: string, suffix: string): string {
|
|
const normalizedPrefix = prefix === "/" ? "" : prefix.replace(/\/+$/, "");
|
|
const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`;
|
|
return `${normalizedPrefix}${normalizedSuffix}` || "/";
|
|
}
|
|
|
|
function createPluginLogger(pluginId: string): PluginLogger {
|
|
const prefix = `[plugin:${pluginId}]`;
|
|
return {
|
|
debug: (...args) => console.debug(prefix, ...args),
|
|
error: (...args) => console.error(prefix, ...args),
|
|
info: (...args) => console.info(prefix, ...args),
|
|
warn: (...args) => console.warn(prefix, ...args)
|
|
};
|
|
}
|
|
|
|
function sendJson(response: ServerResponse, statusCode: number, body: unknown): void {
|
|
response.writeHead(statusCode, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify(body)}\n`);
|
|
}
|
|
|
|
function readJson(request: IncomingMessage): Promise<unknown> {
|
|
return readBody(request).then((body) => JSON.parse(body.toString("utf8") || "{}") as unknown);
|
|
}
|
|
|
|
function readBody(request: IncomingMessage): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
request.once("end", () => resolve(Buffer.concat(chunks)));
|
|
request.once("error", reject);
|
|
});
|
|
}
|
|
|
|
function expandHome(value: string): string {
|
|
if (value === "~") {
|
|
return os.homedir();
|
|
}
|
|
if (value.startsWith("~/")) {
|
|
return path.join(os.homedir(), value.slice(2));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sanitizeFileSegment(value: string): string {
|
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "plugin";
|
|
}
|
|
|
|
function providerAccountConnectorKey(pluginId: string, connectorId: string): string {
|
|
return `${pluginId.trim()}:${connectorId.trim()}`;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function formatError(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|