alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
813 lines (708 loc) • 24.7 kB
text/typescript
import { join } from "node:path";
import { __alephaRef, $inject, type Alepha, AlephaError } from "alepha";
import { $logger, ConsoleColorProvider } from "alepha/logger";
import { FileSystemProvider } from "alepha/system";
import type { Plugin, ViteDevServer } from "vite";
import { ViteUtils } from "../services/ViteUtils.ts";
import type { AppEntry } from "./AppEntryProvider.ts";
export interface DevServerOptions {
/**
* Root directory of the project.
*/
root: string;
/**
* Path to the server entry file.
*/
entry: AppEntry;
/**
* Disable Vite React plugin.
*/
noViteReactPlugin?: boolean;
}
/**
* Hook called after Alepha is loaded during dev server init/reload.
*/
export type OnAlephaLoadedHook = (
alepha: Alepha,
server: ViteDevServer,
) => Promise<void>;
/**
* Vite development server with Alepha integration.
*
* Architecture:
* - Vite owns the HTTP server
* - Alepha handles requests via Vite plugin middleware
* - Request flow: Vite built-in (HMR, assets) → Alepha routes
*
* HMR Strategy:
* - Browser-only changes (CSS, client components) → Vite HMR (React Fast Refresh)
* - Server-only changes → Reload Alepha → Full browser reload
* - Shared changes → Reload Alepha → Let HMR propagate
*/
export class ViteDevServerProvider {
protected readonly log = $logger();
protected readonly fs = $inject(FileSystemProvider);
protected readonly colors = $inject(ConsoleColorProvider);
protected readonly viteUtils = $inject(ViteUtils);
protected server!: ViteDevServer;
protected options!: DevServerOptions;
public alepha: Alepha | null = null;
protected hasError = false;
protected currentError: Error | null = null;
protected changedFiles = new Set<string>();
protected waitingForRetry = false;
protected reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
protected isReloading = false;
protected needsBrowserReload = false;
protected currentReloadPromise: Promise<void> | null = null;
protected extraVitePlugins: Plugin[] = [];
protected alephaLoadedHooks: OnAlephaLoadedHook[] = [];
/**
* Register an additional Vite plugin.
* Must be called before init().
*/
public addVitePlugin(plugin: Plugin): void {
this.extraVitePlugins.push(plugin);
}
/**
* Register a hook called after Alepha is loaded/reloaded.
*/
public onAlephaLoaded(hook: OnAlephaLoadedHook): void {
this.alephaLoadedHooks.push(hook);
}
/**
* Trigger a full Alepha reload programmatically.
*/
public reload(): void {
this.hasError = true;
this.needsBrowserReload = true;
this.changedFiles.add("__manual_reload__");
this.scheduleReload();
}
/**
* Initialize the dev server and load Alepha.
*/
public async init(options: DevServerOptions): Promise<Alepha> {
this.options = options;
await this.createServer();
try {
return await this.loadAlepha(true);
} catch (err) {
this.hasError = true;
this.currentError = err instanceof Error ? err : new Error(String(err));
this.logError("Startup failed", err);
this.alepha = null;
return await this.waitForSuccessfulLoad();
}
}
/**
* Start the Alepha server and begin listening.
*/
public async start(): Promise<void> {
try {
await this.alepha?.start();
await this.listen();
const port = this.server.config.server.port ?? 5173;
const url = `http://localhost:${port}/`;
const log = this.alepha?.log ?? this.log;
log.info(`Listening on ${this.colors.set("CYAN", url)}`);
} catch (err) {
this.hasError = true;
this.currentError = err instanceof Error ? err : new Error(String(err));
this.logError("Startup failed", err);
this.alepha = null;
this.alepha = await this.waitForSuccessfulLoad();
await this.alepha.start();
await this.listen();
}
}
/**
* Check if project uses React.
*/
public hasReact(): boolean {
try {
this.alepha?.inject("ReactServerProvider");
return true;
} catch {
return false;
}
}
/**
* Create the Vite server with Alepha plugin.
*/
protected async createServer(): Promise<void> {
const { createServer, resolveConfig } = await this.viteUtils.importVite();
const viteReact = await this.viteUtils.importViteReact();
const plugins: Plugin[] = [];
if (viteReact && !this.options.noViteReactPlugin) plugins.push(viteReact());
plugins.push(this.viteUtils.createTsconfigPathsPlugin());
plugins.push(this.viteUtils.createSsrPreloadPlugin());
plugins.push(...this.extraVitePlugins);
plugins.push(this.createAlephaPlugin());
// DEFAULT PORT
// Dev: 5173
// Prod: 3000
let port: number;
if (process.env.SERVER_PORT) {
port = Number(process.env.SERVER_PORT);
} else {
const config = await resolveConfig({}, "serve", "development");
port = config.server?.port ? Number(config.server.port) : 5173;
}
this.server = await createServer({
root: this.options.root,
plugins,
appType: "custom",
resolve: {
dedupe: [
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
],
},
server: {
port,
},
optimizeDeps: {
entries: [
...(this.options.entry.browser ? [this.options.entry.browser] : []),
],
},
});
this.patchServerRestartForEnvReload();
}
/**
* Intercept Vite's server.restart() to handle .env file changes.
* Vite calls restart() when .env files change.
*/
protected patchServerRestartForEnvReload(): void {
this.server.restart = async () => {
if (this.waitingForRetry || this.isReloading) return;
this.isReloading = true;
console.log();
console.log(this.colors.set("CYAN", " ⟳ Reloading ..."));
console.log();
try {
this.hasError = true; // Force full invalidation for env changes
await this.loadAlepha(false);
await this.alepha?.start();
this.currentError = null;
this.sendBrowserReload();
} catch (err) {
this.hasError = true;
this.currentError = err instanceof Error ? err : new Error(String(err));
this.logError("Reload failed", err);
this.alepha = null;
this.sendErrorOverlay(this.currentError);
} finally {
this.isReloading = false;
}
};
}
/**
* Start listening for connections.
*/
protected async listen(): Promise<void> {
await this.server.listen();
}
/**
* Vite plugin that integrates Alepha.
*/
protected createAlephaPlugin(): Plugin {
return {
name: "alepha",
configureServer: (server) => {
// Re-send error overlay when a new browser connects (e.g. page refresh during error state)
server.hot.on("connection", () => {
if (this.currentError) {
setTimeout(() => this.sendErrorOverlay(this.currentError!), 50);
}
});
// Readiness endpoint: responds only when Alepha is fully loaded
server.middlewares.use(async (req, res, next) => {
if (req.url !== "/__alepha/ready") {
next();
return;
}
if (this.currentReloadPromise) {
await this.currentReloadPromise;
}
if (this.alepha?.isReady()) {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
} else {
res.writeHead(503, { "content-type": "text/plain" });
res.end("not ready");
}
});
// Return function to run AFTER Vite's built-in middleware
return () => {
server.middlewares.use(async (req, res, next) => {
// Skip Vite internal routes
const url = req.url || "/";
if (url.startsWith("/@") || url.startsWith("/__vite")) {
next();
return;
}
// Wait for in-progress reload to complete before serving
if (this.currentReloadPromise) {
await this.currentReloadPromise;
}
// In error state, serve a minimal HTML shell so the browser
// can connect to Vite's HMR and display the error overlay
if (this.hasError && !this.alepha) {
if (req.headers.accept?.includes("text/html")) {
res.writeHead(200, { "content-type": "text/html" });
res.end(
'<!DOCTYPE html><html><head><script type="module" src="/@vite/client"></script></head><body></body></html>',
);
return;
}
next();
return;
}
// Emit to Alepha's request handler
try {
await this.alepha?.events.emit("node:request", { req, res });
} catch (err) {
this.log.error("Request handler error", err);
if (!res.headersSent) {
res.writeHead(500, { "content-type": "text/plain" });
res.end("Internal Server Error");
}
return;
}
// If Alepha didn't handle it, pass to next (404 handled by Vite)
if (!res.headersSent && !res.writableEnded) {
next();
}
});
};
},
handleHotUpdate: async (ctx) => {
// Ignore IDE files
if (/[/\\]\.idea[/\\]/.test(ctx.file)) return [];
// Skip when waiting for startup retry
if (this.waitingForRetry) return [];
// React component (.tsx/.jsx) edits always need the SSR module
// graph invalidated — the `_ssrModule` shortcut below misses files
// that are workspace-linked source-only (e.g. `@alepha/ui/...`).
// Those don't carry an `_ssrModule` ref on `ctx.modules[0]` on
// first edit and get classified as browser-only, leaving stale
// SSR HTML and triggering hydration mismatches on next request.
const isTsx = /\.(tsx|jsx)$/.test(ctx.file);
if (!isTsx) {
const firstModule = ctx.modules[0] as
| { _ssrModule?: unknown; _clientModule?: unknown }
| undefined;
const isBrowserOnly = firstModule && !firstModule._ssrModule;
// Browser-only: let Vite HMR handle it (React Fast Refresh)
if (isBrowserOnly) return;
}
// Queue Alepha reload for server-side invalidation
this.changedFiles.add(ctx.file);
// React components (.tsx/.jsx): reload Alepha BEFORE letting
// Vite HMR reach the browser, to prevent 503 errors from
// components that fetch data on mount during server reload.
if (/\.(tsx|jsx)$/.test(ctx.file)) {
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
this.reloadDebounceTimer = null;
}
await this.performReload();
return;
}
// Pure server files: need full browser reload after Alepha restart
this.needsBrowserReload = true;
this.scheduleReload();
return [];
},
};
}
/**
* Send full browser reload via Vite's HMR.
* Uses a custom event so the client can poll for readiness before reloading.
*/
protected sendBrowserReload(): void {
this.server.hot.send("alepha:reload", {});
}
/**
* Send error to Vite's native error overlay.
*/
protected sendErrorOverlay(err: Error): void {
this.fixStacktrace(err);
this.server.hot.send({
type: "error",
err: {
message: err.message,
stack: err.stack ?? "",
plugin: "alepha",
id: this.options.entry.server,
},
});
}
/**
* Schedule a debounced reload.
* Batches rapid file changes into a single reload operation.
*/
protected scheduleReload(): void {
// Clear any pending reload
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
}
// If already reloading, the pending changes will be picked up
// when the current reload checks changedFiles
if (this.isReloading) {
return;
}
this.reloadDebounceTimer = setTimeout(() => {
this.reloadDebounceTimer = null;
this.performReload();
}, 100);
}
/**
* Perform the actual reload.
* Returns a promise that callers can await to know when reload is done.
* If a reload is already in progress, returns that promise.
*/
protected performReload(): Promise<void> {
if (this.changedFiles.size === 0) {
return this.currentReloadPromise ?? Promise.resolve();
}
if (this.isReloading) {
return this.currentReloadPromise ?? Promise.resolve();
}
this.currentReloadPromise = this.executeReload();
return this.currentReloadPromise;
}
/**
* Execute the reload work.
*/
protected async executeReload(): Promise<void> {
this.isReloading = true;
// Snapshot files to process and clear immediately
// New files arriving during reload will go to fresh set
const filesToInvalidate = new Set(this.changedFiles);
const sendReload = this.needsBrowserReload;
const wasInError = this.hasError;
this.changedFiles.clear();
this.needsBrowserReload = false;
console.log();
console.log(this.colors.set("CYAN", " ⟳ Reloading..."));
console.log();
try {
await this.loadAlepha(false, filesToInvalidate);
await this.alepha?.start();
this.currentError = null;
if (sendReload || wasInError) {
this.sendBrowserReload();
}
} catch (err) {
this.hasError = true;
this.currentError = err instanceof Error ? err : new Error(String(err));
this.logError("Reload failed", err);
this.alepha = null;
this.sendErrorOverlay(this.currentError);
} finally {
this.isReloading = false;
this.currentReloadPromise = null;
// If more files changed during reload, schedule another
if (this.changedFiles.size > 0) {
this.scheduleReload();
}
}
}
/**
* Load or reload the Alepha instance.
*/
protected async loadAlepha(
isInitialLoad = false,
filesToInvalidate?: Set<string>,
): Promise<Alepha> {
await this.destroyAlepha();
this.clearAlephaRefs();
if (isInitialLoad || this.hasError) {
this.server.moduleGraph.invalidateAll();
} else {
this.invalidateModulesWithImporters(filesToInvalidate ?? new Set());
}
// Snapshot and restore process.env to isolate each reload
const envSnapshot = { ...process.env };
await this.setupEnvironment();
try {
await this.server.ssrLoadModule(this.options.entry.server, {
fixStacktrace: true,
});
} catch (err) {
this.hasError = true;
process.env = envSnapshot;
throw err;
}
const alepha = this.getLoadedAlepha();
// Expose Vite server to Alepha for Logger SSR stack trace fixing
alepha.store.set("alepha.vite.server" as any, this.server);
for (const hook of this.alephaLoadedHooks) {
await hook(alepha, this.server);
}
this.alepha = alepha;
await this.setupAlepha();
this.hasError = false;
process.env = envSnapshot;
return alepha;
}
/**
* Setup Alepha instance with dev-specific configuration.
*/
protected async setupAlepha(): Promise<void> {
if (!this.alepha || !this.hasReact()) {
return;
}
const devHead = await this.generateDevHead();
const favicon = await this.detectFavicon(join(this.options.root, "public"));
this.alepha.store.set("alepha.react.ssr.manifest", { devHead, favicon });
}
/**
* Detect a favicon file in the given directory.
* Returns "mimeType:/path" if found, undefined otherwise.
*/
protected async detectFavicon(
publicDir: string,
): Promise<string | undefined> {
const candidates: [string, string][] = [
["favicon.svg", "image/svg+xml"],
["favicon.png", "image/png"],
["favicon.ico", "image/x-icon"],
];
for (const [file, mime] of candidates) {
if (await this.fs.exists(join(publicDir, file))) {
return `${mime}:/${file}`;
}
}
return undefined;
}
/**
* Generate dev head content for SSR.
*
* IMPORTANT: We call transformIndexHtml() on an EMPTY <head> (no script/link
* tags) to collect plugin-injected preambles — specifically the React Fast
* Refresh runtime from @vitejs/plugin-react. We then manually append our
* own browser entry and stylesheet tags.
*
* Why not include <script>/<link> tags in the HTML passed to transformIndexHtml?
* Because Vite would pre-transform the referenced browser entry module, which
* walks the entire client module graph and triggers dep discovery. This creates
* a race condition with Vite's background dep scanner: both find the same deps,
* but the scanner commits a second optimization pass that replaces the first,
* causing "504 Outdated Optimize Dep" errors on cold start.
*
* By passing empty HTML, we get the preamble without triggering the module
* graph walk, leaving the scanner as the single dep discovery mechanism.
*/
protected async generateDevHead(): Promise<string> {
const { browser, style } = this.options.entry;
// Get plugin preambles (React Fast Refresh, etc.) without triggering
// client module graph walk — see JSDoc above for why this matters.
const emptyHtml = "<!DOCTYPE html><html><head></head><body></body></html>";
const transformed = await this.server.transformIndexHtml("/", emptyHtml);
const headMatch = transformed.match(/<head>([\s\S]*?)<\/head>/i);
const preamble = headMatch?.[1]?.trim() ?? "";
const tags: string[] = [];
if (preamble) {
tags.push(preamble);
}
// Reload handler: polls /__alepha/ready before reloading to avoid
// hitting the server while it's still restarting.
tags.push(`<script type="module">
if (import.meta.hot) {
import.meta.hot.on("alepha:reload", async () => {
for (let i = 0; i < 50; i++) {
try {
const res = await fetch("/__alepha/ready");
if (res.ok) { window.location.reload(); return; }
} catch {}
await new Promise(r => setTimeout(r, 200));
}
window.location.reload();
});
}
</script>`);
if (style) {
tags.push(`<script type="module">import "/${style}";</script>`);
}
if (browser) {
tags.push(`<script type="module" src="/${browser}"></script>`);
}
return tags.join("\n");
}
/**
* Setup environment variables for dev mode.
*/
protected async setupEnvironment(): Promise<void> {
const { loadEnv } = await this.viteUtils.importVite();
process.env.VITE_ALEPHA_DEV = "true";
process.env.NODE_ENV ??= "development";
const mode = process.env.NODE_ENV;
const env = loadEnv(mode, this.options.root, "");
// Merge into process.env (only set if not already defined)
for (const [key, value] of Object.entries(env)) {
process.env[key] ??= value;
}
const port = this.server.config.server.port ?? 3000;
process.env.SERVER_PORT ??= `${port}`;
}
/**
* Invalidate modules and all their importers, across both client and
* SSR module graphs.
*
* Vite registers a file under multiple module ids (one per query
* variant — e.g. `?v=…`, `?import` and the bare path), and `getModuleById`
* only matches one. For workspace-linked source files (`@alepha/ui/...`)
* the SSR-graph entry is keyed by the absolute path while the client
* may use the package-qualified id. `getModulesByFile` returns every
* variant Vite knows about for a given absolute path — invalidating
* each catches both halves of the dual graph and prevents stale SSR
* compiled exports from surviving across edits.
*/
protected invalidateModulesWithImporters(changedFiles: Set<string>): void {
const graph = this.server.moduleGraph;
const invalidated = new Set<string>();
const queue: string[] = [...changedFiles];
while (queue.length > 0) {
const file = queue.pop()!;
if (invalidated.has(file)) continue;
invalidated.add(file);
const mods = new Set([
...(graph.getModulesByFile(file) ?? []),
...(graph.getModuleById(file) ? [graph.getModuleById(file)!] : []),
]);
if (mods.size === 0) continue;
for (const mod of mods) {
graph.invalidateModule(mod);
for (const importer of mod.importers) {
const key = importer.file ?? importer.id;
if (key && !invalidated.has(key)) {
queue.push(key);
}
}
}
}
// Always invalidate entry module
const entryPath = this.options.entry.server;
const absoluteEntryPath = join(this.options.root, entryPath);
const entryMod =
graph.getModuleById(absoluteEntryPath) ??
graph.getModuleById(entryPath) ??
graph.getModuleById(`/${entryPath}`);
if (entryMod) {
graph.invalidateModule(entryMod);
}
}
/**
* Wait for file changes and retry loading until successful.
*/
protected waitForSuccessfulLoad(): Promise<Alepha> {
this.waitingForRetry = true;
return new Promise((resolve) => {
const onFileChange = async (file: string) => {
if (/[/\\]\.idea[/\\]/.test(file)) return;
console.log();
console.log(this.colors.set("CYAN", " ⟳ Retrying..."));
const filesToInvalidate = new Set([file]);
try {
const alepha = await this.loadAlepha(false, filesToInvalidate);
this.waitingForRetry = false;
this.currentError = null;
this.server.watcher.off("change", onFileChange);
this.server.watcher.off("add", onFileChange);
this.sendBrowserReload();
resolve(alepha);
} catch (err) {
this.hasError = true;
this.currentError =
err instanceof Error ? err : new Error(String(err));
this.logError("Startup failed", err);
this.alepha = null;
this.sendErrorOverlay(this.currentError);
}
};
this.server.watcher.on("change", onFileChange);
this.server.watcher.on("add", onFileChange);
});
}
/**
* Clear global Alepha references before reload.
*/
protected clearAlephaRefs(): void {
__alephaRef.alepha = undefined;
__alephaRef.service = undefined;
__alephaRef.parent = undefined;
(globalThis as any).__alepha = undefined;
}
/**
* Destroy the current Alepha instance.
*/
protected async destroyAlepha(): Promise<void> {
if (this.alepha) {
await this.alepha
.destroy()
.catch((err) => this.log.warn("Error destroying Alepha", err));
this.alepha = null;
}
}
/**
* Get the loaded Alepha instance from globalThis.
*/
protected getLoadedAlepha(): Alepha {
const alepha: Alepha = (globalThis as any).__alepha;
if (!alepha) {
throw new AlephaError(
"Alepha instance not found after loading entry module",
);
}
return alepha;
}
/**
* Fix stack trace using Vite's SSR stack trace fixer.
*/
protected fixStacktrace(error: Error): void {
this.server.ssrFixStacktrace(error);
}
/**
* Log a formatted error with stack trace.
*/
protected logError(title: string, err: unknown): void {
const c = this.colors;
console.log();
console.log(c.set("RED", ` ✗ ${title}`));
this.logErrorWithCause(err);
console.log();
console.log(c.set("GREY_DARK", " Waiting for file changes to retry..."));
console.log();
}
/**
* Log error message and stack, recursively logging cause if present.
*/
protected logErrorWithCause(err: unknown, depth = 0): void {
const error = err instanceof Error ? err : new Error(String(err));
const indent = ` ${" ".repeat(depth)}`;
this.fixStacktrace(error);
const name = error.name || "Error";
const message = error.message || "Unknown error";
const stackLines = error.stack?.split("\n").slice(1);
console.log();
if (depth > 0) {
console.log(this.colors.set("GREY_DARK", `${indent}Caused by:`));
}
console.log(this.colors.set("WHITE_BOLD", `${indent + name}: ${message}`));
if (stackLines?.length) {
console.log();
for (const line of stackLines) {
console.log(`${indent}${this.colors.set("GREY_DARK", line)}`);
}
}
if (error.cause) {
this.logErrorWithCause(error.cause, depth + 1);
}
}
}