serverless-offline 14.5.0 → 14.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
+ import { spawn } from "node:child_process"
2
+ import { watch } from "node:fs"
1
3
  import { EOL, platform } from "node:os"
2
- import { relative } from "node:path"
3
- import { cwd } from "node:process"
4
+ import { resolve, relative } from "node:path"
5
+ import process, { cwd, nextTick } from "node:process"
6
+ import { createInterface } from "node:readline"
4
7
  import { join } from "desm"
5
- import { execa } from "execa"
6
8
  import { log } from "../../../utils/log.js"
7
9
  import { splitHandlerPathAndName } from "../../../utils/index.js"
8
10
 
@@ -12,28 +14,199 @@ const { hasOwn } = Object
12
14
  export default class RubyRunner {
13
15
  static #payloadIdentifier = "__offline_payload__"
14
16
 
17
+ static #errorIdentifier = "__offline_error__"
18
+
15
19
  #env = null
16
20
 
17
- #handlerName = null
21
+ #handlerProcess = null
22
+
23
+ #readline = null
24
+
25
+ #runtime = null
26
+
27
+ #spawnArgs = null
28
+
29
+ #spawnError = null
30
+
31
+ #spawnOptions = null
32
+
33
+ #watchers = []
34
+
35
+ #debounceTimer = null
36
+
37
+ #busy = false
38
+
39
+ #restartQueued = false
40
+
41
+ #watchDirs = []
18
42
 
19
- #handlerPath = null
43
+ // Serializes concurrent run() calls so writes to the shared Ruby stdin
44
+ // and reads from stdout cannot interleave.
45
+ #queue = Promise.resolve()
20
46
 
21
- constructor(funOptions, env) {
47
+ // Spawn a persistent Ruby process in the constructor (mirrors PythonRunner).
48
+ // The process stays alive across invocations and communicates via stdin/stdout.
49
+ // File changes trigger an automatic restart when rubyWatchDirs is configured.
50
+ constructor(funOptions, env, options = {}) {
22
51
  const [handlerPath, handlerName] = splitHandlerPathAndName(
23
52
  funOptions.handler,
24
53
  )
25
54
 
26
55
  this.#env = env
27
- this.#handlerName = handlerName
28
- this.#handlerPath = handlerPath
56
+ this.#runtime = platform() === "win32" ? "ruby.exe" : "ruby"
57
+
58
+ this.#spawnArgs = [
59
+ join(import.meta.url, "invoke.rb"),
60
+ relative(cwd(), handlerPath),
61
+ handlerName,
62
+ ]
63
+
64
+ this.#spawnOptions = {
65
+ env: options.localEnvironment
66
+ ? { ...process.env, ...this.#env }
67
+ : { ...this.#env },
68
+ }
69
+
70
+ const rawWatchDirs = options.rubyWatchDirs ?? []
71
+ this.#watchDirs =
72
+ typeof rawWatchDirs === "string"
73
+ ? rawWatchDirs
74
+ .split(",")
75
+ .map((dir) => dir.trim())
76
+ .filter(Boolean)
77
+ : rawWatchDirs
78
+
79
+ this.#spawnProcess()
80
+
81
+ if (this.#watchDirs.length > 0) {
82
+ this.#setupFileWatcher()
83
+ }
84
+ }
85
+
86
+ #spawnProcess() {
87
+ this.#spawnError = null
88
+ this.#readline = null
89
+
90
+ this.#handlerProcess = spawn(
91
+ this.#runtime,
92
+ this.#spawnArgs,
93
+ this.#spawnOptions,
94
+ )
95
+
96
+ // Persistent error listener so an async spawn failure (e.g., Ruby not
97
+ // on PATH) does not crash serverless-offline with an unhandled "error"
98
+ // event. The stored error is surfaced from the next run() call.
99
+ this.#handlerProcess.on("error", (err) => {
100
+ this.#spawnError = err
101
+ log.error(`Ruby process error: ${err.message}`)
102
+ })
103
+
104
+ // When spawn fails synchronously the returned ChildProcess can have
105
+ // null stdio streams. Mark a spawn error immediately so the next run()
106
+ // rejects with a useful message instead of letting createInterface or
107
+ // stderr.on() throw on null streams.
108
+ if (!this.#handlerProcess.stdout || !this.#handlerProcess.stderr) {
109
+ this.#spawnError = new Error(
110
+ `Failed to spawn Ruby process "${this.#runtime}". Is Ruby installed and on PATH?`,
111
+ )
112
+ return
113
+ }
114
+
115
+ this.#readline = createInterface({
116
+ input: this.#handlerProcess.stdout,
117
+ })
118
+ }
119
+
120
+ #setupFileWatcher() {
121
+ const watchDirs = this.#watchDirs.map((dir) => resolve(cwd(), dir))
122
+
123
+ for (const dir of watchDirs) {
124
+ try {
125
+ const watcher = watch(
126
+ dir,
127
+ { recursive: true },
128
+ (_eventType, filename) => {
129
+ if (!filename?.endsWith(".rb")) {
130
+ return
131
+ }
132
+
133
+ this.#onFileChanged(filename)
134
+ },
135
+ )
136
+
137
+ this.#watchers.push(watcher)
138
+ } catch (err) {
139
+ log.warning(
140
+ `Ruby hot-reload watcher could not be enabled for "${dir}": ${err.message}. ` +
141
+ "Recursive fs.watch may not be supported on this platform.",
142
+ )
143
+ }
144
+ }
145
+ }
146
+
147
+ #onFileChanged(filename) {
148
+ if (this.#debounceTimer) {
149
+ clearTimeout(this.#debounceTimer)
150
+ }
151
+
152
+ this.#debounceTimer = setTimeout(() => {
153
+ log.notice(`Ruby file changed: ${filename}, reloading handler...`)
154
+ this.#scheduleRestart()
155
+ }, 100)
156
+ }
157
+
158
+ #scheduleRestart() {
159
+ if (this.#busy) {
160
+ // Defer restart until the current invocation completes
161
+ this.#restartQueued = true
162
+ } else {
163
+ this.#restartProcess()
164
+ }
165
+ }
166
+
167
+ #restartProcess() {
168
+ this.#disposeProcess()
169
+ this.#spawnProcess()
170
+ }
171
+
172
+ #disposeProcess() {
173
+ if (this.#readline) {
174
+ this.#readline.close()
175
+ this.#readline = null
176
+ }
177
+
178
+ if (this.#handlerProcess && this.#handlerProcess.exitCode == null) {
179
+ try {
180
+ this.#handlerProcess.kill()
181
+ } catch (err) {
182
+ if (err.code !== "ESRCH") {
183
+ log.warning(`Failed to kill Ruby process: ${err.message}`)
184
+ }
185
+ }
186
+ }
187
+
188
+ this.#handlerProcess = null
29
189
  }
