@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
316 lines (281 loc) • 10.2 kB
JavaScript
/* eslint-disable no-console */
import { run } from "@typespec/internal-build-utils";
import pkg from "fs-extra";
import { copyFile, mkdir, rm } from "fs/promises";
import { globby } from "globby";
import inquirer from "inquirer";
import ora from "ora";
import pLimit from "p-limit";
import { basename, dirname, join, resolve } from "path";
import pc from "picocolors";
import { fileURLToPath } from "url";
import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";
const { pathExists, stat, readFile, writeFile } = pkg;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, "../..");
const tspConfig = join(__dirname, "tspconfig.yaml");
const basePath = join(projectRoot, "node_modules", "@typespec", "http-specs", "specs");
const ignoreFilePath = join(projectRoot, ".testignore");
const logDirRoot = join(projectRoot, "temp", "emit-e2e-logs");
const reportFilePath = join(logDirRoot, "report.txt");
// Remove the log directory if it exists.
async function clearLogDirectory() {
if (await pathExists(logDirRoot)) {
await rm(logDirRoot, { recursive: true, force: true });
}
}
// Parse command-line arguments.
const argv = yargs(hideBin(process.argv))
.option("interactive", {
type: "boolean",
describe: "Enable interactive mode",
default: false,
})
.positional("paths", {
describe: "Optional list of specific file or directory paths to process (relative to basePath)",
type: "string",
array: true,
default: [],
})
.option("build", {
type: "boolean",
describe: "Build the generated projects",
default: false,
})
.help().argv;
// Read and parse the ignore file.
async function getIgnoreList() {
try {
const content = await readFile(ignoreFilePath, "utf8");
return content
.split(/\r?\n/)
.filter((line) => line.trim() && !line.startsWith("#"))
.map((line) => line.trim());
} catch {
console.warn(pc.yellow("No ignore file found."));
return [];
}
}
// Recursively process paths (files or directories relative to basePath).
async function processPaths(paths, ignoreList) {
const results = [];
for (const relativePath of paths) {
const fullPath = resolve(basePath, relativePath);
if (!(await pathExists(fullPath))) {
console.warn(pc.yellow(`Path not found: ${relativePath}`));
continue;
}
const stats = await stat(fullPath);
if (stats.isFile() && fullPath.endsWith("main.tsp")) {
if (ignoreList.some((ignore) => relativePath.startsWith(ignore))) continue;
results.push({ fullPath, relativePath });
} else if (stats.isDirectory()) {
const patterns = ["**/main.tsp"];
const discoveredPaths = await globby(patterns, { cwd: fullPath });
const validFiles = discoveredPaths
.map((p) => ({
fullPath: join(fullPath, p),
relativePath: join(relativePath, p),
}))
.filter((file) => !ignoreList.some((ignore) => file.relativePath.startsWith(ignore)));
results.push(...validFiles);
} else {
console.warn(pc.yellow(`Skipping unsupported path: ${relativePath}`));
}
}
// Deduplicate.
const filesByDir = new Map();
for (const file of results) {
const dir = dirname(file.relativePath);
const existing = filesByDir.get(dir);
if (!existing) {
filesByDir.set(dir, file);
}
}
return Array.from(filesByDir.values());
}
// Run a shell command silently.
async function runCommand(command, args, options = {}) {
// Remove clutter by not printing anything; capture output by setting stdio to 'pipe'.
return await run(command, args, {
stdio: "pipe",
env: { NODE_ENV: "test", ...process.env },
silent: true,
...options,
});
}
// Process a single file.
async function processFile(file, options) {
const { fullPath, relativePath } = file;
const { build, interactive } = options;
const outputDir = join("test", "e2e", "generated", dirname(relativePath));
const specCopyPath = join(outputDir, "spec.tsp");
const logDir = join(projectRoot, "temp", "emit-e2e-logs", dirname(relativePath));
let spinner;
if (interactive) {
spinner = ora({ text: `Processing: ${relativePath}`, color: "cyan" }).start();
}
try {
if (await pathExists(outputDir)) {
if (spinner) spinner.text = `Clearing directory: ${outputDir}`;
await rm(outputDir, { recursive: true, force: true });
}
if (spinner) spinner.text = `Creating directory: ${outputDir}`;
await mkdir(outputDir, { recursive: true });
if (spinner) spinner.text = `Copying spec to: ${specCopyPath}`;
await copyFile(fullPath, specCopyPath);
if (spinner) spinner.text = `Compiling: ${relativePath}`;
await runCommand("npx", [
"tsp",
"compile",
fullPath,
"--emit",
resolve(import.meta.dirname, "../.."),
"--config",
tspConfig,
"--output-dir",
outputDir,
]);
if (spinner) spinner.text = `Formatting with Prettier: ${relativePath}`;
await runCommand("npx", ["prettier", outputDir, "--write"]);
if (build) {
if (spinner) spinner.text = `Building project: ${relativePath}`;
await runCommand("npm", ["run", "build"], { cwd: outputDir });
}
if (spinner) {
spinner.succeed(`Finished processing: ${relativePath}`);
}
return { status: "succeeded", relativePath };
} catch (error) {
if (spinner) {
spinner.fail(`Failed processing: ${relativePath}`);
}
const errorDetails = error.stdout || error.stderr || error.message;
// Write error details to a log file.
await mkdir(logDir, { recursive: true });
const logFilePath = join(logDir, `${basename(relativePath, ".tsp")}-error.log`);
await writeFile(logFilePath, errorDetails, "utf8");
if (interactive) {
const { action } = await inquirer.prompt([
{
type: "list",
name: "action",
message: `Processing failed for ${relativePath}. What would you like to do?`,
choices: [
{ name: "Retry", value: "retry" },
{ name: "Skip to next file", value: "next" },
{ name: "Abort processing", value: "abort" },
],
},
]);
if (action === "retry") {
if (spinner) spinner.start(`Retrying: ${relativePath}`);
return await processFile(file, options);
} else if (action === "next") {
console.log(pc.yellow(`Skipping: ${relativePath}`));
} else if (action === "abort") {
console.log(pc.red("Aborting processing."));
throw new Error("Processing aborted by user");
}
}
return { status: "failed", relativePath, errorDetails };
}
}
// Process all files.
async function processFiles(files, options) {
const { interactive } = options;
const succeeded = [];
const failed = [];
if (interactive) {
// Sequential processing so each spinner is visible.
for (const file of files) {
try {
const result = await processFile(file, options);
if (result.status === "succeeded") {
succeeded.push(result.relativePath);
} else {
failed.push({ relativePath: result.relativePath, errorDetails: result.errorDetails });
}
} catch (err) {
break;
}
}
} else {
// Global progress spinner.
const total = files.length;
let completed = 0;
const globalSpinner = ora({ text: `Processing 0/${total} files...`, color: "cyan" }).start();
const limit = pLimit(4);
const tasks = files.map((file) =>
limit(() =>
processFile(file, options).then((result) => {
completed++;
globalSpinner.text = `Processing ${completed}/${total} files...`;
return result;
}),
),
);
const results = await Promise.all(tasks);
globalSpinner.succeed(`Processed ${total} files`);
for (const result of results) {
if (result.status === "succeeded") {
succeeded.push(result.relativePath);
} else {
failed.push({ relativePath: result.relativePath, errorDetails: result.errorDetails });
}
}
}
console.log(pc.bold(pc.green("\nProcessing Complete:")));
console.log(pc.green(`Succeeded: ${succeeded.length}`));
console.log(pc.red(`Failed: ${failed.length}`));
if (failed.length > 0) {
console.log(pc.red("\nFailed Specs:"));
failed.forEach((f) => {
console.log(pc.red(` - ${f.relativePath}`));
});
console.log(pc.blue(`\nLogs available at: ${logDirRoot}`));
}
// Ensure the log directory exists before writing the report.
await mkdir(logDirRoot, { recursive: true });
const report = [
"Succeeded Files:",
...succeeded.map((f) => ` - ${f}`),
"Failed Files:",
...failed.map((f) => ` - ${f.relativePath}\n Error: ${f.errorDetails}`),
].join("\n");
await writeFile(reportFilePath, report, "utf8");
console.log(pc.blue(`Report written to: ${reportFilePath}`));
}
// Main execution function
async function main() {
const startTime = process.hrtime.bigint(); // ✅ High precision time tracking
let exitCode = 0; // ✅ Track success/failure
try {
await clearLogDirectory(); // ✅ Clear logs at the start
const ignoreList = await getIgnoreList();
const paths = argv._.length
? await processPaths(argv._, ignoreList)
: await processPaths(["."], ignoreList);
if (paths.length === 0) {
console.log(pc.yellow("⚠️ No files to process."));
return;
}
await processFiles(paths, {
interactive: argv.interactive,
build: argv.build,
});
} catch (error) {
console.error(pc.red(`❌ Fatal Error: ${error.message}`));
exitCode = 1; // ✅ Ensure graceful failure handling
} finally {
// ✅ Always log execution time before exit
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1e9; // Convert nanoseconds to seconds
console.log(pc.blue(`⏱️ Total execution time: ${duration.toFixed(2)} seconds`));
process.exit(exitCode); // ✅ Ensures proper exit handling
}
}
await main();