AWS Lambda - ESM Node.js

Algunas generalidades previas

En la mayoría de los paradigmas de programación, podemos encontrar sistemas que nos permiten auto organizar el código en pequeñas piezas o para incorporar librerías/bibliotecas de terceros a nuestro código. Al combinar todo lo anterior resulta una pieza de código más grande y compleja.

Desde un inicio Javascript utilizó el sistema de carga de módulos llamado CommonsJS (CJS) y es parte integral de Node.js hasta la version v8.5.0 donde se incorpora un nuevo sistema de carga de módulos, ESM. A partir de la version v13.2.0 de Node.js fue estabilizado e incorporado como un nuevo estándar.

¿ Por qué deberíamos usar ESM en AWS Lambdas ?

James Beswick (Principal Developer Advocate for the AWS Serverless Team) escribió un artículo titulado Using Node.js ES modules and top-level await in AWS Lambda ,donde detalla el por qué y en qué casos deberías usar ESM como cargador de módulos de Javascript en el contexto de un AWS Lambda. Uno de los motivos más importantes de usar ESM, es que la carga en frío de un lambda tarda casi un tercio en comparación con CommonsJS.

Dejo aquí la comparativa entre CJS y ESM donde en el p99 (carga en frío) la partida se reduce a un tercio mejorando el rendimiento en un 43,5%. Para esta prueba, todas las métricas de ESM salieron por debajo (mejores en tiempo) de las de CJS es simplemente marginal (alrededor de 2-5ms).

¿ Cómo usamos ESM en AWS Lambdas ?

Configuración del artefacto - package.json

Primero que todo se deben ajustar un par de atributos en el archivo package.json para indicar que el módulo es del tipo ESM.

package.json
1
2
3
4
5
6
7
8
9
{
"name": "serverless-lambda-esm",
"version": "1.0.0-beta",
"description": "Serverless Lambda ESM",
"main": "src/Handler.js", // <-- Sera cargado como módulo
"license": "UNLICENSED",
"private": true,
"type": "module", // <-- Este atributo debe tener el valor: module
}

Con este ajuste le indicamos al cargador que trate los archivos como ES módulos según su extensión, es decir, con el atributo type y valor en module todos los archivos con extensión .js serán tratados como ESM. Si por alguna razón quieres mezclar ambos mundos debes hacerlo de forma explícita usando como extensión .cjs. Por el contrario, si no utilizas el atributo type o lo dejas con valor commonjs, todos los archivos con extension .js serán tratados como CJS y si quieres utilizar ESM estos deben tener extension .mjs.

Preparamos el Handler

Handler.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { DateTime } from "luxon";

/**
*
* @param {*} event
* @returns
*/
export const handler = async (event, context) => {
const date = DateTime.now().toFormat("yyyy-MM-dd");
console.log("Date: ", date);
console.log(JSON.stringify(event));

return {
statusCode: 200,
body: JSON.stringify({ message: "ok" })
};
};

El handler debe estar expuesto de esa forma para que sea cargado como ESM. Se puede apreciar que ya no usamos la sentencia require para cargar una dependencia externa (en esta caso luxon) y en su reemplazo utilizamos import.

Algunos detallitos que he ido aprendiendo en el camino y que no está muy explícito en la documentación, es que los imports de nuestros archivos, es decir, el código que está dentro del proyecto, deben ser cargados y nombrados con su extensión, dejo un ejemplo:

Handler.js
1
2
import { DateTime } from "luxon"; // <-- Sin la extension (lib)
import Service from './service/Service.js'; // <-- Con la extension

Export de los módulos

Service.js
1
2
3
4
5
6
7
8
9
10
11

const Service = {
/**
* Funcion
*/
doSomething: async(event)=>{
console.log("Event: ", JSON.stringify(event));
}
};

export default Service; // <-- Así se exponen los módulos al código

Palabras al cierre

Espero les sea útil para sus desarrollos de lambdas con Node.js. Más adelante ire dejando nuevos artículos con ejemplos e ideas para implementar con AWS Lambdas. Pase y deje su comentario.

Enlaces de interés

Escuchando Subterranean Homesick Alien del disco OK Computer

Radiohead

TodoList Con Node.js Y MongoDB - Parte II

Lo prometido es deuda, aquí vamos con la segunda parte donde integraremos Passport para poder validar los request y contronlar sesiones de usuarios.

Primero debemos agregar los siguientes imports al archivo app.js y la estrategia (plugin de autenticación) a utilizar. Además se agregan los archivos UserModel.js y security.js extensión del modelo de User y métodos de validación del request respectivamente:

