How do I properly structure, configure, and bundle shared code in a Typescript AWS stack monorepo?

454 views Asked by At

I'm having trouble getting my head around how to configure this setup so it bundles, builds, and executes without problems. The crux of the issue lies in how AWS Lambda cannot properly bundle/reference a shared library in a monorepo. This is likely due to hoisting and/or symlinking.

It's a fullstack serverless (AWS) monorepo using npm workspaces, built entirely in TS, with React as a front-end. I'm looking to create a great DX that debugs easily on a local machine and is deployed via the CDK. Using Serverless Framework strictly for local debugging.

Happy to share the final repo once it all works! I'd even like to do a writeup as I feel like this is valuable to a lot of devs.

I'm not married to npm workspaces, I just wanted things to be as bare-bones simple as possible, and using npm means not installing another package manager. This is why I avoided any monorepo frameworks like bit, rush, etc. Open to suggestions here, but would prefer the absolute least configuration possible to get this working. We're buried in configuration complexity these days.

Monorepo is structured like so:

my-monorepo/
├── node_modules
├── packages/
│   ├── lib/
│   │   ├── index.ts
│   │   ├── random.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── lambda/
│   │   ├── test.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── web/
│       └── react-vite/
│           ├── App.tsx
│           ├── package.json
│           └── tsconfig.json
├── package.json
├── tsconfig.base.json
└── tsconfig.json

I'm simply trying to make it so all external and internal npm package references work, so I can share code between all projects out of the /lib folder. Therefore I'm testing with one local package function and one third-party, ulid.

Ideally, everything gets bundled automatically, across the stack, and there aren't a bunch of build steps and workarounds. Using Vite and CDK's NodejsFunction construct should allow that, and those are the only contact-points for the code in the /lib package.

Here's lambda/test.ts:

import { ulid } from "ulid";
import { random } from "@my-monorepo/lib";

export async function routesHandler(event: any) {
    try {
        return {
            statusCode: 200,
            // body: JSON.stringify(`${random.randomPhrase()} Lambda!`)
            body: JSON.stringify(`Hello Lambda! How about a ULID? -> ${ulid()}`)
        };
    } catch (e) {
        console.log("It's dead:", e);
    }
}

This executes just fine, and prints:

Compiling with Typescript...
Using local tsconfig.json - ./packages/lambda/tsconfig.json
Typescript compiled.
{
    "statusCode": 200,
    "body": "\"Hello Lambda! How about a ULID? -> 01HE33FGF9DE4Q6H8K1KP42KEA\""
}

When I try to call from the random module by swapping the body lines in the lambda above, like this:

return {
    statusCode: 200,
    body: JSON.stringify(`${random.randomPhrase()} Lambda!`)
    //body: JSON.stringify(`Hello Lambda! How about a ULID? -> ${ulid()}`)
};

I get this:

import * as random from "./random"; ^^^^^^

SyntaxError: Cannot use import statement outside a module

I tried to manually tsc the /lib project and change the main reference in package.json to "main": "build/index.js", and get it working locally for the lambda, while debugging w/ serverless framework.

It also didn't help me at all once the Lambda was deployed to AWS. I still get this error on execution:

{
  "errorType": "Runtime.ImportModuleError",
  "errorMessage": "Error: Cannot find module '@my-monorepo/lib'\nRequire stack:\n- /var/task/index.js\n- /var/runtime/index.mjs",
  "trace": [
    "Runtime.ImportModuleError: Error: Cannot find module '@my-monorepo/lib'",
    "Require stack:",
    "- /var/task/index.js",
    "- /var/runtime/index.mjs",
    "    at _loadUserApp (file:///var/runtime/index.mjs:1087:17)",
    "    at async UserFunction.js.module.exports.load (file:///var/runtime/index.mjs:1119:21)",
    "    at async start (file:///var/runtime/index.mjs:1282:23)",
    "    at async file:///var/runtime/index.mjs:1288:1"
  ]
}

