Skip to content

Commit c41174a

Browse files
os-zhuangclaude
andauthored
perf(scripts): cache registry responses so --check runs in 0.05s instead of 29s (#3062)
`--check` makes one HTTPS request per tracked component — 46 round trips for a run that is almost entirely I/O wait: 28.6s wall against 0.13s of user time. That is enough friction that nobody runs the status command, which rather defeats having one. Responses are now cached under `node_modules/.cache/shadcn-sync/`, keyed by source URL, with a 60-minute TTL: cold (empty cache) 26.86s 0 cached, 46 fetched warm 0.05s 46 cached, 0 fetched --no-cache 28.74s 0 cached, 46 fetched `node_modules/.cache/` is the conventional home: already gitignored (verified via git check-ignore), and a clean install discards it, which is the right lifetime for a cache. The load-bearing detail is that `allowCache` gates READS only, and only read-only commands pass it. `--check` and `--diff` may answer from cache; `--update` and `--update-all` never do. A stale `--check` is a slightly out-of-date status line, but a stale `--update` would put hour-old code on disk under a message saying it is current. Writes still refresh the entry, so an update leaves the cache warm for the next check. Proved that split by poisoning the cached `button` entry: `--check` reported button as 51-line modified — it reads the cache — while `--update button` ignored the poison entirely, wrote the real upstream content (clean tree, zero POISONED lines), and write-refreshed the entry. Degradation is quiet by design: a missing, unreadable or corrupt entry falls through to the network (garbage file → 45 cached, 1 fetched, exit 0), and a cache directory that cannot be written never fails the run. Expiry verified by ageing every entry to 61 minutes — 0 cached, 46 fetched. The summary always reports where the data came from. A run that returns in 50ms is otherwise indistinguishable from one that silently fetched nothing. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 6e6f5ef commit c41174a

1 file changed

Lines changed: 85 additions & 11 deletions

File tree

scripts/shadcn-sync.js

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@
1818
* --backup Create backup before updating
1919
* --force Overwrite even when local edits would be lost
2020
* --no-verify Skip the type-check that runs after an update
21+
* --no-cache Bypass cached registry responses (--check / --diff)
2122
*/
2223

2324
import fs from 'fs/promises';
2425
import path from 'path';
2526
import { fileURLToPath } from 'url';
2627
import https from 'https';
2728
import { spawnSync } from 'child_process';
29+
import crypto from 'crypto';
2830

2931
const __filename = fileURLToPath(import.meta.url);
3032
const __dirname = path.dirname(__filename);
@@ -34,6 +36,21 @@ const COMPONENTS_DIR = path.join(REPO_ROOT, 'packages/components/src/ui');
3436
const MANIFEST_PATH = path.join(REPO_ROOT, 'packages/components/shadcn-components.json');
3537
const BACKUP_DIR = path.join(REPO_ROOT, 'packages/components/.backup');
3638

39+
/**
40+
* Registry response cache.
41+
*
42+
* `--check` makes one request per tracked component — 46 round trips for a
43+
* run that is otherwise pure I/O wait (28.6s wall, 0.13s of it user time).
44+
* That is enough friction to stop people running it, which defeats the point
45+
* of having a status command at all.
46+
*
47+
* Lives under `node_modules/.cache/` by convention: already gitignored, and a
48+
* clean install discards it, which is the right lifetime for a cache.
49+
*/
50+
const CACHE_DIR = path.join(REPO_ROOT, 'node_modules/.cache/shadcn-sync');
51+
const CACHE_TTL_MS = 60 * 60 * 1000;
52+
const cacheStats = { hits: 0, misses: 0 };
53+
3754
// ANSI color codes
3855
const colors = {
3956
reset: '\x1b[0m',
@@ -73,6 +90,48 @@ async function fetchUrl(url) {
7390
});
7491
}
7592

