@cfxlabsinc/nx-lambdaWorkspace-internal Nx plugin for CFX lambda API projects.
The plugin's createNodes watches for **/src/lambda.ts. When it finds one alongside src/main.ts, it contributes these targets to the project (in addition to whatever's in the project's project.json):
build — nx:noop, dependsOn: ["build-lambda"] (fully defined here)build-lambda — nx:run-commands running tools/bundler/build.ts (Bun's bundler), with its cache, inputs, dependsOn and two configurations (fully defined here):
production (the default) — --target=lambda: src/lambda.ts and the Runtime API loop compiled into one Bun executable, dist/{projectRoot}/bootstrap. That directory is the function's code, and the sourcemap is inside the executabledevelopment — --target=bun: src/main.ts as a JS bundle in dist/serve/{projectName}, for serveserve — builds the development bundle and runs it with the host's bun, no container: src/main.ts serves HTTP itself. It rebuilds when the project or a dependency changes and restarts on the new bundle. @cfxlabsinc/nx-secrets wraps it as secrets-serveserve-lambda, secrets-serve-lambda — run the built function as a Lambda in public.ecr.aws/lambda/provided:al2023, with dist/{projectRoot} at /var/task, where the image executes bootstraptest — name-only; @cfxlabsinc/nx-bun-test defines itIt also tags the project with lambda.
A lambda runs on Bun, which AWS ships no managed runtime for, so every function is provided.al2023 and its code is a single Bun executable. There is no layer and nothing at /opt: Lambda runs /var/task/bootstrap for a provided.* runtime, and that file is the executable.
build.ts --target=lambda compiles (bun build --compile --bytecode --sourcemap, for bun-linux-arm64) one entry: the shims, the Runtime API loop in tools/bundler/lambda-runtime.ts and the app's src/lambda.ts.
| Path | What it is |
|---|---|
dist/packages/<app>/bootstrap |
The executable: the Bun runtime, the loop, the app's bundle, its bytecode and its sourcemap. About 113 MB. |
dist/packages/<app>/valkey-glide.linux-arm64-gnu.node |
The Valkey native addon, when the app loads it. 12 MB. bootstrap loads it from its own directory. |
dist/packages/<app> is the function's code and holds nothing else. Zipped it is 56 to 58 MB, past the 50 MB Lambda accepts inline, so a stack uploads it to the cfx-lambda-artifacts-<stack> bucket with declareLambdaArtifact in pulumi-templates and its functions name that object. From S3 the limit is 250 MB unzipped.
build.ts, which compiles it into every executable, and its tests are in tools/bundler/test, which nx run tools:test runs.build-lambda's inputs name tools/bundler/* and .bun-version, and that is what redeploys. No lambda's source imports the loop, so there is no graph edge to it. Nx attributes a {workspaceRoot} input to the projects that declare it, so a change to the loop, the bundler or the pin puts every lambda in nx affected. A file in a subdirectory of tools/bundler, such as a test, does not match.bun --version and uname -sm. The executable embeds the Bun that built it, and off linux-arm64 it lacks the Valkey addon, so neither a Bun version nor a host platform may share a cache entry with another.build-lambda's hash through ^bundle, not ^production. bundle is production in nx.json, and a project narrows it in its own project.json when a build reads fewer of its files than production covers. pulumi-templates does. No bundle contains one of its files, and tools/bundler/build.ts reads only lambdaArch.ts, for the compile target. An edit to any other pulumi-templates file changes each stack's deploy hash and leaves every build-lambda hash alone, so the cached executable is reused and the function code does not roll. production stays whole because pulumi-templates' own typecheck and test read every file. The Temporal workers' build-lambda and build-local use the same input, but both workers' build-lambda also hash SENTRY_RELEASE and GITHUB_SHA, so in CI they rebuild on every commit and their code rolls on every deploy. tools/ci/bundle-inputs.test.ts fails when a bundler target hashes a file of a narrowed project that no build reads, or leaves out one that a build does.deposit-api it adds 17 MB to the executable and 8 MB to the zip; in the provided:al2023 image that executable got as far as loading the Valkey addon in about 57 ms, against 160 ms without. Bytecode is only valid for the Bun that wrote it, which is the one embedded./tmp at every cold start.SENTRY_AUTH_TOKEN is not read.The executable carries its own sourcemap, and Bun rewrites a stack to source positions as the error is created. A frame in a log line or a Sentry event reads
at assertWithinLimit (../packages/deposit-api/src/settlement.ts:8:15)
dist/, the outdir the compile is given, so it is the same on every build host: ../packages/<pkg>/src/…, ../node_modules/.bun/…, ../tools/bundler/…. The leading .. is load-bearing. The Sentry SDK takes a bare relative path (packages/…) for a runtime internal and marks it not in-app. The build fails if the map names a source any other way.at y (…); with syntax minified a single-use function is inlined and reads <anonymous>. Keeping both costs about 2.4 MB of executable against full minification.globalThis.SENTRY_RELEASE from SENTRY_RELEASE, GITHUB_SHA or git rev-parse HEAD, which the SDK reads at init. The release appears in Sentry with its first event. Nothing associates commits with it or finalises it.build-lambda's cache key holds no commit, so a deploy can replay a cached bundle with the release of the run that built it, and that run can be a pull request's Previews job at its merge commit, which is not on main. The bundle's source inputs are the same either way, so its code should match the deploying commit's code for that package. Check a release with git merge-base --is-ancestor <release> origin/main. Exit status 1 means it is not on main. Status 128 means the commit is missing from the local clone, as a pull request's merge commit usually is, so run git fetch origin or ask GitHub with gh api repos/CFXLabsInc/cfx-platform/compare/main...<release> --jq .status, where diverged means not on main.aws lambda get-function, Code.Location), unzip it and search bootstrap as text, because it is a binary, with rg -a -o 'SENTRY_RELEASE = \{ id: "[0-9a-f]{40}"' bootstrap. temporal-worker and background-worker hash SENTRY_RELEASE and GITHUB_SHA into build-lambda, so their release is the deploying commit, and the description of their live version carries it too. To find the commit that deployed any function, read its LastModified (aws lambda get-function-configuration) and match it to the deploy job that was running then, Deploy Dev / deploy (dev) in a main.yml run or a deploy.yml dispatch and its ref for prod. gh run list reaches only recent runs, so for an older LastModified list them by date with gh api 'repos/CFXLabsInc/cfx-platform/actions/workflows/main.yml/runs?created=<from>..<to>'.cfx <env> deployed prints the commit that deployed a function's code from its Commit tag, so it needs no package download. LambdaApp sets the tag through deployedCommit in @cfxlabsinc/pulumi-templates to the commit of the deploy that stamped the code, and a deploy that ships the same code leaves it alone. For a function with no Commit tag, use its LastModified as above.scripts/sentry/surface-classify.ts reads the package from the path.Handlers are unchanged from the Node runtime: _HANDLER is <file>.<export> (lambda.handler, lambda.snsHandler), and the loop calls that export as
(event: unknown, context: InvocationContext) => Promise<unknown>;
src/lambda.ts. <file> names a module compiled into it (lambda), not a file on disk; each function picks its export with its own _HANDLER, so two functions can share one artifact.undefined is sent as null.Response is streamed. A handler that resolves to a web Response has its status, headers and cookies sent as Lambda's HTTP response prelude, and its body sent chunk by chunk as the body yields it. A body that fails part-way ends the stream with Lambda's error trailers and one Invoke Error {…} line. Return one only from a function whose caller reads a stream, such as a Function URL in RESPONSE_STREAM mode: a buffered caller hands the client the prelude as body text.context.waitUntil(promise) holds the sandbox open. The loop sends the response, then waits for every promise passed to it before it polls for the next invocation, which is when Lambda may freeze the sandbox. A rejection is logged as one waitUntil Error {…} line and fails nothing.{ errorType, errorMessage, stackTrace } and logged as one Invoke Error {…} line. The loop then takes the next invocation.context is aws-lambda's Context plus waitUntil: the request id, invoked ARN, deadline, client context and Cognito identity come from the invocation's headers, the function name, version, memory and log names from the environment. process.env._X_AMZN_TRACE_ID is set per invocation.context.done / fail / succeed throw. callbackWaitsForEmptyEventLoop is present and has no effect._HANDLER, a module the executable does not hold, a module that throws while loading, a missing or non-function export) logs one line starting Init Error, reports it to the Runtime API and exits non-zero. The app's module is imported on demand for this reason: a throw while it loads reaches the loop. LambdaApp's init-failure alarm matches that text, so the loop's test holds it.console call becomes one log event per line./var/task but the addon. The executable ignores .env and bunfig.toml, and a package the bundle lacks fails to load: Bun does not fetch it..bun-version is the only pin. CI installs that version (setup-toolchain), and the executable embeds the Bun that runs the build, so the function runs the Bun that built and tested it. The compile target is always bun-linux-arm64 (LAMBDA_TARGET_ARCH in packages/pulumi-templates/src/lambdaArch.ts), whatever the host.
| Build host | Result |
|---|---|
linux-arm64 |
The deployable artifact. Fails if the running Bun is not the one .bun-version names. |
| Linux on another arch | Fails for an app that loads Valkey: bun installed only the host's native package. Fails on a Bun version mismatch too. |
| macOS, or anything not Linux | Cross-compiles the same executable (Bun downloads the Linux runtime once), warns, and ships no Valkey addon. Fine for build, test and a Pulumi preview's shape. |
As an HTTP server, with no Lambda in the picture: nx serve <app> (or nx secrets-serve <app>) bundles src/main.ts to JS and runs it with the host's bun, rebuilding and restarting on change. serve compiles nothing: compiling adds seconds and produces a Linux executable a Mac cannot run. The bundle goes to a directory of its own so it never lands in the function's code. serve has one shape, with no production configuration: the artifact production ships is what serve-lambda runs.
As a Lambda, in AWS's provided:al2023 image and its runtime interface emulator: nx serve-lambda <app> (or nx secrets-serve-lambda <app>), then
curl -X POST localhost:8080/2015-03-31/functions/function/invocations -d '{}'
The image runs dist/packages/<app>/bootstrap, so a macOS build works here for an app that never loads Valkey. bun installs only the host's native packages, so one that needs the addon fails at init; build it on a linux-arm64 host.
serve builds inside its command, not through dependsOn. Nx hands a dependency the configuration given on the command line and never a target's defaultConfiguration, so a bare nx serve would otherwise build the production executable and then look for main.js.
Pulumi targets (deploy, pulumi-preview, pulumi-cancel, pulumi-stack-init, pulumi-refresh) are contributed by @cfxlabsinc/nx-pulumi whenever a pulumi/Pulumi.yaml is present.
Detection is by file presence, not by the lambda tag — the tag is an output of this plugin. bai2-api is tagged lambda but is a Go build with no src/lambda.ts, so it is correctly skipped and keeps its own build target.
Nx merge order is inferred (plugin) < targetDefaults < project.json. A targetDefault overwrites every key an inferred target sets, but never a key project.json sets. Two consequences govern this plugin:
nx.json declares no targetDefault for that key. build and build-lambda can live here only because targetDefaults has no build-lambda entry and targetDefaults.build carries no dependsOn. Re-adding either would silently clobber the values in src/plugin.ts. The non-lambda projects that still need build to wait on their deps (db, docs-site, bai2-api) declare dependsOn: ["^build"] in their own project.json.build-lambda carries its own cache, inputs and dependsOn: ["^build"] for that reason: a targetDefaults["build-lambda"] or targetDefaults["nx:run-commands"] entry would overwrite them.typecheck is still left to targetDefaults plus per-project overrides; its options.tsConfig would be clobbered the same way.
Plugin is registered in workspace nx.json under plugins:
{
"plugin": "@cfxlabsinc/nx-lambda",
"options": {}
}
Resolves through the workspace dependency in the root package.json:
"@cfxlabsinc/nx-lambda": "workspace:*"