AWS CDKでLambda Function用のTypeScriptのバンドルを簡単に行う

AWS CDKにParcelを使ってJavaScript/TypeScriptをバンドルしてくれるモジュールが追加されました!使い方をご紹介します。
2020.02.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おはようございます、加藤です。先日リリースされたAWS CDK 1.23から、aws-lambda-nodejsというモジュールが追加されました。これを使う事で、Lambda Function用のTypeScriptのトランスコンパイルとバンドルを簡単に行う事ができるのでご紹介します。

aws-lambda-nodejs ってなに?

現在このモジュールはベータ版です。ご注意ください

Node.jsでLambda Functionを作る為のHigh level Constructです。Lambda Functionに外部モジュールを参照するコードをデプロイする場合は、当然それらを一緒にデプロイするかLambda Layerにデプロイする必要があります。TypeScriptで書いている場合は合わせてトランスコンパイルも必要になります。

なので、AWS CDKでTypeScriptのLambda Functionを書く場合は、以下から方法を選択する必要があります。

  • tscでトランスコンパイルして、node_modulesはLayerで持つ
  • tscでトランスコンパイルして、node_modulesを全てのFunctionに持たせる
  • webpackやparcelでトランスコンパイル&バンドル

aws-lambda-nodejsはAWS CDK側でJavaScript/TypeScriptをParcelで、バンドルしてくれるモジュールです。これによって開発者はJavaScript/TypeScriptを書く事に集中でき、「あっ、トランスコンパイルするの忘れた。。。」が起きなくなります(私は良くやります。。。)

AWS CDKはTypeScriptで書いた場合、ts-nodeで実行するのでユーザー側で意識してトランスコンパイルする必要がありません。このモジュールを使う場合はユーザーはトランスコンパイルやバンドルを一切考えなくて良くなります。

  • ドキュメント: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-nodejs-readme.html
  • プルリクエスト: https://github.com/aws/aws-cdk/pull/5532

使い方

AWS CDKで下記の様に書けばOKです。

lib/stack.ts

import {NodejsFunction} from '@aws-cdk/aws-lambda-nodejs';

const fnDemo = new NodejsFunction(this, 'demo', {
  entry: 'src/lambda/handlers/demo.ts',
});

お試しで下記の様にLambda Functionを書きます。外部ライブラリを使用するTypeScriptなので、このままではLambda Function上で動作させる事ができません。

src/lambda/handlers/demo.ts

import {APIGatewayEventRequestContext, APIGatewayProxyEvent, APIGatewayProxyResult} from 'aws-lambda';
import {v4 as uuid} from 'uuid';

export async function handler(
  event: APIGatewayProxyEvent,
  context: APIGatewayEventRequestContext
): Promise<APIGatewayProxyResult> {

  return {
    statusCode: 201,
    headers: event.headers,
    body: JSON.stringify({
      id: uuid(),
      method: event.httpMethod,
      query: event.queryStringParameters,
    })
  }
}

cdk synthでどのようにバンドルされるか確認します。

yarn run cdk synth

元々のTypeScriptが置いてあるディレクトリに .build というディレクトリが作成されバンドルされたJavaScriptが生成されました。また、実際にデプロイして動作する事も確認しました。下記が生成されたJavaScriptです。

