serverless-offline 14.6.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.
package/README.md CHANGED
@@ -32,23 +32,14 @@
32
32
  <a href="https://www.npmjs.com/package/serverless-offline">
33
33
  <img src="https://img.shields.io/npm/v/serverless-offline.svg?style=flat-square">
34
34
  </a>
35
- <a href="https://github.com/dherault/serverless-offline/actions/workflows/integrate.yml">
36
- <img src="https://img.shields.io/github/workflow/status/dherault/serverless-offline/Integrate">
37
- </a>
38
35
  <img src="https://img.shields.io/node/v/serverless-offline.svg?style=flat-square">
39
36
  <a href="https://github.com/serverless/serverless">
40
37
  <img src="https://img.shields.io/npm/dependency-version/serverless-offline/peer/serverless.svg?style=flat-square">
41
38
  </a>
42
- <a href="https://github.com/prettier/prettier">
43
- <img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square">
44
- </a>
45
39
  <img src="https://img.shields.io/npm/l/serverless-offline.svg?style=flat-square">
46
40
  <a href="#contributing">
47
41
  <img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square">
48
42
  </a>
49
- <a href="https://gitter.im/serverless-offline/community">
50
- <img src="https://badges.gitter.im/serverless-offline.png">
51
- </a>
52
43
  </p>
53
44
 
