serverless-offline 12.0.4 → 13.1.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
@@ -524,9 +524,16 @@ only enabled with the `--ignoreJWTSignature` flag. Make sure to only set this fl
524
524
 
525
525
  ### Serverless plugin authorizers
526
526
 
527
- If your authentication needs are custom and not satisfied by the existing capabilities of the Serverless offline project, you can inject your own authentication strategy. To inject a custom strategy for Lambda invocation, you define a custom variable under `serverless-offline` called `authenticationProvider` in the serverless.yml file. The value of the custom variable will be used to `require(your authenticationProvider value)` where the location is expected to return a function with the following signature.
527
+ If your authentication needs are custom and not satisfied by the existing capabilities of the Serverless offline project, you can inject your own authentication strategy. To inject a custom strategy for Lambda invocation, you define a custom variable under `offline` called `customAuthenticationProvider` in the serverless.yml file. The value of the custom variable will be used to `require(your customAuthenticationProvider value)` where the location is expected to return a function with the following signature.
528
+
529
+ ```yaml
530
+ offline:
531
+ customAuthenticationProvider: ./path/to/custom-authentication-provider
532
+ ```
528
533
 
529
534
  ```js
535
+ // ./path/to/customer-authentication-provider.js
536
+
530
537
  module.exports = function (endpoint, functionKey, method, path) {
531
538
  return {
532
539
  getAuthenticateFunction() {
@@ -543,7 +550,7 @@ module.exports = function (endpoint, functionKey, method, path) {
543
550
  }
544
551
  ```
545
552
 
546
- A working example of injecting a custom authorization provider can be found in the projects integration tests under the folder `custom-authentication`.
553
+ 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).
547
554
 
548
555
  ## Custom headers
549
556
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "dedicatedTo": "Blue, a great migrating bird.",
3
3
  "name": "serverless-offline",
4
- "version": "12.0.4",
4
+ "version": "13.1.0",
5
5
  "description": "Emulate AWS λ and API Gateway locally when developing your Serverless project",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -18,6 +18,7 @@
18
18
  "prepare-release": "standard-version && prettier --write CHANGELOG.md",
19
19
  "prepublishOnly": "npm run lint",
20
20
  "prettier": "prettier --check .",
21
+ "prettier:fix": "prettier --write .",
21
22
  "prettier-check": "prettier -c --ignore-path .gitignore \"**/*.{css,html,js,json,md,yaml,yml}\"",
22
23
  "prettier-check:updated": "pipe-git-updated --ext=css --ext=html --ext=js --ext=json --ext=md --ext=yaml --ext=yml -- prettier -c",
23
24
  "prettify": "prettier --write --ignore-path .gitignore \"**/*.{css,html,js,json,md,yaml,yml}\"",
@@ -51,7 +52,7 @@
51
52
  ],
52
53
  "author": "David Hérault <dherault@gmail.com> (https://github.com/dherault)",
53
54
  "engines": {
54
- "node": ">=14.18.0"
55
+ "node": ">=18.12.0"
55
56
  },
56
57
  "standard-version": {
57
58
  "skip": {
@@ -78,49 +79,47 @@
78
79
  ]
79
80
  },
