Saltar al contenido

¿Cómo subir archivos a S3 usando @aws-sdk/client-s3?

Cómo usar @aws-sdk/client-s3 para subir archivos y carpetas enteras a AWS S3 desde Node.js, con los gotchas más comunes (content type, claves con barras, paths de Windows).

4 min de lectura
  • javascript
  • node.js
  • aws
  • aws-s3
  • tooling

Introducción

En uno de mis últimos proyectos me vi con la necesidad de crear un script que subiera archivos estáticos a AWS S3. Por motivos del entorno donde el script corría, no podía instalar herramientas como aws-cli, así que terminé usando la librería @aws-sdk/client-s3 para resolverlo.

En este post voy a mostrar cómo usar @aws-sdk/client-s3 para subir archivos y carpetas a S3, junto con algunos puntos a tener en cuenta cuando se trabaja con esta librería.

Instalación

Además del SDK, vamos a necesitar mime-types y slash. Más adelante explico para qué sirve cada uno:

yarn add @aws-sdk/client-s3 mime-types slash

¿Cómo se usa @aws-sdk/client-s3?

El flujo del SDK es bastante directo:

  1. Inicializas el cliente con tus credenciales de AWS.
  2. Creas un comando PutObject con el bucket, el archivo y su contenido.
  3. Le pides al cliente que ejecute (send) el comando.
const fs = require('fs');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
 
// 1. Inicializas el cliente con tus credenciales de AWS.
const { AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET } =
  process.env;
 
const client = new S3Client({
  region: AWS_REGION,
  credentials: {
    accessKeyId: AWS_ACCESS_KEY_ID,
    secretAccessKey: AWS_SECRET_ACCESS_KEY,
  },
});
 
// 2. Creas el comando con el bucket, el archivo y su contenido.
const pathFile = './my-file.txt';
const params = {
  Bucket: S3_BUCKET, // nombre del bucket. Por ejemplo, 'sample_bucket_101'.
  Key: 'my-file.txt', // nombre del objeto. Por ejemplo, 'my-file.txt'.
  Body: fs.readFileSync(pathFile), // contenido del objeto.
  ContentType: 'application/text', // content type del archivo.
};
const uploadCommand = new PutObjectCommand(params);
 
// 3. Le pides al cliente que ejecute el comando.
client
  .send(uploadCommand)
  .then(() => {
    console.log('Success uploading the file to S3.');
  })
  .catch(err => {
    console.log('Error uploading file', err);
  });

Cosas a tener en cuenta

Aunque la librería es bastante robusta, hay detalles que o no están documentados, o no son tan intuitivos. Esta es una lista corta de cosas que vale la pena saber al usar @aws-sdk/client-s3; con suerte, te ahorra horas de búsqueda en internet:

  1. A diferencia del CLI de AWS, @aws-sdk/client-s3 solo deja subir un archivo a la vez. Para subir un conjunto de archivos o una carpeta entera hay que escribir lógica extra en JavaScript (lo veremos en la siguiente sección).

  2. Hay que especificar ContentType por archivo o @aws-sdk/client-s3 usará por defecto "application/octet-stream". Para esto se puede usar mime-types, que tiene utilitarios para resolver content-types desde la extensión del archivo. Puedes leer el issue relacionado acá.

    const mime = require('mime-types');
    const uploadCommand = new PutObjectCommand({
      // ...
      ContentType: mime.lookup(filePath),
    });
    client.send(uploadCommand);
  3. Si el Key del archivo tiene la forma de una ruta, @aws-sdk/client-s3 va a crear esas mismas carpetas en el bucket de S3.

    Tomando como ejemplo static/js/app.js:

    const uploadCommand = new PutObjectCommand({
      // ...
      Key: 'static/js/app.js',
    });
    client.send(uploadCommand);

    Esta sería la estructura resultante en el bucket:

    my-bucket-s3/
    └── static/
        └── js/
            └── app.js
  4. Relacionado al punto anterior: si el Key empieza con /, @aws-sdk/client-s3 va a crear una carpeta llamada /. Tomando /app.js como ejemplo:

    const uploadCommand = new PutObjectCommand({
      // ...
      Key: '/app.js',
    });
    client.send(uploadCommand);

    Resultado en el bucket:

    my-bucket-s3/
    └── /
        └── app.js
  5. Si vas a usar una ruta como Key, recuerda convertir las barras invertidas de Windows a forward-slashes. Para esto podemos usar slash:

    const slash = require('slash');
     
    const filePath = 'folder\\dist\\app.js';
    const uploadCommand = new PutObjectCommand({
      // ...
      Key: slash(filePath), // 'folder\\dist\\app.js' → 'folder/dist/app.js'
    });
    client.send(uploadCommand);

