UNPKG

serverless-offline

Version:

Emulate AWS λ and API Gateway locally when developing your Serverless project

390 lines (320 loc) 10.4 kB
import { spawn } from "node:child_process" import { watch } from "node:fs" import { EOL, platform } from "node:os" import { resolve, relative } from "node:path" import process, { cwd, nextTick } from "node:process" import { createInterface } from "node:readline" import { join } from "desm" import { log } from "../../../utils/log.js" import { splitHandlerPathAndName } from "../../../utils/index.js" const { parse, stringify } = JSON const { hasOwn } = Object export default class RubyRunner { static #payloadIdentifier = "__offline_payload__" static #errorIdentifier = "__offline_error__" #env = null #handlerProcess = null #readline = null #runtime = null #spawnArgs = null #spawnError = null #spawnOptions = null #watchers = [] #debounceTimer = null #busy = false #restartQueued = false #watchDirs = [] // Serializes concurrent run() calls so writes to the shared Ruby stdin // and reads from stdout cannot interleave. #queue = Promise.resolve() // Spawn a persistent Ruby process in the constructor (mirrors PythonRunner). // The process stays alive across invocations and communicates via stdin/stdout. // File changes trigger an automatic restart when rubyWatchDirs is configured. constructor(funOptions, env, options = {}) { const [handlerPath, handlerName] = splitHandlerPathAndName( funOptions.handler, ) this.#env = env this.#runtime = platform() === "win32" ? "ruby.exe" : "ruby" this.#spawnArgs = [ join(import.meta.url, "invoke.rb"), relative(cwd(), handlerPath), handlerName, ] this.#spawnOptions = { env: options.localEnvironment ? { ...process.env, ...this.#env } : { ...this.#env }, } const rawWatchDirs = options.rubyWatchDirs ?? [] this.#watchDirs = typeof rawWatchDirs === "string" ? rawWatchDirs .split(",") .map((dir) => dir.trim()) .filter(Boolean) : rawWatchDirs this.#spawnProcess() if (this.#watchDirs.length > 0) { this.#setupFileWatcher() } } #spawnProcess() { this.#spawnError = null this.#readline = null this.#handlerProcess = spawn( this.#runtime, this.#spawnArgs, this.#spawnOptions, ) // Persistent error listener so an async spawn failure (e.g., Ruby not // on PATH) does not crash serverless-offline with an unhandled "error" // event. The stored error is surfaced from the next run() call. this.#handlerProcess.on("error", (err) => { this.#spawnError = err log.error(`Ruby process error: ${err.message}`) }) // When spawn fails synchronously the returned ChildProcess can have // null stdio streams. Mark a spawn error immediately so the next run() // rejects with a useful message instead of letting createInterface or // stderr.on() throw on null streams. if (!this.#handlerProcess.stdout || !this.#handlerProcess.stderr) { this.#spawnError = new Error( `Failed to spawn Ruby process "${this.#runtime}". Is Ruby installed and on PATH?`, ) return } this.#readline = createInterface({ input: this.#handlerProcess.stdout, }) } #setupFileWatcher() { const watchDirs = this.#watchDirs.map((dir) => resolve(cwd(), dir)) for (const dir of watchDirs) { try { const watcher = watch( dir, { recursive: true }, (_eventType, filename) => { if (!filename?.endsWith(".rb")) { return } this.#onFileChanged(filename) }, ) this.#watchers.push(watcher) } catch (err) { log.warning( `Ruby hot-reload watcher could not be enabled for "${dir}": ${err.message}. ` + "Recursive fs.watch may not be supported on this platform.", ) } } } #onFileChanged(filename) { if (this.#debounceTimer) { clearTimeout(this.#debounceTimer) } this.#debounceTimer = setTimeout(() => { log.notice(`Ruby file changed: ${filename}, reloading handler...`) this.#scheduleRestart() }, 100) } #scheduleRestart() { if (this.#busy) { // Defer restart until the current invocation completes this.#restartQueued = true } else { this.#restartProcess() } } #restartProcess() { this.#disposeProcess() this.#spawnProcess() } #disposeProcess() { if (this.#readline) { this.#readline.close() this.#readline = null } if (this.#handlerProcess && this.#handlerProcess.exitCode == null) { try { this.#handlerProcess.kill() } catch (err) { if (err.code !== "ESRCH") { log.warning(`Failed to kill Ruby process: ${err.message}`) } } } this.#handlerProcess = null } // () => void cleanup() { for (const watcher of this.#watchers) { watcher.close() } this.#watchers = [] if (this.#debounceTimer) { clearTimeout(this.#debounceTimer) this.#debounceTimer = null } this.#disposeProcess() } #parsePayload(value) { let payload let error for (const item of value.split(EOL)) { let json // first check if it's JSON try { json = parse(item) // nope, it's not JSON } catch { // no-op } if (json && typeof json === "object") { if (hasOwn(json, RubyRunner.#errorIdentifier)) { error = json[RubyRunner.#errorIdentifier] } else if (hasOwn(json, RubyRunner.#payloadIdentifier)) { payload = json[RubyRunner.#payloadIdentifier] } else { log.notice(item) } } else { log.notice(item) } } return { error, payload } } // invokeLocalRuby, loosely based on: // https://github.com/serverless/serverless/blob/v1.50.0/lib/plugins/aws/invokeLocal/index.js#L556 async run(event, context) { // Chain onto the queue so each invocation has exclusive access to the // shared stdin/stdout channel. Errors in the chain must not poison // subsequent runs. const result = this.#queue.then(() => this.#runOne(event, context)) this.#queue = result.then( () => {}, () => {}, ) return result } async #runOne(event, context) { // Respawn if the Ruby process died (handler crash, OOM kill, etc.) or // failed to spawn previously. Without this, subsequent runs would fail // with EPIPE forever. if ( this.#handlerProcess == null || this.#handlerProcess.exitCode != null || this.#spawnError != null || this.#readline == null ) { this.#disposeProcess() this.#spawnProcess() } // If respawn also failed (e.g., Ruby is still missing), bail out with // the stored spawn error rather than touching null streams below. if (this.#spawnError != null || this.#readline == null) { throw this.#spawnError ?? new Error("Ruby process is not running") } this.#busy = true try { return await new Promise((res, rej) => { // https://docs.aws.amazon.com/lambda/latest/dg/ruby-context.html // exclude callbackWaitsForEmptyEventLoop, don't mutate context const { callbackWaitsForEmptyEventLoop, ..._context } = context const input = stringify({ context: _context, event, }) const handlerProcess = this.#handlerProcess const readline = this.#readline let onLine let onErr let onProcessError let onProcessExit const cleanupListeners = () => { // Defensive null guards: readline/stderr should be present here // because #runOne() bails out before listener attachment when // they are null, but a process can crash mid-flight. readline?.removeListener("line", onLine) handlerProcess.stderr?.removeListener("data", onErr) handlerProcess.removeListener("error", onProcessError) handlerProcess.removeListener("exit", onProcessExit) } const settleResolve = (value) => { cleanupListeners() res(value) } const settleReject = (err) => { cleanupListeners() rej(err) } onErr = (data) => { // TODO log.notice(data.toString()) } onProcessError = (err) => { settleReject(err) } onProcessExit = (code, signal) => { settleReject( new Error( `Ruby process exited unexpectedly (code=${code}, signal=${signal}) before responding`, ), ) } onLine = (line) => { try { const { error, payload } = this.#parsePayload(line.toString()) if (error !== undefined) { const err = new Error(error.errorMessage ?? "Ruby handler error") err.name = error.errorType ?? "RubyHandlerError" if (error.stackTrace) { err.stack = `${err.name}: ${err.message}\n${ Array.isArray(error.stackTrace) ? error.stackTrace.join("\n") : error.stackTrace }` } settleReject(err) return } if (payload !== undefined) { settleResolve(payload) } } catch (err) { settleReject(err) } } readline.on("line", onLine) handlerProcess.stderr.on("data", onErr) handlerProcess.once("error", onProcessError) handlerProcess.once("exit", onProcessExit) nextTick(() => { try { handlerProcess.stdin.write(input, (writeErr) => { if (writeErr) { settleReject(writeErr) return } handlerProcess.stdin.write("\n", (nlErr) => { if (nlErr) { settleReject(nlErr) } }) }) } catch (err) { settleReject(err) } }) }) } finally { this.#busy = false if (this.#restartQueued) { this.#restartQueued = false this.#restartProcess() } } } }