diff --git a/package.json b/package.json index c49bc4c..0815838 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,8 @@ "@biomejs/biome": "2.3.11", "husky": "^9.1.7", "jest": "^30.2.0" + }, + "dependencies": { + "esbuild": "^0.27.2" } } diff --git a/src/warmer.js b/src/warmer.js index cf14a5f..47e3c05 100644 --- a/src/warmer.js +++ b/src/warmer.js @@ -3,6 +3,7 @@ const path = require('path'); const { exec } = require('child_process'); const util = require('util'); const { capitalize } = require('./utils'); +const esbuild = require('esbuild'); const execAsync = util.promisify(exec); @@ -124,7 +125,7 @@ const uninstrumentedLambdaClient = new LambdaClient({ ${ tracing ? `import * as AWSXRay from 'aws-xray-sdk'; -const lambdaClient = AWSXRay.captureAWSv3Client(uninstrumentedLambdaClient);` + const lambdaClient = AWSXRay.captureAWSv3Client(uninstrumentedLambdaClient);` : 'const lambdaClient = uninstrumentedLambdaClient;' } @@ -196,6 +197,32 @@ export const warmUp = async (event, context) => { await execAsync('npm init -y', { cwd: handlerFolder }); await execAsync('npm install --save aws-xray-sdk-core', { cwd: handlerFolder }); } + + // Bundle warmup handler so it does not rely on node_modules at runtime + const entryFile = path.join(handlerFolder, 'index.mjs'); + const bundledFile = path.join(handlerFolder, 'index.bundled.mjs'); + + try { + await esbuild.build({ + entryPoints: [entryFile], + outfile: bundledFile, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node18', + packages: 'bundle', + // Important: bundle everything needed by the warmer into a single file + // (no externals), so the artifact can stay minimal. + sourcemap: false, + minify: false, + }); + + // Replace original with bundled output + await fs.unlink(entryFile); + await fs.rename(bundledFile, entryFile); + } catch (error) { + throw new Error(`Error bundling warmup function: ${error.message}`); + } } /** diff --git a/test/bakcwardsCompatibility.test.js b/test/bakcwardsCompatibility.test.js index dfac9df..91a5f9f 100644 --- a/test/bakcwardsCompatibility.test.js +++ b/test/bakcwardsCompatibility.test.js @@ -4,10 +4,14 @@ jest.mock('fs', () => ({ promises: { mkdir: jest.fn(), unlink: jest.fn(), + rename: jest.fn(), writeFile: jest.fn(), rm: jest.fn(), }, })); +jest.mock('esbuild', () => ({ + build: jest.fn(() => Promise.resolve()), +})); const fs = require('fs').promises; const path = require('path'); @@ -71,6 +75,13 @@ describe('Backward compatibility', () => { fs.mkdir.mockResolvedValue(undefined); fs.writeFile.mockClear(); fs.writeFile.mockResolvedValue(undefined); + fs.unlink.mockClear(); + fs.unlink.mockResolvedValue(undefined); + fs.rename.mockClear(); + fs.rename.mockResolvedValue(undefined); + const esbuild = require('esbuild'); + esbuild.build.mockClear(); + esbuild.build.mockResolvedValue({}); }); it('should fallback to servicePath if serviceDir is not defined', async () => { diff --git a/test/hook.warmupAddWamersAddWamers.test.js b/test/hook.warmupAddWamersAddWamers.test.js index 47b5f7b..03b7c36 100644 --- a/test/hook.warmupAddWamersAddWamers.test.js +++ b/test/hook.warmupAddWamersAddWamers.test.js @@ -4,6 +4,7 @@ jest.mock('fs', () => ({ promises: { mkdir: jest.fn(), unlink: jest.fn(), + rename: jest.fn(), writeFile: jest.fn(), rm: jest.fn(), }, @@ -11,8 +12,12 @@ jest.mock('fs', () => ({ jest.mock('child_process', () => ({ exec: jest.fn((_path, _opts, cb) => cb()), })); +jest.mock('esbuild', () => ({ + build: jest.fn(() => Promise.resolve()), +})); const fs = require('fs').promises; const { exec } = require('child_process'); +const esbuild = require('esbuild'); const path = require('path'); const WarmUp = require('../src/index'); const { @@ -30,7 +35,13 @@ describe('Serverless warmup plugin warmup:warmers:addWarmers:addWarmers hook', ( fs.mkdir.mockResolvedValue(undefined); fs.writeFile.mockClear(); fs.writeFile.mockResolvedValue(undefined); + fs.unlink.mockClear(); + fs.unlink.mockResolvedValue(undefined); + fs.rename.mockClear(); + fs.rename.mockResolvedValue(undefined); exec.mockClear(); + esbuild.build.mockClear(); + esbuild.build.mockResolvedValue({}); }); it('Should be called after package:initialize', async () => { @@ -2756,6 +2767,52 @@ describe('Serverless warmup plugin warmup:warmers:addWarmers:addWarmers hook', ( }), ); }); + + it('Should bundle warmup handler with esbuild to make it self-contained', async () => { + const serverless = getServerlessConfig({ + service: { + custom: { + warmup: { + default: { + enabled: true, + }, + }, + }, + functions: { someFunc1: { name: 'someFunc1' }, someFunc2: { name: 'someFunc2' } }, + }, + }); + const pluginUtils = getPluginUtils(); + const plugin = new WarmUp(serverless, {}, pluginUtils); + + await plugin.hooks['before:warmup:addWarmers:addWarmers'](); + await plugin.hooks['warmup:addWarmers:addWarmers'](); + + const handlerFolder = path.join('testPath', '.warmup', 'default'); + const entryFile = path.join(handlerFolder, 'index.mjs'); + const bundledFile = path.join(handlerFolder, 'index.bundled.mjs'); + + // Verify esbuild.build was called with correct parameters + expect(esbuild.build).toHaveBeenCalledTimes(1); + expect(esbuild.build).toHaveBeenCalledWith({ + entryPoints: [entryFile], + outfile: bundledFile, + bundle: true, + platform: 'node', + packages: 'bundle', + format: 'esm', + target: 'node18', + sourcemap: false, + minify: false, + }); + + // Verify the original file was deleted + expect(fs.unlink).toHaveBeenCalledTimes(1); + expect(fs.unlink).toHaveBeenCalledWith(entryFile); + + // Verify the bundled file was renamed to replace the original + expect(fs.rename).toHaveBeenCalledTimes(1); + expect(fs.rename).toHaveBeenCalledWith(bundledFile, entryFile); + }); }); describe('Other plugins integrations', () => {