Subir una carpeta de archivos a S3

A diferencia de aws-cli, @aws-sdk/client-s3 solo permite subir un archivo por vez. Por eso necesitamos algunos utilitarios:

  1. walk, para recorrer una carpeta de forma recursiva.
  2. uploadFile, para subir cada archivo encontrado a S3.

Asumamos que la carpeta a subir es my-app/dist, que contiene los estáticos que la app necesita para funcionar. Y que dentro del bucket queremos que esos estáticos vivan bajo la carpeta static.

Al final, lo que queremos lograr es poder acceder a los archivos vía la URL url-publica-de-s3.com/static/:

my-app/
└── dist/         <== subir esto a S3
    ├── app.js
    ├── manifest.json
    ├── chunks/
    │   ├── app.js
    │   ├── chunk-1.js
    │   ├── chunk-2.js
    │   └── chunk-3.js
    └── images/
        └── background.png
└── src/

Util: walk

Esta función recorre toda una carpeta de forma recursiva — me refiero a que también entra en las subcarpetas. Como segundo parámetro recibe un callback que se ejecuta para cada archivo encontrado:

const path = require('path');
const fs = require('fs');
 
function walk(dir, callback) {
  const files = fs.readdirSync(dir);
 
  files.forEach(file => {
    const filePath = path.join(dir, file);
 
    // si es un directorio, recursamos
    if (fs.statSync(filePath).isDirectory()) {
      walk(filePath, callback);
    } else {
      // ejecutamos el callback en cada archivo
      callback(filePath);
    }
  });
}

Util: uploadFile

Sube un archivo específico a S3. Acá usamos mime-types y slash:

const fs = require('fs');
const path = require('path');
 
const mime = require('mime-types');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const slash = require('slash');
 
const { AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET } =
  process.env;
const client = new S3Client({
  region: AWS_REGION,
  credentials: {
    accessKeyId: AWS_ACCESS_KEY_ID,
    secretAccessKey: AWS_SECRET_ACCESS_KEY,
  },
});
 
function uploadFile({ filePath, folderPath, bucketBasePath = '' }) {
  /**
   * Quitamos el folderPath para quedarnos con la ruta relativa
   * de '/project/my-app/dist/chunks/app.js' a 'chunks/app.js'
   */
  const filename = filePath.replace(folderPath, '');
  /**
   * Anteponemos la carpeta destino
   * de 'chunks/chunk-1.js' a 'static/js/chunks/chunk-1.js'
   */
  const s3File = slash(path.join(bucketBasePath, filename));
  /**
   * Removemos el '/' inicial si existe
   * algunos archivos vienen como '/image.png' y eso crearía una carpeta llamada '/'
   */
  const s3KeyFile = s3File[0] === '/' ? s3File.slice(1) : s3File;
 
  const params = {
    Bucket: S3_BUCKET,
    Key: s3KeyFile,
    Body: fs.readFileSync(filePath),
    ContentType: mime.lookup(filePath),
  };
 
  console.log('Uploading file: ', s3KeyFile);
  const uploadCommand = new PutObjectCommand(params);
  return client.send(uploadCommand).catch(err => {
    console.log('Error uploading file', err);
    process.exit(1);
  });
}

Finalmente, combinamos los dos utils para subir la carpeta entera:

async function uploadFolderToS3(folderPath, bucketBasePath) {
  const filesArr = [];
 
  // recolectamos todos los archivos
  walk(folderPath, filePath => {
    filesArr.push(filePath);
  });
 
  // los subimos uno por uno, en serie
  await filesArr.reduce((p, filePath) => {
    return p.then(() => uploadFile({ filePath, folderPath, bucketBasePath }));
  }, Promise.resolve());
}
 
const distFolderPath = path.join(process.cwd(), './dist');
// 'static' es la carpeta destino dentro del bucket
uploadFolderToS3(distFolderPath, 'static');

Conclusión

Como vimos, usar @aws-sdk/client-s3 no es tan complicado. Lo difícil es escribir la lógica alrededor para cubrir el caso de uso real — en este caso, subir una carpeta entera de estáticos a S3.

¡Gracias por leer!