Skip to main content
Glama
coordination.ts11 kB
/** * Multi-instance coordination for WordPress OAuth */ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { EventEmitter } from 'node:events'; import { AuthCoordinator, WPTokens, OAuthError, LockfileData } from './oauth-types.js'; import { getConfigDir, getValidTokens } from './persistent-auth-config.js'; import { PersistentWPOAuthClientProvider } from './persistent-oauth-client-provider.js'; import { logger } from './utils.js'; import { CONFIG } from './config.js'; /** * Lockfile management for coordinating between multiple instances */ class LockfileManager { private lockfilePath: string; private isOwner: boolean = false; private checkInterval: NodeJS.Timeout | null = null; constructor(serverUrlHash: string) { const authDir = getConfigDir(); this.lockfilePath = path.join(authDir, `${serverUrlHash}_auth.lock`); } /** * Try to acquire the lock */ tryAcquire(): boolean { try { // Check if lockfile already exists if (fs.existsSync(this.lockfilePath)) { const lockData = this.readLockfile(); if (lockData && this.isLockValid(lockData)) { logger.debug(`Lock is held by PID ${lockData.pid}`, 'COORDINATION'); return false; // Lock is held by another process } // Lock is stale, remove it this.release(); } // Create new lockfile const lockData: LockfileData = { pid: process.pid, port: CONFIG.OAUTH_CALLBACK_PORT || 0, // 0 means auto-select timestamp: Date.now(), hostname: os.hostname(), }; fs.writeFileSync(this.lockfilePath, JSON.stringify(lockData), { mode: 0o600 }); this.isOwner = true; // Start monitoring the lock this.startMonitoring(); logger.debug(`Acquired auth lock: ${this.lockfilePath}`, 'COORDINATION'); return true; } catch (error) { logger.error('Error acquiring lock', 'COORDINATION', error); return false; } } /** * Release the lock */ release(): void { try { if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } if (this.isOwner && fs.existsSync(this.lockfilePath)) { fs.unlinkSync(this.lockfilePath); logger.debug(`Released auth lock: ${this.lockfilePath}`, 'COORDINATION'); } this.isOwner = false; } catch (error) { logger.error('Error releasing lock', 'COORDINATION', error); } } /** * Check if we own the lock */ isLockOwner(): boolean { return this.isOwner; } /** * Wait for lock to be released */ async waitForRelease(timeout: number = CONFIG.LOCK_TIMEOUT): Promise<void> { const startTime = Date.now(); return new Promise((resolve, reject) => { const checkLock = () => { if (!fs.existsSync(this.lockfilePath)) { logger.debug('Lock file no longer exists', 'COORDINATION'); resolve(); return; } const lockData = this.readLockfile(); if (!lockData || !this.isLockValid(lockData)) { // Lock is stale, clean it up logger.debug('Lock is stale, cleaning up', 'COORDINATION'); this.release(); resolve(); return; } if (Date.now() - startTime > timeout) { reject(new OAuthError('Timeout waiting for auth lock', 'LOCK_TIMEOUT')); return; } // Check again in 1 second setTimeout(checkLock, 1000); }; logger.info('Waiting for other instance to complete authentication...', 'COORDINATION'); checkLock(); }); } /** * Read lockfile data */ private readLockfile(): LockfileData | null { try { const data = fs.readFileSync(this.lockfilePath, 'utf8'); return JSON.parse(data) as LockfileData; } catch { return null; } } /** * Check if lock is still valid (process is running) */ private isLockValid(lockData: LockfileData): boolean { try { // Check if the process is still running process.kill(lockData.pid, 0); // Check if lock is not too old (safety measure) const age = Date.now() - lockData.timestamp; const maxAge = 600000; // 10 minutes max age if (age > maxAge) { logger.debug( `Lock is too old (${Math.round(age / 1000)}s), considering invalid`, 'COORDINATION' ); return false; } return true; } catch { // Process doesn't exist or we can't signal it logger.debug(`Process ${lockData.pid} is not running`, 'COORDINATION'); return false; } } /** * Monitor lock validity */ private startMonitoring(): void { this.checkInterval = setInterval(() => { if (this.isOwner && !fs.existsSync(this.lockfilePath)) { logger.warn('Lock file was removed externally', 'COORDINATION'); this.isOwner = false; if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } } }, 5000); // Check every 5 seconds } } /** * WordPress OAuth authentication coordinator */ export class WPAuthCoordinator implements AuthCoordinator { private serverUrlHash: string; private serverUrl: string; private callbackPort: number; private events: EventEmitter; private lockManager: LockfileManager; private oauthProvider: PersistentWPOAuthClientProvider | null = null; private isStarted: boolean = false; constructor( serverUrlHash: string, serverUrl: string, callbackPort: number, events: EventEmitter ) { this.serverUrlHash = serverUrlHash; this.serverUrl = serverUrl; this.callbackPort = callbackPort; this.events = events; this.lockManager = new LockfileManager(serverUrlHash); } async start(): Promise<void> { if (this.isStarted) { return; } logger.debug('Starting WordPress auth coordinator', 'COORDINATION'); this.isStarted = true; // Setup cleanup on process exit process.on('exit', () => this.cleanup()); process.on('SIGINT', () => this.cleanup()); process.on('SIGTERM', () => this.cleanup()); process.on('uncaughtException', () => this.cleanup()); process.on('unhandledRejection', () => this.cleanup()); } async stop(): Promise<void> { logger.debug('Stopping WordPress auth coordinator', 'COORDINATION'); this.cleanup(); this.isStarted = false; } async waitForAuth(): Promise<WPTokens> { if (!this.isStarted) { throw new OAuthError('Auth coordinator not started'); } // First, check if we already have valid tokens const existingTokens = await getValidTokens(this.serverUrlHash); if (existingTokens) { logger.debug('Found existing valid tokens', 'COORDINATION'); return existingTokens; } // Try to acquire the auth lock if (this.lockManager.tryAcquire()) { // We got the lock, perform authentication return await this.performAuthentication(); } else { // Another instance is handling auth, wait for it return await this.waitForOtherInstanceAuth(); } } private async performAuthentication(): Promise<WPTokens> { try { logger.info('Performing OAuth authentication as lock owner', 'COORDINATION'); if (!this.oauthProvider) { this.oauthProvider = new PersistentWPOAuthClientProvider({ serverUrl: this.serverUrl, callbackPort: this.callbackPort, host: CONFIG.OAUTH_HOST, clientId: CONFIG.WP_OAUTH_CLIENT_ID, }); } await this.oauthProvider.authorize(); const tokens = await this.oauthProvider.tokens(); if (!tokens) { throw new OAuthError('Authentication completed but no tokens available'); } logger.info('Authentication successful, tokens obtained', 'COORDINATION'); return tokens; } finally { // Always release the lock when done this.lockManager.release(); } } private async waitForOtherInstanceAuth(): Promise<WPTokens> { logger.info('Waiting for another instance to complete authentication', 'COORDINATION'); try { // Wait for the lock to be released await this.lockManager.waitForRelease(); // Check if tokens are now available const tokens = await getValidTokens(this.serverUrlHash); if (tokens) { logger.info('Tokens are now available from other instance', 'COORDINATION'); return tokens; } // No tokens available, try to auth ourselves logger.debug( 'No tokens found after waiting, trying to authenticate ourselves', 'COORDINATION' ); return await this.waitForAuth(); } catch (error) { logger.error('Error waiting for other instance auth', 'COORDINATION', error); throw error; } } private cleanup(): void { this.lockManager.release(); } } /** * Create a WordPress auth coordinator */ export function createWPAuthCoordinator( serverUrlHash: string, serverUrl: string, callbackPort: number, events: EventEmitter ): AuthCoordinator { return new WPAuthCoordinator(serverUrlHash, serverUrl, callbackPort, events); } /** * Lazy authentication coordinator that initializes only when needed */ export class LazyWPAuthCoordinator implements AuthCoordinator { private coordinator: WPAuthCoordinator | null = null; private serverUrlHash: string; private serverUrl: string; private callbackPort: number; private events: EventEmitter; constructor( serverUrlHash: string, serverUrl: string, callbackPort: number, events: EventEmitter ) { this.serverUrlHash = serverUrlHash; this.serverUrl = serverUrl; this.callbackPort = callbackPort; this.events = events; } async start(): Promise<void> { // Lazy initialization - start only when actually needed } async stop(): Promise<void> { if (this.coordinator) { await this.coordinator.stop(); this.coordinator = null; } } async waitForAuth(): Promise<WPTokens> { // First check if we already have valid tokens const existingTokens = await getValidTokens(this.serverUrlHash); if (existingTokens) { return existingTokens; } // Initialize coordinator if needed if (!this.coordinator) { this.coordinator = new WPAuthCoordinator( this.serverUrlHash, this.serverUrl, this.callbackPort, this.events ); await this.coordinator.start(); } return await this.coordinator.waitForAuth(); } } /** * Create a lazy WordPress auth coordinator */ export function createLazyWPAuthCoordinator( serverUrlHash: string, serverUrl: string, callbackPort: number, events: EventEmitter ): AuthCoordinator { return new LazyWPAuthCoordinator(serverUrlHash, serverUrl, callbackPort, events); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Automattic/mcp-wordpress-remote'

If you have feedback or need assistance with the MCP directory API, please join our Discord server