Skip to content

Commit a7aa142

Browse files
authored
fix: handle the side-effect artifacts case (#683)
* handle the side-effect artifacts case * lint fix * update comments * update comment * lint * lint * update the looksLikeArtifact logic * update the tests
1 parent 9121ac4 commit a7aa142

File tree

2 files changed

+150
-7
lines changed

2 files changed

+150
-7
lines changed

src/utils/handler.spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,125 @@ describe("promisifiedHandler", () => {
146146

147147
expect(result).toEqual({ statusCode: 200, body: "Sync response" });
148148
});
149+
150+
it("waits for callback when handler returns non-promise artifact with callback parameter", async () => {
151+
// Simulates aws-serverless-express pattern where a server instance is returned
152+
// but the actual response comes through the callback
153+
const serverArtifact = { type: "server-instance", listen: () => {} };
154+
const handler: Handler = (event, context, callback) => {
155+
// Simulate async processing that eventually calls callback
156+
setTimeout(() => {
157+
callback(null, { statusCode: 200, body: "Actual response from callback" });
158+
}, 10);
159+
return serverArtifact as unknown as void;
160+
};
161+
162+
const promHandler = promisifiedHandler(handler) as any;
163+
164+
const result = await promHandler({}, mockContext);
165+
166+
// Should return the callback result, not the server artifact
167+
expect(result).toEqual({ statusCode: 200, body: "Actual response from callback" });
168+
expect(result).not.toBe(serverArtifact);
169+
});
170+
171+
it("detects http.Server-like artifact (has listen AND close)", async () => {
172+
// Detection method 1: Node.js http.Server or similar (has both listen and close)
173+
const serverArtifact = {
174+
listen: () => {},
175+
close: () => {},
176+
_handle: {},
177+
};
178+
const handler = (event: any, context: Context) => {
179+
setTimeout(() => {
180+
context.done(undefined, { statusCode: 200, body: "Response from context.done" });
181+
}, 10);
182+
return serverArtifact;
183+
};
184+
185+
const promHandler = promisifiedHandler(handler as any) as any;
186+
const result = await promHandler({}, mockContext);
187+
188+
expect(result).toEqual({ statusCode: 200, body: "Response from context.done" });
189+
expect(result).not.toBe(serverArtifact);
190+
});
191+
192+
it("detects EventEmitter-like artifact (has on AND emit)", async () => {
193+
// Detection method 2: EventEmitter-like (has .on and .emit)
194+
const emitterArtifact = {
195+
on: () => {},
196+
emit: () => {},
197+
listeners: [],
198+
};
199+
const handler = (event: any, context: Context) => {
200+
setTimeout(() => {
201+
context.succeed({ statusCode: 200, body: "EventEmitter response" });
202+
}, 10);
203+
return emitterArtifact;
204+
};
205+
206+
const promHandler = promisifiedHandler(handler as any) as any;
207+
const result = await promHandler({}, mockContext);
208+
209+
expect(result).toEqual({ statusCode: 200, body: "EventEmitter response" });
210+
expect(result).not.toBe(emitterArtifact);
211+
});
212+
213+
it("detects EventEmitter instance", async () => {
214+
// Detection method 3: Instance of EventEmitter (covers Server, Socket, etc.)
215+
const { EventEmitter } = require("events");
216+
const artifact = new EventEmitter();
217+
218+
const handler = (event: any, context: Context) => {
219+
setTimeout(() => {
220+
context.succeed({ statusCode: 200, body: "From EventEmitter instance" });
221+
}, 10);
222+
return artifact;
223+
};
224+
225+
const promHandler = promisifiedHandler(handler as any) as any;
226+
const result = await promHandler({}, mockContext);
227+
228+
expect(result).toEqual({ statusCode: 200, body: "From EventEmitter instance" });
229+
expect(result).not.toBe(artifact);
230+
});
231+
232+
it("detects artifact by constructor name (Server/Socket/Emitter)", async () => {
233+
// Detection method 4: Constructor name matches /Server|Socket|Emitter/i
234+
class CustomServer {
235+
public port = 3000;
236+
public start() {
237+
return "started";
238+
}
239+
}
240+
const artifact = new CustomServer();
241+
242+
const handler = (event: any, context: Context) => {
243+
setTimeout(() => {
244+
context.succeed({ statusCode: 200, body: "From CustomServer" });
245+
}, 10);
246+
return artifact;
247+
};
248+
249+
const promHandler = promisifiedHandler(handler as any) as any;
250+
const result = await promHandler({}, mockContext);
251+
252+
expect(result).toEqual({ statusCode: 200, body: "From CustomServer" });
253+
expect(result).not.toBe(artifact);
254+
});
255+
256+
it("does NOT treat plain response objects as artifacts", async () => {
257+
// Plain objects that happen to have some function properties should still
258+
// be treated as artifacts to be safe, but objects without functions are not artifacts
259+
const handler = (event: any, context: Context) => {
260+
// This is a legitimate Lambda response
261+
return { statusCode: 200, body: "Plain response", headers: { "Content-Type": "text/plain" } };
262+
};
263+
264+
const promHandler = promisifiedHandler(handler as any) as any;
265+
const result = await promHandler({}, mockContext);
266+
267+
// Should return immediately with the response object
268+
expect(result).toEqual({ statusCode: 200, body: "Plain response", headers: { "Content-Type": "text/plain" } });
269+
});
149270
});

