Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@
"@biomejs/biome": "2.3.11",
"husky": "^9.1.7",
"jest": "^30.2.0"
},
"dependencies": {
"esbuild": "^0.27.2"
}
}
29 changes: 28 additions & 1 deletion src/warmer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;'
}

Expand Down Expand Up @@ -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}`);
}
}

/**
Expand Down
11 changes: 11 additions & 0 deletions test/bakcwardsCompatibility.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 () => {
Expand Down
57 changes: 57 additions & 0 deletions test/hook.warmupAddWamersAddWamers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
unlink: jest.fn(),
rename: jest.fn(),
writeFile: jest.fn(),
rm: jest.fn(),
},
}));
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 {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down