// modules are defined as an array
// [ module function, map of requires ]
//
// map of requires is short require name -> numeric require
//
// anything defined in a previous bundle is accessed via the
// orig method which is the require for previous bundles
parcelRequire = (function (modules, cache, entry, globalName) {
  // Save the require from previous bundle to this closure if any
  var previousRequire = typeof parcelRequire === 'function' && parcelRequire;
  var nodeRequire = typeof require === 'function' && require;

  function newRequire(name, jumped) {
    if (!cache[name]) {
      if (!modules[name]) {
        // if we cannot find the module within our internal map or
        // cache jump to the current global require ie. the last bundle
        // that was added to the page.
        var currentRequire = typeof parcelRequire === 'function' && parcelRequire;
        if (!jumped && currentRequire) {
          return currentRequire(name, true);
        }

        // If there are other bundles on this page the require from the
        // previous one is saved to 'previousRequire'. Repeat this as
        // many times as there are bundles until the module is found or
        // we exhaust the require chain.
        if (previousRequire) {
          return previousRequire(name, true);
        }

        // Try the node require function if it exists.
        if (nodeRequire && typeof name === 'string') {
          return nodeRequire(name);
        }

        var err = new Error('Cannot find module \'' + name + '\'');
        err.code = 'MODULE_NOT_FOUND';
        throw err;
      }

      localRequire.resolve = resolve;
      localRequire.cache = {};

      var module = cache[name] = new newRequire.Module(name);

      modules[name][0].call(module.exports, localRequire, module, module.exports, this);
    }

    return cache[name].exports;

    function localRequire(x){
      return newRequire(localRequire.resolve(x));
    }

    function resolve(x){
      return modules[name][1][x] || x;
    }
  }

  function Module(moduleName) {
    this.id = moduleName;
    this.bundle = newRequire;
    this.exports = {};
  }

  newRequire.isParcelRequire = true;
  newRequire.Module = Module;
  newRequire.modules = modules;
  newRequire.cache = cache;
  newRequire.parent = previousRequire;
  newRequire.register = function (id, exports) {
    modules[id] = [function (require, module) {
      module.exports = exports;
    }, {}];
  };

  var error;
  for (var i = 0; i < entry.length; i++) {
    try {
      newRequire(entry[i]);
    } catch (e) {
      // Save first error but execute all entries
      if (!error) {
        error = e;
      }
    }
  }

  if (entry.length) {
    // Expose entry point to Node, AMD or browser globals
    // Based on https://github.com/ForbesLindesay/umd/blob/master/template.js
    var mainExports = newRequire(entry[entry.length - 1]);

    // CommonJS
    if (typeof exports === "object" && typeof module !== "undefined") {
      module.exports = mainExports;

    // RequireJS
    } else if (typeof define === "function" && define.amd) {
     define(function () {
       return mainExports;
     });

    // <script>
    } else if (globalName) {
      this[globalName] = mainExports;
    }
  }

  // Override the current require with this new one
  parcelRequire = newRequire;

  if (error) {
    // throw error from earlier, _after updating parcelRequire_
    throw error;
  }

  return newRequire;
})({"Ls8Z":[function(require,module,exports) {
// Unique ID creation requires a high quality random # generator.  In node.js
// this is pretty straight-forward - we use the crypto API.

var crypto = require('crypto');

module.exports = function nodeRNG() {
  return crypto.randomBytes(16);
};

},{}],"bRX8":[function(require,module,exports) {
/**
 * Convert array of 16 byte values to UUID string format of the form:
 * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
 */
var byteToHex = [];
for (var i = 0; i < 256; ++i) {
  byteToHex[i] = (i + 0x100).toString(16).substr(1);
}

function bytesToUuid(buf, offset) {
  var i = offset || 0;
  var bth = byteToHex;
  // join used to fix memory issue caused by concatenation: https://bugs.chromium.org/p/v8/issues/detail?id=3175#c4
  return ([
    bth[buf[i++]], bth[buf[i++]],
    bth[buf[i++]], bth[buf[i++]], '-',
    bth[buf[i++]], bth[buf[i++]], '-',
    bth[buf[i++]], bth[buf[i++]], '-',
    bth[buf[i++]], bth[buf[i++]], '-',
    bth[buf[i++]], bth[buf[i++]],
    bth[buf[i++]], bth[buf[i++]],
    bth[buf[i++]], bth[buf[i++]]
  ]).join('');
}

module.exports = bytesToUuid;

},{}],"Hr1T":[function(require,module,exports) {
var rng = require('./lib/rng');
var bytesToUuid = require('./lib/bytesToUuid');

// **`v1()` - Generate time-based UUID**
//
// Inspired by https://github.com/LiosK/UUID.js
// and http://docs.python.org/library/uuid.html

var _nodeId;
var _clockseq;

// Previous uuid creation time
var _lastMSecs = 0;
var _lastNSecs = 0;

// See https://github.com/uuidjs/uuid for API details
function v1(options, buf, offset) {
  var i = buf && offset || 0;
  var b = buf || [];

  options = options || {};
  var node = options.node || _nodeId;
  var clockseq = options.clockseq !== undefined ? options.clockseq : _clockseq;

  // node and clockseq need to be initialized to random values if they're not
  // specified.  We do this lazily to minimize issues related to insufficient
  // system entropy.  See #189
  if (node == null || clockseq == null) {
    var seedBytes = rng();
    if (node == null) {
      // Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1)
      node = _nodeId = [
        seedBytes[0] | 0x01,
        seedBytes[1], seedBytes[2], seedBytes[3], seedBytes[4], seedBytes[5]
      ];
    }
    if (clockseq == null) {
      // Per 4.2.2, randomize (14 bit) clockseq
      clockseq = _clockseq = (seedBytes[6] << 8 | seedBytes[7]) & 0x3fff;
    }
  }

  // UUID timestamps are 100 nano-second units since the Gregorian epoch,
  // (1582-10-15 00:00).  JSNumbers aren't precise enough for this, so
  // time is handled internally as 'msecs' (integer milliseconds) and 'nsecs'
  // (100-nanoseconds offset from msecs) since unix epoch, 1970-01-01 00:00.
  var msecs = options.msecs !== undefined ? options.msecs : new Date().getTime();

  // Per 4.2.1.2, use count of uuid's generated during the current clock
  // cycle to simulate higher resolution clock
  var nsecs = options.nsecs !== undefined ? options.nsecs : _lastNSecs + 1;

  // Time since last uuid creation (in msecs)
  var dt = (msecs - _lastMSecs) + (nsecs - _lastNSecs)/10000;

  // Per 4.2.1.2, Bump clockseq on clock regression
  if (dt < 0 && options.clockseq === undefined) {
    clockseq = clockseq + 1 & 0x3fff;
  }

  // Reset nsecs if clock regresses (new clockseq) or we've moved onto a new
  // time interval
  if ((dt < 0 || msecs > _lastMSecs) && options.nsecs === undefined) {
    nsecs = 0;
  }

  // Per 4.2.1.2 Throw error if too many uuids are requested
  if (nsecs >= 10000) {
    throw new Error('uuid.v1(): Can\'t create more than 10M uuids/sec');
  }

  _lastMSecs = msecs;
  _lastNSecs = nsecs;
  _clockseq = clockseq;

  // Per 4.1.4 - Convert from unix epoch to Gregorian epoch
  msecs += 12219292800000;

  // `time_low`
  var tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000;
  b[i++] = tl >>> 24 & 0xff;
  b[i++] = tl >>> 16 & 0xff;
  b[i++] = tl >>> 8 & 0xff;
  b[i++] = tl & 0xff;

  // `time_mid`
  var tmh = (msecs / 0x100000000 * 10000) & 0xfffffff;
  b[i++] = tmh >>> 8 & 0xff;
  b[i++] = tmh & 0xff;

  // `time_high_and_version`
  b[i++] = tmh >>> 24 & 0xf | 0x10; // include version
  b[i++] = tmh >>> 16 & 0xff;

  // `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant)
  b[i++] = clockseq >>> 8 | 0x80;

  // `clock_seq_low`
  b[i++] = clockseq & 0xff;

  // `node`
  for (var n = 0; n < 6; ++n) {
    b[i + n] = node[n];
  }

  return buf ? buf : bytesToUuid(b);
}

module.exports = v1;

},{"./lib/rng":"Ls8Z","./lib/bytesToUuid":"bRX8"}],"SC1p":[function(require,module,exports) {
var rng = require('./lib/rng');
var bytesToUuid = require('./lib/bytesToUuid');

function v4(options, buf, offset) {
  var i = buf && offset || 0;

  if (typeof(options) == 'string') {
    buf = options === 'binary' ? new Array(16) : null;
    options = null;
  }
  options = options || {};

  var rnds = options.random || (options.rng || rng)();

  // Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
  rnds[6] = (rnds[6] & 0x0f) | 0x40;
  rnds[8] = (rnds[8] & 0x3f) | 0x80;

  // Copy bytes to buffer, if provided
  if (buf) {
    for (var ii = 0; ii < 16; ++ii) {
      buf[i + ii] = rnds[ii];
    }
  }

  return buf || bytesToUuid(rnds);
}

module.exports = v4;

},{"./lib/rng":"Ls8Z","./lib/bytesToUuid":"bRX8"}],"SkFz":[function(require,module,exports) {
var v1 = require('./v1');
var v4 = require('./v4');

var uuid = v4;
uuid.v1 = v1;
uuid.v4 = v4;

module.exports = uuid;

},{"./v1":"Hr1T","./v4":"SC1p"}],"EWEi":[function(require,module,exports) {
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});

const uuid_1 = require("uuid");

async function handler(event, context) {
  return {
    statusCode: 201,
    headers: event.headers,
    body: JSON.stringify({
      id: uuid_1.v4(),
      method: event.httpMethod,
      query: event.queryStringParameters
    })
  };
}

exports.handler = handler;
},{"uuid":"SkFz"}]},{},["EWEi"], "handler")