1
2
3
4
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var security = require('./config/security');
var User = require('./model/UserModel');

Ahora configuramos Express para que pueda utilizar Passport como administrador de sesiones de usuario. Es necesario que express use su propio administrador de sesiones y además debe ser inicializado antes que el de passport.

1
2
3
4
5
6
app.use(express.cookieParser('your secret here'));
app.use(express.session());

// passport initialize
app.use(passport.initialize());
app.use(passport.session());

Luego agregamos mecanismos de autenticación, serialización y deserialización a passport:

1
2
3
passport.use(new LocalStrategy(User.authenticate()));
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());

Model

Ahora extendemos el modelo de usuario para agregar nuestros atributos (si es es necesario, por ahora lo dejaremos vacío):

1
2
3
4
5
6
7
8
9
10
var mongoose = require('mongoose');
var passportLocalMongoose = require('passport-local-mongoose');

var UserSchema = new mongoose.Schema({});

UserSchema.plugin(passportLocalMongoose);

var UserModel = mongoose.model('User', UserSchema);

module.exports = UserModel;

Seguridad

Hay un archivo que se llama security.js que contiene una función que valida si el request solicitado está autenticado por passport, si está autenticado lo “deja pasar” (next), de lo contrario lo redirecciona al template de login.

1
2
3
4
5
6
exports.ensureAuthenticated = function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
};

Nos queda asegurar las urls o recursos que necesitemos con la función ensureAuthenticated en el archivo app.js. La idea es que sea lo menos intrusivo posible. Hay dos formas de hacerlo, la primera es intrusiva (y no recomendada aunque puede utilizarse con fines específicos) y se debe realizar por cada request en los archivos que contienen los Controllers:

1
2
3
4
5
6
7
exports.authenticationExample = function(req, res) {
if (req.isAuthenticated()) {
// Do something
} else {
res.redirect('/login');
}
}

La segunda es interceptando los request y validando según algún patrón, que es nuestro caso. Esta configuración esta en app.js donde se declaran las rutas a los Controllers:

1
2
app.all('/api/*', security.ensureAuthenticated);
app.all('/todos', security.ensureAuthenticated);

Con esto interceptamos todos los request que van a la API y los que van a /todos y los hacemos pasar por la función de autenticación de request. Con esto evitamos poner dichas líneas de autenticación en cada controller.

Login y Logout

Finalmente nos queda la última parte respecto de la autenticación y es configurar el login y el logout. Para el login usamos la API de passport y delegamos el trabajo de autenticación de la siguiente forma (configuración):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Funcion sin autenticación que permite desplegar el template de Login
*/
app.get('/login', function(req, res) {
res.render('login', {});
});

/**
* Función de login que permite delegar la autenticacion en la API de
* passport, se configura además cuando la autenticación es exitosa a que url
* lo debe redireccinar y lo mismo en caso de error.
*/
app.post('/login', passport.authenticate('local', {
successRedirect: '/todos',
failureRedirect: '/login'
}));

El logout es más sencillo y lo único que hay que hacer es invalidar el request, destruir la sesión del usuario y redireccionar al login.

1
2
3
4
app.get('/logout', function(req, res) {
req.logout();
res.redirect('/login');
});

Me queda pendiente en la próxima parte del tutorial explicar cómo consumir estos servicios usando angular y desplegar los templates.

TodoList con Node.js y MongoDB - Parte I

Llevo un tiempo estudiando y haciendo algunas cosas con Node.js y me he llevado una grata impresión con esta plataforma. Al principio cuesta un poco entender el cómo funciona ya que el paradigma es muy distinto del que he venido usando durante este último tiempo.

Me animé y empece a usar Node.js para hacer algo simple y así tratar de entender el cómo funciona. Para hacer algo mas sabrosa la experiencia agregué algunos ingredientes adicionales, un framework para desarrollo web llamado Express, Mongoose para modelar los documentos que van a ir a parar a MongoDB, Passport para autenticar los request (control de usuarios y permisos), uso de templates con EJS y finalmente para la vista usamos el framework AngularJS. Como podrán ver es un stack de tecnologías basadas en Javascript.

Para iniciar voy a explicar algunas partes del código que personalmente me costó entender e implementar. Algunos de esos puntos fueron la integración con passport para la autenticación de los request, control de usuario y uso de datos en sesión.

Configuración de node.js

