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.
This commit is contained in:
2025-11-28 12:35:59 +00:00
parent 45114f89d4
commit 5d9cd3ad85
13 changed files with 794 additions and 47 deletions

3
.gitignore vendored
View File

@@ -7,6 +7,9 @@ dist/
.angular/
out-tsc/
# Generated files
ts/embedded-ui.generated.ts
# Deno
.deno/

View File

@@ -1,5 +1,18 @@
# Changelog
## 2025-11-28 - 1.1.0 - 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.
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected clients for hot-reload.
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at /ws/reload.
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS and trigger page reloads with exponential reconnect.
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to index.html.
- Ignore generated embedded UI file (ts/embedded-ui.generated.ts) in .gitignore.
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and a release task to bundle + compile.
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task bundle-ui:watch).
## 2025-11-28 - 1.0.1 - fix(smartdata)
Bump @push.rocks/smartdata to ^7.0.13 in deno.json

View File

@@ -7,7 +7,15 @@
"start": "deno run --allow-all mod.ts server",
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
"test": "deno test --allow-all",
"build": "cd ui && pnpm run build"
"build": "cd ui && pnpm run build",
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",
"compile": "deno compile --allow-all --output dist/stack-gallery-registry mod.ts",
"compile:linux-x64": "deno compile --allow-all --target x86_64-unknown-linux-gnu --output dist/stack-gallery-registry-linux-x64 mod.ts",
"compile:linux-arm64": "deno compile --allow-all --target aarch64-unknown-linux-gnu --output dist/stack-gallery-registry-linux-arm64 mod.ts",
"compile:macos-x64": "deno compile --allow-all --target x86_64-apple-darwin --output dist/stack-gallery-registry-macos-x64 mod.ts",
"compile:macos-arm64": "deno compile --allow-all --target aarch64-apple-darwin --output dist/stack-gallery-registry-macos-arm64 mod.ts",
"release": "deno task bundle-ui && deno task compile:linux-x64 && deno task compile:linux-arm64 && deno task compile:macos-x64 && deno task compile:macos-arm64"
},
"imports": {
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0",

3
deno.lock generated
View File

@@ -2,6 +2,7 @@
"version": "5",
"specifiers": {
"jsr:@std/cli@^1.0.24": "1.0.24",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.8": "1.0.8",
"jsr:@std/fs@1": "1.0.20",
@@ -56,7 +57,7 @@
"integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a",
"dependencies": [
"jsr:@std/cli",
"jsr:@std/encoding",
"jsr:@std/encoding@^1.0.10",
"jsr:@std/fmt",
"jsr:@std/fs@^1.0.20",
"jsr:@std/html",

361
install.sh Executable file
View File

@@ -0,0 +1,361 @@
#!/bin/bash
# Stack.Gallery Registry Installer Script
# Downloads and installs pre-compiled Stack.Gallery Registry binary from Gitea releases
#
# Usage:
# Direct piped installation (recommended):
# curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
#
# With version specification:
# curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.0.0
#
# Options:
# -h, --help Show this help message
# --version VERSION Install specific version (e.g., v1.0.0)
# --install-dir DIR Installation directory (default: /opt/stack-gallery-registry)
# --setup-service Install and enable systemd service
set -e
# Default values
SHOW_HELP=0
SPECIFIED_VERSION=""
INSTALL_DIR="/opt/stack-gallery-registry"
SETUP_SERVICE=0
GITEA_BASE_URL="https://code.foss.global"
GITEA_REPO="stack.gallery/registry"
BINARY_NAME="stack-gallery-registry"
SERVICE_NAME="stack-gallery-registry"
CONFIG_DIR="/etc/stack-gallery-registry"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
SHOW_HELP=1
shift
;;
--version)
SPECIFIED_VERSION="$2"
shift 2
;;
--install-dir)
INSTALL_DIR="$2"
shift 2
;;
--setup-service)
SETUP_SERVICE=1
shift
;;
*)
echo "Unknown option: $1"
echo "Use -h or --help for usage information"
exit 1
;;
esac
done
if [ $SHOW_HELP -eq 1 ]; then
echo "Stack.Gallery Registry Installer Script"
echo "Downloads and installs pre-compiled Stack.Gallery Registry binary"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --version VERSION Install specific version (e.g., v1.0.0)"
echo " --install-dir DIR Installation directory (default: /opt/stack-gallery-registry)"
echo " --setup-service Install and enable systemd service"
echo ""
echo "Examples:"
echo " # Install latest version"
echo " curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash"
echo ""
echo " # Install specific version with systemd service"
echo " curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.0.0 --setup-service"
exit 0
fi
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
exit 1
fi
# Helper function to detect OS and architecture
detect_platform() {
local os=$(uname -s)
local arch=$(uname -m)
# Map OS
case "$os" in
Linux)
os_name="linux"
;;
Darwin)
os_name="macos"
;;
MINGW*|MSYS*|CYGWIN*)
os_name="windows"
;;
*)
echo "Error: Unsupported operating system: $os"
echo "Supported: Linux, macOS"
exit 1
;;
esac
# Map architecture
case "$arch" in
x86_64|amd64)
arch_name="x64"
;;
aarch64|arm64)
arch_name="arm64"
;;
*)
echo "Error: Unsupported architecture: $arch"
echo "Supported: x86_64/amd64 (x64), aarch64/arm64 (arm64)"
exit 1
;;
esac
# Construct binary name
echo "${BINARY_NAME}-${os_name}-${arch_name}"
}
# Get latest release version from Gitea API
get_latest_version() {
echo "Fetching latest release version from Gitea..." >&2
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
local response=$(curl -sSL "$api_url" 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$response" ]; then
echo "Error: Failed to fetch latest release information from Gitea API" >&2
echo "URL: $api_url" >&2
exit 1
fi
# Extract tag_name from JSON response
local version=$(echo "$response" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
if [ -z "$version" ]; then
echo "Error: Could not determine latest version from API response" >&2
exit 1
fi
echo "$version"
}
# Create systemd service file
create_service_file() {
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=Stack.Gallery Registry
Documentation=https://code.foss.global/stack.gallery/registry
After=network.target mongodb.service
[Service]
Type=simple
User=root
ExecStart=${INSTALL_DIR}/${BINARY_NAME} server
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
# Environment variables (customize these)
Environment=PORT=3000
Environment=HOST=0.0.0.0
# Environment=MONGODB_URL=mongodb://localhost:27017/stackgallery
# Environment=S3_ENDPOINT=http://localhost:9000
# Environment=S3_ACCESS_KEY=minioadmin
# Environment=S3_SECRET_KEY=minioadmin
# Environment=S3_BUCKET=registry
# Environment=JWT_SECRET=your-secret-here
WorkingDirectory=${INSTALL_DIR}
[Install]
WantedBy=multi-user.target
EOF
echo "Created systemd service file: /etc/systemd/system/${SERVICE_NAME}.service"
}
# Main installation process
echo "================================================"
echo " Stack.Gallery Registry Installation Script"
echo "================================================"
echo ""
# Detect platform
PLATFORM_BINARY=$(detect_platform)
echo "Detected platform: $PLATFORM_BINARY"
echo ""
# Determine version to install
if [ -n "$SPECIFIED_VERSION" ]; then
VERSION="$SPECIFIED_VERSION"
echo "Installing specified version: $VERSION"
else
VERSION=$(get_latest_version)
echo "Installing latest version: $VERSION"
fi
echo ""
# Construct download URL
DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${PLATFORM_BINARY}"
echo "Download URL: $DOWNLOAD_URL"
echo ""
# Check if service is running and stop it
SERVICE_WAS_RUNNING=0
if systemctl is-enabled --quiet ${SERVICE_NAME} 2>/dev/null || systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
SERVICE_WAS_RUNNING=1
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
echo "Stopping ${SERVICE_NAME} service..."
systemctl stop ${SERVICE_NAME}
fi
fi
# Clean installation directory - ensure only binary exists
if [ -d "$INSTALL_DIR" ]; then
echo "Cleaning installation directory: $INSTALL_DIR"
rm -rf "$INSTALL_DIR"
fi
# Create fresh installation directory
echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# Create config directory if it doesn't exist
if [ ! -d "$CONFIG_DIR" ]; then
echo "Creating config directory: $CONFIG_DIR"
mkdir -p "$CONFIG_DIR"
fi
# Download binary
echo "Downloading Stack.Gallery Registry binary..."
TEMP_FILE="$INSTALL_DIR/${BINARY_NAME}.download"
curl -sSL "$DOWNLOAD_URL" -o "$TEMP_FILE"
if [ $? -ne 0 ]; then
echo "Error: Failed to download binary from $DOWNLOAD_URL"
echo ""
echo "Please check:"
echo " 1. Your internet connection"
echo " 2. The specified version exists: ${GITEA_BASE_URL}/${GITEA_REPO}/releases"
echo " 3. The platform binary is available for this release"
rm -f "$TEMP_FILE"
exit 1
fi
# Check if download was successful (file exists and not empty)
if [ ! -s "$TEMP_FILE" ]; then
echo "Error: Downloaded file is empty or does not exist"
rm -f "$TEMP_FILE"
exit 1
fi
# Move to final location
BINARY_PATH="$INSTALL_DIR/${BINARY_NAME}"
mv "$TEMP_FILE" "$BINARY_PATH"
if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
echo "Error: Failed to move binary to $BINARY_PATH"
rm -f "$TEMP_FILE" 2>/dev/null
exit 1
fi
# Make executable
chmod +x "$BINARY_PATH"
if [ $? -ne 0 ]; then
echo "Error: Failed to make binary executable"
exit 1
fi
echo "Binary installed successfully to: $BINARY_PATH"
echo ""
# Check if /usr/local/bin is in PATH
if [[ ":$PATH:" == *":/usr/local/bin:"* ]]; then
BIN_DIR="/usr/local/bin"
else
BIN_DIR="/usr/bin"
fi
# Create symlink for global access
ln -sf "$BINARY_PATH" "$BIN_DIR/${BINARY_NAME}"
echo "Symlink created: $BIN_DIR/${BINARY_NAME} -> $BINARY_PATH"
echo ""
# Setup systemd service if requested
if [ $SETUP_SERVICE -eq 1 ]; then
echo "Setting up systemd service..."
create_service_file
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
echo "Systemd service enabled: ${SERVICE_NAME}"
echo ""
fi
# Restart service if it was running before update
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "Restarting ${SERVICE_NAME} service..."
systemctl restart ${SERVICE_NAME}
echo "Service restarted successfully."
echo ""
fi
echo "================================================"
echo " Stack.Gallery Registry Installation Complete!"
echo "================================================"
echo ""
echo "Installation details:"
echo " Binary location: $BINARY_PATH"
echo " Symlink location: $BIN_DIR/${BINARY_NAME}"
echo " Config directory: $CONFIG_DIR"
echo " Version: $VERSION"
echo ""
# Check if configuration exists
if [ -f "${CONFIG_DIR}/config.json" ]; then
echo "Configuration: ${CONFIG_DIR}/config.json (preserved)"
echo ""
echo "Your existing configuration has been preserved."
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "The service has been restarted with your current settings."
else
echo "Start the service with: sudo systemctl start ${SERVICE_NAME}"
fi
else
echo "Get started:"
echo " ${BINARY_NAME} --help"
echo " ${BINARY_NAME} server # Start the registry server"
echo ""
echo "Configure environment variables:"
echo " - MONGODB_URL: MongoDB connection string"
echo " - S3_ENDPOINT: S3-compatible storage endpoint"
echo " - S3_ACCESS_KEY: S3 access key"
echo " - S3_SECRET_KEY: S3 secret key"
echo " - S3_BUCKET: S3 bucket name"
echo " - JWT_SECRET: Secret for JWT signing"
echo " - PORT: Server port (default: 3000)"
echo ""
if [ $SETUP_SERVICE -eq 1 ]; then
echo "Edit the service file to configure environment:"
echo " sudo nano /etc/systemd/system/${SERVICE_NAME}.service"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl start ${SERVICE_NAME}"
else
echo "To setup as a systemd service, re-run with --setup-service:"
echo " curl -sSL ${GITEA_BASE_URL}/${GITEA_REPO}/raw/branch/main/install.sh | sudo bash -s -- --setup-service"
fi
fi
echo ""

View File

@@ -7,7 +7,7 @@
"scripts": {
"start": "deno run --allow-all mod.ts server",
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
"watch": "concurrently --kill-others --names \"BACKEND,UI\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run watch\"",
"watch": "concurrently --kill-others --names \"BACKEND,UI,BUNDLER\" --prefix-colors \"cyan,magenta,yellow\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run watch\" \"deno task bundle-ui:watch\"",
"build": "cd ui && pnpm run build",
"test": "deno test --allow-all"
},

214
scripts/bundle-ui.ts Normal file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env -S deno run --allow-all
/**
* UI Bundler Script
* Encodes all files from ui/dist/registry-ui/browser/ as base64
* and generates ts/embedded-ui.generated.ts
*
* Usage:
* deno task bundle-ui # One-time bundle
* deno task bundle-ui:watch # Watch mode for development
*/
import { walk } from 'jsr:@std/fs@1/walk';
import { extname, relative } from 'jsr:@std/path@1';
import { encodeBase64 } from 'jsr:@std/encoding@1/base64';
const UI_DIST_PATH = './ui/dist/registry-ui/browser';
const OUTPUT_PATH = './ts/embedded-ui.generated.ts';
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.otf': 'font/otf',
'.map': 'application/json',
'.txt': 'text/plain',
'.xml': 'application/xml',
'.webp': 'image/webp',
'.webmanifest': 'application/manifest+json',
};
interface IEmbeddedFile {
path: string;
base64: string;
contentType: string;
size: number;
}
async function bundleUI(): Promise<void> {
console.log('[bundle-ui] Starting UI bundling...');
console.log(`[bundle-ui] Source: ${UI_DIST_PATH}`);
console.log(`[bundle-ui] Output: ${OUTPUT_PATH}`);
// Check if UI dist exists
try {
await Deno.stat(UI_DIST_PATH);
} catch {
console.error(`[bundle-ui] ERROR: UI dist not found at ${UI_DIST_PATH}`);
console.error('[bundle-ui] Run "deno task build" first to build the UI');
Deno.exit(1);
}
const files: IEmbeddedFile[] = [];
let totalSize = 0;
// Walk through all files in the dist directory
for await (const entry of walk(UI_DIST_PATH, { includeFiles: true, includeDirs: false })) {
const relativePath = '/' + relative(UI_DIST_PATH, entry.path).replace(/\\/g, '/');
const ext = extname(entry.path).toLowerCase();
const contentType = CONTENT_TYPES[ext] || 'application/octet-stream';
// Read file and encode as base64
const content = await Deno.readFile(entry.path);
const base64 = encodeBase64(content);
files.push({
path: relativePath,
base64,
contentType,
size: content.length,
});
totalSize += content.length;
console.log(`[bundle-ui] Encoded: ${relativePath} (${formatSize(content.length)})`);
}
// Sort files for consistent output
files.sort((a, b) => a.path.localeCompare(b.path));
// Generate TypeScript module
const tsContent = generateTypeScript(files, totalSize);
// Write output file
await Deno.writeTextFile(OUTPUT_PATH, tsContent);
console.log(`[bundle-ui] Generated ${OUTPUT_PATH}`);
console.log(`[bundle-ui] Total files: ${files.length}`);
console.log(`[bundle-ui] Total size: ${formatSize(totalSize)}`);
console.log(`[bundle-ui] Bundling complete!`);
}
function generateTypeScript(files: IEmbeddedFile[], totalSize: number): string {
const fileEntries = files
.map(
(f) =>
` ['${f.path}', { base64: '${f.base64}', contentType: '${f.contentType}' }]`
)
.join(',\n');
return `// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by scripts/bundle-ui.ts
// Total files: ${files.length}
// Total size: ${formatSize(totalSize)}
// Generated at: ${new Date().toISOString()}
interface IEmbeddedFile {
base64: string;
contentType: string;
}
const EMBEDDED_FILES: Map<string, IEmbeddedFile> = new Map([
${fileEntries}
]);
/**
* Get an embedded file by path
* @param path - The file path (e.g., '/index.html')
* @returns The file data and content type, or null if not found
*/
export function getEmbeddedFile(path: string): { data: Uint8Array; contentType: string } | null {
const file = EMBEDDED_FILES.get(path);
if (!file) return null;
// Decode base64 to Uint8Array
const binaryString = atob(file.base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return { data: bytes, contentType: file.contentType };
}
/**
* Check if an embedded file exists
* @param path - The file path to check
*/
export function hasEmbeddedFile(path: string): boolean {
return EMBEDDED_FILES.has(path);
}
/**
* List all embedded file paths
*/
export function listEmbeddedFiles(): string[] {
return Array.from(EMBEDDED_FILES.keys());
}
/**
* Get the total number of embedded files
*/
export function getEmbeddedFileCount(): number {
return EMBEDDED_FILES.size;
}
`;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
async function watchMode(): Promise<void> {
console.log('[bundle-ui] Starting watch mode...');
console.log(`[bundle-ui] Watching: ${UI_DIST_PATH}`);
console.log('[bundle-ui] Press Ctrl+C to stop');
console.log('');
// Initial bundle
await bundleUI();
// Watch for changes
const watcher = Deno.watchFs(UI_DIST_PATH);
let debounceTimer: number | null = null;
for await (const event of watcher) {
if (event.kind === 'modify' || event.kind === 'create' || event.kind === 'remove') {
// Debounce - wait 500ms after last change
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(async () => {
console.log('');
console.log(`[bundle-ui] Change detected: ${event.kind}`);
try {
await bundleUI();
} catch (error) {
console.error('[bundle-ui] Error during rebundle:', error);
}
}, 500);
}
}
}
// Main entry point
const args = Deno.args;
const isWatch = args.includes('--watch') || args.includes('-w');
if (isWatch) {
await watchMode();
} else {
await bundleUI();
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@stack.gallery/registry',
version: '1.0.1',
version: '1.1.0',
description: 'Enterprise-grade multi-protocol package registry'
}

View File

@@ -8,6 +8,8 @@ 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
@@ -41,6 +43,7 @@ export class StackGalleryRegistry {
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) {
@@ -110,6 +113,9 @@ export class StackGalleryRegistry {
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');
}
@@ -182,57 +188,41 @@ export class StackGalleryRegistry {
}
}
// WebSocket upgrade for hot reload
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
return this.reloadSocket!.handleUpgrade(request);
}
// Serve static UI files
return await this.serveStaticFile(path);
return this.serveStaticFile(path);
}
/**
* Serve static files from UI dist
* Serve static files from embedded UI
*/
private async serveStaticFile(path: string): Promise<Response> {
const uiDistPath = './ui/dist/registry-ui/browser';
private serveStaticFile(path: string): Response {
const filePath = path === '/' ? '/index.html' : path;
// Map path to file
let filePath = path === '/' ? '/index.html' : path;
// Content type mapping
const contentTypes: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
};
try {
const fullPath = `${uiDistPath}${filePath}`;
const file = await Deno.readFile(fullPath);
const ext = filePath.substring(filePath.lastIndexOf('.'));
const contentType = contentTypes[ext] || 'application/octet-stream';
return new Response(file, {
// Get embedded file
const embeddedFile = getEmbeddedFile(filePath);
if (embeddedFile) {
return new Response(embeddedFile.data, {
status: 200,
headers: { 'Content-Type': contentType },
headers: { 'Content-Type': embeddedFile.contentType },
});
} catch {
// For SPA routing, serve index.html for unknown paths
try {
const indexFile = await Deno.readFile(`${uiDistPath}/index.html`);
return new Response(indexFile, {
}
// 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' },
});
} catch {
}
return new Response('Not Found', { status: 404 });
}
}
}
/**
* Handle API requests

65
ts/reload-socket.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* WebSocket manager for hot reload
* Generates a unique instance ID on startup and broadcasts it to connected clients.
* When the server restarts, clients detect the new ID and reload the page.
*/
export class ReloadSocketManager {
private instanceId: string;
private clients: Set<WebSocket> = new Set();
constructor() {
this.instanceId = crypto.randomUUID();
console.log(`[ReloadSocket] Instance ID: ${this.instanceId}`);
}
/**
* Get the current instance ID
*/
getInstanceId(): string {
return this.instanceId;
}
/**
* Handle WebSocket upgrade request
*/
handleUpgrade(request: Request): Response {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onopen = () => {
this.clients.add(socket);
console.log(`[ReloadSocket] Client connected (${this.clients.size} total)`);
// Send instance ID immediately
socket.send(JSON.stringify({ type: 'instance', id: this.instanceId }));
};
socket.onclose = () => {
this.clients.delete(socket);
console.log(`[ReloadSocket] Client disconnected (${this.clients.size} remaining)`);
};
socket.onerror = (error) => {
console.error('[ReloadSocket] WebSocket error:', error);
};
return response;
}
/**
* Broadcast a message to all connected clients
*/
broadcast(message: object): void {
const msg = JSON.stringify(message);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
}
}
/**
* Get the number of connected clients
*/
getClientCount(): number {
return this.clients.size;
}
}

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ReloadService } from './core/services/reload.service';
@Component({
selector: 'app-root',
@@ -7,4 +8,7 @@ import { RouterOutlet } from '@angular/router';
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class AppComponent {}
export class AppComponent {
// Inject to trigger instantiation for hot reload
private reloadService = inject(ReloadService);
}

View File

@@ -3,11 +3,13 @@ import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { ReloadService } from './core/services/reload.service';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
ReloadService,
],
};

