Skip to content

Commit f301d30

Browse files
committed
refactor(handler): address PR #784 review feedback
- Rename `userIsEsm()` to `shouldUseEsmHandler()`. The function returns a routing decision based on heuristics (env regex + package.json sniff), not an authoritative fact about the user's code, so the new name reads more honestly at the call site. - Inline the former `src/handler.cjs` body into the CJS branch of the shim and delete `src/handler.cjs`. Lambda's resolver doesn't auto-resolve `.cjs`, so handler.cjs was only ever reachable via this shim's `require("./handler.cjs")`. Keeping it as a separate file made the load graph confusing — future readers grepping for "what loads handler.cjs?" would find one caller in the same directory. The heavy `require()`s remain scoped to the CJS branch so ESM users don't pay the cost of pulling in the full tracer at require-time. - Add `src/handler.spec.ts` covering shouldUseEsmHandler's four-case matrix (.mjs in _HANDLER / DD_LAMBDA_HANDLER, package.json type=module, package.json without type field, package.json missing, package.json unparseable). Detection is the load-bearing piece — a regression there means either ESM users keep seeing ERR_REQUIRE_ESM or CJS users pay an unnecessary async-import cold-start cost. - Tighten `scripts/update_dist_version.sh`'s `cp src/handler.*` to list the runtime artifacts explicitly. The broader glob also picked up `src/handler.spec.ts`, which is a test fixture, not a runtime file — shipping it would also be silently inconsistent with the Dockerfile's hard-coded `rm /nodejs/.../handler.js`. - Expand the Dockerfile comment to point at `scripts/update_dist_version.sh` as the source of the file being removed, so a future change to how `src/handler.js` is copied into `dist/` is visibly tied to the layer-side `rm`.
1 parent 270a846 commit f301d30

5 files changed

Lines changed: 166 additions & 57 deletions

File tree

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ RUN cp -r dist /nodejs/node_modules/datadog-lambda-js
1919
# resolves `handler.handler` to handler.mjs as long as handler.js is absent,
2020
# which handles both CJS and ESM user code via the async load(). Drop the
2121
# shim here so the layer keeps that direct-to-mjs behavior.
22+
#
23+
# The file being removed is shipped into dist/ by the `cp src/handler.* dist/`
24+
# step in scripts/update_dist_version.sh — if that script changes how
25+
# src/handler.js gets copied, this rm must be revisited.
2226
RUN rm /nodejs/node_modules/datadog-lambda-js/handler.js
2327
RUN cp ./src/runtime/module_importer.js /nodejs/node_modules/datadog-lambda-js/runtime
2428
RUN node <<'EOF'

scripts/update_dist_version.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ echo "$TRACE_CONSTANTS" |
1616
sed "s/\(ddtraceVersion =\) \"\(X\.X\.X\)\"/\1 \"$DD_TRACE_VERSION\"/" > ./dist/trace/constants.js
1717

1818
echo "Copying handler js files"
19-
cp src/handler.* dist/
19+
# Be explicit about which handler files ship — a broader `src/handler.*` glob
20+
# would also pick up `src/handler.spec.ts`, which is a test fixture, not a
21+
# runtime artifact. The Dockerfile's layer build also assumes `dist/handler.js`
22+
# is the shim from `src/handler.js`, not some other file.
23+
cp src/handler.js src/handler.mjs dist/
2024
cp src/init.js dist/init.js
2125
cp src/runtime/module_importer.js dist/runtime/

src/handler.cjs

Lines changed: 0 additions & 43 deletions
This file was deleted.

src/handler.js

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
1-
// CJS entry shim used by AWS Lambda's bootstrap when the handler string
2-
// resolves to `node_modules/datadog-lambda-js/dist/handler.handler`.
1+
// CJS entry used by AWS Lambda's bootstrap when the handler string resolves
2+
// to `node_modules/datadog-lambda-js/dist/handler.handler` from the published
3+
// npm package. Lambda's resolver picks `.js` before `.mjs`, so this file is
4+
// what gets loaded for both CJS and ESM user functions. We branch at runtime:
35
//
4-
// Lambda's resolver searches for `.js` before `.mjs`, so this file is what
5-
// gets loaded for both CJS and ESM user functions. We branch at runtime:
6-
//
7-
// - CJS user code: synchronously delegate to handler.cjs (preserves the
8-
// prior fast-path behavior — no extra dynamic import on cold start).
6+
// - CJS user code: synchronously load and wrap the handler in-place (the
7+
// prior fast-path behavior, formerly delegated to handler.cjs).
98
//
109
// - ESM user code: expose an async handler that lazily `import()`s
1110
// handler.mjs on the first invocation. handler.mjs's async `load()`
12-
// transparently handles both CJS and ESM user modules, so this also
11+
// transparently handles both CJS and ESM user modules, so this is what
1312
// fixes the ERR_REQUIRE_ESM failure mode reported in
1413
// https://github.com/DataDog/datadog-lambda-js/issues/782.
14+
//
15+
// In the published layer this file is removed by the Dockerfile so Lambda's
16+
// resolver falls through to handler.mjs directly.
1517

