serverless-offline
Version:
Emulate AWS λ and API Gateway locally when developing your Serverless project
341 lines (299 loc) • 10.7 kB
JavaScript
/* eslint-disable import/no-dynamic-require */
/* eslint-disable global-require */
/* eslint-disable no-underscore-dangle */
/* eslint-disable func-names */
/**
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* This code was copied from:
* https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js
*
* This module defines the functions for loading the user's code as specified
* in a handler string.
*/
const path = require("node:path")
const { pathToFileURL } = require("node:url")
const fs = require("node:fs")
const process = require("node:process")
const { require: tsxRequire } = require(`tsx/cjs/api`)
const { tsImport } = require(`tsx/esm/api`)
const {
HandlerNotFound,
MalformedHandlerName,
ImportModuleError,
UserCodeSyntaxError,
MalformedStreamingHandler,
} = require("./Errors.js")
const { verbose } = require("./VerboseLog.js").logger("LOADER")
const { HttpResponseStream } = require("./HttpResponseStream.js")
const FUNCTION_EXPR = /^([^.]*)\.(.*)$/
const RELATIVE_PATH_SUBSTRING = ".."
const HANDLER_STREAMING = Symbol.for("aws.lambda.runtime.handler.streaming")
const HANDLER_HIGHWATERMARK = Symbol.for(
"aws.lambda.runtime.handler.streaming.highWaterMark",
)
const STREAM_RESPONSE = "response"
// `awslambda.streamifyResponse` function is provided by default.
const NoGlobalAwsLambda =
process.env.AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA === "1" ||
process.env.AWS_LAMBDA_NODEJS_NO_GLOBAL_AWSLAMBDA === "true"
/**
* Break the full handler string into two pieces, the module root and the actual
* handler string.
* Given './somepath/something/module.nestedobj.handler' this returns
* ['./somepath/something', 'module.nestedobj.handler']
*/
function _moduleRootAndHandler(fullHandlerString) {
const handlerString = path.basename(fullHandlerString)
const moduleRoot = fullHandlerString.substring(
0,
fullHandlerString.indexOf(handlerString),
)
return [moduleRoot, handlerString]
}
/**
* Split the handler string into two pieces: the module name and the path to
* the handler function.
*/
function _splitHandlerString(handler) {
const match = handler.match(FUNCTION_EXPR)
if (!match || match.length !== 3) {
throw new MalformedHandlerName("Bad handler")
}
return [match[1], match[2]] // [module, function-path]
}
/**
* Resolve the user's handler function from the module.
*/
function _resolveHandler(object, nestedProperty) {
return nestedProperty.split(".").reduce((nested, key) => {
return nested && nested[key]
}, object)
}
function _tryRequireFile(file, extension) {
const pathRequireFile = file + (extension || "")
verbose("Try loading as commonjs:", pathRequireFile)
return fs.existsSync(pathRequireFile) ? require(pathRequireFile) : undefined
}
async function _tryAwaitImport(file, extension) {
const pathAwaitImport = file + (extension || "")
verbose("Try loading as esmodule:", pathAwaitImport)
if (fs.existsSync(pathAwaitImport)) {
// eslint-disable-next-line no-return-await
return await import(pathToFileURL(pathAwaitImport).href)
}
return undefined
}
function _hasFolderPackageJsonTypeModule(folder) {
// Check if package.json exists, return true if type === "module" in package json.
// If there is no package.json, and there is a node_modules, return false.
// Check parent folder otherwise, if there is one.
if (folder.endsWith("/node_modules")) {
return false
}
const pj = path.join(folder, "/package.json")
if (fs.existsSync(pj)) {
try {
const pkg = JSON.parse(fs.readFileSync(pj))
if (pkg) {
if (pkg.type === "module") {
verbose("type: module detected in", pj)
return true
}
verbose("type: module not detected in", pj)
return false
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn(
`${pj} cannot be read, it will be ignored for ES module detection purposes.`,
e,
)
return false
}
}
if (folder === "/") {
// We have reached root without finding either a package.json or a node_modules.
return false
}
return _hasFolderPackageJsonTypeModule(path.resolve(folder, ".."))
}
function _hasPackageJsonTypeModule(file) {
const dir = path.dirname(file)
return fs.existsSync(dir)
? _hasFolderPackageJsonTypeModule(path.resolve(dir))
: false
}
/**
* Attempt to load the user's module.
* Attempts to directly resolve the module relative to the application root,
* then falls back to the more general require().
*/
async function _tryRequire(appRoot, moduleRoot, module) {
verbose(
"Try loading as commonjs: ",
module,
" with paths: ,",
appRoot,
moduleRoot,
)
const lambdaStylePath = path.resolve(appRoot, moduleRoot, module)
// Extensionless files are loaded via require.
const extensionless = _tryRequireFile(lambdaStylePath)
if (extensionless) {
return extensionless
}
// If package.json type != module, .js files are loaded via require.
const pjHasModule = _hasPackageJsonTypeModule(lambdaStylePath)
if (!pjHasModule) {
const loaded = _tryRequireFile(lambdaStylePath, ".js")
if (loaded) {
return loaded
}
}
// If still not loaded, try .js, .mjs, .cjs and .ts in that order.
// Files ending with .js and .ts are loaded as ES modules when the nearest parent package.json
// file contains a top-level field "type" with a value of "module".
// https://nodejs.org/api/packages.html#packages_type
const loaded =
(pjHasModule && (await _tryAwaitImport(lambdaStylePath, ".js"))) ||
(await _tryAwaitImport(lambdaStylePath, ".mjs")) ||
_tryRequireFile(lambdaStylePath, ".cjs") ||
(pjHasModule &&
(await tsImport(`${lambdaStylePath}.ts`, `${lambdaStylePath}.ts`))) ||
tsxRequire(`${lambdaStylePath}.ts`, `${lambdaStylePath}.ts`)
if (loaded) {
return loaded
}
verbose(
"Try loading as commonjs: ",
module,
" with path(s): ",
appRoot,
moduleRoot,
)
// Why not just require(module)?
// Because require() is relative to __dirname, not process.cwd(). And the
// runtime implementation is not located in /var/task
// This won't work (yet) for esModules as import.meta.resolve is still experimental
// See: https://nodejs.org/api/esm.html#esm_import_meta_resolve_specifier_parent
const nodeStylePath = require.resolve(module, {
paths: [appRoot, moduleRoot],
})
return require(nodeStylePath)
}
/**
* Load the user's application or throw a descriptive error.
* @throws Runtime errors in two cases
* 1 - UserCodeSyntaxError if there's a syntax error while loading the module
* 2 - ImportModuleError if the module cannot be found
*/
async function _loadUserApp(appRoot, moduleRoot, module) {
if (!NoGlobalAwsLambda) {
globalThis.awslambda = {
HttpResponseStream,
streamifyResponse: (handler, options) => {
// eslint-disable-next-line no-param-reassign
handler[HANDLER_STREAMING] = STREAM_RESPONSE
if (typeof options?.highWaterMark === "number") {
// eslint-disable-next-line no-param-reassign
handler[HANDLER_HIGHWATERMARK] = Number.parseInt(
options.highWaterMark,
10,
)
}
return handler
},
}
}
try {
return await _tryRequire(appRoot, moduleRoot, module)
} catch (e) {
if (e instanceof SyntaxError) {
throw new UserCodeSyntaxError(e)
} else if (e.code !== undefined && e.code === "MODULE_NOT_FOUND") {
verbose("globalPaths", JSON.stringify(require("node:module").globalPaths))
throw new ImportModuleError(e)
} else {
throw e
}
}
}
function _throwIfInvalidHandler(fullHandlerString) {
if (fullHandlerString.includes(RELATIVE_PATH_SUBSTRING)) {
throw new MalformedHandlerName(
`'${fullHandlerString}' is not a valid handler name. Use absolute paths when specifying root directories in handler names.`,
)
}
}
function _isHandlerStreaming(handler) {
if (
handler[HANDLER_STREAMING] === undefined ||
handler[HANDLER_STREAMING] === null ||
handler[HANDLER_STREAMING] === false
) {
return false
}
if (handler[HANDLER_STREAMING] === STREAM_RESPONSE) {
return STREAM_RESPONSE
}
throw new MalformedStreamingHandler("Only response streaming is supported.")
}
function _highWaterMark(handler) {
if (
handler[HANDLER_HIGHWATERMARK] === undefined ||
handler[HANDLER_HIGHWATERMARK] === null ||
handler[HANDLER_HIGHWATERMARK] === false
) {
return undefined
}
const hwm = Number.parseInt(handler[HANDLER_HIGHWATERMARK], 10)
return Number.isNaN(hwm) ? undefined : hwm
}
/**
* Load the user's function with the approot and the handler string.
* @param appRoot {string}
* The path to the application root.
* @param handlerString {string}
* The user-provided handler function in the form 'module.function'.
* @return userFuction {function}
* The user's handler function. This function will be passed the event body,
* the context object, and the callback function.
* @throws In five cases:-
* 1 - if the handler string is incorrectly formatted an error is thrown
* 2 - if the module referenced by the handler cannot be loaded
* 3 - if the function in the handler does not exist in the module
* 4 - if a property with the same name, but isn't a function, exists on the
* module
* 5 - the handler includes illegal character sequences (like relative paths
* for traversing up the filesystem '..')
* Errors for scenarios known by the runtime, will be wrapped by Runtime.* errors.
*/
module.exports.load = async function (appRoot, fullHandlerString) {
_throwIfInvalidHandler(fullHandlerString)
const [moduleRoot, moduleAndHandler] =
_moduleRootAndHandler(fullHandlerString)
const [module, handlerPath] = _splitHandlerString(moduleAndHandler)
const userApp = await _loadUserApp(appRoot, moduleRoot, module)
const handlerFunc = _resolveHandler(userApp, handlerPath)
if (!handlerFunc) {
throw new HandlerNotFound(
`${fullHandlerString} is undefined or not exported`,
)
}
if (typeof handlerFunc !== "function") {
throw new HandlerNotFound(`${fullHandlerString} is not a function`)
}
return handlerFunc
}
module.exports.isHandlerFunction = function (value) {
return typeof value === "function"
}
module.exports.getHandlerMetadata = function (handlerFunc) {
return {
highWaterMark: _highWaterMark(handlerFunc),
streaming: _isHandlerStreaming(handlerFunc),
}
}
module.exports.STREAM_RESPONSE = STREAM_RESPONSE