fix: resolve symlinks in project directory paths (#1316)

* fix: resolve symlinks in project directory paths

OpenCode stores sessions using the canonical (realpath) directory, but
OpenChamber passed the unresolved symlink path in several places. The
string-match directory filter would fail when a project was accessed via
a symlink, making sessions invisible.

Changes:

- Add safeRealpathSync to settings normalization — project paths and
  lastDirectory are canonicalized at persistence time
- Add Express middleware before the API proxy to resolve symlinks in
  ?directory= query params on in-flight requests
- Resolve symlinks in /api/fs/list so the directory browser returns
  canonical paths, allowing the "already added" check to work correctly
- Reconcile the in-memory projects store when the server responds with
  normalized paths, preventing temporary duplicates

Fixes #1315

* fix: avoid sync realpath in opencode proxy

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jeremysamuel13
2026-05-24 15:46:11 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 90b3d4760e
commit c5862cc6ee
12 changed files with 695 additions and 12 deletions
@@ -42,6 +42,18 @@ interface DirectoryTreeProps {
disabledPaths?: Iterable<string>;
}
const areStringSetsEqual = (left: Set<string>, right: Set<string>) => {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
};
export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
currentPath,
onSelectPath,
@@ -244,7 +256,10 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
const normalizedPath = path.replace(/\\/g, '/');
return (stripTrailingSlashes(normalizedPath) as string) ?? normalizedPath;
});
setPinnedPaths(new Set(normalized));
setPinnedPaths((prev) => {
const next = new Set(normalized);
return areStringSetsEqual(prev, next) ? prev : next;
});
};
const loadFromLocalStorage = () => {
@@ -326,7 +341,8 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
const filtered = Array.from(prev)
.map((path) => (stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path)
.filter((path) => isPathWithinHome(path));
return new Set(filtered);
const next = new Set(filtered);
return areStringSetsEqual(prev, next) ? prev : next;
});
}, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]);
+10 -3
View File
@@ -119,6 +119,13 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
}
};
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
};
type PersistApi = {
hasHydrated?: () => boolean;
onFinishHydration?: (callback: () => void) => (() => void) | undefined;
@@ -1165,9 +1172,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
console.warn('applyDesktopUiPreferences failed:', error);
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
}
dispatchSettingsSynced(settings);
};
try {
@@ -1198,6 +1203,7 @@ const _flushSettingsUpdate = async (): Promise<void> => {
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
}
return;
} catch (error) {
@@ -1224,6 +1230,7 @@ const _flushSettingsUpdate = async (): Promise<void> => {
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
}
+1
View File
@@ -198,6 +198,7 @@ const settingsNormalizationRuntime = createSettingsNormalizationRuntime({
os,
path,
processLike: process,
realpathSync: fs.realpathSync,
tunnelBootstrapTtlDefaultMs: TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS,
tunnelBootstrapTtlMinMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
tunnelBootstrapTtlMaxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
+6 -1
View File
@@ -1,3 +1,5 @@
import { createRealpathCache } from '../path-realpath-cache.js';
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
const createCommandTimeoutMs = () => {
@@ -270,6 +272,9 @@ export const registerFsRoutes = (app, dependencies) => {
resolveGitBinaryForSpawn,
openchamberUserConfigRoot,
} = dependencies;
const realpathCache = createRealpathCache({
realpath: fsPromises.realpath.bind(fsPromises),
});
const execJobs = new Map();
const commandTimeoutMs = createCommandTimeoutMs();
@@ -1033,7 +1038,7 @@ export const registerFsRoutes = (app, dependencies) => {
};
try {
resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
resolvedPath = await realpathCache.resolve(path.resolve(normalizeDirectoryPath(rawPath)));
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
@@ -1,3 +1,5 @@
import { createRealpathCache } from '../path-realpath-cache.js';
export const createProjectDirectoryRuntime = (dependencies) => {
const {
fsPromises,
@@ -7,6 +9,9 @@ export const createProjectDirectoryRuntime = (dependencies) => {
getReadSettingsFromDiskMigrated,
sanitizeProjects,
} = dependencies;
const realpathCache = createRealpathCache({
realpath: fsPromises.realpath.bind(fsPromises),
});
const resolveDirectoryCandidate = (value) => {
if (typeof value !== 'string') {
@@ -30,7 +35,8 @@ export const createProjectDirectoryRuntime = (dependencies) => {
if (!stats.isDirectory()) {
return { ok: false, error: 'Specified path is not a directory' };
}
return { ok: true, directory: resolved };
const realPath = await realpathCache.resolve(resolved);
return { ok: true, directory: realPath };
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
@@ -0,0 +1,226 @@
import { describe, expect, it } from 'vitest';
import { createProjectDirectoryRuntime } from './project-directory-runtime.js';
const createTestRuntime = (overrides = {}) => {
const defaults = {
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async (p) => p,
},
path: {
resolve: (p) => p,
},
normalizeDirectoryPath: (value) => value,
readSettingsFromDiskMigrated: async () => ({}),
getReadSettingsFromDiskMigrated: () => async () => ({}),
sanitizeProjects: (input) => input,
};
return createProjectDirectoryRuntime({ ...defaults, ...overrides });
};
describe('project directory runtime', () => {
describe('validateDirectoryPath', () => {
it('returns resolved real path for a valid directory', async () => {
const runtime = createTestRuntime();
const result = await runtime.validateDirectoryPath('/home/user/project');
expect(result).toEqual({ ok: true, directory: '/home/user/project' });
});
it('resolves symlinks via fsPromises.realpath', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/path/to/project',
},
});
const result = await runtime.validateDirectoryPath('/symlink/path/to/project');
expect(result).toEqual({ ok: true, directory: '/real/path/to/project' });
});
it('returns error when candidate is empty', async () => {
const runtime = createTestRuntime();
const result = await runtime.validateDirectoryPath('');
expect(result).toEqual({ ok: false, error: 'Directory parameter is required' });
});
it('returns error when candidate is not a string', async () => {
const runtime = createTestRuntime();
const result = await runtime.validateDirectoryPath(null);
expect(result).toEqual({ ok: false, error: 'Directory parameter is required' });
});
it('returns error when path is not a directory', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => false }),
realpath: async (p) => p,
},
});
const result = await runtime.validateDirectoryPath('/some/file.txt');
expect(result).toEqual({ ok: false, error: 'Specified path is not a directory' });
});
it('returns error when path does not exist', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => { throw { code: 'ENOENT' }; },
realpath: async (p) => p,
},
});
const result = await runtime.validateDirectoryPath('/nonexistent');
expect(result).toEqual({ ok: false, error: 'Directory not found' });
});
it('returns error when access is denied', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => { throw { code: 'EACCES' }; },
realpath: async (p) => p,
},
});
const result = await runtime.validateDirectoryPath('/restricted');
expect(result).toEqual({ ok: false, error: 'Access to directory denied' });
});
it('returns error when realpath fails after stat succeeds', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => { throw { code: 'ENOENT' }; },
},
});
const result = await runtime.validateDirectoryPath('/deleted-after-stat');
expect(result).toEqual({ ok: false, error: 'Directory not found' });
});
});
describe('resolveProjectDirectory', () => {
it('resolves symlinks in x-opencode-directory header', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? '/home/user/workspace/project' : null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('resolves symlinks in query directory parameter', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
});
const req = {
get: () => null,
query: { directory: '/home/user/workspace/project' },
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('resolves symlinks in lastDirectory from settings', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
getReadSettingsFromDiskMigrated: () => async () => ({
lastDirectory: '/home/user/workspace/project',
}),
});
const req = {
get: () => null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('resolves symlinks in active project path from settings', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
getReadSettingsFromDiskMigrated: () => async () => ({
projects: [{ id: 'proj-1', path: '/home/user/workspace/project' }],
activeProjectId: 'proj-1',
}),
sanitizeProjects: (input) => input,
});
const req = {
get: () => null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
});
describe('resolveOptionalProjectDirectory', () => {
it('returns null directory when no directory is requested', async () => {
const runtime = createTestRuntime();
const req = {
get: () => null,
query: {},
};
const result = await runtime.resolveOptionalProjectDirectory(req);
expect(result).toEqual({ directory: null, error: null });
});
it('resolves symlinks when directory is provided', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? '/symlink/workspace/project' : null,
query: {},
};
const result = await runtime.resolveOptionalProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
});
});
+43
View File
@@ -5,6 +5,31 @@ import {
collectForwardProxyHeaders,
shouldForwardProxyResponseHeader,
} from '../../proxy-headers.js';
import { createRealpathCache } from '../path-realpath-cache.js';
export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
return async (requestUrl) => {
if (typeof requestUrl !== 'string' || !requestUrl.includes('directory=')) {
return requestUrl;
}
const url = new URL(requestUrl, 'http://localhost');
const directory = url.searchParams.get('directory');
if (!directory) {
return requestUrl;
}
const canonicalDirectory = await realpathCache.resolve(directory);
if (!canonicalDirectory || canonicalDirectory === directory) {
return requestUrl;
}
url.searchParams.set('directory', canonicalDirectory);
return `${url.pathname}${url.search}`;
};
};
export const waitForSseDrain = (res, signal) => new Promise((resolve) => {
if (signal?.aborted || res.writableEnded || res.destroyed) {
@@ -94,6 +119,9 @@ export const registerOpenCodeProxy = (app, deps) => {
const isAbortError = (error) => error?.name === 'AbortError';
const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902';
const canonicalizeDirectoryQuery = createDirectoryQueryCanonicalizer({
realpath: fs?.promises?.realpath?.bind(fs.promises),
});
const normalizeProxyTarget = (candidate) => {
if (typeof candidate !== 'string') {
@@ -416,5 +444,20 @@ export const registerOpenCodeProxy = (app, deps) => {
},
});
// Best-effort fallback for stale clients still sending symlink paths.
// Settings and project selection normalize at source; this cached async path
// avoids blocking the proxy hot path on every directory-scoped request.
app.use('/api', async (req, _res, next) => {
try {
const rewrittenUrl = await canonicalizeDirectoryQuery(req.url);
if (rewrittenUrl !== req.url) {
req.url = rewrittenUrl;
}
} catch {
// Pass through as-is if URL parsing or realpath resolution fails.
}
next();
});
app.use('/api', apiProxy);
};
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { createDirectoryQueryCanonicalizer } from './proxy.js';
describe('createDirectoryQueryCanonicalizer', () => {
it('canonicalizes directory query params and preserves other params', async () => {
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async (value) => value === '/link/project' ? '/real/project' : value,
});
await expect(canonicalize('/session?foo=1&directory=/link/project&bar=2'))
.resolves.toBe('/session?foo=1&directory=%2Freal%2Fproject&bar=2');
});
it('caches directory realpath lookups', async () => {
let calls = 0;
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => {
calls += 1;
return '/real/project';
},
});
await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject');
await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject');
expect(calls).toBe(1);
});
it('deduplicates concurrent directory realpath lookups', async () => {
let calls = 0;
let release = () => undefined;
const pending = new Promise((resolve) => {
release = () => resolve('/real/project');
});
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => {
calls += 1;
return pending;
},
});
const first = canonicalize('/session?directory=/link/project');
const second = canonicalize('/session?directory=/link/project');
await Promise.resolve();
expect(calls).toBe(1);
release();
await expect(Promise.all([first, second])).resolves.toEqual([
'/session?directory=%2Freal%2Fproject',
'/session?directory=%2Freal%2Fproject',
]);
});
it('falls back to the original URL when realpath fails', async () => {
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => {
throw new Error('missing');
},
});
await expect(canonicalize('/session?foo=1&directory=/missing/project'))
.resolves.toBe('/session?foo=1&directory=/missing/project');
});
it('leaves URLs without directory params unchanged', async () => {
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => '/real/project',
});
await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1');
});
});
@@ -3,6 +3,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
os,
path,
processLike,
realpathSync,
tunnelBootstrapTtlDefaultMs,
tunnelBootstrapTtlMinMs,
tunnelBootstrapTtlMaxMs,
@@ -32,7 +33,19 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return trimmed;
};
const normalizePathForPersistence = (value) => {
// Resolve symlinks, falling back to the original value on failure.
const safeRealpathSync = (value) => {
if (!realpathSync || typeof value !== 'string' || !value) {
return value;
}
try {
return realpathSync(value);
} catch {
return value;
}
};
const normalizePathForPersistence = (value, options = {}) => {
if (typeof value !== 'string') {
return value;
}
@@ -47,11 +60,13 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return trimmed;
}
const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed);
if (processLike.platform !== 'win32') {
return trimmed;
return resolved;
}
return trimmed.replace(/\//g, '\\');
return resolved.replace(/\//g, '\\');
};
const areStringArraysEqual = (a, b) => {
@@ -107,8 +122,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : '';
const resolvedPath = rawPath ? safeRealpathSync(path.resolve(normalizeDirectoryPath(rawPath))) : '';
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath, { resolveRealpath: false }) : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
const createTestRuntime = (overrides = {}) => {
const defaults = {
os: { homedir: () => '/home/testuser' },
path: {
resolve: (...args) => args[args.length - 1],
sep: '/',
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
},
processLike: { platform: 'linux', env: {} },
realpathSync: (p) => p,
tunnelBootstrapTtlDefaultMs: 600000,
tunnelBootstrapTtlMinMs: 60000,
tunnelBootstrapTtlMaxMs: 3600000,
tunnelSessionTtlDefaultMs: 86400000,
tunnelSessionTtlMinMs: 3600000,
tunnelSessionTtlMaxMs: 604800000,
};
return createSettingsNormalizationRuntime({ ...defaults, ...overrides });
};
describe('settings normalization runtime - symlink resolution', () => {
describe('normalizePathForPersistence', () => {
it('resolves symlinks via realpathSync', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace' ? '/workplace/user' : p,
});
const result = runtime.normalizePathForPersistence('/home/user/workplace');
expect(result).toBe('/workplace/user');
});
it('falls back to original path when realpathSync throws', () => {
const runtime = createTestRuntime({
realpathSync: () => {
throw new Error('ENOENT');
},
});
const result = runtime.normalizePathForPersistence('/nonexistent/path');
expect(result).toBe('/nonexistent/path');
});
it('passes through when realpathSync is not provided', () => {
const runtime = createTestRuntime({ realpathSync: undefined });
const result = runtime.normalizePathForPersistence('/some/path');
expect(result).toBe('/some/path');
});
});
describe('sanitizeProjects', () => {
it('resolves symlinks in project paths', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace/MyProject'
? '/workplace/user/MyProject'
: p,
});
const projects = [
{ id: 'proj1', path: '/home/user/workplace/MyProject', label: 'MyProject', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].path).toBe('/workplace/user/MyProject');
});
it('falls back to path.resolve when realpathSync throws', () => {
const runtime = createTestRuntime({
realpathSync: () => { throw new Error('ENOENT'); },
path: { resolve: (p) => '/resolved' + p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/missing/path', label: 'Missing', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].path).toBe('/resolved/missing/path');
});
it('deduplicates projects that resolve to the same realpath', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/symlink/a', label: 'A', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
{ id: 'proj2', path: '/symlink/b', label: 'B', color: 'keyword', addedAt: 2000, lastOpenedAt: 2000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('proj1');
});
});
describe('normalizeSettingsPaths', () => {
it('resolves symlinks in lastDirectory', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace/LyraRefactoring'
? '/workplace/user/LyraRefactoring'
: p,
});
const result = runtime.normalizeSettingsPaths({
lastDirectory: '/home/user/workplace/LyraRefactoring',
});
expect(result.changed).toBe(true);
expect(result.settings.lastDirectory).toBe('/workplace/user/LyraRefactoring');
});
it('does not flag as changed when path is already canonical', () => {
const runtime = createTestRuntime();
const result = runtime.normalizeSettingsPaths({
lastDirectory: '/real/path',
});
expect(result.changed).toBe(false);
});
});
});
@@ -0,0 +1,85 @@
export const createRealpathCache = ({
realpath,
successTtlMs = 600_000,
failureTtlMs = 60_000,
maxEntries = 256,
fallbackOnError = false,
now = () => Date.now(),
} = {}) => {
const cache = new Map();
const resolveRealpath = typeof realpath === 'function' ? realpath : null;
const prune = () => {
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) {
return;
}
cache.delete(oldestKey);
}
};
const remember = (key, entry, ttlMs) => {
if (!Number.isFinite(maxEntries) || maxEntries <= 0) {
return;
}
cache.delete(key);
cache.set(key, { ...entry, expiresAt: now() + Math.max(0, ttlMs) });
prune();
};
const resolve = async (value) => {
if (typeof value !== 'string' || value.length === 0 || !resolveRealpath) {
return value;
}
const cached = cache.get(value);
const currentTime = now();
if (cached?.promise) {
return cached.promise;
}
if (cached && cached.expiresAt > currentTime) {
cache.delete(value);
cache.set(value, cached);
if (cached.error) {
if (fallbackOnError) {
return value;
}
throw cached.error;
}
return cached.value;
}
if (cached) {
cache.delete(value);
}
const promise = Promise.resolve()
.then(() => resolveRealpath(value))
.then((resolved) => {
const next = typeof resolved === 'string' && resolved.length > 0 ? resolved : value;
remember(value, { value: next }, successTtlMs);
return next;
})
.catch((error) => {
remember(value, { value, error }, failureTtlMs);
if (!fallbackOnError) {
throw error;
}
return value;
});
if (Number.isFinite(maxEntries) && maxEntries > 0) {
cache.delete(value);
cache.set(value, { value, expiresAt: 0, promise });
prune();
}
return promise;
};
return {
resolve,
clear: () => cache.clear(),
size: () => cache.size,
};
};
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { createRealpathCache } from './path-realpath-cache.js';
describe('createRealpathCache', () => {
it('caches successful realpath lookups until the success TTL expires', async () => {
let now = 1_000;
let calls = 0;
const cache = createRealpathCache({
now: () => now,
successTtlMs: 1_000,
realpath: async () => {
calls += 1;
return `/real-${calls}`;
},
});
await expect(cache.resolve('/link')).resolves.toBe('/real-1');
await expect(cache.resolve('/link')).resolves.toBe('/real-1');
expect(calls).toBe(1);
now += 1_001;
await expect(cache.resolve('/link')).resolves.toBe('/real-2');
expect(calls).toBe(2);
});
it('shares in-flight realpath lookups for the same path', async () => {
let calls = 0;
let release = () => undefined;
const pending = new Promise((resolve) => {
release = () => resolve('/real/path');
});
const cache = createRealpathCache({
realpath: async () => {
calls += 1;
return pending;
},
});
const first = cache.resolve('/link/path');
const second = cache.resolve('/link/path');
await Promise.resolve();
expect(calls).toBe(1);
release();
await expect(Promise.all([first, second])).resolves.toEqual(['/real/path', '/real/path']);
});
it('throws realpath failures by default', async () => {
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
const cache = createRealpathCache({
realpath: async () => {
throw error;
},
});
await expect(cache.resolve('/missing')).rejects.toBe(error);
});
it('can fall back to the original path and cache failures briefly', async () => {
let calls = 0;
const cache = createRealpathCache({
fallbackOnError: true,
failureTtlMs: 1_000,
realpath: async () => {
calls += 1;
throw new Error('missing');
},
});
await expect(cache.resolve('/missing')).resolves.toBe('/missing');
await expect(cache.resolve('/missing')).resolves.toBe('/missing');
expect(calls).toBe(1);
});
});