Cuando utilizamos nodejs siempre hay un archivo principal donde se configura toda la plataforma, en este caso el archivo es llamado app.js. Voy a ir mostrando ciertas partes del código (si quieren ver el código, esta disponible en github):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var express = require('express');

var indexController = require('./routes/IndexController');
var todosController = require('./routes/TodosController');
var registerController = require('./routes/RegisterController');

var http = require('http');
var path = require('path');
var app = express();
var db = require('./config/database');


var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;

var security = require('./config/security');
var User = require('./model/UserModel');

Controllers

Lo primero es incluir el módulo de express y luego los Controllers de la aplicación. Veamos el código de IndexController

1
2
3
4
5
exports.index = function(req, res) {
res.render('todos', {
user: req.user
});
};

Este es el Controller mas simple de la aplicación, lo único que hace es exponer mediante exports la función index cuya responsabilidad es desplegar la página principal de la aplicación. Lo demás está en el controlador de la API que será llamada desde dicha página (usando ajax).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
var Todo = require('../model/TodosModel');

/**
* Busca todos los todos para el usuario registrado
* @param {Object} req request
* @param {Object} res response
*/
function findAllTodosByUser(req, res) {
var userId = req.user.id;

Todo.find()
.where('creator')
.equals(userId)
.sort('date')
.exec(function(err, todos) {
if (err) {
res.send(err);
}
res.json(todos);
});
}

exports.allTodos = findAllTodosByUser;

/**
* Crea un todo para el usuario
* @param {Object} req request
* @param {Object} res response
* @return {Object} Lista de todos
*/
exports.createTodo = function(req, res) {
var userId = req.user.id;
var textTodo = req.body.text;

Todo.create({
text: textTodo,
done: false,
creator: userId
}, function(error, todo) {
if (error) {
res.send(error);
}
findAllTodosByUser(req, res);
});
}

/**
* Elimina un Todo por su ID
* @param {Object} req request
* @param {Object} res response
* @return {Object} Lista de todos
*/
exports.deleteTodo = function(req, res) {
Todo.remove({
_id: req.params.todo_id
}, function(error, todo) {
if (error) {
res.send(error);
}
findAllTodosByUser(req, res);
});
}

Este controlador expone la API REST con la que se comunicará la pagina principal, haciendo llamadas ajax a los distintos métodos. En esta pieza se hace un require de otro script que representa el Model, en este caso es un document para MongoDB.

Model con mongoose

Aquí mostraré como se modeló el documento principal de la aplicación Todo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var mongoose = require('mongoose');

var TodoSchema = new mongoose.Schema({
text: String,
date: {
type: Date,
default: Date.now
},
done: {
type: Boolean,
default: false
},
creator: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
});

var TodoModel = mongoose.model('Todo', TodoSchema);

module.exports = TodoModel;

Primero se incluye el módulo de mongoose que nos permitirá modelar los documentos para MongoDB. Luego se crea un schema para modelar y se le agregan los atributos que va a poseer y sus respectivas validaciones. Por ejemplo, el atributo date es de tipo Date y valor por omisión la fecha de hoy.

Además este schema contiene una referencia a otra Collection para dejar relacionado el usuario creador en sus Todos.

Route y manejo de URLs

Finalmente para que la aplicación funcioné, hay que indicarle a node.js que las urls que se soliciten hay que enviarlas a alguien que las atienda, en este caso, los controladores y sus funciones.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
app.get('/login', function(req, res) {
res.render('login', {});
});

app.post('/login', passport.authenticate('local', {
successRedirect: '/todos',
failureRedirect: '/login'
}));

app.get('/logout', function(req, res) {
req.logout();
res.redirect('/login');
});

app.get('/', function(req, res) {
res.redirect('/login')
})

app.get('/register', registerController.index);
app.post('/register', registerController.registerUser);

app.all('/api/*', security.ensureAuthenticated);
app.all('/todos', security.ensureAuthenticated);

app.get('/todos', indexController.index);
app.get('/api/todos', todosController.allTodos);
app.post('/api/todos', todosController.createTodo);
app.delete('/api/todos/:todo_id', todosController.deleteTodo);

No hay mucho que explicar, a buen programador pocas lineas de código, las urls que ahi aparecen son ruteadas a los controladores o se implementa el callback inline, como por ejemplo las urls de login y logout. Los controller que digan security o passport, serán materia del próximo post.

Quedan algunos temas sin tocar en este capítulo, falta que revisemos la integración con AngularJS, el despliegue de los templates con EJS y la integración con passport para autenticar y autorizar los request.

Si tienen preguntas, bienvenidas sean.