serverless-offline-reasint
Version:
Emulate AWS λ and API Gateway locally when developing your Serverless project
333 lines (261 loc) • 8.66 kB
JavaScript
import crypto from "node:crypto"
import { readFile, writeFile } from "node:fs/promises"
import { dirname, join, resolve } from "node:path"
import process from "node:process"
import { performance } from "node:perf_hooks"
import { setTimeout } from "node:timers/promises"
import { log } from "@serverless/utils/log.js"
import { emptyDir, ensureDir, remove } from "fs-extra"
import jszip from "jszip"
import HandlerRunner from "./handler-runner/index.js"
import LambdaContext from "./LambdaContext.js"
import {
DEFAULT_LAMBDA_MEMORY_SIZE,
DEFAULT_LAMBDA_RUNTIME,
DEFAULT_LAMBDA_TIMEOUT,
supportedRuntimes,
} from "../config/index.js"
import { LambdaTimeoutError } from "../errors/index.js"
const { ceil } = Math
const { entries, fromEntries } = Object
export default class LambdaFunction {
#artifact = null
#clientContext = null
#codeDir = null
#event = null
#executionTimeEnded = null
#executionTimeStarted = null
#functionKey = null
#functionName = null
#handler = null
#handlerRunner = null
#idleTimeStarted = null
#initialized = false
#lambdaContext = null
#lambdaDir = null
#memorySize = null
#noTimeout = null
#region = null
#runtime = null
#status = "IDLE" // can be 'BUSY' or 'IDLE'
#timeout = null
constructor(functionKey, functionDefinition, serverless, options) {
const {
service,
config: { serverlessPath, servicePath },
service: { provider, package: servicePackage = {} },
} = serverless
// TEMP options.location, for compatibility with serverless-webpack:
// https://github.com/dherault/serverless-offline/issues/787
// TODO FIXME look into better way to work with serverless-webpack
const servicepath = resolve(servicePath, options.location ?? "")
const { handler, name, package: functionPackage = {} } = functionDefinition
// this._executionTimeout = null
this.#functionKey = functionKey
this.#functionName = name
this.#handler = handler
this.#memorySize =
functionDefinition.memorySize ??
provider.memorySize ??
DEFAULT_LAMBDA_MEMORY_SIZE
this.#noTimeout = options.noTimeout
this.#region = provider.region
this.#runtime =
functionDefinition.runtime ?? provider.runtime ?? DEFAULT_LAMBDA_RUNTIME
this.#timeout =
(functionDefinition.timeout ??
provider.timeout ??
DEFAULT_LAMBDA_TIMEOUT) * 1000
this.#verifySupportedRuntime()
const env = {
...(options.localEnvironment
? process.env
: // we always copy all AWS_xxxx environment variables over from local env
fromEntries(
entries(process.env).filter(([key]) => key.startsWith("AWS_")),
)),
...this.#getAwsEnvVars(),
...provider.environment,
...functionDefinition.environment,
IS_OFFLINE: "true",
}
this.#artifact = functionDefinition.package?.artifact
if (!this.#artifact) {
this.#artifact = service.package?.artifact
}
if (this.#artifact) {
// lambda directory contains code and layers
this.#lambdaDir = join(
servicepath,
".serverless-offline",
"services",
service.service,
functionKey,
crypto.randomUUID(),
)
}
this.#codeDir = this.#lambdaDir
? resolve(this.#lambdaDir, "code")
: servicepath
// TEMP
const funOptions = {
codeDir: this.#codeDir,
functionKey,
functionName: name,
functionPackage: functionPackage.artifact
? resolve(servicepath, functionPackage.artifact)
: undefined,
handler,
layers: functionDefinition.layers || [],
provider,
runtime: this.#runtime,
serverlessPath,
servicePackage: servicePackage.artifact
? resolve(servicepath, servicePackage.artifact)
: undefined,
servicePath: servicepath,
timeout: this.#timeout,
}
this.#handlerRunner = new HandlerRunner(funOptions, options, env)
this.#lambdaContext = new LambdaContext(name, this.#memorySize)
}
#startExecutionTimer() {
this.#executionTimeStarted = performance.now()
// this._executionTimeout = this.#executionTimeStarted + this.#timeout * 1000
}
#stopExecutionTimer() {
this.#executionTimeEnded = performance.now()
}
#startIdleTimer() {
this.#idleTimeStarted = performance.now()
}
#verifySupportedRuntime() {
// print message but keep working (don't error out or exit process)
if (!supportedRuntimes.has(this.#runtime)) {
log.warning()
log.warning(
`Warning: found unsupported runtime '${this.#runtime}' for function '${this.#functionKey}'`,
)
}
}
// based on:
// https://github.com/serverless/serverless/blob/v1.50.0/lib/plugins/aws/invokeLocal/index.js#L108
#getAwsEnvVars() {
return {
_HANDLER: this.#handler,
AWS_DEFAULT_REGION: this.#region,
AWS_LAMBDA_FUNCTION_MEMORY_SIZE: this.#memorySize,
AWS_LAMBDA_FUNCTION_NAME: this.#functionName,
AWS_LAMBDA_FUNCTION_VERSION: "$LATEST",
// https://github.com/serverless/serverless/blob/v1.50.0/lib/plugins/aws/lib/naming.js#L123
AWS_LAMBDA_LOG_GROUP_NAME: `/aws/lambda/${this.#functionName}`,
AWS_LAMBDA_LOG_STREAM_NAME:
"2016/12/02/[$LATEST]f77ff5e4026c45bda9a9ebcec6bc9cad",
AWS_REGION: this.#region,
LAMBDA_RUNTIME_DIR: "/var/runtime",
LAMBDA_TASK_ROOT: "/var/task",
LANG: "en_US.UTF-8",
LD_LIBRARY_PATH:
"/usr/local/lib64/node-v4.3.x/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib",
NODE_PATH: "/var/runtime:/var/task:/var/runtime/node_modules",
}
}
setClientContext(clientContext) {
this.#clientContext = clientContext
}
setEvent(event) {
this.#event = event
}
// () => Promise<void>
async cleanup() {
// TODO console.log('lambda cleanup')
await this.#handlerRunner.cleanup()
if (this.#lambdaDir) {
await remove(this.#lambdaDir)
}
}
#executionTimeInMillis() {
return this.#executionTimeEnded - this.#executionTimeStarted
}
// round up to the nearest ms
#billedExecutionTimeInMillis() {
return ceil(this.#executionTimeEnded - this.#executionTimeStarted)
}
// extractArtifact, loosely based on:
// https://github.com/serverless/serverless/blob/v1.57.0/lib/plugins/aws/invokeLocal/index.js#L312
async #extractArtifact() {
if (!this.#artifact) {
return
}
await emptyDir(this.#codeDir)
const data = await readFile(this.#artifact)
const zip = await jszip.loadAsync(data)
await Promise.all(
entries(zip.files).map(async ([filename, jsZipObj]) => {
const fileData = await jsZipObj.async("nodebuffer")
if (filename.endsWith("/")) {
return undefined
}
await ensureDir(join(this.#codeDir, dirname(filename)))
return writeFile(join(this.#codeDir, filename), fileData, {
mode: jsZipObj.unixPermissions,
})
}),
)
}
async #initialize() {
await this.#extractArtifact()
this.#initialized = true
}
get idleTimeInMillis() {
return performance.now() - this.#idleTimeStarted
}
get functionName() {
return this.#functionName
}
get status() {
return this.#status
}
async #timeoutAndTerminate() {
await setTimeout(this.#timeout)
throw new LambdaTimeoutError("[504] - Lambda timeout.")
}
async runHandler() {
this.#status = "BUSY"
if (!this.#initialized) {
await this.#initialize()
}
const requestId = crypto.randomUUID()
this.#lambdaContext.setRequestId(requestId)
this.#lambdaContext.setClientContext(this.#clientContext)
const context = this.#lambdaContext.create()
this.#startExecutionTimer()
let result
try {
result = await Promise.race([
this.#handlerRunner.run(this.#event, context),
...(this.#noTimeout ? [] : [this.#timeoutAndTerminate()]),
])
this.#stopExecutionTimer()
// TEMP TODO FIXME find better solution
if (!this.#handlerRunner.isDockerRunner()) {
log.notice(
`(λ: ${
this.#functionKey
}) RequestId: ${requestId} Duration: ${this.#executionTimeInMillis().toFixed(
2,
)} ms Billed Duration: ${this.#billedExecutionTimeInMillis()} ms`,
)
}
} catch (err) {
if (err instanceof LambdaTimeoutError) {
await this.#handlerRunner.cleanup()
}
throw err
} finally {
this.#status = "IDLE"
this.#startIdleTimer()
}
return result
}
}