Files
registry/ts/registry.ts
Juergen Kunz 5d9cd3ad85 feat(registry): Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated embedded UI file.
2025-11-28 12:35:59 +00:00

364 lines
11 KiB
TypeScript

/**
* StackGalleryRegistry - Main registry class
* Integrates smartregistry with Stack.Gallery's auth, storage, and database
*/
import * as plugins from './plugins.ts';
import { initDb, closeDb, isDbConnected } from './models/db.ts';
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
import { ApiRouter } from './api/router.ts';
import { getEmbeddedFile } from './embedded-ui.generated.ts';
import { ReloadSocketManager } from './reload-socket.ts';
export interface IRegistryConfig {
// MongoDB configuration
mongoUrl: string;
mongoDb: string;
// S3 configuration
s3Endpoint: string;
s3AccessKey: string;
s3SecretKey: string;
s3Bucket: string;
s3Region?: string;
// Server configuration
host?: string;
port?: number;
// Registry settings
storagePath?: string;
enableUpstreamCache?: boolean;
upstreamCacheExpiry?: number; // hours
// JWT configuration
jwtSecret?: string;
}
export class StackGalleryRegistry {
private config: IRegistryConfig;
private smartBucket: plugins.smartbucket.SmartBucket | null = null;
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
private authProvider: StackGalleryAuthProvider | null = null;
private storageHooks: StackGalleryStorageHooks | null = null;
private apiRouter: ApiRouter | null = null;
private reloadSocket: ReloadSocketManager | null = null;
private isInitialized = false;
constructor(config: IRegistryConfig) {
this.config = {
host: '0.0.0.0',
port: 3000,
storagePath: 'packages',
enableUpstreamCache: true,
upstreamCacheExpiry: 24,
...config,
};
}
/**
* Initialize the registry
*/
public async init(): Promise<void> {
if (this.isInitialized) return;
console.log('[StackGalleryRegistry] Initializing...');
// Initialize MongoDB
console.log('[StackGalleryRegistry] Connecting to MongoDB...');
await initDb(this.config.mongoUrl, this.config.mongoDb);
console.log('[StackGalleryRegistry] MongoDB connected');
// Initialize S3/SmartBucket
console.log('[StackGalleryRegistry] Initializing S3 storage...');
this.smartBucket = new plugins.smartbucket.SmartBucket({
accessKey: this.config.s3AccessKey,
accessSecret: this.config.s3SecretKey,
endpoint: this.config.s3Endpoint,
bucketName: this.config.s3Bucket,
});
console.log('[StackGalleryRegistry] S3 storage initialized');
// Initialize auth provider
this.authProvider = new StackGalleryAuthProvider();
// Initialize storage hooks
this.storageHooks = new StackGalleryStorageHooks({
bucket: this.smartBucket,
basePath: this.config.storagePath!,
});
// Initialize smartregistry
console.log('[StackGalleryRegistry] Initializing smartregistry...');
this.smartRegistry = new plugins.smartregistry.SmartRegistry({
authProvider: this.authProvider,
storageHooks: this.storageHooks,
storage: {
type: 's3',
bucket: this.smartBucket,
basePath: this.config.storagePath,
},
upstreamCache: this.config.enableUpstreamCache
? {
enabled: true,
expiryHours: this.config.upstreamCacheExpiry,
}
: undefined,
});
console.log('[StackGalleryRegistry] smartregistry initialized');
// Initialize API router
console.log('[StackGalleryRegistry] Initializing API router...');
this.apiRouter = new ApiRouter();
console.log('[StackGalleryRegistry] API router initialized');
// Initialize reload socket for hot reload
this.reloadSocket = new ReloadSocketManager();
this.isInitialized = true;
console.log('[StackGalleryRegistry] Initialization complete');
}
/**
* Start the HTTP server
*/
public async start(): Promise<void> {
if (!this.isInitialized) {
await this.init();
}
const port = this.config.port!;
const host = this.config.host!;
console.log(`[StackGalleryRegistry] Starting server on ${host}:${port}...`);
Deno.serve(
{ port, hostname: host },
async (request: Request): Promise<Response> => {
return await this.handleRequest(request);
}
);
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
}
/**
* Handle incoming HTTP request
*/
private async handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
// Health check
if (path === '/health' || path === '/healthz') {
return this.healthCheck();
}
// API endpoints (handled by REST API layer)
if (path.startsWith('/api/')) {
return await this.handleApiRequest(request);
}
// Registry protocol endpoints (handled by smartregistry)
// NPM: /-/..., /@scope/package (but not /packages which is UI route)
// OCI: /v2/...
// Maven: /maven2/...
// PyPI: /simple/..., /pypi/...
// Cargo: /api/v1/crates/...
// Composer: /packages.json, /p/...
// RubyGems: /api/v1/gems/..., /gems/...
const registryPaths = ['/-/', '/v2/', '/maven2/', '/simple/', '/pypi/', '/api/v1/crates/', '/packages.json', '/p/', '/api/v1/gems/', '/gems/'];
const isRegistryPath = registryPaths.some(p => path.startsWith(p)) ||
(path.startsWith('/@') && !path.startsWith('/@stack'));
if (this.smartRegistry && isRegistryPath) {
try {
const response = await this.smartRegistry.handleRequest(request);
if (response) return response;
} catch (error) {
console.error('[StackGalleryRegistry] Request error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// WebSocket upgrade for hot reload
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
return this.reloadSocket!.handleUpgrade(request);
}
// Serve static UI files
return this.serveStaticFile(path);
}
/**
* Serve static files from embedded UI
*/
private serveStaticFile(path: string): Response {
const filePath = path === '/' ? '/index.html' : path;
// Get embedded file
const embeddedFile = getEmbeddedFile(filePath);
if (embeddedFile) {
return new Response(embeddedFile.data, {
status: 200,
headers: { 'Content-Type': embeddedFile.contentType },
});
}
// SPA fallback: serve index.html for unknown paths
const indexFile = getEmbeddedFile('/index.html');
if (indexFile) {
return new Response(indexFile.data, {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
}
return new Response('Not Found', { status: 404 });
}
/**
* Handle API requests
*/
private async handleApiRequest(request: Request): Promise<Response> {
if (!this.apiRouter) {
return new Response(
JSON.stringify({ error: 'API router not initialized' }),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
}
return await this.apiRouter.handle(request);
}
/**
* Health check endpoint
*/
private healthCheck(): Response {
const healthy = this.isInitialized && isDbConnected();
const status = {
status: healthy ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
services: {
mongodb: isDbConnected() ? 'connected' : 'disconnected',
s3: this.smartBucket ? 'initialized' : 'not initialized',
registry: this.smartRegistry ? 'initialized' : 'not initialized',
},
};
return new Response(JSON.stringify(status), {
status: healthy ? 200 : 503,
headers: { 'Content-Type': 'application/json' },
});
}
/**
* Stop the registry
*/
public async stop(): Promise<void> {
console.log('[StackGalleryRegistry] Shutting down...');
await closeDb();
this.isInitialized = false;
console.log('[StackGalleryRegistry] Shutdown complete');
}
/**
* Get the smartregistry instance
*/
public getSmartRegistry(): plugins.smartregistry.SmartRegistry | null {
return this.smartRegistry;
}
/**
* Get the smartbucket instance
*/
public getSmartBucket(): plugins.smartbucket.SmartBucket | null {
return this.smartBucket;
}
/**
* Check if registry is initialized
*/
public getIsInitialized(): boolean {
return this.isInitialized;
}
}
/**
* Create registry from environment variables
*/
export function createRegistryFromEnv(): StackGalleryRegistry {
const config: IRegistryConfig = {
mongoUrl: Deno.env.get('MONGODB_URL') || 'mongodb://localhost:27017',
mongoDb: Deno.env.get('MONGODB_DB') || 'stackgallery',
s3Endpoint: Deno.env.get('S3_ENDPOINT') || 'http://localhost:9000',
s3AccessKey: Deno.env.get('S3_ACCESS_KEY') || 'minioadmin',
s3SecretKey: Deno.env.get('S3_SECRET_KEY') || 'minioadmin',
s3Bucket: Deno.env.get('S3_BUCKET') || 'registry',
s3Region: Deno.env.get('S3_REGION'),
host: Deno.env.get('HOST') || '0.0.0.0',
port: parseInt(Deno.env.get('PORT') || '3000', 10),
storagePath: Deno.env.get('STORAGE_PATH') || 'packages',
enableUpstreamCache: Deno.env.get('ENABLE_UPSTREAM_CACHE') !== 'false',
upstreamCacheExpiry: parseInt(Deno.env.get('UPSTREAM_CACHE_EXPIRY') || '24', 10),
jwtSecret: Deno.env.get('JWT_SECRET'),
};
return new StackGalleryRegistry(config);
}
/**
* Create registry from .nogit/env.json file (for local development)
* Falls back to environment variables if file doesn't exist
*/
export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry> {
const envPath = '.nogit/env.json';
try {
const envText = await Deno.readTextFile(envPath);
const env = JSON.parse(envText);
console.log('[StackGalleryRegistry] Loading config from .nogit/env.json');
// Build S3 endpoint from host/port/ssl settings
const s3Protocol = env.S3_USESSL ? 'https' : 'http';
const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
const config: IRegistryConfig = {
mongoUrl: env.MONGODB_URL || `mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
mongoDb: env.MONGODB_NAME || 'stackgallery',
s3Endpoint: s3Endpoint,
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
s3SecretKey: env.S3_SECRETKEY || env.S3_SECRET_KEY || 'minioadmin',
s3Bucket: env.S3_BUCKET || 'registry',
s3Region: env.S3_REGION,
host: env.HOST || '0.0.0.0',
port: parseInt(env.PORT || '3000', 10),
storagePath: env.STORAGE_PATH || 'packages',
enableUpstreamCache: env.ENABLE_UPSTREAM_CACHE !== false,
upstreamCacheExpiry: parseInt(env.UPSTREAM_CACHE_EXPIRY || '24', 10),
jwtSecret: env.JWT_SECRET,
};
return new StackGalleryRegistry(config);
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
console.log('[StackGalleryRegistry] No .nogit/env.json found, using environment variables');
} else {
console.warn('[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:', error);
}
return createRegistryFromEnv();
}
}