仕組み

どういう仕組みかコードを見てみます。

aws-cdk/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts

export class NodejsFunction extends lambda.Function {
  constructor(scope: cdk.Construct, id: string, props: NodejsFunctionProps = {}) {
    if (props.runtime && props.runtime.family !== lambda.RuntimeFamily.NODEJS) {
      throw new Error('Only `NODEJS` runtimes are supported.');
    }

    const entry = findEntry(id, props.entry);
    const handler = props.handler || 'handler';
    const buildDir = props.buildDir || path.join(path.dirname(entry), '.build');
    const handlerDir = path.join(buildDir, crypto.createHash('sha256').update(entry).digest('hex'));
    const defaultRunTime = nodeMajorVersion() >= 12
    ? lambda.Runtime.NODEJS_12_X
    : lambda.Runtime.NODEJS_10_X;
    const runtime = props.runtime || defaultRunTime;

    // Build with Parcel
    build({
      entry,
      outDir: handlerDir,
      global: handler,
      minify: props.minify,
      sourceMaps: props.sourceMaps,
      cacheDir: props.cacheDir,
      nodeVersion: extractVersion(runtime),
    });

    super(scope, id, {
      ...props,
      runtime,
      code: lambda.Code.fromAsset(handlerDir),
      handler: `index.${handler}`,
    });
  }
}