1618
"use strict";
1719

1820
const fs = require("fs");
1921
const path = require("path");
2022

21-
function userIsEsm() {
23+
function shouldUseEsmHandler() {
2224
const handlerEnv = process.env.DD_LAMBDA_HANDLER || process.env._HANDLER || "";
2325
if (/\.mjs(\..*)?$/.test(handlerEnv)) {
2426
return true;
@@ -27,8 +29,7 @@ function userIsEsm() {
2729
const taskRoot = process.env.LAMBDA_TASK_ROOT || process.cwd();
2830
try {
2931
const pkgPath = path.join(taskRoot, "package.json");
30-
const raw = fs.readFileSync(pkgPath, "utf8");
31-
const pkg = JSON.parse(raw);
32+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
3233
if (pkg && pkg.type === "module") {
3334
return true;
3435
}
@@ -39,7 +40,10 @@ function userIsEsm() {
3940
return false;
4041
}
4142

42-
if (userIsEsm()) {
43+
// Exported for unit tests. Not part of the public surface.
44+
exports._shouldUseEsmHandler = shouldUseEsmHandler;
45+
46+
if (shouldUseEsmHandler()) {
4347
let cachedHandler;
4448
let cachedError;
4549

@@ -59,5 +63,53 @@ if (userIsEsm()) {
5963
return cachedHandler(event, context);
6064
};
6165
} else {
62-
module.exports = require("./handler.cjs");
66+
// Inlined former handler.cjs body. Lambda's resolver doesn't auto-resolve
67+
// `.cjs` for the handler string, so handler.cjs was only ever reachable
68+
// via the shim's `require("./handler.cjs")` — keeping it as a separate
69+
// file made the load graph confusing. Requires are scoped to this branch
70+
// so ESM users don't pay the cost of pulling in the full tracer at
71+
// require-time when they only need handler.mjs.
72+
const {
73+
datadog,
74+
datadogHandlerEnvVar,
75+
lambdaTaskRootEnvVar,
76+
traceExtractorEnvVar,
77+
emitTelemetryOnErrorOutsideHandler,
78+
getEnvValue,
79+
} = require("./index.js");
80+
const { logDebug, logError } = require("./utils/index.js");
81+
const { loadSync } = require("./runtime/index.js");
82+
const { initTracer } = require("./runtime/module_importer");
83+
84+
if (process.env.DD_TRACE_DISABLED_PLUGINS === undefined) {
85+
process.env.DD_TRACE_DISABLED_PLUGINS = "fs";
86+
logDebug("disabled the dd-trace plugin 'fs'");
87+
}
88+
89+
if (getEnvValue("DD_TRACE_ENABLED", "true").toLowerCase() === "true") {
90+
initTracer();
91+
}
92+
93+
const taskRootEnv = getEnvValue(lambdaTaskRootEnvVar, "");
94+
const handlerEnv = getEnvValue(datadogHandlerEnvVar, "");
95+
const extractorEnv = getEnvValue(traceExtractorEnvVar, "");
96+
let traceExtractor;
97+
98+
if (extractorEnv) {
99+
try {
100+
traceExtractor = loadSync(taskRootEnv, extractorEnv);
101+
logDebug("loaded custom trace context extractor", { extractorEnv });
102+
} catch (error) {
103+
logError("an error occurred while loading the custom trace context extractor", { error, extractorEnv });
104+
}
105+
}
106+
107+
try {
108+
exports.handler = datadog(loadSync(taskRootEnv, handlerEnv), { traceExtractor });
109+
} catch (error) {
110+
emitTelemetryOnErrorOutsideHandler(error, handlerEnv, Date.now()).catch(
111+
logDebug("failed to error telemetry on error outside handler"),
112+
);
113+
throw error;
114+
}
63115
}

src/handler.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as fs from "fs";
2+
import * as os from "os";
3+
import * as path from "path";
4+
5+
// Load handler.js with environment that forces the ESM branch on first
6+
// require. The ESM branch only defines an async handler — it does not call
7+
// loadSync or initTracer — so the module loads safely in a test process
8+
// without a real user app.
9+
//
10+
// shouldUseEsmHandler reads process.env / fs at call time, not module-load
11+
// time, so each test case can mutate the environment freely after the
12+
// initial require.
13+
const ORIGINAL_ENV = { ...process.env };
14+
process.env.DD_LAMBDA_HANDLER = "_handler_spec_init.mjs.handler";
15+
delete process.env._HANDLER;
16+
process.env.LAMBDA_TASK_ROOT = os.tmpdir();
17+
18+
// eslint-disable-next-line @typescript-eslint/no-var-requires
19+
const handlerModule: { _shouldUseEsmHandler: () => boolean } = require("./handler");
20+
const { _shouldUseEsmHandler: shouldUseEsmHandler } = handlerModule;
21+
22+
describe("handler.js shouldUseEsmHandler", () => {
23+
let tmpDirs: string[];
24+
25+
beforeEach(() => {
26+
tmpDirs = [];
27+
process.env = { ...ORIGINAL_ENV };
28+
});
29+
30+
afterEach(() => {
31+
for (const dir of tmpDirs) {
32+
fs.rmSync(dir, { recursive: true, force: true });
33+
}
34+
});
35+
36+
afterAll(() => {
37+
process.env = { ...ORIGINAL_ENV };
38+
});
39+
40+
function makeTaskRoot(packageJson?: object): string {
41+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "handler-spec-"));
42+
tmpDirs.push(dir);
43+
if (packageJson !== undefined) {
44+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(packageJson));
45+
}
46+
return dir;
47+
}
48+
49+
it("returns true when _HANDLER points at a .mjs file", () => {
50+
process.env._HANDLER = "app.mjs.handler";
51+
delete process.env.DD_LAMBDA_HANDLER;
52+
process.env.LAMBDA_TASK_ROOT = makeTaskRoot();
53+
expect(shouldUseEsmHandler()).toBe(true);
54+
});
55+
56+
it("returns true when DD_LAMBDA_HANDLER points at a .mjs file", () => {
57+
process.env.DD_LAMBDA_HANDLER = "app.mjs.handler";
58+
delete process.env._HANDLER;
59+
process.env.LAMBDA_TASK_ROOT = makeTaskRoot();
60+
expect(shouldUseEsmHandler()).toBe(true);
61+
});
62+
63+
it('returns true when LAMBDA_TASK_ROOT/package.json has type: "module"', () => {
64+
process.env.DD_LAMBDA_HANDLER = "app.handler";
65+
delete process.env._HANDLER;
66+
process.env.LAMBDA_TASK_ROOT = makeTaskRoot({ type: "module" });
67+
expect(shouldUseEsmHandler()).toBe(true);
68+
});
69+
70+
it("returns false when package.json exists without type: module", () => {
71+
process.env.DD_LAMBDA_HANDLER = "app.handler";
72+
delete process.env._HANDLER;
73+
process.env.LAMBDA_TASK_ROOT = makeTaskRoot({ name: "app" });
74+
expect(shouldUseEsmHandler()).toBe(false);
75+
});
76+
77+
it("returns false when package.json is missing from LAMBDA_TASK_ROOT", () => {
78+
process.env.DD_LAMBDA_HANDLER = "app.handler";
79+
delete process.env._HANDLER;
80+
process.env.LAMBDA_TASK_ROOT = makeTaskRoot();
81+
expect(shouldUseEsmHandler()).toBe(false);
82+
});
83+
84+
it("returns false when package.json is unparseable", () => {
85+
process.env.DD_LAMBDA_HANDLER = "app.handler";
86+
delete process.env._HANDLER;
87+
const dir = makeTaskRoot();
88+
fs.writeFileSync(path.join(dir, "package.json"), "{ not valid json");
89+
process.env.LAMBDA_TASK_ROOT = dir;
90+
expect(shouldUseEsmHandler()).toBe(false);
91+
});
92+
});

0 commit comments

Comments
 (0)