Merge pull request #54 from jgor20/feature/auto-rebuild-native-modules
feat: Add automatic native module rebuild on Node.js version mismatch
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -101,7 +101,8 @@ src/
|
|||||||
│
|
│
|
||||||
└── utils/ # Utilities
|
└── utils/ # Utilities
|
||||||
├── helpers.js # formatDuration, sleep
|
├── helpers.js # formatDuration, sleep
|
||||||
└── logger.js # Structured logging
|
├── logger.js # Structured logging
|
||||||
|
└── native-module-helper.js # Auto-rebuild for native modules
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Modules:**
|
**Key Modules:**
|
||||||
@@ -109,7 +110,7 @@ src/
|
|||||||
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
|
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
|
||||||
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
|
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
|
||||||
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
|
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
|
||||||
- **src/auth/**: Authentication including Google OAuth, token extraction, and database access
|
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
|
||||||
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
|
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
|
||||||
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
|
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
|
||||||
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
|
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
|
||||||
@@ -144,6 +145,13 @@ src/
|
|||||||
- For Gemini targets: strict validation - drops unknown or mismatched signatures
|
- For Gemini targets: strict validation - drops unknown or mismatched signatures
|
||||||
- For Claude targets: lenient - lets Claude validate its own signatures
|
- For Claude targets: lenient - lets Claude validate its own signatures
|
||||||
|
|
||||||
|
**Native Module Auto-Rebuild:**
|
||||||
|
- When Node.js is updated, native modules like `better-sqlite3` may become incompatible
|
||||||
|
- The proxy automatically detects `NODE_MODULE_VERSION` mismatch errors
|
||||||
|
- On detection, it attempts to rebuild the module using `npm rebuild`
|
||||||
|
- If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required
|
||||||
|
- Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js`
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|
||||||
- Tests require the server to be running (`npm start` in separate terminal)
|
- Tests require the server to be running (`npm start` in separate terminal)
|
||||||
|
|||||||
@@ -6,10 +6,80 @@
|
|||||||
* - Windows compatibility (no CLI dependency)
|
* - Windows compatibility (no CLI dependency)
|
||||||
* - Native performance
|
* - Native performance
|
||||||
* - Synchronous API (simple error handling)
|
* - Synchronous API (simple error handling)
|
||||||
|
*
|
||||||
|
* Includes auto-rebuild capability for handling Node.js version updates
|
||||||
|
* that cause native module incompatibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import { createRequire } from 'module';
|
||||||
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
||||||
|
import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { NativeModuleError } from '../errors.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Lazy-loaded Database constructor
|
||||||
|
let Database = null;
|
||||||
|
let moduleLoadError = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the better-sqlite3 module with auto-rebuild on version mismatch
|
||||||
|
* Uses synchronous require to maintain API compatibility
|
||||||
|
* @returns {Function} The Database constructor
|
||||||
|
* @throws {Error} If module cannot be loaded even after rebuild
|
||||||
|
*/
|
||||||
|
function loadDatabaseModule() {
|
||||||
|
// Return cached module if already loaded
|
||||||
|
if (Database) return Database;
|
||||||
|
|
||||||
|
// Re-throw cached error if previous load failed permanently
|
||||||
|
if (moduleLoadError) throw moduleLoadError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database = require('better-sqlite3');
|
||||||
|
return Database;
|
||||||
|
} catch (error) {
|
||||||
|
if (isModuleVersionError(error)) {
|
||||||
|
logger.warn('[Database] Native module version mismatch detected');
|
||||||
|
|
||||||
|
if (attemptAutoRebuild(error)) {
|
||||||
|
// Clear require cache and retry
|
||||||
|
try {
|
||||||
|
const resolvedPath = require.resolve('better-sqlite3');
|
||||||
|
// Clear the module and all its dependencies from cache
|
||||||
|
clearRequireCache(resolvedPath, require.cache);
|
||||||
|
|
||||||
|
Database = require('better-sqlite3');
|
||||||
|
logger.success('[Database] Module reloaded successfully after rebuild');
|
||||||
|
return Database;
|
||||||
|
} catch (retryError) {
|
||||||
|
// Rebuild succeeded but reload failed - user needs to restart
|
||||||
|
moduleLoadError = new NativeModuleError(
|
||||||
|
'Native module rebuild completed. Please restart the server to apply the fix.',
|
||||||
|
true, // rebuildSucceeded
|
||||||
|
true // restartRequired
|
||||||
|
);
|
||||||
|
logger.info('[Database] Rebuild succeeded - server restart required');
|
||||||
|
throw moduleLoadError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
moduleLoadError = new NativeModuleError(
|
||||||
|
'Failed to auto-rebuild native module. Please run manually:\n' +
|
||||||
|
' npm rebuild better-sqlite3\n' +
|
||||||
|
'Or if using npx, find the package location in the error and run:\n' +
|
||||||
|
' cd /path/to/better-sqlite3 && npm rebuild',
|
||||||
|
false, // rebuildSucceeded
|
||||||
|
false // restartRequired
|
||||||
|
);
|
||||||
|
throw moduleLoadError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-version-mismatch error, just throw it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query Antigravity database for authentication status
|
* Query Antigravity database for authentication status
|
||||||
@@ -18,10 +88,11 @@ import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
|||||||
* @throws {Error} If database doesn't exist, query fails, or no auth status found
|
* @throws {Error} If database doesn't exist, query fails, or no auth status found
|
||||||
*/
|
*/
|
||||||
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||||
|
const Db = loadDatabaseModule();
|
||||||
let db;
|
let db;
|
||||||
try {
|
try {
|
||||||
// Open database in read-only mode
|
// Open database in read-only mode
|
||||||
db = new Database(dbPath, {
|
db = new Db(dbPath, {
|
||||||
readonly: true,
|
readonly: true,
|
||||||
fileMustExist: true
|
fileMustExist: true
|
||||||
});
|
});
|
||||||
@@ -56,6 +127,10 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
|||||||
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
|
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
// Re-throw native module errors from loadDatabaseModule without wrapping
|
||||||
|
if (error instanceof NativeModuleError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new Error(`Failed to read Antigravity database: ${error.message}`);
|
throw new Error(`Failed to read Antigravity database: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
// Always close database connection
|
// Always close database connection
|
||||||
@@ -73,7 +148,8 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
|||||||
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
|
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||||
let db;
|
let db;
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath, {
|
const Db = loadDatabaseModule();
|
||||||
|
db = new Db(dbPath, {
|
||||||
readonly: true,
|
readonly: true,
|
||||||
fileMustExist: true
|
fileMustExist: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,23 @@ export class ApiError extends AntigravityError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native module error (version mismatch, rebuild required)
|
||||||
|
*/
|
||||||
|
export class NativeModuleError extends AntigravityError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - Error message
|
||||||
|
* @param {boolean} rebuildSucceeded - Whether auto-rebuild succeeded
|
||||||
|
* @param {boolean} restartRequired - Whether server restart is needed
|
||||||
|
*/
|
||||||
|
constructor(message, rebuildSucceeded = false, restartRequired = false) {
|
||||||
|
super(message, 'NATIVE_MODULE_ERROR', false, { rebuildSucceeded, restartRequired });
|
||||||
|
this.name = 'NativeModuleError';
|
||||||
|
this.rebuildSucceeded = rebuildSucceeded;
|
||||||
|
this.restartRequired = restartRequired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is a rate limit error
|
* Check if an error is a rate limit error
|
||||||
* Works with both custom error classes and legacy string-based errors
|
* Works with both custom error classes and legacy string-based errors
|
||||||
@@ -154,6 +171,7 @@ export default {
|
|||||||
NoAccountsError,
|
NoAccountsError,
|
||||||
MaxRetriesError,
|
MaxRetriesError,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
NativeModuleError,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
isAuthError
|
isAuthError
|
||||||
};
|
};
|
||||||
|
|||||||
162
src/utils/native-module-helper.js
Normal file
162
src/utils/native-module-helper.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Native Module Helper
|
||||||
|
* Detects and auto-rebuilds native Node.js modules when they become
|
||||||
|
* incompatible after a Node.js version update.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a NODE_MODULE_VERSION mismatch error
|
||||||
|
* @param {Error} error - The error to check
|
||||||
|
* @returns {boolean} True if it's a version mismatch error
|
||||||
|
*/
|
||||||
|
export function isModuleVersionError(error) {
|
||||||
|
const message = error?.message || '';
|
||||||
|
return message.includes('NODE_MODULE_VERSION') &&
|
||||||
|
message.includes('was compiled against a different Node.js version');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the module path from a NODE_MODULE_VERSION error message
|
||||||
|
* @param {Error} error - The error containing the module path
|
||||||
|
* @returns {string|null} The path to the .node file, or null if not found
|
||||||
|
*/
|
||||||
|
export function extractModulePath(error) {
|
||||||
|
const message = error?.message || '';
|
||||||
|
// Match pattern like: "The module '/path/to/module.node'"
|
||||||
|
const match = message.match(/The module '([^']+\.node)'/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the package root directory from a .node file path
|
||||||
|
* @param {string} nodeFilePath - Path to the .node file
|
||||||
|
* @returns {string|null} Path to the package root, or null if not found
|
||||||
|
*/
|
||||||
|
export function findPackageRoot(nodeFilePath) {
|
||||||
|
// Walk up from the .node file to find package.json
|
||||||
|
let dir = dirname(nodeFilePath);
|
||||||
|
while (dir) {
|
||||||
|
const packageJsonPath = join(dir, 'package.json');
|
||||||
|
if (existsSync(packageJsonPath)) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const parentDir = dirname(dir);
|
||||||
|
// Stop when we've reached the filesystem root (dirname returns same path)
|
||||||
|
if (parentDir === dir) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dir = parentDir;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to rebuild a native module
|
||||||
|
* @param {string} packagePath - Path to the package root directory
|
||||||
|
* @returns {boolean} True if rebuild succeeded, false otherwise
|
||||||
|
*/
|
||||||
|
export function rebuildModule(packagePath) {
|
||||||
|
try {
|
||||||
|
logger.info(`[NativeModule] Rebuilding native module at: ${packagePath}`);
|
||||||
|
|
||||||
|
// Run npm rebuild in the package directory
|
||||||
|
const output = execSync('npm rebuild', {
|
||||||
|
cwd: packagePath,
|
||||||
|
stdio: 'pipe', // Capture output instead of printing
|
||||||
|
timeout: 120000 // 2 minute timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log rebuild output for debugging
|
||||||
|
const outputStr = output?.toString().trim();
|
||||||
|
if (outputStr) {
|
||||||
|
logger.debug(`[NativeModule] Rebuild output:\n${outputStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('[NativeModule] Rebuild completed successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// Include stdout/stderr from the failed command for troubleshooting
|
||||||
|
const stdout = error.stdout?.toString().trim();
|
||||||
|
const stderr = error.stderr?.toString().trim();
|
||||||
|
let errorDetails = `[NativeModule] Rebuild failed: ${error.message}`;
|
||||||
|
if (stdout) {
|
||||||
|
errorDetails += `\n[NativeModule] stdout: ${stdout}`;
|
||||||
|
}
|
||||||
|
if (stderr) {
|
||||||
|
errorDetails += `\n[NativeModule] stderr: ${stderr}`;
|
||||||
|
}
|
||||||
|
logger.error(errorDetails);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to auto-rebuild a native module from an error
|
||||||
|
* @param {Error} error - The NODE_MODULE_VERSION error
|
||||||
|
* @returns {boolean} True if rebuild succeeded, false otherwise
|
||||||
|
*/
|
||||||
|
export function attemptAutoRebuild(error) {
|
||||||
|
const nodePath = extractModulePath(error);
|
||||||
|
if (!nodePath) {
|
||||||
|
logger.error('[NativeModule] Could not extract module path from error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagePath = findPackageRoot(nodePath);
|
||||||
|
if (!packagePath) {
|
||||||
|
logger.error('[NativeModule] Could not find package root');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('[NativeModule] Native module version mismatch detected');
|
||||||
|
logger.info('[NativeModule] Attempting automatic rebuild...');
|
||||||
|
|
||||||
|
return rebuildModule(packagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively clear a module and its dependencies from the require cache
|
||||||
|
* This is needed after rebuilding a native module to force re-import
|
||||||
|
* @param {string} modulePath - Resolved path to the module
|
||||||
|
* @param {object} cache - The require.cache object
|
||||||
|
* @param {Set} [visited] - Set of already-visited paths to prevent cycles
|
||||||
|
*/
|
||||||
|
export function clearRequireCache(modulePath, cache, visited = new Set()) {
|
||||||
|
if (visited.has(modulePath)) return;
|
||||||
|
visited.add(modulePath);
|
||||||
|
|
||||||
|
const mod = cache[modulePath];
|
||||||
|
if (!mod) return;
|
||||||
|
|
||||||
|
// Recursively clear children first
|
||||||
|
if (mod.children) {
|
||||||
|
for (const child of mod.children) {
|
||||||
|
clearRequireCache(child.id, cache, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from parent's children array
|
||||||
|
if (mod.parent && mod.parent.children) {
|
||||||
|
const idx = mod.parent.children.indexOf(mod);
|
||||||
|
if (idx !== -1) {
|
||||||
|
mod.parent.children.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from cache
|
||||||
|
delete cache[modulePath];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isModuleVersionError,
|
||||||
|
extractModulePath,
|
||||||
|
findPackageRoot,
|
||||||
|
rebuildModule,
|
||||||
|
attemptAutoRebuild,
|
||||||
|
clearRequireCache
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user