30
190
 
31
- // no-op
32
191
  // () => void
33
- cleanup() {}
192
+ cleanup() {
193
+ for (const watcher of this.#watchers) {
194
+ watcher.close()
195
+ }
196
+
197
+ this.#watchers = []
198
+
199
+ if (this.#debounceTimer) {
200
+ clearTimeout(this.#debounceTimer)
201
+ this.#debounceTimer = null
202
+ }
203
+
204
+ this.#disposeProcess()
205
+ }
34
206
 
35
207
  #parsePayload(value) {
36
208
  let payload
209
+ let error
37
210
 
38
211
  for (const item of value.split(EOL)) {
39
212
  let json
@@ -46,61 +219,171 @@ export default class RubyRunner {
46
219
  // no-op
47
220
  }
48
221
 
49
- // now let's see if we have a property __offline_payload__
50
- if (
51
- json &&
52
- typeof json === "object" &&
53
- hasOwn(json, RubyRunner.#payloadIdentifier)
54
- ) {
55
- payload = json[RubyRunner.#payloadIdentifier]
222
+ if (json && typeof json === "object") {
223
+ if (hasOwn(json, RubyRunner.#errorIdentifier)) {
224
+ error = json[RubyRunner.#errorIdentifier]
225
+ } else if (hasOwn(json, RubyRunner.#payloadIdentifier)) {
226
+ payload = json[RubyRunner.#payloadIdentifier]
227
+ } else {
228
+ log.notice(item)
229
+ }
56
230
  } else {
57
231
  log.notice(item)
58
232
  }
59
233
  }
60
234
 
61
- return payload
235
+ return { error, payload }
62
236
  }
63
237
 
64
238
  // invokeLocalRuby, loosely based on:
65
239
  // https://github.com/serverless/serverless/blob/v1.50.0/lib/plugins/aws/invokeLocal/index.js#L556
66
- // invoke.rb, copy/pasted entirely as is:
67
- // https://github.com/serverless/serverless/blob/v1.50.0/lib/plugins/aws/invokeLocal/invoke.rb
68
240
  async run(event, context) {
69
- const runtime = platform() === "win32" ? "ruby.exe" : "ruby"
241
+ // Chain onto the queue so each invocation has exclusive access to the
242
+ // shared stdin/stdout channel. Errors in the chain must not poison
243
+ // subsequent runs.
244
+ const result = this.#queue.then(() => this.#runOne(event, context))
245
+ this.#queue = result.then(
246
+ () => {},
247
+ () => {},
248
+ )
249
+ return result
250
+ }
70
251
 
71
- // https://docs.aws.amazon.com/lambda/latest/dg/ruby-context.html
252
+ async #runOne(event, context) {
253
+ // Respawn if the Ruby process died (handler crash, OOM kill, etc.) or
254
+ // failed to spawn previously. Without this, subsequent runs would fail
255
+ // with EPIPE forever.
256
+ if (
257
+ this.#handlerProcess == null ||
258
+ this.#handlerProcess.exitCode != null ||
259
+ this.#spawnError != null ||
260
+ this.#readline == null
261
+ ) {
262
+ this.#disposeProcess()
263
+ this.#spawnProcess()
264
+ }
72
265
 
73
- // https://docs.aws.amazon.com/lambda/latest/dg/ruby-context.html
74
- // exclude callbackWaitsForEmptyEventLoop, don't mutate context
75
- const { callbackWaitsForEmptyEventLoop, ..._context } = context
266
+ // If respawn also failed (e.g., Ruby is still missing), bail out with
267
+ // the stored spawn error rather than touching null streams below.
268
+ if (this.#spawnError != null || this.#readline == null) {
269
+ throw this.#spawnError ?? new Error("Ruby process is not running")
270
+ }
76
271
 
77
- const input = stringify({
78
- context: _context,
79
- event,
80
- })
272
+ this.#busy = true
81
273
 
82
- // console.log(input)
83
-
84
- const { stderr, stdout } = await execa(
85
- runtime,
86
- [
87
- join(import.meta.url, "invoke.rb"),
88
- relative(cwd(), this.#handlerPath),
89
- this.#handlerName,
90
- ],
91
- {
92
- env: this.#env,
93
- input,
94
- // shell: true,
95
- },
96
- )
274
+ try {
275
+ return await new Promise((res, rej) => {
276
+ // https://docs.aws.amazon.com/lambda/latest/dg/ruby-context.html
277
+ // exclude callbackWaitsForEmptyEventLoop, don't mutate context
278
+ const { callbackWaitsForEmptyEventLoop, ..._context } = context
97
279
 
98
- if (stderr) {
99
- // TODO
280
+ const input = stringify({
281
+ context: _context,
282
+ event,
283
+ })
100
284
 
101
- log.notice(stderr)
102
- }
285
+ const handlerProcess = this.#handlerProcess
286
+ const readline = this.#readline
287
+
288
+ let onLine
289
+ let onErr
290
+ let onProcessError
291
+ let onProcessExit
292
+
293
+ const cleanupListeners = () => {
294
+ // Defensive null guards: readline/stderr should be present here
295
+ // because #runOne() bails out before listener attachment when
296
+ // they are null, but a process can crash mid-flight.
297
+ readline?.removeListener("line", onLine)
298
+ handlerProcess.stderr?.removeListener("data", onErr)
299
+ handlerProcess.removeListener("error", onProcessError)
300
+ handlerProcess.removeListener("exit", onProcessExit)
301
+ }
302
+
303
+ const settleResolve = (value) => {
304
+ cleanupListeners()
305
+ res(value)
306
+ }
307
+
308
+ const settleReject = (err) => {
309
+ cleanupListeners()
310
+ rej(err)
311
+ }
312
+
313
+ onErr = (data) => {
314
+ // TODO
103
315
 
104
- return this.#parsePayload(stdout)
316
+ log.notice(data.toString())
317
+ }
318
+
319
+ onProcessError = (err) => {
320
+ settleReject(err)
321
+ }
322
+
323
+ onProcessExit = (code, signal) => {
324
+ settleReject(
325
+ new Error(
326
+ `Ruby process exited unexpectedly (code=${code}, signal=${signal}) before responding`,
327
+ ),
328
+ )
329
+ }
330
+
331
+ onLine = (line) => {
332
+ try {
333
+ const { error, payload } = this.#parsePayload(line.toString())
334
+
335
+ if (error !== undefined) {
336
+ const err = new Error(error.errorMessage ?? "Ruby handler error")
337
+ err.name = error.errorType ?? "RubyHandlerError"
338
+ if (error.stackTrace) {
339
+ err.stack = `${err.name}: ${err.message}\n${
340
+ Array.isArray(error.stackTrace)
341
+ ? error.stackTrace.join("\n")
342
+ : error.stackTrace
343
+ }`
344
+ }
345
+ settleReject(err)
346
+ return
347
+ }
348
+
349
+ if (payload !== undefined) {
350
+ settleResolve(payload)
351
+ }
352
+ } catch (err) {
353
+ settleReject(err)
354
+ }
355
+ }
356
+
357
+ readline.on("line", onLine)
358
+ handlerProcess.stderr.on("data", onErr)
359
+ handlerProcess.once("error", onProcessError)
360
+ handlerProcess.once("exit", onProcessExit)
361
+
362
+ nextTick(() => {
363
+ try {
364
+ handlerProcess.stdin.write(input, (writeErr) => {
365
+ if (writeErr) {
366
+ settleReject(writeErr)
367
+ return
368
+ }
369
+ handlerProcess.stdin.write("\n", (nlErr) => {
370
+ if (nlErr) {
371
+ settleReject(nlErr)
372
+ }
373
+ })
374
+ })
375
+ } catch (err) {
376
+ settleReject(err)
377
+ }
378
+ })
379
+ })
380
+ } finally {
381
+ this.#busy = false
382
+
383
+ if (this.#restartQueued) {
384
+ this.#restartQueued = false
385
+ this.#restartProcess()
386
+ }
387
+ }
105
388
  }
106
389
  }
@@ -1,4 +1,7 @@
1
- # copy/pasted entirely from:
1
+ # Persistent Ruby invoke script for serverless-offline.
2
+ # Mirrors the Python runner pattern: spawn once, loop forever via stdin/stdout.
3
+ #
4
+ # Original one-shot version was based on:
2
5
  # https://github.com/serverless/serverless/blob/v1.50.0/lib/plugins/aws/invokeLocal/invoke.rb
3
6
 
4
7
  require 'json'
@@ -28,26 +31,6 @@ class FakeLambdaContext
28
31
  def get_remaining_time_in_millis
29
32
  [@timeout*1000 - ((Time.now() - @created_time)*1000).round, 0].max
30
33
  end
31
-
32
- # def invoked_function_arn
33
- # "arn:aws:lambda:serverless:#{function_name}"
34
- # end
35
- #
36
- # def memory_limit_in_mb
37
- # return @memory_limit_in_mb
38
- # end
39
- #
40
- # def log_group_name
41
- # return @log_group_name
42
- # end
43
- #
44
- # def log_stream_name
45
- # return Time.now.strftime('%Y/%m/%d') +'/[$' + function_version + ']58419525dade4d17a495dceeeed44708'
46
- # end
47
- #
48
- # def log(message)
49
- # puts message
50
- # end
51
34
  end
52
35
 
53
36
 
@@ -56,7 +39,7 @@ def attach_tty
56
39
  $stdin.reopen "/dev/tty", "a+"
57
40
  end
58
41
  rescue
59
- puts "tty unavailable"
42
+ $stderr.puts "tty unavailable"
60
43
  end
61
44
 
62
45
  if __FILE__ == $0
@@ -68,8 +51,7 @@ if __FILE__ == $0
68
51
  handler_path = ARGV[0]
69
52
  handler_name = ARGV[1]
70
53
 
71
- input = JSON.load($stdin) || {}
72
-
54
+ # Load the handler module ONCE at startup
73
55
  require("./#{handler_path}")
74
56
 
75
57
  # handler name is either a global method or a static method in a class
@@ -77,16 +59,46 @@ if __FILE__ == $0
77
59
  handler_method, handler_class = handler_name.split(".").reverse
78
60
  handler_class ||= "Kernel"
79
61
 
80
- attach_tty
81
-
82
- context = FakeLambdaContext.new(context: input['context'])
83
- result = Object.const_get(handler_class).send(handler_method, event: input['event'], context: context)
62
+ # Keep a reference to the original stdin for reading from the parent process
63
+ original_stdin = $stdin.dup
84
64
 
85
- data = {
86
- # just an identifier to distinguish between
87
- # interesting data (result) and stdout/print
88
- '__offline_payload__': result
89
- }
65
+ attach_tty
90
66
 
91
- puts data.to_json
67
+ # Persistent loop: read JSON from stdin, invoke handler, write result to stdout
68
+ while (line = original_stdin.gets)
69
+ line = line.strip
70
+ next if line.empty?
71
+
72
+ begin
73
+ input = JSON.parse(line)
74
+
75
+ context = FakeLambdaContext.new(context: input['context'] || {})
76
+ result = Object.const_get(handler_class).send(handler_method, event: input['event'], context: context)
77
+
78
+ data = {
79
+ # just an identifier to distinguish between
80
+ # interesting data (result) and stdout/print
81
+ '__offline_payload__': result
82
+ }
83
+
84
+ $stdout.write(data.to_json)
85
+ $stdout.write("\n")
86
+ $stdout.flush
87
+ rescue => e
88
+ $stderr.write("#{e.class}: #{e.message}\n")
89
+ $stderr.write(e.backtrace.join("\n") + "\n")
90
+ $stderr.flush
91
+
92
+ error_data = {
93
+ '__offline_error__': {
94
+ 'errorType' => e.class.name,
95
+ 'errorMessage' => e.message,
96
+ 'stackTrace' => e.backtrace
97
+ }
98
+ }
99
+ $stdout.write(error_data.to_json)
100
+ $stdout.write("\n")
101
+ $stdout.flush
102
+ end
103
+ end
92
104
  end