From eab0c3d14111ab92481d8264cd4e707786632069 Mon Sep 17 00:00:00 2001 From: Rafael Ferreira <36054+rferreira@users.noreply.github.com> Date: Tue, 12 May 2026 12:11:36 -0400 Subject: [PATCH 1/3] fix: replace callback-based handlers with async return for Node.js 24 compatibility AWS Lambda Node.js 24 runtime removed support for callback-based function handlers (Runtime.CallbackHandlerDeprecated). Both s3-handler and ag-handler were async functions still using the deprecated callback(null, response) pattern. Converted to return the response object directly. Updated all unit tests accordingly; also fixed a pre-existing silent test bug where the "directory" fixture key had no trailing slash and assertions inside callbacks were silently swallowed by mocha. Fixes #110 --- lib/ag-handler.js | 16 +++---- lib/s3-handler.js | 13 +++--- tests/ag-handler.js | 102 +++++++++++++++----------------------------- tests/s3-handler.js | 63 +++++++++++---------------- 4 files changed, 70 insertions(+), 124 deletions(-) diff --git a/lib/ag-handler.js b/lib/ag-handler.js index 2c62e7b..e599138 100644 --- a/lib/ag-handler.js +++ b/lib/ag-handler.js @@ -7,9 +7,8 @@ const pkg = require('../package.json'); * Handles HTTP result callback * @param {Object} event the API gateway event to be processed * @param context - * @param callback */ -exports.handler = async (event, context, callback) => { +exports.handler = async (event, context) => { console.log(`handling callback event using ${pkg.name}/v${pkg.version}`); try { @@ -40,26 +39,25 @@ exports.handler = async (event, context, callback) => { } } - // returning callback - callback(null, { + console.log("handling completed"); + + return { "statusCode": 200, "headers": { "Content-Type": "application/json" }, "body": JSON.stringify({status: "OK"}, null, 2) - }); - - console.log("handling completed"); + }; } catch (error) { console.log("something went wrong..."); console.error(error); // an error occurred - callback(null, { + return { "statusCode": 500, "headers": { "Content-Type": "application/json" }, "body": JSON.stringify({status: error.message}, null, 2) - }); + }; } }; diff --git a/lib/s3-handler.js b/lib/s3-handler.js index cd9a2f2..d100585 100644 --- a/lib/s3-handler.js +++ b/lib/s3-handler.js @@ -14,9 +14,8 @@ const s3Client = new S3Client({}); * Handles events from S3 and submits object for processing * @param event {Object} S3 event to be processed * @param context the AWS lambda context - * @param callback AWS lambda callback */ -exports.handler = async (event, context, callback) => { +exports.handler = async (event, context) => { try { console.log(`handling s3 event using ${pkg.name}/v${pkg.version}`); @@ -65,24 +64,22 @@ exports.handler = async (event, context, callback) => { assert.ok(submitResult.resourceLocation !== undefined, "invalid response from server, no response received"); console.log(`contents submitted for processing with id: ${submitResult.id} and location: ${submitResult.resourceLocation}`); - - // returning back to - callback(null, { + return { "statusCode": 200, "headers": { "Content-Type": "application/json" }, "body": JSON.stringify({status: "OK"}, null, 2) - }); + }; } catch (error) { console.error(error, error.stack); // an error occurred - callback(null, { + return { "statusCode": 500, "headers": { "Content-Type": "application/json" }, "body": JSON.stringify({status: error.message}, null, 2) - }); + }; } }; diff --git a/tests/ag-handler.js b/tests/ag-handler.js index 44cab73..1279b57 100644 --- a/tests/ag-handler.js +++ b/tests/ag-handler.js @@ -31,7 +31,7 @@ describe('Api Gateway handler tests', () => { }); it('should handle a callback without findings', async () => { - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -43,25 +43,18 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 200); - }); + }), {}); + assert(result.statusCode === 200); }); it('should handle a bogus callback', async () => { - await handler(hydrateEvent({"hello": "world"}), - {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 500, "should return the file id"); - assert(result.body.includes("no id provided")); - }); + const result = await handler(hydrateEvent({"hello": "world"}), {}); + assert(result.statusCode === 500, "should return the file id"); + assert(result.body.includes("no id provided")); }); it('should require bucket/key in callback metadata', async () => { - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -71,18 +64,13 @@ describe('Api Gateway handler tests', () => { "metadata": { "signature": utils.generateSignature("test-bucket", "test-key"), } - }), - {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 500, "should return the file id"); - assert(result.body.includes("no bucket supplied in metadata")); - }); + }), {}); + assert(result.statusCode === 500, "should return the file id"); + assert(result.body.includes("no bucket supplied in metadata")); }); it('should handle callbacks with findings', async () => { - - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -94,15 +82,12 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 200); - }); + }), {}); + assert(result.statusCode === 200); }); it('should ensure callback signatures match', async () => { - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -114,15 +99,12 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 200); - }); + }), {}); + assert(result.statusCode === 200); }); - it('should ensure callback signatures match - negative', async () => { - await handler(hydrateEvent({ + it('should ensure callback signatures match - negative', async () => { + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -134,15 +116,12 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 500); - }); + }), {}); + assert(result.statusCode === 500); }); it('should enforce signatures in callbacks', async () => { - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -154,17 +133,13 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 500, "should return the file id"); - assert(result.body.includes("invalid signature")); - }); + }), {}); + assert(result.statusCode === 500, "should return the file id"); + assert(result.body.includes("invalid signature")); }); it('should handle api gateway proxy callbacks', async () => { - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -176,15 +151,12 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 200); - }); + }), {}); + assert(result.statusCode === 200); }); it('should handle api gateway proxy callbacks and findings', async () => { - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "id": "2e4612793298b1d691202e75dc125f6e", "checksum": "30d3007d8fa7e76f2741805fbaf1c8bba9a00051", "content_length": "1251174", @@ -196,14 +168,12 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 200); - }); + }), {}); + assert(result.statusCode === 200); }); + it('should handle api gateway callbacks with errors', async () => { - await handler(hydrateEvent({ + const result = await handler(hydrateEvent({ "error": "error message", "id": "a62a6f0ba82f6ac11e95d09b8bdf965c", "metadata": { @@ -211,11 +181,8 @@ describe('Api Gateway handler tests', () => { "bucket": "test-bucket", "key": "test-key" } - }), {}, (error, result) => { - "use strict"; - assert(error === null, "there should be no errors"); - assert(result.statusCode === 200); - }); + }), {}); + assert(result.statusCode === 200); }); }); @@ -283,4 +250,3 @@ const hydrateEvent = (body) => { } } }; - diff --git a/tests/s3-handler.js b/tests/s3-handler.js index 8bc038a..ccac761 100644 --- a/tests/s3-handler.js +++ b/tests/s3-handler.js @@ -35,7 +35,7 @@ describe('S3 handler tests', () => { .post('/v2.2/files/fetch') .reply(202, Buffer.from("{\"id\":\"12356789\"}"), {"Location": "https://api-us1.scanii.com/v2.2/files/1234"}); - await handler({ + const result = await handler({ "Records": [ { "eventVersion": "2.0", @@ -72,10 +72,8 @@ describe('S3 handler tests', () => { } } ] - }, {}, (error, result) => { - assert(error === null, "there should be no errors"); - assert(result.statusCode === 200, "should return the file id"); - }); + }, {}); + assert(result.statusCode === 200, "should return 200"); }); it('should fail to process a s3 event missing the object key', async () => { @@ -84,7 +82,7 @@ describe('S3 handler tests', () => { .post('/v2.2/files/fetch') .reply(202, Buffer.from("{\"id\":\"12356789\"}"), {"Location": "https://api-us1.scanii.com/v2.2/files/1234"}); - await handler({ + const result = await handler({ "Records": [ { "eventVersion": "2.0", @@ -120,11 +118,9 @@ describe('S3 handler tests', () => { } } ] - }, {}, (error, result) => { - assert(error === null, "there should be no errors"); - assert(result.statusCode === 500, "should return the file id"); - assert(result.body.includes("key not present")); - }); + }, {}); + assert(result.statusCode === 500, "should return 500"); + assert(result.body.includes("key not present")); }); it('should fail to process a s3 event missing the object bucket', async () => { @@ -133,8 +129,7 @@ describe('S3 handler tests', () => { .post('/v2.2/files/fetch') .reply(202, Buffer.from("{\"id\":\"12356789\"}"), {"Location": "https://api-us1.scanii.com/v2.2/files/1234"}); - - await handler({ + const result = await handler({ "Records": [ { "eventVersion": "2.0", @@ -170,19 +165,14 @@ describe('S3 handler tests', () => { } } ] - }, {}, (error, result) => { - assert(error === null, "there should be no errors"); - assert(result.statusCode === 500, "should return the file id"); - assert(result.body.includes("bucket not present")); - }); + }, {}); + assert(result.statusCode === 500, "should return 500"); + assert(result.body.includes("bucket not present")); }); - it('should fail to process a directory', async () => { - nock('https://api-us1.scanii.com') - .post('/v2.2/files/fetch') - .reply(202, Buffer.from("{\"id\":\"12356789\"}"), {"Location": "https://api-us1.scanii.com/v2.2/files/1234"}); + it('should fail to process a directory', async () => { - await handler({ + const result = await handler({ "Records": [ { "eventVersion": "2.0", @@ -211,19 +201,19 @@ describe('S3 handler tests', () => { "arn": "arn:aws:s3:::scanii-mu" }, "object": { - "size": 519, - "eTag": "aa1e5c8a6a07217c25f55aa8e96ea37a", - "key": "Screen+Shot+2016-01-19+at+7.24.37+PM.png", + "size": 0, + "eTag": "d41d8cd98f00b204e9800998ecf8427e", + "key": "some-directory%2F", "sequencer": "00560DC1B62F962FCD" } } } ] - }, {}, (error, result) => { - assert(error === null, "there should be no errors"); - assert(result.body.includes("cannot process directory")); - }); + }, {}); + assert(result.statusCode === 500, "should return 500"); + assert(result.body.includes("cannot process directory")); }); + it('should honor configurable signed url timeout', async () => { nock('https://api-us1.scanii.com') @@ -236,7 +226,7 @@ describe('S3 handler tests', () => { return 'https://s3.amazonaws.com/'; }; - return await handler({ + const result = await handler({ "Records": [ { "eventVersion": "2.0", @@ -273,13 +263,8 @@ describe('S3 handler tests', () => { } } ] - - }, {}, (error, result) => { - assert.ok(error === null, "there should be no errors"); - assert.ok(result.statusCode === 200, "signed url timeout not configurable"); - assert.ok(capturedOptions.expiresIn === CONFIG.SIGNED_URL_DURATION); - - }); + }, {}); + assert.ok(result.statusCode === 200, "signed url timeout not configurable"); + assert.ok(capturedOptions.expiresIn === CONFIG.SIGNED_URL_DURATION); }); }) - From a019caeeaf0734e8c451c40cc642cae94317d08b Mon Sep 17 00:00:00 2001 From: Rafael Ferreira <36054+rferreira@users.noreply.github.com> Date: Tue, 12 May 2026 12:20:09 -0400 Subject: [PATCH 2/3] test: guard against Lambda Node.js 24 handler-shape regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary checks to prevent #110 from recurring: 1. Unit-test arity assertion. Each handler test asserts `handler.length <= 2`, which is what AWS Lambda's runtime client checks (`errorOnDeprecatedCallback`) before deciding whether to reject async-with-callback handlers. Cheap, runs on every Node version in the existing matrix. 2. Runtime init smoke job. New CI job spins up each handler inside the official `public.ecr.aws/lambda/nodejs:24` image (with the Runtime Interface Emulator bundled) and POSTs an invocation. The handler's own assertions on the empty event are expected to fail — we only fail CI if the response contains a `Runtime.*` errorType (CallbackHandlerDeprecated, ImportModuleError, MalformedHandlerName, etc.), which means init itself broke. Verified locally against a synthetic callback-shaped handler: detected. --- .github/workflows/pr.yml | 63 ++++++++++++++++++++++++++++++++++++++++ tests/ag-handler.js | 8 +++++ tests/s3-handler.js | 8 +++++ 3 files changed, 79 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3dcff7b..5586b57 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -31,3 +31,66 @@ jobs: - name: Test run: npm test + + runtime-init: + name: Lambda Node 24 runtime init smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + + - run: npm ci --omit=dev + + # Initialize each handler inside the real Lambda Node.js 24 runtime image + # (Runtime Interface Emulator bundled). This exercises the same init code + # path (errorOnDeprecatedCallback, ImportModuleError, etc.) that runs in + # production — catches callback-shape regressions and other runtime-init + # incompatibilities that plain `node` cannot surface. + - name: Probe handlers in Lambda Node 24 runtime + run: | + set -euo pipefail + + probe() { + local handler=$1 + echo "::group::Probing $handler" + + local container + container=$(docker run -d --rm -p 9000:8080 \ + -v "$PWD":/var/task:ro \ + public.ecr.aws/lambda/nodejs:24 \ + "$handler") + + local response="" + for _ in $(seq 1 60); do + if response=$(curl -s -m 1 -X POST \ + http://localhost:9000/2015-03-31/functions/function/invocations \ + -d '{}' 2>/dev/null) && [ -n "$response" ]; then + break + fi + sleep 0.5 + done + + echo "response: $response" + + local exit_code=0 + # Runtime.* errorTypes are init-level errors (CallbackHandlerDeprecated, + # ImportModuleError, MalformedHandlerName, ...). Handler-side errors + # from invoking with an empty event use different errorTypes and are + # expected here — we only care that init succeeded. + if echo "$response" | grep -Eq '"errorType":[[:space:]]*"Runtime\.'; then + echo "::error::$handler failed Lambda Node 24 runtime init" + docker logs "$container" 2>&1 || true + exit_code=1 + fi + + docker stop "$container" >/dev/null 2>&1 || true + echo "::endgroup::" + return $exit_code + } + + probe lib/s3-handler.handler + probe lib/ag-handler.handler diff --git a/tests/ag-handler.js b/tests/ag-handler.js index 1279b57..d8b5a73 100644 --- a/tests/ag-handler.js +++ b/tests/ag-handler.js @@ -12,6 +12,14 @@ const { S3Client, DeleteObjectCommand, PutObjectTaggingCommand, GetObjectTagging const s3Mock = mockClient(S3Client); describe('Api Gateway handler tests', () => { + // AWS Lambda Node.js 24+ rejects async handlers that declare a `callback` param + // (Runtime.CallbackHandlerDeprecated). handler.length encodes that arity — keep + // it at ≤ 2 so init never regresses. + it('handler signature is compatible with Lambda Node.js 24+', () => { + assert.ok(handler.length <= 2, + `handler accepts ${handler.length} params; Lambda Node.js 24+ rejects callback-based handlers`); + }); + beforeEach(() => { s3Mock.reset(); diff --git a/tests/s3-handler.js b/tests/s3-handler.js index ccac761..55372bf 100644 --- a/tests/s3-handler.js +++ b/tests/s3-handler.js @@ -13,6 +13,14 @@ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); "use strict"; describe('S3 handler tests', () => { + // AWS Lambda Node.js 24+ rejects async handlers that declare a `callback` param + // (Runtime.CallbackHandlerDeprecated). handler.length encodes that arity — keep + // it at ≤ 2 so init never regresses. + it('handler signature is compatible with Lambda Node.js 24+', () => { + assert.ok(handler.length <= 2, + `handler accepts ${handler.length} params; Lambda Node.js 24+ rejects callback-based handlers`); + }); + beforeEach(() => { s3HandlerModule._getSignedUrl = async () => 'https://s3.amazonaws.com/'; From 7af326a2aa93e139aa6cd9feb2b64cb39dd19de7 Mon Sep 17 00:00:00 2001 From: Rafael Ferreira <36054+rferreira@users.noreply.github.com> Date: Tue, 12 May 2026 12:24:29 -0400 Subject: [PATCH 3/3] ci: drop windows/macos from PR matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanii-lambda only ever runs on the Linux Lambda runtime — testing on windows and macos was burning runner minutes for no signal. --- .github/workflows/pr.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5586b57..d4d53d6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -8,12 +8,11 @@ concurrency: jobs: verify: - name: Node ${{ matrix.node }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] node: ['22', '24'] steps: - uses: actions/checkout@v4