54
45
  This [Serverless](https://github.com/serverless/serverless) plugin emulates [AWS λ](https://aws.amazon.com/lambda) and [API Gateway](https://aws.amazon.com/api-gateway) on your local machine to speed up your development cycles.
@@ -59,7 +50,7 @@ To do so, it starts an HTTP server that handles the request's lifecycle like API
59
50
  - [Node.js](https://nodejs.org), [Python](https://www.python.org), [Ruby](https://www.ruby-lang.org), [Go](https://golang.org), [Java](https://www.java.com) (incl. [Kotlin](https://kotlinlang.org), [Groovy](https://groovy-lang.org), [Scala](https://www.scala-lang.org)) λ runtimes.
60
51
  - Velocity templates support.
61
52
  - Lazy loading of your handler files.
62
- - And more: integrations, authorizers, proxies, timeouts, responseParameters, HTTPS, CORS, etc...
53
+ - And more: integrations, authorizers, proxies, timeouts, responseParameters, HTTPS, CORS, etc.
63
54
 
64
55
  This plugin is updated by its users, I just do maintenance and ensure that PRs are relevant to the community. In other words, if you [find a bug or want a new feature](https://github.com/dherault/serverless-offline/issues), please help us by becoming one of the [contributors](https://github.com/dherault/serverless-offline/graphs/contributors) :v: ! See the [contributing section](#contributing).
65
56
 
@@ -104,7 +95,7 @@ First, add Serverless Offline to your project:
104
95
 
105
96
  `npm install serverless-offline --save-dev`
106
97
 
107
- Then inside your project's `serverless.yml` file add following entry to the plugins section: `serverless-offline`. If there is no plugin section you will need to add it to the file.
98
+ Then inside your project's `serverless.yml` file add the following entry to the plugins section: `serverless-offline`. If there is no plugin section you will need to add it to the file.
108
99
 
109
100
  **Note that the "plugin" section for serverless-offline must be at root level on serverless.yml.**
110
101
 
@@ -119,15 +110,15 @@ You can check whether you have successfully installed the plugin by running the
119
110
 
120
111
  `serverless --verbose`
121
112
 
122
- the console should display _Offline_ as one of the plugins now available in your Serverless project.
113
+ The console should display _Offline_ as one of the plugins now available in your Serverless project.
123
114
 
124
115
  ## Usage and command line options
125
116
 
126
- In your project root run:
117
+ In your project root, run:
127
118
 
128
119
  `serverless offline` or `sls offline`.
129
120
 
130
- to list all the options for the plugin run:
121
+ To list all the options for the plugin, run:
131
122
 
132
123
  `sls offline --help`
133
124
 
@@ -145,8 +136,8 @@ Default: '\*'
145
136
 
146
137
  #### corsDisallowCredentials
147
138
 
148
- When provided, the default Access-Control-Allow-Credentials header value will be passed as 'false'.\
149
- Default: true
139
+ When provided, sets the Access-Control-Allow-Credentials header to 'false'.\
140
+ Default (without flag): credentials are allowed ('true')
150
141
 
151
142
  #### corsExposedHeaders
152
143
 
@@ -181,7 +172,7 @@ Enforce secure cookies
181
172
 
182
173
  #### host
183
174
 
184
- -o Host name to listen on.<br />
175
+ Host name to listen on.<br />
185
176
  Default: localhost
186
177
 
187
178
  #### httpPort
@@ -191,7 +182,7 @@ Default: 3000
191
182
 
192
183
  #### httpsProtocol
193
184
 
194
- -H To enable HTTPS, specify directory (relative to your cwd, typically your project dir) for both cert.pem and key.pem files.
185
+ To enable HTTPS, specify directory (relative to your cwd, typically your project dir) for both cert.pem and key.pem files.
195
186
 
196
187
  #### ignoreJWTSignature
197
188
 
@@ -205,7 +196,7 @@ Default: 3002
205
196
  #### layersDir
206
197
 
207
198
  The directory layers should be stored in.<br />
208
- Default: ${codeDir}/.serverless-offline/layers'
199
+ Default: '${codeDir}/.serverless-offline/layers'
209
200
 
210
201
  #### localEnvironment
211
202
 
@@ -226,17 +217,21 @@ Remove sponsor message from the output.
226
217
 
227
218
  #### noTimeout
228
219
 
229
- -t Disables the timeout feature.
220
+ Disables the timeout feature.
230
221
 
231
222
  #### prefix
232
223
 
233
- -p Adds a prefix to every path, to send your requests to http://localhost:3000/[prefix]/[your_path] instead.<br />
224
+ Adds a prefix to every path, to send your requests to http://localhost:3000/[prefix]/[your_path] instead.<br />
234
225
  Default: ''
235
226
 
236
227
  #### reloadHandler
237
228
 
238
229
  Reloads handler with each request.
239
230
 
231
+ #### rubyWatchDirs
232
+
233
+ List of directories to watch for Ruby file changes. Automatically restarts the Ruby process on change, enabling hot-reload during local development.
234
+
240
235
  #### resourceRoutes
241
236
 
242
237
  Turns on loading of your HTTP proxy settings from serverless.yml.
@@ -280,7 +275,7 @@ custom:
280
275
  serverless-offline:
281
276
  httpsProtocol: 'dev-certs'
282
277
  httpPort: 4000
283
- foo: 'bar'
278
+ foo: 'bar'
284
279
  ```
285
280
 
286
281
  Options passed on the command line override YAML options.
@@ -296,7 +291,7 @@ By default you can send your requests to `http://localhost:3000/`. Please note t
296
291
 
297
292
  ### node.js
298
293
 
299
- Lambda handlers with `serverless-offline` for the `node.js` runtime can run in different execution modes and have some differences with a variety of pros and cons. they are currently mutually exclusive and it's not possible to use a combination, e.g. use `in-process` for one Lambda, and `worker-threads` for another. It is planned to combine the flags into one single flag in the future and also add support for combining run modes.
294
+ Lambda handlers with `serverless-offline` for the `node.js` runtime can run in different execution modes and have some differences with a variety of pros and cons. They are currently mutually exclusive and it's not possible to use a combination, e.g. use `in-process` for one Lambda, and `worker-threads` for another. It is planned to combine the flags into one single flag in the future and also add support for combining run modes.
300
295
 
301
296
  #### worker-threads (default)
302
297
 
@@ -331,7 +326,7 @@ NOTE:
331
326
 
332
327
  ### Python, Ruby, Go, Java (incl. Kotlin, Groovy, Scala)
333
328
 
334
- the Lambda handler process is running in a child process.
329
+ The Lambda handler process is running in a child process.
335
330
 
336
331
  ## Invoke Lambda
337
332
 
@@ -389,7 +384,7 @@ export async function handler() {
389
384
  }
390
385
  ```
391
386
 
392
- You can also invoke using the aws cli by specifying `--endpoint-url`
387
+ You can also invoke using the AWS CLI by specifying `--endpoint-url`
393
388
 
394
389
  ```
395
390
  aws lambda invoke /dev/null \
@@ -415,7 +410,7 @@ offline: Function names exposed for local invocation by aws-sdk:
415
410
  ```
416
411
 
417
412
  To list the available manual invocation paths exposed for targeting
418
- by `aws-sdk` and `aws-cli`, use `SLS_DEBUG=*` with `serverless offline`. After the invoke server starts up, full list of endpoints will be displayed:
413
+ by `aws-sdk` and `aws-cli`, use `SLS_DEBUG=*` with `serverless offline`. After the invoke server starts up, the full list of endpoints will be displayed:
419
414
 
420
415
  ```
421
416
  SLS_DEBUG=* serverless offline
@@ -433,7 +428,7 @@ offline: Function names exposed for local invocation by aws-sdk:
433
428
 
434
429
  You can manually target these endpoints with a REST client to debug your lambda
435
430
  function if you want to. Your `POST` JSON body will be the `Payload` passed to your function if you were
436
- to calling it via `aws-sdk`.
431
+ to call it via `aws-sdk`.
437
432
 
438
433
  ## The `process.env.IS_OFFLINE` variable
439
434
 
@@ -441,7 +436,7 @@ Will be `"true"` in your handlers when using `serverless-offline`.
441
436
 
442
437
  ## Docker and Layers
443
438
 
444
- To use layers with serverless-offline, you need to have the `useDocker` option set to true. This can either be by using the `--useDocker` command, or in your serverless.yml like this:
439
+ To use layers with serverless-offline, you need to have the `useDocker` option set to true. This can be done either by using the `--useDocker` command, or in your serverless.yml like this:
445
440
 
446
441
  ```yml
447
442
  custom:
@@ -453,7 +448,7 @@ This will allow the docker container to look up any information about layers, do
453
448
 
454
449
  - AWS as a provider, it won't work with other provider types.
455
450
  - Layers that are compatible with your runtime.
456
- - ARNs for layers. Local layers aren't supported as yet.
451
+ - ARNs for layers. Local layers are not yet supported.
457
452
  - A local AWS account set-up that can query and download layers.
458
453
 
459
454
  If you're using least-privilege principals for your AWS roles, this policy should get you by:
@@ -471,7 +466,7 @@ If you're using least-privilege principals for your AWS roles, this policy shoul
471
466
  }
472
467
  ```
473
468
 
474
- Once you run a function that boots up the Docker container, it'll look through the layers for that function, download them in order to your layers folder, and save a hash of your layers so it can be re-used in future. You'll only need to re-download your layers if they change in the future. If you want your layers to re-download, simply remove your layers folder.
469
+ Once you run a function that boots up the Docker container, it'll look through the layers for that function, download them in order to your layers folder, and save a hash of your layers so it can be re-used in the future. You'll only need to re-download your layers if they change in the future. If you want your layers to re-download, simply remove your layers folder.
475
470
 
476
471
  You should then be able to invoke functions as normal, and they're executed against the layers in your docker container.
477
472
 
@@ -541,7 +536,7 @@ The plugin only supports retrieving Tokens from headers. You can configure the h
541
536
 
542
537
  ### Remote authorizers
543
538
 
544
- You are able to mock the response from remote authorizers by setting the environmental variable `AUTHORIZER` before running `sls offline start`
539
+ You are able to mock the response from remote authorizers by setting the environment variable `AUTHORIZER` before running `sls offline start`
545
540
 
546
541
  Example:
547
542
 
@@ -566,7 +561,7 @@ offline:
566
561
  ```
567
562
 
568
563
  ```js
569
- // ./path/to/customer-authentication-provider.js
564
+ // ./path/to/custom-authentication-provider.js
570
565
 
571
566
  module.exports = function (endpoint, functionKey, method, path) {
572
567
  return {
@@ -584,7 +579,7 @@ module.exports = function (endpoint, functionKey, method, path) {
584
579
  }
585
580
  ```
586
581
 
587
- A working example of injecting a custom authorization provider can be found in the projects integration tests under the folder [`custom-authentication`](./tests/integration/custom-authentication).
582
+ A working example of injecting a custom authorization provider can be found in the project's integration tests under the folder [`custom-authentication`](./tests/integration/custom-authentication).
588
583
 
589
584
  ## Custom headers
590
585
 
@@ -622,7 +617,7 @@ You can use [serverless-dotenv-plugin](https://github.com/colynb/serverless-dote
622
617
  [Serverless doc](https://serverless.com/framework/docs/providers/aws/events/apigateway#request-templates)
623
618
  ~ [AWS doc](http://docs.aws.amazon.com/apigateway/latest/developerguide/models-mappings.html#models-mappings-mappings)
624
619
 
625
- You can supply response and request templates for each function. This is optional. To do so you will have to place function specific template files in the same directory as your function file and add the .req.vm extension to the template filename.
620
+ You can supply response and request templates for each function. This is optional. To do so, you will have to place function-specific template files in the same directory as your function file and add the .req.vm extension to the template filename.
626
621
  For example,
627
622
  if your function is in code-file: `helloworld.js`,
628
623
  your response template should be in file: `helloworld.res.vm` and your request template in file `helloworld.req.vm`.
@@ -776,17 +771,17 @@ apiGatewayManagementApi.postToConnection({
776
771
 
777
772
  Where the `event` is received in the lambda handler function.
778
773
 
779
- There's support for [websocketsApiRouteSelectionExpression](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-selection-expressions.html) in it's basic form: `$request.body.x.y.z`, where the default value is `$request.body.action`.
774
+ There's support for [websocketsApiRouteSelectionExpression](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-selection-expressions.html) in its basic form: `$request.body.x.y.z`, where the default value is `$request.body.action`.
780
775
 
781
776
  ## Debug process
782
777
 
783
- Serverless offline plugin will respond to the overall framework settings and output additional information to the console in debug mode. In order to do this you will have to set the `SLS_DEBUG` environmental variable. You can run the following in the command line to switch to debug mode execution.
778
+ The Serverless offline plugin will respond to the overall framework settings and output additional information to the console in debug mode. In order to do this you will have to set the `SLS_DEBUG` environmental variable. You can run the following in the command line to switch to debug mode execution.
784
779
 
785
780
  > Unix: `export SLS_DEBUG=*`
786
781
 
787
782
  > Windows: `SET SLS_DEBUG=*`
788
783
 
789
- Interactive debugging is also possible for your project if you have installed the node-inspector module and chrome browser. You can then run the following command line inside your project's root.
784
+ Interactive debugging is also possible for your project if you have installed the node-inspector module and Chrome browser. You can then run the following command line inside your project's root.
790
785
 
791
786
  Initial installation:
792
787
  `npm install -g node-inspector`
@@ -800,7 +795,7 @@ Depending on the breakpoint, you may need to call the URL path for your function
800
795
 
801
796
  ### Interactive Debugging with Visual Studio Code (VSC)
802
797
 
803
- With newer versions of node (6.3+) the node inspector is already part of your node environment and you can take advantage of debugging inside your IDE with source-map support. Here is the example configuration to debug interactively with VSC. It has two steps.
798
+ With newer versions of Node.js (6.3+) the node inspector is already part of your Node.js environment and you can take advantage of debugging inside your IDE with source-map support. Here is the example configuration to debug interactively with VSC. It has two steps.
804
799
 
805
800
  #### Step 1 : Adding a launch configuration in IDE
806
801
 
@@ -818,9 +813,9 @@ Add a new [launch configuration](https://code.visualstudio.com/docs/editor/debug
818
813
  }
819
814
  ```
820
815
 
821
- #### Step2 : Adding a debug script
816
+ #### Step 2: Adding a debug script
822
817
 
823
- You will also need to add a `debug` script reference in your `package.json file`
818
+ You will also need to add a `debug` script reference in your `package.json` file
824
819
 
825
820
  Add this to the `scripts` section:
826
821
 
@@ -837,13 +832,13 @@ Example:
837
832
  }
838
833
  ```
839
834
 
840
- In VSC, you can, then, add breakpoints to your code. To start a debug sessions you can either start your script in `package.json` by clicking the hovering debug intellisense icon or by going to your debug pane and selecting the Debug Serverless Offline configuration.
835
+ In VSC, you can, then, add breakpoints to your code. To start a debug session you can either start your script in `package.json` by clicking the hovering debug intellisense icon or by going to your debug pane and selecting the Debug Serverless Offline configuration.
841
836
 
842
837
  ## Resource permissions and AWS profile
843
838
 
844
- Lambda functions assume an IAM role during execution: the framework creates this role and set all the permission provided in the `iamRoleStatements` section of `serverless.yml`.
839
+ Lambda functions assume an IAM role during execution: the framework creates this role and sets all the permissions provided in the `iamRoleStatements` section of `serverless.yml`.
845
840
 
846
- However, serverless offline makes use of your local AWS profile credentials to run the lambda functions and that might result in a different set of permissions. By default, the aws-sdk would load credentials for you default AWS profile specified in your configuration file.
841
+ However, serverless offline makes use of your local AWS profile credentials to run the lambda functions and that might result in a different set of permissions. By default, the aws-sdk would load credentials for your default AWS profile specified in your configuration file.
847
842
 
848
843
  You can change this profile directly in the code or by setting proper environment variables. Setting the `AWS_PROFILE` environment variable before calling `serverless` offline to a different profile would effectively change the credentials, e.g.
849
844
 
@@ -852,13 +847,13 @@ You can change this profile directly in the code or by setting proper environmen
852
847
  ## Simulation quality
853
848
 
854
849
  This plugin simulates API Gateway for many practical purposes, good enough for development - but is not a perfect simulator.
855
- Specifically, Lambda currently runs on Node.js v12.x, v14.x and v16.x ([AWS Docs](https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html)), whereas _Offline_ runs on your own runtime where no memory limits are enforced.
850
+ Specifically, Lambda currently runs on Node.js v18.x, v20.x and v22.x ([AWS Docs](https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html)), whereas _Offline_ runs on your own runtime where no memory limits are enforced.
856
851
 
857
852
  ## Usage with other plugins
858
853
 
859
854
  When combining this plugin with other plugins there are a few things that you need to keep in mind.
860
855
 
861
- You should run `serverless offline start` instead of `serverless offline`. The `start` command fires the `offline:start:init` and `offline:start:end` lifecycle hooks which can be used by other plugins to process your code, add resources, perform cleanups, etc.
856
+ You should run `serverless offline start` instead of `serverless offline`. The `start` command fires the `offline:start:init` and `offline:start:end` lifecycle hooks, which can be used by other plugins to process your code, add resources, perform cleanups, etc.
862
857
 
863
858
  The order in which plugins are added to `serverless.yml` is relevant.
864
859
  Plugins are executed in order, so plugins that process your code or add resources should be added first so they are ready when this plugin starts.
@@ -873,7 +868,7 @@ plugins:
873
868
  - serverless-offline # runs last so your code has been pre-processed and dynamo is ready
874
869
  ```
875
870
 
876
- That works because all those plugins listen to the `offline:start:init` to do their processing.
871
+ That works because all those plugins listen to the `offline:start:init` hook to do their processing.
877
872
  Similarly they listen to `offline:start:end` to perform cleanup (stop dynamo db, remove temporary files, etc).
878
873
 
879
874
  ## Credits and inspiration
@@ -889,7 +884,6 @@ MIT
889
884
  Yes, thank you!
890
885
  This plugin is community-driven, most of its features are from different authors.
891
886
  Please update the docs and tests and add your name to the package.json file.
892
- We try to follow [Airbnb's JavaScript Style Guide](https://github.com/airbnb/javascript).
893
887
 
894
888
  ## Contributors
895
889
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-offline",
3
- "version": "14.6.0",
3
+ "version": "14.7.0",
4
4
  "description": "Emulate AWS λ and API Gateway locally when developing your Serverless project",
5
5
  "license": "MIT",
6
6
  "exports": {
@@ -14,8 +14,8 @@
14
14
  "lint": "eslint .",
15
15
  "lint:fix": "eslint . --fix",
16
16
  "list-contributors": "echo 'clone https://github.com/mgechev/github-contributors-list.git first, then run npm install' && cd ../github-contributors-list && node bin/githubcontrib --owner dherault --repo serverless-offline --sortBy contributions --sortOrder desc > contributors.md",
17
- "prepare-release": "commit-and-tag-version && prettier --write CHANGELOG.md",
18
17
  "prepublishOnly": "npm run lint",
18
+ "changelog": "auto-changelog",
19
19
  "prettier": "prettier --check .",
20
20
  "prettier:fix": "prettier --write .",
21
21
  "test": "mocha --require ./tests/mochaHooks.cjs",
@@ -51,32 +51,14 @@
51
51
  "engines": {
52
52
  "node": ">=20.0.0"
53
53
  },
54
- "commit-and-tag-version": {
55
- "skip": {
56
- "commit": true,
57
- "tag": true
58
- },
59
- "types": [
60
- {
61
- "type": "feat",
62
- "section": "Features"
63
- },
64
- {
65
- "type": "fix",
66
- "section": "Bug Fixes"
67
- },
68
- {
69
- "type": "perf",
70
- "section": "Performance Improvements"
71
- },
72
- {
73
- "type": "refactor",
74
- "section": "Maintenance Improvements"
75
- }
76
- ]
54
+ "auto-changelog": {
55
+ "output": "CHANGELOG.md",
56
+ "unreleased": false,
57
+ "commitLimit": false,
58
+ "hideCredit": true
77
59
  },
78
60
  "dependencies": {
79
- "@aws-sdk/client-lambda": "^3.1045.0",
61
+ "@aws-sdk/client-lambda": "^3.1052.0",
80
62
  "@hapi/boom": "^10.0.1",
81
63
  "@hapi/h2o2": "^10.0.4",
82
64
  "@hapi/hapi": "^21.4.9",
@@ -98,24 +80,24 @@
98
80
  "node-schedule": "^2.1.1",
99
81
  "p-memoize": "^7.1.1",
100
82
  "tree-kill": "^1.2.2",
101
- "tsx": "^4.21.0",
83
+ "tsx": "^4.22.3",
102
84
  "velocityjs": "^2.1.6",
103
- "ws": "^8.20.0"
85
+ "ws": "^8.21.0"
104
86
  },
105
87
  "devDependencies": {
106
88
  "@istanbuljs/esm-loader-hook": "^0.3.0",
107
89
  "archiver": "^7.0.1",
108
- "commit-and-tag-version": "^12.7.3",
90
+ "auto-changelog": "^2.5.1",
109
91
  "eslint": "^8.57.0",
110
92
  "eslint-config-airbnb-base": "^15.0.0",
111
93
  "eslint-config-prettier": "^9.1.0",
112
94
  "eslint-plugin-import": "^2.32.0",
113
95
  "eslint-plugin-prettier": "^5.5.5",
114
96
  "eslint-plugin-unicorn": "^54.0.0",
115
- "mocha": "^10.7.3",
97
+ "mocha": "^11.7.6",
116
98
  "nyc": "^17.0.0",
117
99
  "prettier": "^3.8.3",
118
- "serverless": "^4.35.1"
100
+ "serverless": "^4.36.1"
119
101
  },
120
102
  "peerDependencies": {
121
103
  "serverless": "^4.0.0"
@@ -116,6 +116,11 @@ export default {
116
116
  type: "boolean",
117
117
  usage: "Turns on loading of your HTTP proxy settings from serverless.yml.",
118
118
  },
119
+ rubyWatchDirs: {
120
+ type: "string",
121
+ usage:
122
+ "Comma-separated list of directories to watch for Ruby (.rb) file changes. When set, the persistent Ruby process is automatically restarted on change for hot reload.",
123
+ },
119
124
  terminateIdleLambdaTime: {
120
125
  type: "string",
121
126
  usage:
@@ -23,6 +23,7 @@ export default {
23
23
  preLoadModules: "",
24
24
  reloadHandler: false,
25
25
  resourceRoutes: false,
26
+ rubyWatchDirs: [],
26
27
  terminateIdleLambdaTime: 60,
27
28
  useDocker: false,
28
29
  useInProcess: false,
@@ -79,7 +79,7 @@ export default class HandlerRunner {
79
79
  if (supportedRuby.has(runtime)) {
80
80
  const { default: RubyRunner } = await import("./ruby-runner/index.js")
81
81
 
82
- return new RubyRunner(this.#funOptions, this.#env)
82
+ return new RubyRunner(this.#funOptions, this.#env, this.#options)
83
83
  }
84
84
 
85
85
  if (supportedJava.has(runtime)) {
@@ -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