30

I am looking to make use of the native import/export that comes with ES6.

I am using Serverless Containers within AWS Lambda.

I have my Dockerfile which looks like this:

FROM public.ecr.aws/lambda/nodejs:14

COPY app ./

RUN npm install

CMD [ "app.handler" ]

I then have an app directory with my application code. The app.js code looks like this:

import { success } from './utils/log';

exports.handler = async () => {
  success('lambda invoked');
  const response = 'Hello World';
  return {
    statusCode: 200,
    body: JSON.stringify(response),
    isBase64Encoded: false,
  };
};

As you can see from this line import { success } from './utils/log'; I am making use of native imports.

In my package.json I specify this:

  "type": "module"

As I need to tell my application this is a module and I would like imports natively. If I don't specify this, I get:

{
    "errorType": "Runtime.UserCodeSyntaxError",
    "errorMessage": "SyntaxError: Cannot use import statement outside a module",
    "stack": [
        "Runtime.UserCodeSyntaxError: SyntaxError: Cannot use import statement outside a module",
        "    at _loadUserApp (/var/runtime/UserFunction.js:98:13)",
        "    at Object.module.exports.load (/var/runtime/UserFunction.js:140:17)",
        "    at Object.<anonymous> (/var/runtime/index.js:43:30)",
        "    at Module._compile (internal/modules/cjs/loader.js:1063:30)",
        "    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)",
        "    at Module.load (internal/modules/cjs/loader.js:928:32)",
        "    at Function.Module._load (internal/modules/cjs/loader.js:769:14)",
        "    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)",
        "    at internal/main/run_main_module.js:17:47"
    ]
}

So, I specify it, telling Lambda this is a module. However, for the life of me I can't get it to work, I am seeing this error:

{
    "errorType": "Error",
    "errorMessage": "Must use import to load ES Module: /var/task/app.js\nrequire() of ES modules is not supported.\nrequire() of /var/task/app.js from /var/runtime/UserFunction.js is an ES module file as it is a .js file whose nearest parent package.json contains \"type\": \"module\" which defines all .js files in that package scope as ES modules.\nInstead rename app.js to end in .cjs, change the requiring code to use import(), or remove \"type\": \"module\" from /var/task/package.json.\n",
    "code": "ERR_REQUIRE_ESM",
    "stack": [
        "Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /var/task/app.js",
        "require() of ES modules is not supported.",
        "require() of /var/task/app.js from /var/runtime/UserFunction.js is an ES module file as it is a .js file whose nearest parent package.json contains \"type\": \"module\" which defines all .js files in that package scope as ES modules.",
        "Instead rename app.js to end in .cjs, change the requiring code to use import(), or remove \"type\": \"module\" from /var/task/package.json.",
        "",
        "    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1080:13)",
        "    at Module.load (internal/modules/cjs/loader.js:928:32)",
        "    at Function.Module._load (internal/modules/cjs/loader.js:769:14)",
        "    at Module.require (internal/modules/cjs/loader.js:952:19)",
        "    at require (internal/modules/cjs/helpers.js:88:18)",
        "    at _tryRequire (/var/runtime/UserFunction.js:75:12)",
        "    at _loadUserApp (/var/runtime/UserFunction.js:95:12)",
        "    at Object.module.exports.load (/var/runtime/UserFunction.js:140:17)",
        "    at Object.<anonymous> (/var/runtime/index.js:43:30)",
        "    at Module._compile (internal/modules/cjs/loader.js:1063:30)"
    ]
}