80
81
  "dependencies": {
81
- "@aws-sdk/client-lambda": "^3.241.0",
82
- "@hapi/boom": "^10.0.0",
83
- "@hapi/h2o2": "^10.0.0",
84
- "@hapi/hapi": "^21.1.0",
85
- "@serverless/utils": "^6.8.2",
82
+ "@aws-sdk/client-lambda": "^3.418.0",
83
+ "@hapi/boom": "^10.0.1",
84
+ "@hapi/h2o2": "^10.0.4",
85
+ "@hapi/hapi": "^21.3.2",
86
+ "@serverless/utils": "^6.15.0",
86
87
  "array-unflat-js": "^0.1.3",
87
- "boxen": "^7.0.1",
88
- "chalk": "^5.2.0",
88
+ "boxen": "^7.1.1",
89
+ "chalk": "^5.3.0",
89
90
  "desm": "^1.3.0",
90
- "execa": "^6.1.0",
91
- "fs-extra": "^11.1.0",
92
- "is-wsl": "^2.2.0",
91
+ "execa": "^8.0.1",
92
+ "fs-extra": "^11.1.1",
93
+ "is-wsl": "^3.0.0",
93
94
  "java-invoke-local": "0.0.6",
94
- "jose": "^4.11.2",
95
+ "jose": "^4.14.6",
95
96
  "js-string-escape": "^1.0.1",
96
97
  "jsonpath-plus": "^7.2.0",
97
98
  "jsonschema": "^1.4.1",
98
99
  "jszip": "^3.10.1",
99
100
  "luxon": "^3.2.0",
100
- "node-fetch": "^3.3.0",
101
- "node-schedule": "^2.1.0",
102
- "object.hasown": "^1.1.2",
101
+ "node-schedule": "^2.1.1",
103
102
  "p-memoize": "^7.1.1",
104
- "p-retry": "^5.1.2",
103
+ "p-retry": "^6.0.0",
105
104
  "velocityjs": "^2.0.6",
106
- "ws": "^8.11.0"
105
+ "ws": "^8.14.2"
107
106
  },
108
107
  "devDependencies": {
109
108
  "@istanbuljs/esm-loader-hook": "^0.2.0",
110
- "archiver": "^5.3.1",
111
- "eslint": "^8.31.0",
109
+ "archiver": "^6.0.1",
110
+ "eslint": "^8.50.0",
112
111
  "eslint-config-airbnb-base": "^15.0.0",
113
- "eslint-config-prettier": "^8.6.0",
114
- "eslint-plugin-import": "^2.25.4",
115
- "eslint-plugin-prettier": "^4.2.1",
116
- "eslint-plugin-unicorn": "^45.0.2",
112
+ "eslint-config-prettier": "^9.0.0",
113
+ "eslint-plugin-import": "^2.28.1",
114
+ "eslint-plugin-prettier": "^5.0.0",
115
+ "eslint-plugin-unicorn": "^48.0.1",
117
116
  "git-list-updated": "^1.2.1",
118
117
  "husky": "^8.0.3",
119
- "lint-staged": "^13.1.0",
118
+ "lint-staged": "^14.0.1",
120
119
  "mocha": "^10.2.0",
121
120
  "nyc": "^15.1.0",
122
- "prettier": "^2.8.1",
123
- "serverless": "^3.26.0",
121
+ "prettier": "^3.0.3",
122
+ "serverless": "^3.35.2",
124
123
  "standard-version": "^9.5.0"
125
124
  },
