Serverless Clojurescript

Deploy Clojurescript to AWS Lambda w/ Serverless.

Motivation

I’ve previously written about cljs-lambda, a Leiningen plugin & utility library for writing AWS Lambda functions in Clojurescript. I’ve been using it in production for years, as have others. It’s great.

Serverless is a means of declaring the depencies of AWS components (e.g. function X runs under this role, depends on this Dynamo table, etc.), as well an extensible mechanism for building and deploying the code which constitutes each service.

The ability to declaratively specify permissions & API Gateway integrations in a versionable, readable format - alongside the source code, is awesome - and will greatly increase the reach and maintainability of Clojurescript Lambda projects.

Rather than reimplement portions of Servless in Clojure, I’ve written a Serverless plugin which delegates the build portion of a Lambda deployment to lein-cljs-lambda, allowing Serverless to handle everything else - function permissions, creation of ancillary resources, etc. It’s going to make my life a lot easier to manage.

Credit goes to Jeremy Barnet for initially suggesting the plugin.

Usage

Running lein new serverless-cljs <name> creates an autoscaling echo microservice which is exposed over HTTP - let’s briefly walk through the output of lein new serverless-cljs example.

serverless.yml

service: example

provider:
  name: aws
  runtime: nodejs4.3

functions:
  echo:
    cljs: example.core/echo
    events:
      - http:
          path: echo
          method: post

plugins:
  - serverless-cljs-plugin

Much of this is likely self explanatory - serverless-cljs-plugin (README) will cause serverless deploy to delegate the construction of the service’s build artifact to lein-cljs-lambda.

In the above example, the Lambda function echo will invoke the example.core/echo var within the output of Clojurescript build process1. We’re also requesting that a POST endpoint be created via API gateway, so that we can invoke our Clojurescript function over HTTP.

1 See project.clj coverage below.

project.clj

(defproject example "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure       "1.8.0"]
                 [org.clojure/clojurescript "1.8.51"]
                 [io.nervous/cljs-lambda    "0.3.4"]]
  :plugins [[lein-cljsbuild "1.1.5"]
            [lein-npm       "0.6.2"]
            [io.nervous/lein-cljs-lambda "0.6.4"]]
  :cljsbuild
  {:builds [{:id "example"
             :source-paths ["src"]
             :compiler {:output-to     "target/example/example.js"
                        :output-dir    "target/example"
                        :target        :nodejs
                        :language-in   :ecmascript5
                        :optimizations :none}}]})

Note that our project file doesn’t have any :cljs-lambda key, as none is required in the simple case1 - the first cljsbuild entry will be used to build the project, and the resulting directory zipped alongside a small Javascript file with the appropriate exports.

In this example, we’re using cljs-lambda, the runtime library, for convenience, but it’s not a requirement - dependence on the lein-cljs-lambda plugin (and the existence of the vars listed in serverless.yml) is the only necessity.

1 Only a subset of the plugin’s configuration keys make sense when deploying via Serverless, but could, e.g. specify :resource-dirs or :cljs-build-id.

src/example/core.cljs

(ns example.core
  (:require [cljs-lambda.macros :refer-macros [defgateway]]))

(defgateway echo [event ctx]
  {:status  200
   :headers {:content-type (-> event :headers :content-type)}
   :body    (event :body)})

defgateway is a trivial wrapper around the existing deflambda convenience macro, adding minimal key translation specific to API Gateway integrations.

Here we’re just immediately returning a response map, but could just as well return a promise, or core.async channel.

Deploying

Running serverless deploy in the generated example/ directory might output something like:

service: example
stage: dev
region: us-east-1
endpoints:
  POST - https://nt4n0ldrrc.execute-api...com/dev/echo
functions:
  example-dev-echo: arn:aws:lambda:...:example-dev-echo

Let’s try invoking with curl:

$ curl -v -X POST 'https://nt4n0ldrrc.execute-api...' \
    -H 'Content-Type: application/json' \
    -d'{"body": "hi"}'
...
< HTTP/1.1 200 OK
< Content-Type: application/json
< Content-Length: 14
< Connection: keep-alive
< Date: Mon, 06 Feb 2017 11:31:54 GMT
< x-amzn-RequestId: dc0f305a-ec5f-11e6-9b1e-4b3ff3a1e585
< X-Amzn-Trace-Id: Root=1-58985ea9-5d002f2ef3c06861a57401e5
< X-Cache: Miss from cloudfront
< Via: 1.1 1d16403705fd4f8204c72a3a35f60982.cloudfront.net
< X-Amz-Cf-Id: 9pb7dVHJDwwaOCyDGHnim...
<
{"body": "hi"}

Neat.