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:
214
scripts/bundle-ui.ts
Normal file
214
scripts/bundle-ui.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user