NodejsFunction は、 Function を継承していました。 以下の優先順位でjs/tsファイルを探し、Parcelを使ってバンドルしています。

  1. Given entry file
  2. A .ts file named as the defining file with id as suffix (defining-file.id.ts)
  3. A .js file name as the defining file with id as suffix (defining-file.id.js)
export function build(options: BuildOptions): void {
  const pkgPath = findPkgPath();
  let originalPkg;

  try {
    if (options.nodeVersion && pkgPath) {
      // Update engines.node (Babel target)
      originalPkg = updatePkg(pkgPath, {
        engines: { node: `>= ${options.nodeVersion}` }
      });
    }

    const args = [
      'build', options.entry,
      '--out-dir', options.outDir,
      '--out-file', 'index.js',
      '--global', options.global,
      '--target', 'node',
      '--bundle-node-modules',
      '--log-level', '2',
      !options.minify && '--no-minify',
      !options.sourceMaps && '--no-source-maps',
      ...options.cacheDir
        ? ['--cache-dir', options.cacheDir]
        : [],
    ].filter(Boolean) as string[];

    const parcel = spawnSync('parcel', args);

    if (parcel.error) {
      throw parcel.error;
    }

    if (parcel.status !== 0) {
      throw new Error(parcel.stderr.toString().trim());
    }
  } catch (err) {
    throw new Error(`Failed to build file at ${options.entry}: ${err}`);
  } finally { // Always restore package.json to original
    if (pkgPath && originalPkg) {
      fs.writeFileSync(pkgPath, originalPkg);
    }
  }
}

spawnSync を使ってシンプルにParcelを呼び出していますね。

まとめ

こういうモジュールを作れる所が、AWS CDKが真のInfrastructure as Codeというか、Infrastructure is Codeだなぁと思いました。念の為、セルフフォローすると、他のツールをディスっているんじゃなくて、YAMLやHCLはプログラミング言語じゃないよねって意味です。 オレはWebpack使いたいだ!!って人もこのモジュールのプルリクエストを参考にすれば追加できそうですね。AWS CDKは開発が活発で触っていて本当に楽しいです!! 以上でした!