It also breaks the reference in Vite for some reason, for which I'd rather not try to wrangle, since I'd like to avoid manually transpiling to JS before deploying, anyhow...if possible.

Another thing I took a shot at, was just making a direct reference to the /lib, which works across the board, but isn't very "monorepo-y". It works locally and in AWS, so why wouldn't I? Would my bundles blow out in size as my /lib size and usage grows? I don't anticipate putting more than shared types and validation schemas in there, as well as some basic utility code.

import { random } from "../lib";

export async function routesHandler(event: any) {
    try {
        return {
            statusCode: 200,
            body: JSON.stringify(`${random.randomPhrase()} Lambda!`)
        };
    } catch (e) {
        console.log("It's dead:", e);
    }
}

But I digress...moving on w/ the assumption that /lib should be treated like a monorepo package...

Here's the CDK stack I'm deploying with:

export class LambdaStack extends Stack {
    constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props);

        const prototypeLambda = new NodejsFunction(this, "PrototypeLambda", {
            functionName: "prototypeLambda",
            handler: "routesHandler",
            runtime: Runtime.NODEJS_18_X,
            timeout: Duration.minutes(3),
            entry: "../lambda/test.ts",
            bundling: {
                nodeModules: [
                    "@my-monorepo/lib",
                    "ulid"
                ]
            }
        });
    }
}

Once deployed, you can see ulid is present in node_modules, but my lib is not:

enter image description here

Here's the test /lib:

lib/index.ts:

import * as random from "./random";
import * as model from "./model/model";

export {
    random,
    model
};

lib/random.ts:

const randomPhrase = (): string => {
    const phrases = [
        "Gadzooks!",
        "Eureka!",
        "D'oh!",
        "Egads!",
        "Bazinga!"
    ];
    return phrases[Math.round(Math.random() * phrases.length)];
};

export {
    randomPhrase
};

It has to be a combination of tsconfig props that I'm getting wrong, because I can execute code from the /lib within App.tsx in the web/react-vite project, it renders and works as expected. Here's that snippet:

import { random } from "@my-monorepo/lib";

function App() {
    return (
        <div>Welcome to React...{random.randomPhrase()}</div>
    )
}

export default App

I see what I should:

enter image description here

Here's the root config stuff:

./package.json

{
    "name": "my-monorepo",
    "private": true,
    "workspaces": [
        "packages/lambda",
        "packages/web/react-vite",
        "packages/lib"
    ],
    "devDependencies": {
        ...  
    },
    "dependencies": {
        ...
    }
}

./tsconfig.base.json

{
    "compilerOptions": {
        "target": "ES2022",
        "module": "CommonJS",
        "declaration": true,
        "sourceMap": true,
        "strict": true,
        "moduleResolution": "node",
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "composite": true
    },
    "exclude": ["node_modules"],
    "include": [
        "packages/lib/*",
        "packages/lib/**/*"
    ]
}

./tsconfig.json

{
    "references": [
        {
            "path": "packages/lambda"
        },
        {
            "path": "packages/lib"
        }
    ]
}

/lib config files:

lib/package.json:

{
    "name": "@my-monorepo/lib",
    "version": "1.0.0",
    "main": "index.ts"
}

lib/tsconfig.json:

{
    "extends": "../../tsconfig.base.json"
}

/lambda config files:

lambda/package.json

{
    "name": "@my-monorepo/lambda",
    "version": "1.0.0",
    "main": "test.ts",
    "dependencies": {
        "@my-monorepo/lib": "^1.0.0",
        "ulid": "^2.3.0"
    }
}

lambda/tsconfig.json

{
    "extends": "../../tsconfig.base.json"
}

I took a look at using Lambda Layers as well, but the configuration to get this working both locally, where it's debuggable in VS Code, as well as properly deploying, is fairly painful.

0

There are 0 answers