alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
445 lines (376 loc) • 14.5 kB
text/typescript
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, relative, resolve } from "node:path";
import { $hook, $inject, type Alepha, AlephaError } from "alepha";
import { FileSystemProvider } from "alepha/system";
import type { InlineConfig, Logger, Plugin, ViteDevServer } from "vite";
import type { AppEntry } from "../providers/AppEntryProvider.ts";
// -----------------------------------------------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------------------------------------------
interface BufferedLogEntry {
level: "info" | "warn" | "error";
msg: string;
timestamp: Date;
}
export interface BufferedLogger extends Logger {
/**
* Flush all buffered log messages to console.
* Call this on build failure to show what happened.
*/
flush(): void;
/**
* Get all buffered log entries.
*/
getEntries(): BufferedLogEntry[];
/**
* Clear all buffered entries without printing.
*/
clear(): void;
}
/**
* Preload manifest mapping short keys to source paths.
* Generated at build time, consumed by SSRManifestProvider at runtime.
*/
export interface PreloadManifest {
[key: string]: string;
}
// -----------------------------------------------------------------------------------------------------------------
// ViteUtils
// -----------------------------------------------------------------------------------------------------------------
/**
* Vite integration utilities for the Alepha CLI.
*
* Centralizes all Vite-specific code: lazy loading, plugin creation,
* buffered logger, dev server management.
* When Vite is replaced, only this file needs to change.
*/
export class ViteUtils {
protected readonly fs = $inject(FileSystemProvider);
protected viteDevServer?: ViteDevServer;
// ---------------------------------------------------------------------------------------------------------------
// Vite loaders
// ---------------------------------------------------------------------------------------------------------------
/**
* Lazy-load Vite (with rolldown-vite fallback).
*/
public async importVite(): Promise<typeof import("vite")> {
try {
return createRequire(import.meta.url)("rolldown-vite");
} catch {
try {
return createRequire(import.meta.url)("vite");
} catch {
throw new AlephaError(
"Vite is not installed. Please install it with `npm install vite`.",
);
}
}
}
/**
* Lazy-load @vitejs/plugin-react (optional).
*/
public async importViteReact(): Promise<any> {
try {
const { default: viteReact } = createRequire(import.meta.url)(
"@vitejs/plugin-react",
);
return viteReact;
} catch {
// @vitejs/plugin-react not installed, skip
}
}
// ---------------------------------------------------------------------------------------------------------------
// Buffered logger
// ---------------------------------------------------------------------------------------------------------------
/**
* Create a Vite logger that buffers all messages instead of printing them.
* Useful for silent builds that only show output on failure.
*/
public createBufferedLogger(): BufferedLogger {
const entries: BufferedLogEntry[] = [];
const loggedErrors = new WeakSet<Error>();
const warnedMessages = new Set<string>();
let hasWarned = false;
const logger: BufferedLogger = {
get hasWarned() {
return hasWarned;
},
info(msg: string) {
entries.push({ level: "info", msg, timestamp: new Date() });
},
warn(msg: string) {
hasWarned = true;
entries.push({ level: "warn", msg, timestamp: new Date() });
},
warnOnce(msg: string) {
if (warnedMessages.has(msg)) {
return;
}
warnedMessages.add(msg);
hasWarned = true;
entries.push({ level: "warn", msg, timestamp: new Date() });
},
error(msg: string, options?: { error?: Error | null }) {
if (options?.error) {
loggedErrors.add(options.error);
}
entries.push({ level: "error", msg, timestamp: new Date() });
},
clearScreen() {
// No-op in buffered mode
},
hasErrorLogged(error: Error): boolean {
return loggedErrors.has(error);
},
flush() {
for (const entry of entries) {
const prefix =
entry.level === "error"
? "\x1b[31m✖\x1b[0m"
: entry.level === "warn"
? "\x1b[33m⚠\x1b[0m"
: "\x1b[36mℹ\x1b[0m";
console.log(`${prefix} ${entry.msg}`);
}
},
getEntries() {
return [...entries];
},
clear() {
entries.length = 0;
warnedMessages.clear();
hasWarned = false;
},
};
return logger;
}
// ---------------------------------------------------------------------------------------------------------------
// TSConfig paths plugin
// ---------------------------------------------------------------------------------------------------------------
/**
* Vite plugin that reads tsconfig.json `compilerOptions.paths` and converts
* them to Vite `resolve.alias` entries. Enables `@/*` → `src/*` style imports
* with zero config beyond tsconfig.json.
*/
public createTsconfigPathsPlugin(): Plugin {
return {
name: "alepha-tsconfig-paths",
async config(config) {
const root = config.root || process.cwd();
const tsconfigPath = join(root, "tsconfig.json");
let content: string;
try {
content = await readFile(tsconfigPath, "utf-8");
} catch {
return;
}
// Strip JSONC comments before parsing
const clean = content
.replace(/\/\/.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
let tsconfig: any;
try {
tsconfig = JSON.parse(clean);
} catch {
return;
}
const paths = tsconfig?.compilerOptions?.paths;
if (!paths || typeof paths !== "object") return;
const alias: Record<string, string> = {};
for (const [pattern, targets] of Object.entries(paths)) {
if (!Array.isArray(targets) || targets.length === 0) continue;
const target = targets[0] as string;
const aliasKey = pattern.replace(/\*$/, "");
const aliasPath = target.replace(/\*$/, "").replace(/^\.\//, "");
const resolved = resolve(root, aliasPath);
alias[aliasKey] = aliasKey.endsWith("/") ? `${resolved}/` : resolved;
}
if (Object.keys(alias).length === 0) return;
return { resolve: { alias } };
},
};
}
// ---------------------------------------------------------------------------------------------------------------
// SSR preload plugin
// ---------------------------------------------------------------------------------------------------------------
/**
* Vite plugin that generates a preload manifest for SSR module preloading.
*
* Collects lazy import paths from $page definitions during transform,
* generates a manifest mapping short keys to resolved source paths,
* and injects only the short key into $page definitions.
*/
public createSsrPreloadPlugin(): Plugin {
let root = "";
const preloadMap = new Map<string, string>();
function generateKey(sourcePath: string): string {
return createHash("md5").update(sourcePath).digest("hex").slice(0, 8);
}
return {
name: "alepha-preload",
configResolved(config) {
root = config.root;
},
transform(code, id) {
if (!id.match(/\.[tj]sx?$/)) return null;
if (id.includes("node_modules")) return null;
if (!code.includes("$page") || !code.includes("lazy")) return null;
const insertions: Array<{ position: number; text: string }> = [];
const pageStartRegex = /\$page\s*\(\s*\{/g;
let pageMatch: RegExpExecArray | null = pageStartRegex.exec(code);
while (pageMatch !== null) {
const objectStartIndex = pageMatch.index + pageMatch[0].length - 1;
let braceCount = 1;
let i = objectStartIndex + 1;
while (i < code.length && braceCount > 0) {
if (code[i] === "{") braceCount++;
else if (code[i] === "}") braceCount--;
i++;
}
if (braceCount !== 0) {
pageMatch = pageStartRegex.exec(code);
continue;
}
const objectEndIndex = i - 1;
const pageContent = code.slice(objectStartIndex, objectEndIndex + 1);
if (pageContent.includes("alepha.page.preload")) {
pageMatch = pageStartRegex.exec(code);
continue;
}
const lazyRegex =
/lazy\s*:\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)/;
const lazyMatch = lazyRegex.exec(pageContent);
if (!lazyMatch) {
pageMatch = pageStartRegex.exec(code);
continue;
}
const importPath = lazyMatch[1];
const currentDir = dirname(id);
let resolvedPath: string;
if (importPath.startsWith(".")) {
resolvedPath = resolve(currentDir, importPath);
} else if (importPath.startsWith("/")) {
resolvedPath = resolve(root, importPath.slice(1));
} else {
pageMatch = pageStartRegex.exec(code);
continue;
}
let relativePath = relative(root, resolvedPath);
relativePath = relativePath.replace(/\\/g, "/");
if (!relativePath.match(/\.[tj]sx?$/)) {
relativePath = `${relativePath}.tsx`;
} else if (relativePath.endsWith(".jsx")) {
relativePath = relativePath.replace(/\.jsx$/, ".tsx");
} else if (relativePath.endsWith(".js")) {
relativePath = relativePath.replace(/\.js$/, ".ts");
}
const key = generateKey(relativePath);
preloadMap.set(key, relativePath);
const beforeBrace = code.slice(0, objectEndIndex).trimEnd();
const needsComma = !beforeBrace.endsWith(",");
const preloadProperty = `${needsComma ? "," : ""} [Symbol.for("alepha.page.preload")]: "${key}"`;
insertions.push({ position: objectEndIndex, text: preloadProperty });
pageMatch = pageStartRegex.exec(code);
}
if (insertions.length === 0) return null;
let result = code;
for (let j = insertions.length - 1; j >= 0; j--) {
const { position, text } = insertions[j];
result = result.slice(0, position) + text + result.slice(position);
}
return { code: result, map: null };
},
writeBundle(options) {
const outDir = options.dir || "";
if (outDir.includes("server")) return;
if (preloadMap.size > 0) {
const viteDir = join(outDir, ".vite");
if (!existsSync(viteDir)) {
mkdirSync(viteDir, { recursive: true });
}
const manifest: PreloadManifest = Object.fromEntries(preloadMap);
const manifestPath = join(viteDir, "preload-manifest.json");
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}
},
};
}
// ---------------------------------------------------------------------------------------------------------------
// HTML template
// ---------------------------------------------------------------------------------------------------------------
public generateIndexHtml(entry: AppEntry, opts?: { pwa?: boolean }): string {
const style = entry.style;
const browser = entry.browser ?? entry.server;
const manifestLink = opts?.pwa
? '\n<link rel="manifest" href="/manifest.webmanifest" />'
: "";
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>App</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>${manifestLink}
${style ? `<link rel="stylesheet" href="/${style}" />` : ""}
</head>
<body>
<div id="root"></div>
<script type="module" src="/${browser}"></script>
</body>
</html>
`.trim();
}
// ---------------------------------------------------------------------------------------------------------------
// Dev server management
// ---------------------------------------------------------------------------------------------------------------
/**
* We need to close the Vite dev server after build is done.
*/
protected onReady = $hook({
on: "ready",
priority: "last",
handler: async () => {
await this.viteDevServer?.close();
},
});
protected onStop = $hook({
on: "stop",
handler: async () => {
await this.viteDevServer?.close();
},
});
public async runAlepha(opts: {
entry: AppEntry;
mode: "production" | "development";
}): Promise<Alepha> {
const { createServer } = await this.importVite();
process.env.NODE_ENV = opts.mode;
process.env.ALEPHA_CLI_IMPORT = "true"; // signal Alepha App about CLI import, run(alepha) won't start server
process.env.LOG_LEVEL ??= "warn"; // reduce log noise
process.env.APP_SECRET ??= "123456"; // avoid warning about missing secret, not used in CLI context
/**
* 01/26 Vite 7
* "runnerImport" doesn't work as expected here. (e.g. build docs fail)
* -> We still use devServer and ssrLoadModule for now.
* -> This is clearly a bad stuff, we need to find better way.
*/
this.viteDevServer = await createServer({
server: { middlewareMode: true },
appType: "custom",
logLevel: "silent",
plugins: [this.createTsconfigPathsPlugin()],
} satisfies InlineConfig);
await this.viteDevServer.ssrLoadModule(opts.entry.server);
delete process.env.ALEPHA_CLI_IMPORT;
const alepha: Alepha = (globalThis as any).__alepha;
if (!alepha) {
throw new AlephaError(
"Alepha instance not found after loading entry module",
);
}
return alepha;
}
}