93+
function cacheFileFor(url) {
94+
return path.join(CACHE_DIR, `${crypto.createHash('sha1').update(url).digest('hex').slice(0, 16)}.json`);
95+
}
96+
97+
/**
98+
* Fetch a registry entry, optionally serving it from the on-disk cache.
99+
*
100+
* ⚠️ `allowCache` controls READING only. Writes always refresh the entry, so a
101+
* command that must see live data (`--update`) still warms the cache for the
102+
* next `--check`.
103+
*
104+
* The read/write split is the safety property: read-only commands may answer
105+
* from a cached copy, but nothing that writes a component file ever does.
106+
* A stale `--check` is a mildly out-of-date status line; a stale `--update`
107+
* would put hour-old code on disk under a message saying it is current.
108+
*/
109+
async function fetchRegistry(url, { allowCache = false } = {}) {
110+
if (allowCache) {
111+
try {
112+
const entry = JSON.parse(await fs.readFile(cacheFileFor(url), 'utf-8'));
113+
if (typeof entry?.fetchedAt === 'number' && Date.now() - entry.fetchedAt < CACHE_TTL_MS) {
114+
cacheStats.hits++;
115+
return entry.data;
116+
}
117+
} catch {
118+
/* absent, unreadable or corrupt — fall through and refetch */
119+
}
120+
}
121+
122+
const data = await fetchUrl(url);
123+
cacheStats.misses++;
124+
125+
try {
126+
await fs.mkdir(CACHE_DIR, { recursive: true });
127+
await fs.writeFile(cacheFileFor(url), JSON.stringify({ url, fetchedAt: Date.now(), data }));
128+
} catch {
129+
/* the cache is an optimisation; never fail a run because it can't be written */
130+
}
131+
132+
return data;
133+
}
134+
76135
async function loadManifest() {
77136
try {
78137
const content = await fs.readFile(MANIFEST_PATH, 'utf-8');
@@ -215,7 +274,7 @@ function verifyPackageCompiles({ backup } = {}) {
215274
return false;
216275
}
217276

218-
async function checkComponent(name, manifest) {
277+
async function checkComponent(name, manifest, options = {}) {
219278
const componentInfo = manifest.components[name];
220279
if (!componentInfo) {
221280
return {
@@ -232,7 +291,7 @@ async function checkComponent(name, manifest) {
232291

233292
// Fetch latest from registry
234293
try {
235-
const registryData = await fetchUrl(componentInfo.source);
294+
const registryData = await fetchRegistry(componentInfo.source, { allowCache: options.allowCache });
236295
// Rewrite before comparing, so import-path style alone does not read as
237296
// a difference — the local file has already been through this transform.
238297
const shadcnContent = rewriteRegistryImports(registryData.files?.[0]?.content || '');
@@ -300,7 +359,7 @@ async function checkComponent(name, manifest) {
300359
}
301360
}
302361

303-
async function checkAllComponents() {
362+
async function checkAllComponents(options = {}) {
304363
logSection('Checking Components Status');
305364

306365
const manifest = await loadManifest();
@@ -319,7 +378,7 @@ async function checkAllComponents() {
319378
};
320379

321380
for (const component of localComponents) {
322-
const result = await checkComponent(component, manifest);
381+
const result = await checkComponent(component, manifest, options);
323382
results[result.status].push(result);
324383

325384
const symbol = {
@@ -349,6 +408,17 @@ async function checkAllComponents() {
349408
log(`● Custom: ${results.custom.length} components (not tracked upstream)`, 'blue');
350409
log(`✗ Errors: ${results.error.length} components`, 'red');
351410

411+
// A run that returns in under a second is otherwise indistinguishable from
412+
// one that silently fetched nothing, so always say where the data came from.
413+
if (cacheStats.hits > 0 || cacheStats.misses > 0) {
414+
const ttlMin = Math.round(CACHE_TTL_MS / 60000);
415+
log(
416+
`\nRegistry: ${cacheStats.hits} cached, ${cacheStats.misses} fetched ` +
417+
`(cache TTL ${ttlMin}min — --no-cache to force live)`,
418+
'dim',
419+
);
420+
}
421+
352422
// The actionable bucket: upstream moved and nothing local is at risk.
353423
if (results.outdated.length > 0) {
354424
log('\nSafe to sync now:', 'cyan');
@@ -403,8 +473,9 @@ async function updateComponent(name, manifest, options = {}) {
403473

404474
try {
405475
// Fetch from registry
406-
const registryData = await fetchUrl(componentInfo.source);
407-
476+
// allowCache omitted on purpose: a write must never come from a cached copy.
477+
const registryData = await fetchRegistry(componentInfo.source);
478+
408479
if (!registryData.files || registryData.files.length === 0) {
409480
log(`No files found in registry for ${name}`, 'red');
410481
return false;
@@ -551,7 +622,7 @@ async function updateAllComponents(options = {}) {
551622
}
552623
}
553624

554-
async function showDiff(name) {
625+
async function showDiff(name, options = {}) {
555626
logSection(`Component Diff: ${name}`);
556627

557628
const manifest = await loadManifest();
@@ -565,7 +636,7 @@ async function showDiff(name) {
565636
try {
566637
const localPath = path.join(COMPONENTS_DIR, `${name}.tsx`);
567638
const localContent = await fs.readFile(localPath, 'utf-8');
568-
const registryData = await fetchUrl(componentInfo.source);
639+
const registryData = await fetchRegistry(componentInfo.source, { allowCache: options.allowCache });
569640
// Same transform `--update` would apply, so the two sides are comparable.
570641
const shadcnContent = rewriteRegistryImports(registryData.files?.[0]?.content || '');
571642

@@ -607,9 +678,11 @@ async function listComponents() {
607678
// Main CLI
608679
async function main() {
609680
const args = process.argv.slice(2);
610-
681+
// Read-only commands may answer from cache; --no-cache forces live fetches.
682+
const allowCache = !args.includes('--no-cache');
683+
611684
if (args.length === 0 || args.includes('--check')) {
612-
await checkAllComponents();
685+
await checkAllComponents({ allowCache });
613686
} else if (args.includes('--update-all')) {
614687
const backup = args.includes('--backup');
615688
const verify = !args.includes('--no-verify');
@@ -637,7 +710,7 @@ async function main() {
637710
log('Error: --diff requires a component name', 'red');
638711
process.exit(1);
639712
}
640-
await showDiff(componentName);
713+
await showDiff(componentName, { allowCache });
641714
} else if (args.includes('--list')) {
642715
await listComponents();
643716
} else if (args.includes('--help') || args.includes('-h')) {
@@ -652,6 +725,7 @@ async function main() {
652725
console.log(' --backup Create backup before updating');
653726
console.log(' --force Overwrite even if local edits would be lost');
654727
console.log(' --no-verify Skip the post-update type-check');
728+
console.log(' --no-cache Bypass the cached registry responses (--check/--diff)');
655729
console.log(' --help, -h Show this help message\n');
656730
} else {
657731
log('Unknown option. Use --help for usage information.', 'red');

0 commit comments

Comments
 (0)