symlink-dir
Version:
Cross-platform directory symlinking
344 lines • 13.4 kB
JavaScript
import { betterPathResolve } from 'better-path-resolve';
import { promises as fs, symlinkSync, mkdirSync, readlinkSync, unlinkSync } from 'fs';
import { types } from 'util';
import pathLib from 'path';
import { renameOverwrite, renameOverwriteSync } from 'rename-overwrite';
const IS_WINDOWS = process.platform === 'win32' || /^(msys|cygwin)$/.test(process.env.OSTYPE);
function resolveSrcOnWinJunction(src) {
return `${src}\\`;
}
function resolveSrcOnTrueSymlink(src, dest) {
return pathLib.relative(pathLib.dirname(dest), src);
}
export function symlinkDir(target, path, opts) {
path = betterPathResolve(path);
target = betterPathResolve(target);
if (target === path)
throw new Error(`Symlink path is the same as the target path (${target})`);
return forceSymlink(target, path, opts);
}
export function symlinkDirSync(target, path, opts) {
path = betterPathResolve(path);
target = betterPathResolve(target);
if (target === path)
throw new Error(`Symlink path is the same as the target path (${target})`);
return forceSymlinkSync(target, path, opts);
}
function isExistingSymlinkUpToDate(wantedTarget, path, linkString) {
// path is going to be that of the symlink, so never be a (drive) root, therefore dirname(path) is different from path
const existingTarget = pathLib.isAbsolute(linkString) ? linkString : pathLib.join(pathLib.dirname(path), linkString);
return pathLib.relative(wantedTarget, existingTarget) === '';
}
let createSymlinkAsync;
let createSymlinkSync;
if (IS_WINDOWS) {
// Falls back to "junctions" on Windows if "symbolic links" is disallowed. Even though support for "symbolic links" was added in Vista+, users by default
// lack permission to create them
createSymlinkAsync = async (target, path) => {
try {
await createTrueSymlinkAsync(target, path);
createSymlinkSync = createTrueSymlinkSync;
createSymlinkAsync = createTrueSymlinkAsync;
}
catch (err) {
if (err.code === 'EPERM') {
await createJunctionAsync(target, path);
createSymlinkSync = createJunctionSync;
createSymlinkAsync = createJunctionAsync;
}
else {
throw err;
}
}
};
createSymlinkSync = (target, path) => {
try {
createTrueSymlinkSync(target, path);
createSymlinkSync = createTrueSymlinkSync;
createSymlinkAsync = createTrueSymlinkAsync;
}
catch (err) {
if (err.code === 'EPERM') {
createJunctionSync(target, path);
createSymlinkSync = createJunctionSync;
createSymlinkAsync = createJunctionAsync;
}
else {
throw err;
}
}
};
}
else {
createSymlinkAsync = createTrueSymlinkAsync;
createSymlinkSync = createTrueSymlinkSync;
}
function createTrueSymlinkAsync(target, path) {
return fs.symlink(resolveSrcOnTrueSymlink(target, path), path, 'dir');
}
function createTrueSymlinkSync(target, path) {
symlinkSync(resolveSrcOnTrueSymlink(target, path), path, 'dir');
}
function createJunctionAsync(target, path) {
return fs.symlink(resolveSrcOnWinJunction(target), path, 'junction');
}
function createJunctionSync(target, path) {
symlinkSync(resolveSrcOnWinJunction(target), path, 'junction');
}
// Windows leaves a path another process has just created or unlinked
// unavailable for a moment, answering EPERM, EACCES or EBUSY where a
// definitive answer is due elsewhere. `rename-overwrite` already waits these
// out on a rename, and a read of the same path needs it for the same reason:
// without it a link a concurrent writer is still holding is taken for
// something that is not a link at all.
//
// EPERM and EACCES also carry the permanent case, a path the user may not read
// at all, which no amount of waiting will change. They get a second rather
// than a minute so a real denial still surfaces promptly. EBUSY only ever
// means a handle is open, so it keeps the full budget.
const TRANSIENT_REFUSAL_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
const PERMISSION_REFUSAL_CODES = new Set(['EPERM', 'EACCES']);
const REFUSAL_BUDGET_MS = 60000;
const PERMISSION_REFUSAL_BUDGET_MS = 1000;
const MAX_BACKOFF_MS = 100;
function refusalCode(err) {
if (!IS_WINDOWS || !types.isNativeError(err) || !('code' in err))
return undefined;
const code = err.code;
return TRANSIENT_REFUSAL_CODES.has(code) ? code : undefined;
}
// The deadline shrinks to whichever of the refusals seen so far allows the
// least, so one EPERM caps the wait even if an EBUSY came first.
function budgetFor(code) {
return PERMISSION_REFUSAL_CODES.has(code) ? PERMISSION_REFUSAL_BUDGET_MS : REFUSAL_BUDGET_MS;
}
async function readlinkWaitingOutARefusal(path) {
const started = Date.now();
let deadline = Number.POSITIVE_INFINITY;
let backoff = 0;
while (true) {
try {
return await fs.readlink(path);
}
catch (err) {
const code = refusalCode(err);
if (code == null)
throw err;
deadline = Math.min(deadline, started + budgetFor(code));
if (Date.now() >= deadline)
throw err;
await new Promise((resolve) => setTimeout(resolve, backoff));
backoff = Math.min(backoff + 10, MAX_BACKOFF_MS);
}
}
}
function readlinkSyncWaitingOutARefusal(path) {
const started = Date.now();
let deadline = Number.POSITIVE_INFINITY;
let backoff = 0;
while (true) {
try {
return readlinkSync(path);
}
catch (err) {
const code = refusalCode(err);
if (code == null)
throw err;
deadline = Math.min(deadline, started + budgetFor(code));
if (Date.now() >= deadline)
throw err;
sleepSync(backoff);
backoff = Math.min(backoff + 10, MAX_BACKOFF_MS);
}
}
}
// `Atomics.wait` parks the thread. Polling `Date.now()` in a loop would hold a
// core for as long as the refusal lasts.
function sleepSync(ms) {
if (ms === 0)
return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
async function forceSymlink(target, path, opts) {
let initialErr;
try {
if (opts?.noJunction === true) {
await createTrueSymlinkAsync(target, path);
}
else {
await createSymlinkAsync(target, path);
}
return { reused: false };
}
catch (err) {
switch (err.code) {
case 'ENOENT':
try {
await fs.mkdir(pathLib.dirname(path), { recursive: true });
}
catch (mkdirError) {
mkdirError.message = `Error while trying to symlink "${target}" to "${path}". ` +
`The error happened while trying to create the parent directory for the symlink target. ` +
`Details: ${mkdirError}`;
throw mkdirError;
}
await forceSymlink(target, path, opts);
return { reused: false };
case 'EEXIST':
case 'EISDIR':
initialErr = err;
// If the target file already exists then we proceed.
// Additional checks are done below.
break;
default:
throw err;
}
}
let linkString;
try {
linkString = await readlinkWaitingOutARefusal(path);
}
catch (err) {
if (opts?.overwrite === false) {
throw initialErr;
}
// path is not a link
const parentDir = pathLib.dirname(path);
let warn;
if (opts?.renameTried) {
// This is needed in order to fix a mysterious bug that sometimes happens on macOS.
// It is hard to reproduce and is described here: https://github.com/pnpm/pnpm/issues/5909#issuecomment-1400066890
await fs.unlink(path);
warn = `Symlink wanted name was occupied by directory or file. Old entity removed: "${parentDir}${pathLib.sep}{${pathLib.basename(path)}".`;
}
else {
const ignore = `.ignored_${pathLib.basename(path)}`;
try {
await renameOverwrite(path, pathLib.join(parentDir, ignore));
}
catch (error) {
if (!types.isNativeError(error) || !('code' in error) || error.code !== 'ENOENT') {
throw error;
}
// `renameOverwrite` reports ENOENT only when `path` itself is gone, so
// the conflict `initialErr` describes has already been cleared by
// whoever won the race for it. Reissue the create instead of reporting
// a conflict with something that is no longer there. Once only: a path
// this can neither create at nor find anything at surfaces its error.
if (opts?.vanishedRetried)
throw initialErr;
return await forceSymlink(target, path, { ...opts, vanishedRetried: true });
}
warn = `Symlink wanted name was occupied by directory or file. Old entity moved: "${parentDir}${pathLib.sep}{${pathLib.basename(path)} => ${ignore}".`;
}
return {
...await forceSymlink(target, path, { ...opts, renameTried: true }),
warn,
};
}
if (isExistingSymlinkUpToDate(target, path, linkString)) {
return { reused: true };
}
if (opts?.overwrite === false) {
throw initialErr;
}
try {
await fs.unlink(path);
}
catch (error) {
if (!types.isNativeError(error) || !('code' in error) || error.code !== 'ENOENT') {
throw error;
}
}
return await forceSymlink(target, path, opts);
}
function forceSymlinkSync(target, path, opts) {
let initialErr;
try {
if (opts?.noJunction === true) {
createTrueSymlinkSync(target, path);
}
else {
createSymlinkSync(target, path);
}
return { reused: false };
}
catch (err) {
initialErr = err;
switch (err.code) {
case 'ENOENT':
try {
mkdirSync(pathLib.dirname(path), { recursive: true });
}
catch (mkdirError) {
mkdirError.message = `Error while trying to symlink "${target}" to "${path}". ` +
`The error happened while trying to create the parent directory for the symlink target. ` +
`Details: ${mkdirError}`;
throw mkdirError;
}
forceSymlinkSync(target, path, opts);
return { reused: false };
case 'EEXIST':
case 'EISDIR':
// If the target file already exists then we proceed.
// Additional checks are done below.
break;
default:
throw err;
}
}
let linkString;
try {
linkString = readlinkSyncWaitingOutARefusal(path);
}
catch (err) {
if (opts?.overwrite === false) {
throw initialErr;
}
// path is not a link
const parentDir = pathLib.dirname(path);
let warn;
if (opts?.renameTried) {
// This is needed in order to fix a mysterious bug that sometimes happens on macOS.
// It is hard to reproduce and is described here: https://github.com/pnpm/pnpm/issues/5909#issuecomment-1400066890
unlinkSync(path);
warn = `Symlink wanted name was occupied by directory or file. Old entity removed: "${parentDir}${pathLib.sep}{${pathLib.basename(path)}".`;
}
else {
const ignore = `.ignored_${pathLib.basename(path)}`;
try {
renameOverwriteSync(path, pathLib.join(parentDir, ignore));
}
catch (error) {
if (!types.isNativeError(error) || !('code' in error) || error.code !== 'ENOENT') {
throw error;
}
// See the matching branch in `forceSymlink`.
if (opts?.vanishedRetried)
throw initialErr;
return forceSymlinkSync(target, path, { ...opts, vanishedRetried: true });
}
warn = `Symlink wanted name was occupied by directory or file. Old entity moved: "${parentDir}${pathLib.sep}{${pathLib.basename(path)} => ${ignore}".`;
}
return {
...forceSymlinkSync(target, path, { ...opts, renameTried: true }),
warn,
};
}
if (isExistingSymlinkUpToDate(target, path, linkString)) {
return { reused: true };
}
if (opts?.overwrite === false) {
throw initialErr;
}
try {
unlinkSync(path);
}
catch (error) {
if (!types.isNativeError(error) || !('code' in error) || error.code !== 'ENOENT') {
throw error;
}
}
return forceSymlinkSync(target, path, opts);
}
//# sourceMappingURL=index.js.map