126
125
  "peerDependencies": {
@@ -107,8 +107,10 @@ export default class ServerlessOffline {
107
107
  const eventModules = []
108
108
 
109
109
  if (this.#lambda) {
110
- eventModules.push(this.#lambda.cleanup())
111
- eventModules.push(this.#lambda.stop(SERVER_SHUTDOWN_TIMEOUT))
110
+ eventModules.push(
111
+ this.#lambda.cleanup(),
112
+ this.#lambda.stop(SERVER_SHUTDOWN_TIMEOUT),
113
+ )
112
114
  }
113
115
 
114
116
  if (this.#alb) {
@@ -259,13 +261,13 @@ export default class ServerlessOffline {
259
261
 
260
262
  // Parse CORS options
261
263
  this.#options.corsAllowHeaders = this.#options.corsAllowHeaders
262
- .replace(/\s/g, '')
264
+ .replaceAll(' ', '')
263
265
  .split(',')
264
266
  this.#options.corsAllowOrigin = this.#options.corsAllowOrigin
265
- .replace(/\s/g, '')
267
+ .replaceAll(' ', '')
266
268
  .split(',')
267
269
  this.#options.corsExposedHeaders = this.#options.corsExposedHeaders
268
- .replace(/\s/g, '')
270
+ .replaceAll(' ', '')
269
271
  .split(',')
270
272
 
271
273
  this.#options.corsConfig = {
@@ -4,18 +4,16 @@
4
4
  // .NET CORE
5
5
  // export const supportedDotnetcore = new Set([
6
6
  // 'dotnet6',
7
- // 'dotnetcore3.1',
8
7
  // ])
9
8
 
10
9
  // GO
11
10
  export const supportedGo = new Set(['go1.x'])
12
11
 
13
12
  // JAVA
14
- export const supportedJava = new Set(['java8', 'java8.al2', 'java11'])
13
+ export const supportedJava = new Set(['java8', 'java8.al2', 'java11', 'java17'])
15
14
 
16
15
  // NODE.JS
17
16
  export const supportedNodejs = new Set([
18
- 'nodejs12.x',
19
17
  'nodejs14.x',
20
18
  'nodejs16.x',
21
19
  'nodejs18.x',
@@ -26,14 +24,15 @@ export const supportedProvided = new Set(['provided', 'provided.al2'])
26
24
 
27
25
  // PYTHON
28
26
  export const supportedPython = new Set([
29
- 'python3.6',
30
27
  'python3.7',
31
28
  'python3.8',
32
29
  'python3.9',
30
+ 'python3.10',
31
+ 'python3.11',
33
32
  ])
34
33
 
35
34
  // RUBY
36
- export const supportedRuby = new Set(['ruby2.7'])
35
+ export const supportedRuby = new Set(['ruby2.7', 'ruby3.2'])
37
36
 
38
37
  // deprecated runtimes
39
38
  // https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html
@@ -1,4 +1,3 @@
1
- import { log } from '@serverless/utils/log.js'
2
1
  import AlbEventDefinition from './AlbEventDefinition.js'
3
2
  import HttpServer from './HttpServer.js'
4
3
 
@@ -15,11 +14,6 @@ export default class Alb {
15
14
  this.#lambda = lambda
16
15
  this.#options = options
17
16
  this.#serverless = serverless
18
-
19
- log.warning(`
20
- Application Load Balancer (ALB) support in serverless-offline is experimental.
21
- Please file an issue for any bugs, missing features or other feedback: https://github.com/dherault/serverless-offline/issues
22
- `)
23
17
  }
24
18
 
25
19
  start() {
@@ -105,13 +105,9 @@ export default class HttpServer {
105
105
  if (request.method === 'options') {
106
106
  response.statusCode = 200
107
107
 
108
- if (request.headers['access-control-expose-headers']) {
109
- response.headers['access-control-expose-headers'] =
110
- request.headers['access-control-expose-headers']
111
- } else {
112
- response.headers['access-control-expose-headers'] =
113
- 'content-type, content-length, etag'
114
- }
108
+ response.headers['access-control-expose-headers'] =
109
+ request.headers['access-control-expose-headers'] ||
110
+ 'content-type, content-length, etag'
115
111
  response.headers['access-control-max-age'] = 60 * 10
116
112
 
117
113
  if (request.headers['access-control-request-headers']) {
@@ -61,7 +61,7 @@ export default function authMatchPolicyResource(policyResource, resource) {
61
61
  // for the requested resource and the resource defined in the policy
62
62
  // Need to create a regex replacing ? with one character and * with any number of characters
63
63
  const regExp = new RegExp(
64
- parsedPolicyResource.path.replace(/\*/g, '.*').replace(/\?/g, '.'),
64
+ parsedPolicyResource.path.replaceAll('*', '.*').replaceAll('?', '.'),
65
65
  )
66
66
 
67
67
  return regExp.test(parsedResource.path)
@@ -17,7 +17,7 @@ const defaultResponseTemplate = readFileSync(
17
17
 
18
18
  function getResponseContentType(fep) {
19
19
  if (fep.response && fep.response.headers['Content-Type']) {
20
- return fep.response.headers['Content-Type'].replace(/'/gm, '')
20
+ return fep.response.headers['Content-Type'].replaceAll(/'/gm, '')
21
21
  }
22
22
 
23
23
  return 'application/json'
@@ -164,13 +164,9 @@ export default class HttpServer {
164
164
  if (request.method === 'options') {
165
165
  response.statusCode = 200
166
166
 
167
- if (request.headers['access-control-expose-headers']) {
168
- response.headers['access-control-expose-headers'] =
169
- request.headers['access-control-expose-headers']
170
- } else {
171
- response.headers['access-control-expose-headers'] =
172
- 'content-type, content-length, etag'
173
- }
167
+ response.headers['access-control-expose-headers'] =
168
+ request.headers['access-control-expose-headers'] ||
169
+ 'content-type, content-length, etag'
174
170
  response.headers['access-control-max-age'] = 60 * 10
175
171
 
176
172
  if (request.headers['access-control-request-headers']) {
@@ -323,9 +319,7 @@ export default class HttpServer {
323
319
  (endpoint.isHttpApi &&
324
320
  serverlessAuthorizerOptions?.enableSimpleResponses) ||
325
321
  false,
326
- identitySource:
327
- serverlessAuthorizerOptions?.identitySource ||
328
- 'method.request.header.Authorization',
322
+ identitySource: serverlessAuthorizerOptions?.identitySource,
329
323
  identityValidationExpression:
330
324
  serverlessAuthorizerOptions?.identityValidationExpression || '(.*)',
331
325
  payloadVersion: endpoint.isHttpApi
@@ -351,6 +345,16 @@ export default class HttpServer {
351
345
  assign(authorizerOptions, endpoint.authorizer)
352
346
  }
353
347
 
348
+ if (
349
+ !authorizerOptions.identitySource &&
350
+ !(
351
+ authorizerOptions.type === 'request' &&
352
+ authorizerOptions.resultTtlInSeconds === 0
353
+ )
354
+ ) {
355
+ authorizerOptions.identitySource = 'method.request.header.Authorization'
356
+ }
357
+
354
358
  // Create a unique scheme per endpoint
355
359
  // This allows the methodArn on the event property to be set appropriately
356
360
  const authKey = `${functionKey}-${authFunctionName}-${method}-${path}`
@@ -13,6 +13,7 @@ import {
13
13
 
14
14
  const IDENTITY_SOURCE_TYPE_HEADER = 'header'
15
15
  const IDENTITY_SOURCE_TYPE_QUERYSTRING = 'querystring'
16
+ const IDENTITY_SOURCE_TYPE_NONE = 'none'
16
17
 
17
18
  export default function createAuthScheme(authorizerOptions, provider, lambda) {
18
19
  const authFunName = authorizerOptions.name
@@ -65,38 +66,50 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) {
65
66
  const methodArn = `arn:aws:execute-api:${provider.region}:${accountId}:${apiId}/${provider.stage}/${httpMethod}${resourcePath}`
66
67
 
67
68
  let authorization
68
- if (identitySourceType === IDENTITY_SOURCE_TYPE_HEADER) {
69
- const headers = request.raw.req.headers ?? {}
70
- authorization = headers[identitySourceField]
71
- } else if (identitySourceType === IDENTITY_SOURCE_TYPE_QUERYSTRING) {
72
- const queryStringParameters = parseQueryStringParameters(url) ?? {}
73
- authorization = queryStringParameters[identitySourceField]
74
- } else {
75
- throw new Error(
76
- `No Authorization source has been specified. This should never happen. (λ: ${authFunName})`,
77
- )
69
+ switch (identitySourceType) {
70
+ case IDENTITY_SOURCE_TYPE_HEADER: {
71
+ const headers = request.raw.req.headers ?? {}
72
+ authorization = headers[identitySourceField]
73
+ break
74
+ }
75
+ case IDENTITY_SOURCE_TYPE_QUERYSTRING: {
76
+ const queryStringParameters = parseQueryStringParameters(url) ?? {}
77
+ authorization = queryStringParameters[identitySourceField]
78
+ break
79
+ }
80
+ case IDENTITY_SOURCE_TYPE_NONE: {
81
+ break
82
+ }
83
+ default: {
84
+ throw new Error(
85
+ `No Authorization source has been specified. This should never happen. (λ: ${authFunName})`,
86
+ )
87
+ }
78
88
  }
79
89
 
80
- if (authorization === undefined) {
81
- log.error(
82
- `Identity Source is null for ${identitySourceType} ${identitySourceField} (λ: ${authFunName})`,
90
+ let finalAuthorization
91
+ if (identitySourceType !== IDENTITY_SOURCE_TYPE_NONE) {
92
+ if (authorization === undefined) {
93
+ log.error(
94
+ `Identity Source is null for ${identitySourceType} ${identitySourceField} (λ: ${authFunName})`,
95
+ )
96
+ return Boom.unauthorized(
97
+ 'User is not authorized to access this resource',
98
+ )
99
+ }
100
+
101
+ const identityValidationExpression = new RegExp(
102
+ authorizerOptions.identityValidationExpression,
83
103
  )
84
- return Boom.unauthorized(
85
- 'User is not authorized to access this resource',
104
+ const matchedAuthorization =
105
+ identityValidationExpression.test(authorization)
106
+ finalAuthorization = matchedAuthorization ? authorization : ''
107
+
108
+ log.debug(
109
+ `Retrieved ${identitySourceField} ${identitySourceType} "${finalAuthorization}"`,
86
110
  )
87
111
  }
88
112
 
89
- const identityValidationExpression = new RegExp(
90
- authorizerOptions.identityValidationExpression,
91
- )
92
- const matchedAuthorization =
93
- identityValidationExpression.test(authorization)
94
- const finalAuthorization = matchedAuthorization ? authorization : ''
95
-
96
- log.debug(
97
- `Retrieved ${identitySourceField} ${identitySourceType} "${finalAuthorization}"`,
98
- )
99
-
100
113
  if (authorizerOptions.payloadVersion === '1.0') {
101
114
  event = {
102
115
  ...event,
@@ -148,17 +161,10 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) {
148
161
 
149
162
  // methodArn is the ARN of the function we are running we are authorizing access to (or not)
150
163
  // Account ID and API ID are not simulated
151
- if (authorizerOptions.type === 'request') {
152
- event = {
153
- ...event,
154
- type: 'REQUEST',
155
- }
156
- } else {
164
+ event = {
165
+ ...event,
157
166
  // This is safe since type: 'TOKEN' cannot have payload format 2.0
158
- event = {
159
- ...event,
160
- type: 'TOKEN',
161
- }
167
+ type: authorizerOptions.type === 'request' ? 'REQUEST' : 'TOKEN',
162
168
  }
163
169
 
164
170
  const lambdaFunction = lambda.get(authFunName)
@@ -270,7 +276,8 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) {
270
276
  authorizerOptions.type !== 'request' ||
271
277
  authorizerOptions.identitySource
272
278
  ) {
273
- const headerRegExp = /^(method.|\$)request.header.((?:\w+-?)+\w+)$/
279
+ // Only validate the first of N possible headers.
280
+ const headerRegExp = /^(method.|\$)request.header.((?:\w+-?)+\w+).*$/
274
281
  const queryStringRegExp =
275
282
  /^(method.|\$)request.querystring.((?:\w+-?)+\w+)$/
276
283
 
@@ -296,5 +303,10 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) {
296
303
  )
297
304
  }
298
305
 
306
+ if (authorizerOptions.resultTtlInSeconds === 0) {
307
+ identitySourceType = IDENTITY_SOURCE_TYPE_NONE
308
+ return finalizeAuthScheme()
309
+ }
310
+
299
311
  return finalizeAuthScheme()
300
312
  }
@@ -20,10 +20,6 @@ function javaMatches(value) {
20
20
  return this.match(new RegExp(value, 'm'))
21
21
  }
22
22
 
23
- function javaReplaceAll(oldValue, newValue) {
24
- return this.replace(new RegExp(oldValue, 'gm'), newValue)
25
- }
26
-
27
23
  function javaReplaceFirst(oldValue, newValue) {
28
24
  return this.replace(new RegExp(oldValue, 'm'), newValue)
29
25
  }
@@ -74,7 +70,6 @@ const {
74
70
  equalsIgnoreCase,
75
71
  matches,
76
72
  regionMatches,
77
- replaceAll,
78
73
  replaceFirst,
79
74
  },
80
75
  } = String
@@ -85,7 +80,6 @@ export default function runInPollutedScope(runScope) {
85
80
  prototype.equalsIgnoreCase = javaEqualsIgnoreCase
86
81
  prototype.matches = javaMatches
87
82
  prototype.regionMatches = javaRegionMatches
88
- prototype.replaceAll = javaReplaceAll
89
83
  prototype.replaceFirst = javaReplaceFirst
90
84
 
91
85
  const result = runScope()
@@ -95,7 +89,6 @@ export default function runInPollutedScope(runScope) {
95
89
  prototype.equalsIgnoreCase = equalsIgnoreCase
96
90
  prototype.matches = matches
97
91
  prototype.regionMatches = regionMatches
98
- prototype.replaceAll = replaceAll
99
92
  prototype.replaceFirst = replaceFirst
100
93
 
101
94
  return result
@@ -14,7 +14,7 @@ const { assign, entries, fromEntries } = Object
14
14
 
15
15
  function escapeJavaScript(x) {
16
16
  if (typeof x === 'string') {
17
- return jsEscapeString(x).replace(/\\n/g, '\n') // See #26,
17
+ return jsEscapeString(x).replaceAll('\\n', '\n') // See #26,
18
18
  }
19
19
 
20
20
  if (isPlainObject(x)) {
@@ -136,7 +136,7 @@ export default class VelocityContext {
136
136
  Buffer.from(x.toString(), 'binary').toString('base64'),
137
137
  escapeJavaScript,
138
138
  parseJson: parse,
139
- urlDecode: (x) => decodeURIComponent(x.replace(/\+/g, ' ')),
139
+ urlDecode: (x) => decodeURIComponent(x.replaceAll('+', ' ')),
140
140
  urlEncode: encodeURI,
141
141
  },
142
142
  }
@@ -16,7 +16,7 @@ export default class ScheduleEvent {
16
16
  source = 'aws.events'
17
17
 
18
18
  // format of aws displaying the time, e.g.: 2020-02-09T14:13:57Z
19
- time = new Date().toISOString().replace(/\.(.*)(?=Z)/g, '')
19
+ time = new Date().toISOString().replaceAll(/\.(.*)(?=Z)/g, '')
20
20
 
21
21
  version = '0'
22
22
 
package/src/index.js CHANGED
@@ -1,12 +1 @@
1
- // TODO remove with node.js v16.9+ support
2
- import 'object.hasown/auto'
3
-
4
- // install global fetch
5
- // TODO remove `node-fetch` module and use global built-in with node.js v18+ support
6
- if (globalThis.fetch === undefined) {
7
- const { default: fetch, Headers } = await import('node-fetch')
8
- globalThis.fetch = fetch
9
- globalThis.Headers = Headers
10
- }
11
-
12
1
  export { default } from './ServerlessOffline.js'
@@ -250,7 +250,7 @@ export default class LambdaFunction {
250
250
  entries(zip.files).map(async ([filename, jsZipObj]) => {
251
251
  const fileData = await jsZipObj.async('nodebuffer')
252
252
  if (filename.endsWith('/')) {
253
- return Promise.resolve()
253
+ return undefined
254
254
  }
255
255
  await ensureDir(join(this.#codeDir, dirname(filename)))
256
256
  return writeFile(join(this.#codeDir, filename), fileData, {
@@ -313,7 +313,7 @@ export default class DockerContainer {
313
313
  entries(zip.files).map(async ([filename, jsZipObj]) => {
314
314
  const fileData = await jsZipObj.async('nodebuffer')
315
315
  if (filename.endsWith(sep)) {
316
- return Promise.resolve()
316
+ return undefined
317
317
  }
318
318
  await ensureDir(join(layerDir, dirname(filename)))
319
319
  return writeFile(join(layerDir, filename), fileData, {
@@ -12,7 +12,7 @@ export default class InvocationsController {
12
12
  const functionNames = this.#lambda.listFunctionNames()
13
13
  if (functionNames.length === 0 || !functionNames.includes(functionName)) {
14
14
  log.error(
15
- `Attempt to invoke function '${functionName}' failed. Function does not exists.`,
15
+ `Attempt to invoke function '${functionName}' failed. Function does not exist.`,
16
16
  )
17
17
  // Conforms to the actual response from AWS Lambda when invoking a non-existent
18
18
  // function. Details on the error are provided in the Payload.Message key
@@ -15,7 +15,7 @@ export default function generateHapiPath(path, options, serverless) {
15
15
  hapiPath = hapiPath.slice(0, -1)
16
16
  }
17
17
 
18
- hapiPath = hapiPath.replace(/\+}/g, '*}')
18
+ hapiPath = hapiPath.replaceAll('+}', '*}')
19
19
 
20
20
  return hapiPath
21
21
  }