Storing instances in Express sessions

When developing apps with Express, at some point you'll probably need to store user data across different requests, that's where sessions come in. I was using express-session when I came across an issue, I needed to store Shopify-api-node instances across sessions.

This is how the app was structured at first:

// entry-index.js

var express = require("express");
var session = require("express-session");
var RedisStore = require("connect-redis")(session);

var middlewareShopify = require("./middleware-shopify");
var routerRoutes = require("./router-routes");

var app = express();
var appPort = process.env.PORT || 3000;

app.use(session({
  store: new RedisStore({ host: "localhost", port: 6379, ttl: 260 }),
  secret: "gu2cQL7z2z4pKypm",
  resave: false,
  saveUninitialized: true
}));

app.use(middlewareShopify);
app.use(routerRoutes);

app.listen(appPort, () => {
  console.log(`App listening on port ${appPort}!`);
});
// middleware-shopify.js

module.exports = (req, res, next) => {
  if( !req.session.shopName || !req.session.apiKey || !req.session.password ) {
    req.session.shopName = req.query.shopName;
    req.session.apiKey = req.query.apiKey;
    req.session.password = req.query.password;
  }

  var { shopName, apiKey, password }  = req.session;

  if( !shopName || !apiKey || !password ) {
    return res.status(500).send("Something broke.");
  }

  return next();
};
// routes-routes.js

var express = require("express");

var Shopify = require("shopify-api-node");

var router = express.Router();

router.get("/", (req, res) => {
  var { shopName, apiKey, password }  = req.session;

  var shopify = new Shopify({
    shopName,
    apiKey,
    password,
    autoLimit: true
  });

  shopify.shop.get()
    .then((shop) => {
      return res.send(JSON.stringify(shop));
    })
    .catch((err) => {
      return res.status(500).send("Something broke.");
    });
});

router.get("/other-route", (req, res) => {
  var { shopName, apiKey, password }  = req.session;

  var shopify = new Shopify({
    shopName,
    apiKey,
    password,
    autoLimit: true
  });

  shopify.shop.get()
    .then((shop) => {
      return res.send(JSON.stringify(shop));
    })
    .catch(() => {
      return res.status(500).send("Something broke.");
    });
});

module.exports = router;

This works like so:

  1. Visit the URL: https://localhost:3000 and you'll get an error due to the lack of required query parameters (shopName, apiKey and password).
  2. Visit a URL like: http://localhost:3000/?shopName=your-shop-name&apiKey=your-api-key&password=your-app-password and you'll see data displayed from an API call to the your-shop-name shop.
  3. The query parameters are now saved to your session, so you can now visit routes without the query parameters and it'll still work.

This is great and almost works exactly how I want, but there's one issue; every route defines a new Shopify instance. If you take a look at the Shopify-api-node documentation, you'll see it provides a autoLimit option, this is a simple way around hitting the Shopify API call limit. The only catch is that for autoLimit to work properly, there needs to be just one Shopify instance per shop.

My first thought was to setup the Shopify instance in the middleware-shopify middleware, like this:

// middleware-shopify.js

var Shopify = require("shopify-api-node");

module.exports = (req, res, next) => {
  if( !req.session.shopName || !req.session.apiKey || !req.session.password ) {
    req.session.shopName = req.query.shopName;
    req.session.apiKey = req.query.apiKey;
    req.session.password = req.query.password;
  }

  var { shopName, apiKey, password }  = req.session;

  if( !shopName || !apiKey || !password ) {
    return res.status(500).send("Something broke.");
  }

  if( !req.session.shopify ) {
    req.session.shopify = new Shopify({
      shopName,
      apiKey,
      password,
      autoLimit: true
    });
  }

  return next();
};

Then in my router-routes.js router, I could use it like this:

router.get("/", (req, res) => {
  var { shopify }  = req.session;

  shopify.shop.get()
    .then((shop) => {
      return res.send(JSON.stringify(shop));
    })
    .catch((err) => {
      return res.status(500).send("Something broke.");
    });
});

This doesn't work because you can only store JSON-serializable data in req.session, instances can't be serialized. What I ended up doing was creating a session/Shopify instance map, each session gets it's own Shopify instance, identified via the session.id. I started off by creating a new object-shopifys.js file which exports an empty object:

// object-shopifys.js

module.exports = {};

I then changed the middleware-shopify middleware, like this:

// middleware-shopify.js

var Shopify = require("shopify-api-node");

var shopifys = require("./object-shopifys");

module.exports = (req, res, next) => {
  if( !req.session.shopName || !req.session.apiKey || !req.session.password ) {
    req.session.shopName = req.query.shopName;
    req.session.apiKey = req.query.apiKey;
    req.session.password = req.query.password;
  }

  var { shopName, apiKey, password }  = req.session;

  if( !shopName || !apiKey || !password ) {
    return res.status(500).send("Something broke.");
  }

  if( !shopifys[req.session.id] ) {
    shopifys[req.session.id] = new Shopify({
      shopName,
      apiKey,
      password,
      autoLimit: true
    });
  }

  return next();
};

By default, the shopifys object is empty, but after the middleware has executed for the first time of the users session, it'll look like:

// `Shopify...` refers to the actual instance, I left it out
// due to brevity.
{
  "Xur_jku_V-pUfwu3tuNtAqEydp8CTxbK": Shopify...
}

I then changed the router to reference the new shopifys object:

// router-routes.js

var express = require("express");

var shopifys = require("./object-shopifys");

var router = express.Router();

router.get("/", (req, res) => {
  var shopify = shopifys[req.session.id];

  shopify.shop.get()
    .then((shop) => {
      return res.send(JSON.stringify(shop));
    })
    .catch((err) => {
      return res.status(500).send("Something broke.");
    });
});

router.get("/other-route", (req, res) => {
  var shopify = shopifys[req.session.id];

  shopify.shop.get()
    .then((shop) => {
      return res.send(JSON.stringify(shop));
    })
    .catch(() => {
      return res.status(500).send("Something broke.");
    });
});

module.exports = router;

There we go, each session gets one Shopify instance and we don't have to worry about hitting the API call limit if we navigate to multiple API-heavy routes at the same time.

One thing we do have to worry about is a potential memory leak. Over time the shopifys object will build up and get larger. A simple solution would be to setup a cron-job which periodically checks the session IDs in the shopifys map and compares them to the redis sess:* keys, if the key no longer exists in redis, then remove it from the shopifys map.

I'd like to thank Luigi Pinca (the main contributer of the Shopify-api-node module) for walking me through this on a GitHub issue I opened.