View File

@@ -0,0 +1,86 @@
import { Injectable, signal } from '@angular/core';
/**
* Service for automatic page reload when server restarts.
* Connects to WebSocket endpoint and monitors server instance ID.
* When server restarts with new ID, page automatically reloads.
*/
@Injectable({ providedIn: 'root' })
export class ReloadService {
private ws: WebSocket | null = null;
private instanceId = signal<string | null>(null);
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.connect();
}
private connect(): void {
// Clean up any existing connection
if (this.ws) {
this.ws.close();
this.ws = null;
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws/reload`;
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('[ReloadService] Connected to server');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'instance') {
const currentId = this.instanceId();
if (currentId !== null && currentId !== data.id) {
// Server restarted with new ID - reload page
console.log('[ReloadService] Server restarted, reloading...');
location.reload();
} else {
console.log(`[ReloadService] Instance ID: ${data.id}`);
}
this.instanceId.set(data.id);
}
} catch (e) {
console.error('[ReloadService] Failed to parse message:', e);
}
};
this.ws.onclose = () => {
console.log('[ReloadService] Connection closed');
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('[ReloadService] WebSocket error:', error);
this.ws?.close();
};
} catch (e) {
console.error('[ReloadService] Failed to create WebSocket:', e);
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
this.reconnectAttempts++;
console.log(`[ReloadService] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
} else {
console.log('[ReloadService] Max reconnect attempts reached');
}
}
}