perf(core): Lazy-load handlebars, fast-xml-builder, and @clack/prompts to reduce startup cost

Defer importing three expensive modules from CLI startup to actual usage:
- handlebars (~25ms): loaded in outputGenerate.ts on first template compile
- fast-xml-builder (~3ms): loaded in generateParsableXmlOutput on demand
- @clack/prompts (~16ms): loaded in migrationAction/skillPrompts when needed
- packSkill module chain: lazy-loaded via dynamic import in packager.ts

These modules were previously loaded eagerly in the import chain
(defaultAction → packager → outputGenerate → handlebars) even though
they're only needed late in the pipeline during output generation.

Benchmark results (NODE_DISABLE_COMPILE_CACHE=1, 10 runs each):
- Baseline median: 947ms
- After median: 873ms
- Improvement: 74ms (7.8%)

The improvement is most significant on first run, CI/CD, Docker containers,
and Node.js 20 (which lacks compile cache). With warm compile cache the
savings are ~32ms in the import chain, with some modules (packSkill,
@clack/prompts, fast-xml-builder) completely avoided on default runs.

Import chain measurement: 139ms → 107ms (32ms faster startup)
Output is byte-for-byte identical — no functional changes.

https://claude.ai/code/session_0156PKHfb5NcTVQxfZBW6nAn
This commit is contained in:
Claude
2026-04-09 04:58:32 +00:00
parent b6ccc3fc43
commit 082f8b4a31
9 changed files with 84 additions and 41 deletions
+9 -5
View File
@@ -1,6 +1,5 @@
import * as fs from 'node:fs/promises';
import path from 'node:path';
import * as prompts from '@clack/prompts';
import pc from 'picocolors';
import { getGlobalDirectory } from '../../config/globalDirectory.js';
import { logger } from '../../shared/logger.js';
@@ -108,11 +107,12 @@ const migrateFile = async (
const exists = await fileExists(newPath);
if (exists) {
const shouldOverwrite = await prompts.confirm({
const p = await import('@clack/prompts');
const shouldOverwrite = await p.confirm({
message: `${description} already exists at ${newPath}. Do you want to overwrite it?`,
});
if (prompts.isCancel(shouldOverwrite) || !shouldOverwrite) {
if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
logger.info(`Skipping migration of ${description}`);
return false;
}
@@ -244,12 +244,16 @@ export const runMigrationAction = async (rootDir: string): Promise<MigrationResu
if (hasOldGlobalConfig) items.push('global configuration');
migrationMessage += `${items.join(' and ')}. Would you like to migrate to ${pc.green('Repomix')}?`;
// Lazy-load @clack/prompts (~16ms) — only needed when old Repopack files
// are detected, which is rare after initial migration.
const p = await import('@clack/prompts');
// Confirm migration with user
const shouldMigrate = await prompts.confirm({
const shouldMigrate = await p.confirm({
message: migrationMessage,
});
if (prompts.isCancel(shouldMigrate) || !shouldMigrate) {
if (p.isCancel(shouldMigrate) || !shouldMigrate) {
logger.info('Migration cancelled.');
return result;
}
+27 -18
View File
@@ -1,7 +1,6 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import * as prompts from '@clack/prompts';
import pc from 'picocolors';
import { OperationCancelledError, RepomixError } from '../../shared/errorHandle.js';
import { getDisplayPath } from '../cliReport.js';
@@ -13,8 +12,8 @@ export interface SkillPromptResult {
skillDir: string;
}
const onCancelOperation = (): never => {
prompts.cancel('Skill generation cancelled.');
const onCancelOperation = (cancelFn: (message?: string) => void): never => {
cancelFn('Skill generation cancelled.');
throw new OperationCancelledError('Skill generation cancelled');
};
@@ -31,19 +30,29 @@ export const getSkillBaseDir = (cwd: string, location: SkillLocation): string =>
/**
* Prompt user for skill location and handle overwrite confirmation.
*/
// Lazy-load @clack/prompts (~16ms) to build default deps.
// Only called in production; tests always pass deps directly.
const createPromptDeps = async () => {
const p = await import('@clack/prompts');
return {
select: p.select,
confirm: p.confirm,
isCancel: p.isCancel,
cancel: p.cancel,
access: fs.access,
rm: fs.rm,
};
};
export const promptSkillLocation = async (
skillName: string,
cwd: string,
deps = {
select: prompts.select,
confirm: prompts.confirm,
isCancel: prompts.isCancel,
access: fs.access,
rm: fs.rm,
},
deps = {} as Awaited<ReturnType<typeof createPromptDeps>>,
): Promise<SkillPromptResult> => {
// Resolve deps: use provided test deps or lazy-load defaults
const resolvedDeps = Object.keys(deps).length > 0 ? deps : await createPromptDeps();
// Step 1: Ask for skill location
const location = await deps.select({
const location = await resolvedDeps.select({
message: 'Where would you like to save the skill?',
options: [
{
@@ -60,8 +69,8 @@ export const promptSkillLocation = async (
initialValue: 'personal' as SkillLocation,
});
if (deps.isCancel(location)) {
onCancelOperation();
if (resolvedDeps.isCancel(location)) {
onCancelOperation(resolvedDeps.cancel);
}
const skillDir = path.join(getSkillBaseDir(cwd, location as SkillLocation), skillName);
@@ -69,7 +78,7 @@ export const promptSkillLocation = async (
// Step 2: Check if directory exists and ask for overwrite
let dirExists = false;
try {
await deps.access(skillDir);
await resolvedDeps.access(skillDir);
dirExists = true;
} catch {
// Directory doesn't exist
@@ -77,16 +86,16 @@ export const promptSkillLocation = async (
if (dirExists) {
const displayPath = getDisplayPath(skillDir, cwd);
const overwrite = await deps.confirm({
const overwrite = await resolvedDeps.confirm({
message: `Skill directory already exists. Do you want to overwrite it?\n${pc.dim(`path: ${displayPath}`)}`,
});
if (deps.isCancel(overwrite) || !overwrite) {
onCancelOperation();
if (resolvedDeps.isCancel(overwrite) || !overwrite) {
onCancelOperation(resolvedDeps.cancel);
}
// Remove existing directory before regeneration
await deps.rm(skillDir, { recursive: true, force: true });
await resolvedDeps.rm(skillDir, { recursive: true, force: true });
}
return {
+24 -6
View File
@@ -1,7 +1,5 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import XMLBuilder from 'fast-xml-builder';
import Handlebars from 'handlebars';
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { RepomixError } from '../../shared/errorHandle.js';
import { listDirectories, listFiles, searchFiles } from '../file/fileSearch.js';
@@ -22,11 +20,28 @@ import {
import { getMarkdownTemplate } from './outputStyles/markdownStyle.js';
import { getPlainTemplate } from './outputStyles/plainStyle.js';
import { getXmlTemplate } from './outputStyles/xmlStyle.js';
import { registerHandlebarsHelpers } from './outputStyleUtils.js';
// Lazy-load Handlebars to defer its ~25ms import cost until output generation.
// The module is loaded eagerly in the import chain (packager → produceOutput →
// outputGenerate) but only needed late in the pipeline during template rendering.
// biome-ignore lint/suspicious/noExplicitAny: Handlebars type is complex and only used internally
let handlebarsPromise: Promise<any> | undefined;
const getHandlebars = () => {
if (!handlebarsPromise) {
handlebarsPromise = import('handlebars').then((mod) => {
registerHandlebarsHelpers(mod.default);
return mod.default;
});
}
return handlebarsPromise;
};
// Cache for compiled Handlebars templates to avoid recompilation on every call
const compiledTemplateCache = new Map<string, Handlebars.TemplateDelegate>();
// biome-ignore lint/suspicious/noExplicitAny: Handlebars TemplateDelegate type requires the full module
const compiledTemplateCache = new Map<string, any>();
const getCompiledTemplate = (style: string): Handlebars.TemplateDelegate => {
const getCompiledTemplate = async (style: string) => {
const cached = compiledTemplateCache.get(style);
if (cached) {
return cached;
@@ -47,6 +62,7 @@ const getCompiledTemplate = (style: string): Handlebars.TemplateDelegate => {
throw new RepomixError(`Unsupported output style for handlebars template: ${style}`);
}
const Handlebars = await getHandlebars();
const compiled = Handlebars.compile(template);
compiledTemplateCache.set(style, compiled);
return compiled;
@@ -123,7 +139,9 @@ export const createRenderContext = (outputGeneratorContext: OutputGeneratorConte
};
const generateParsableXmlOutput = async (renderContext: RenderContext): Promise<string> => {
const xmlBuilder = new XMLBuilder({ ignoreAttributes: false });
// Lazy-load fast-xml-builder (~3ms) — only used for parsable XML output (non-default)
const FastXmlBuilder = (await import('fast-xml-builder')).default;
const xmlBuilder = new FastXmlBuilder({ ignoreAttributes: false });
const xmlDocument = {
repomix: {
file_summary: renderContext.fileSummaryEnabled
@@ -237,7 +255,7 @@ const generateHandlebarOutput = async (
processedFiles?: ProcessedFile[],
): Promise<string> => {
try {
const compiledTemplate = getCompiledTemplate(config.output.style);
const compiledTemplate = await getCompiledTemplate(config.output.style);
return `${compiledTemplate(renderContext).trim()}\n`;
} catch (error) {
if (error instanceof RangeError && error.message === 'Invalid string length') {
+7 -3
View File
@@ -1,7 +1,6 @@
/**
* Shared utilities for output style generation.
*/
import Handlebars from 'handlebars';
/**
* Map of file extensions to syntax highlighting language names.
@@ -228,14 +227,19 @@ let handlebarsHelpersRegistered = false;
/**
* Register common Handlebars helpers for output generation.
* Accepts a Handlebars instance to avoid requiring a top-level import of handlebars,
* which would add ~25ms to the module import chain on every CLI startup.
* This function is idempotent - calling it multiple times has no effect.
*/
export const registerHandlebarsHelpers = (): void => {
export const registerHandlebarsHelpers = (handlebarsInstance: {
// biome-ignore lint/suspicious/noExplicitAny: Handlebars registerHelper callback signature is loosely typed
registerHelper: (name: string, fn: (...args: any[]) => any) => void;
}): void => {
if (handlebarsHelpersRegistered) {
return;
}
Handlebars.registerHelper('getFileExtension', (filePath: string) => {
handlebarsInstance.registerHelper('getFileExtension', (filePath: string) => {
return getLanguageFromFilePath(filePath);
});
@@ -1,8 +1,3 @@
import { registerHandlebarsHelpers } from '../outputStyleUtils.js';
// Register Handlebars helpers (idempotent)
registerHandlebarsHelpers();
export const getMarkdownTemplate = () => {
return /* md */ `
{{#if fileSummaryEnabled}}
+8 -2
View File
@@ -17,7 +17,7 @@ import { prefetchFileChangeCounts } from './output/outputSort.js';
import { produceOutput } from './packager/produceOutput.js';
import type { SuspiciousFileResult } from './security/securityCheck.js';
import { validateFileSafety } from './security/validateFileSafety.js';
import { packSkill } from './skill/packSkill.js';
import type { PackSkillParams } from './skill/packSkill.js';
export interface PackResult {
totalFiles: number;
@@ -48,7 +48,13 @@ const defaultDeps = {
sortPaths,
getGitDiffs,
getGitLogs,
packSkill,
// Lazy-load packSkill to defer importing the skill module chain
// (skillSectionGenerators, skillStyle → Handlebars), which adds ~25ms
// to module loading. Only used when --skill-generate is active (non-default).
packSkill: async (params: PackSkillParams) => {
const { packSkill } = await import('./skill/packSkill.js');
return packSkill(params);
},
prefetchFileChangeCounts,
};
+4 -2
View File
@@ -3,8 +3,10 @@ import { generateTreeStringWithLineCounts } from '../file/fileTreeGenerate.js';
import type { RenderContext } from '../output/outputGeneratorTypes.js';
import { registerHandlebarsHelpers } from '../output/outputStyleUtils.js';
// Register Handlebars helpers (idempotent)
registerHandlebarsHelpers();
// Register Handlebars helpers (idempotent).
// This module's Handlebars import cost is only paid when --skill-generate
// is active, because packager.ts lazy-loads packSkill via dynamic import.
registerHandlebarsHelpers(Handlebars);
/**
* Generates the summary section for skill output.
+1
View File
@@ -20,6 +20,7 @@ const createMockDeps = (overrides: {
select: vi.fn().mockResolvedValue(overrides.selectValue),
confirm: vi.fn().mockResolvedValue(overrides.confirmValue),
isCancel: overrides.isCancelFn as (value: unknown) => value is symbol,
cancel: vi.fn(),
access: overrides.accessRejects
? vi.fn().mockRejectedValue(new Error('ENOENT'))
: vi.fn().mockResolvedValue(undefined),
@@ -1,6 +1,10 @@
import Handlebars from 'handlebars';
import { describe, expect, test } from 'vitest';
import { getMarkdownTemplate } from '../../../../src/core/output/outputStyles/markdownStyle.js';
import { registerHandlebarsHelpers } from '../../../../src/core/output/outputStyleUtils.js';
// Register helpers that are now lazy-loaded in production code
registerHandlebarsHelpers(Handlebars);
describe('markdownStyle', () => {
describe('getMarkdownTemplate', () => {