It looks like /var/runtime/UserFunction.js is calling my app handler as a require and a module. However, I have no control over /var/runtime/UserFunction.js (I don't believe?). In my Dockerfile I have specified Node14. I don't quite know where I have gone wrong?

What I am looking to do is run the latest and greatest of Node14 (such as imports) without Babel/Transpiler that "bloat" my code. If someone could point me in the right direction of where I have gone wrong, it would be appreciated.

danronmoon
  • 3,814
  • 5
  • 34
  • 56
user3180997
  • 1,836
  • 3
  • 19
  • 31

4 Answers4

66

If anyone sees this, running into the same problem. Please see the below from AWS Offical Technical Support:

"Your instruction to use package.json { "type": "module" } are correct but ECMAScript modules are not supported by Lambda Node.js 14 runtime at this moment".

I will post an update to this post when I hear more about when support is available. I am leaving this question here just in case other people run into the same problem.

user3180997
  • 1,836
  • 3
  • 19
  • 31
  • 1
    I am stuck at this point as well. What's the workaround? – sridharraman May 12 '21 at 11:26
  • 1
    There is no workaround on this, you have to either use the likes of TypeScript or Polyfill. There is no imbedded way of using modules. – user3180997 May 14 '21 at 15:02
  • 2
    You can work around this by putting all your handler entry points in their own folder with an "empty" package.json. If you want ES modules in the same folder as the handlers, you can use the .mjs extension. For a working example, see https://github.com/makenew/serverless-nodejs/blob/fa148a9dd210c21736c84014c32f6da151176aba/handlers/todo.js – Evan Sosenko Jun 14 '21 at 02:22
  • You can also build a custom runtime – Adam Mills Aug 02 '21 at 09:34
  • 1
    ECMAScript modules are now supported in Lambda Node.js 14! https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/ – will Farrell Jan 07 '22 at 21:12
  • @willFarrell How to use with layer? I try on it's not working but es module is working – May Noppadol Jan 10 '22 at 14:18
  • 3
    @MayNoppadol As per https://twitter.com/coderbyheart/status/1486470882008645634 modules from layers are currently not supported when using ESM – pfried Jan 27 '22 at 06:36
13

It appears that since yesterday there finally is native support for the ES6 module syntax in Node 14 lambdas - see https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda

cigien
  • 57,834
  • 11
  • 73
  • 112
Marces Engel
  • 685
  • 6
  • 6
  • 1
    I'm really struggling to get this to work. Has anyone been successful, or seen any complete code examples? Particularly, I always get a `Cannot find package` error when trying to import AWS SDK v3 packages, like `import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';` – Farski Feb 02 '22 at 13:45
  • @farski I had the same issue, do you deploy your deps as a layer? If yes that may be the problem https://twitter.com/coderbyheart/status/1486470882008645634 (see comment above by @pfried) – Marces Engel Feb 03 '22 at 12:49
  • Yes! I just figured that out yesterday and was meaning to come back and update my comment. – Farski Feb 04 '22 at 13:03
  • Beware that there is a bug when you have a "shadow" folder for your handler: https://repost.aws/questions/QUG3mryU5bRJ6ieTR-IiowCg/bug-user-function-js-uses-exists-sync – vincent Feb 08 '22 at 23:56
3

This worked for me on Lambda Node 14.x -

in app.js

exports.lambdaHandler = async (event, context) => {
  const { App } = await import('./lib/app.mjs');
  return new App(event, context);
}

And then in lib/app.mjs -

class App {

  constructor(event, context) {
    return {
      'statusCode': 200,
      'body': JSON.stringify({
        'message': 'hello world'
      })
    }
  }
} 

export {App}
Dan Kantor
  • 431
  • 4
  • 3
2

AWS Lambda does not official support ESM, but with the following workarounds it works smoothly.

This answer is a sum up of different workarounds inspired by answers/comments of Evan Sosenko and Dan Kantor and some additional ideas by me. This includes some nicer handling for typescript projects, but parts of it can be used for plain javascript projects as well.

I assume the following:

  1. Node.js version 14 is used: FROM public.ecr.aws/lambda/nodejs:14
  2. Serverless containers are used: FROM public.ecr.aws/lambda/nodejs:14
  3. Local imports should work without file ending: import { success } from './utils/log';
  4. Imports from libs should use ESM syntax: import AWS from 'aws-sdk';
  5. The lambda is written in typescript: (.ts is file suffix).

(I also provide information for plain .js instead of typescript at the end)

Dockerfile

FROM public.ecr.aws/lambda/nodejs:14

# copy only package.json + package-lock.json 
COPY package*.json ./

# install all npm dependencies including dev dependencies
RUN npm install

# copy all files not excluded by .dockerignore of current directory to docker container
COPY .  ./

# build typescript
RUN tsc

# remove npm dev dependencies as they are not needed anymore
RUN npm prune --production

# remove typescript sources
# RUN rm -r src

# rename all .js files to .mjs except for handler.js
RUN find ./dist -type f -name -and -not -name "handler.js" "*.js" -exec sh -c 'mv "$0" "${0%.js}.mjs"' {} \;

# allow local imports without file ending - see: https://nodejs.org/api/esm.html
ENV NODE_OPTIONS="--experimental-specifier-resolution=node"

# set handler function
CMD ["dist/lambda/handler.handler"]

Explanation

As AWS Lambda only supports commonJS, the Lambda entrypoint is a commonJS file. This is specified by an empty package.json which overwrites the package.json from project root. As this file is empty it does not contain: "type":"module" and defaults all files in that folder and subfolders to commonJS. A commonJS file can access ESM files if the have .mjs extension, but as typescript compiles to .js I use some unix commands to rename all files mathching ".*js" after calling tsc. The handler.js has to stay ".js" so I rename it back from .mjs.
More about ".js/.mjs"

Folder structure

- src
- - lambda
- - - handler.ts (commonJS)
- - - package.json (contains only: `{}`)
- - app.ts (ESM)
- - services
- - - (other .ts files ESM)
- package.json (contains `{"type": "module"}`, but also other settings)
- Dockerfile
- tsconfig.json

handler.ts

exports.handler = async (event) => {
    const {App} = await import('../app.mjs');
    const app = new App();
    return await app.run(event);
};

app.ts

// example import library
import AWS from 'aws-sdk';
// example import local file
import {FileService} from './services/file.service';

class App {
  constructor() {
  }

  async run(event) {
     // write your logic here
     return {
       'statusCode': 200,
       'body': JSON.stringify({'message': 'hello world'})
     }
  }
}
export {App};

tsconfig.json

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "declaration": true,
    "removeComments": true,
    "allowSyntheticDefaultImports": true,
    "target": "es6",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./src",
    "lib": [
      "es6",
      "dom"
    ]
  },
  "include": [
    "src/**/*"
  ]
}

Differences if not using typescript

Let's assume you are not using typescript, but javascript like mentioned in the question than change the following:

  • don't use tsc command in Dockerfile
  • don't have a tsconfig.json file
  • all files should be name *.js instead of *.ts
  • obviously don't use any typescript typing (in my example there are none)
flohall
  • 967
  • 10
  • 19