src/utils/handler.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Callback, Context, Handler } from "aws-lambda";
22
import { HANDLER_STREAMING, STREAM_RESPONSE } from "../constants";
3+
import { EventEmitter } from "events";
34

45
export function promisifiedHandler<TEvent, TResult>(handler: Handler<TEvent, TResult> | any) {
56
// Response Stream Lambda function.
@@ -55,18 +56,39 @@ export function promisifiedHandler<TEvent, TResult>(handler: Handler<TEvent, TRe
5556

5657
const asyncProm = handler(event, context, modifiedCallback) as Promise<TResult> | undefined;
5758
let promise: Promise<TResult | undefined> = callbackProm;
58-
if (asyncProm !== undefined && typeof asyncProm.then === "function") {
59+
60+
if (asyncProm !== undefined && typeof (asyncProm as any).then === "function") {
5961
// Mimics behaviour of lambda runtime, the first method of returning a result always wins.
60-
promise = Promise.race([callbackProm, asyncProm]);
61-
} else if (asyncProm === undefined && handler.length < 3) {
62-
// Handler returned undefined and doesn't take a callback parameter, resolve immediately
63-
promise = Promise.resolve(undefined);
62+
promise = Promise.race([callbackProm, asyncProm as Promise<TResult>]);
6463
} else if (handler.length >= 3) {
6564
// Handler takes a callback, wait for the callback to be called
6665
promise = callbackProm;
6766
} else {
68-
// Handler returned a value directly (sync handler with return value), resolve with that value
69-
promise = Promise.resolve(asyncProm);
67+
// Handler returned a value directly
68+
// Distinguish between:
69+
// - ordinary sync return value -> resolve immediately
70+
// - side-effect artifact (e.g. aws-serverless-express server) -> wait for context.done
71+
72+
// Heuristic: trying to detect common types of side-effect artifacts
73+
const looksLikeArtifact =
74+
asyncProm !== undefined &&
75+
typeof asyncProm === "object" &&
76+
// 1. Node.js http.Server or similar
77+
((typeof (asyncProm as any).listen === "function" && typeof (asyncProm as any).close === "function") ||
78+
// 2. EventEmitter-like (has .on and .emit)
79+
(typeof (asyncProm as any).on === "function" && typeof (asyncProm as any).emit === "function") ||
80+
// 3. Instance of EventEmitter (covers Server, Socket, etc.)
81+
asyncProm instanceof EventEmitter ||
82+
// 4. Constructor name hint
83+
((asyncProm as any).constructor && /Server|Socket|Emitter/i.test((asyncProm as any).constructor.name)));
84+
85+
if (looksLikeArtifact) {
86+
// Wait for callbackProm instead (the context.done/succeed/fail will resolve it)
87+
promise = callbackProm;
88+
} else {
89+
// Return the value directly
90+
promise = Promise.resolve(asyncProm);
91+
}
7092
}
7193
return promise;
7294
};

0 commit comments

Comments
 (0)