@netlify/build 28.0.0-beta → 28.0.0-rc

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/build",
3
- "version": "28.0.0-beta",
3
+ "version": "28.0.0-rc",
4
4
  "description": "Netlify build module",
5
5
  "type": "module",
6
6
  "exports": "./src/core/main.js",
@@ -56,16 +56,18 @@
56
56
  "license": "MIT",
57
57
  "dependencies": {
58
58
  "@bugsnag/js": "^7.0.0",
59
- "@netlify/edge-bundler": "^1.4.2",
59
+ "@netlify/edge-bundler": "^1.8.0",
60
60
  "@netlify/cache-utils": "^4.0.0",
61
- "@netlify/config": "^18.1.1",
62
- "@netlify/functions-utils": "^4.2.0",
61
+ "@netlify/config": "^18.1.2",
62
+ "@netlify/functions-utils": "^4.2.2",
63
63
  "@netlify/git-utils": "^4.0.0",
64
- "@netlify/plugins-list": "^6.29.0",
64
+ "@netlify/plugins-list": "^6.35.0",
65
65
  "@netlify/run-utils": "^4.0.0",
66
- "@netlify/zip-it-and-ship-it": "6.0.0-beta",
66
+ "@netlify/zip-it-and-ship-it": "5.13.2",
67
67
  "@sindresorhus/slugify": "^2.0.0",
68
68
  "@types/node": "^16.0.0",
69
+ "ajv": "^8.11.0",
70
+ "ajv-errors": "^3.0.0",
69
71
  "ansi-escapes": "^5.0.0",
70
72
  "chalk": "^5.0.0",
71
73
  "clean-stack": "^4.0.0",
package/src/core/flags.js CHANGED
@@ -145,6 +145,11 @@ Default: false`,
145
145
  describe: 'Print user-facing debugging information',
146
146
  hidden: true,
147
147
  },
148
+ systemLogFile: {
149
+ type: 'number',
150
+ describe: 'File descriptor to where system logs should be piped',
151
+ hidden: true,
152
+ },
148
153
  verbose: {
149
154
  boolean: true,
150
155
  describe: 'Print internal debugging information',
package/src/core/main.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { handleBuildError } from '../error/handle.js'
3
3
  import { getErrorInfo } from '../error/info.js'
4
4
  import { startErrorMonitor } from '../error/monitor/start.js'
5
- import { getBufferLogs } from '../log/logger.js'
5
+ import { getBufferLogs, getSystemLogger } from '../log/logger.js'
6
6
  import { logBuildStart, logTimer, logBuildSuccess } from '../log/messages/core.js'
7
7
  import { loadPlugins } from '../plugins/load.js'
8
8
  import { getPluginsOptions } from '../plugins/options.js'
@@ -57,6 +57,7 @@ export default async function buildSite(flags = {}) {
57
57
  mode,
58
58
  logs,
59
59
  debug,
60
+ systemLogFile,
60
61
  testOpts,
61
62
  statsdOpts,
62
63
  dry,
@@ -80,6 +81,7 @@ export default async function buildSite(flags = {}) {
80
81
  } = await execBuild({
81
82
  ...flagsA,
82
83
  buildId,
84
+ systemLogFile,
83
85
  deployId,
84
86
  dry,
85
87
  errorMonitor,
@@ -163,6 +165,7 @@ const tExecBuild = async function ({
163
165
  baseRelDir,
164
166
  env: envOpt,
165
167
  debug,
168
+ systemLogFile,
166
169
  verbose,
167
170
  nodePath,
168
171
  functionsDistDir,
@@ -241,6 +244,7 @@ const tExecBuild = async function ({
241
244
  mode,
242
245
  testOpts,
243
246
  })
247
+ const systemLog = getSystemLogger(logs, debug, systemLogFile)
244
248
  const pluginsOptions = addCorePlugins({ netlifyConfig, constants })
245
249
  // `errorParams` is purposely stateful
246
250
  // eslint-disable-next-line fp/no-mutating-assign
@@ -276,6 +280,7 @@ const tExecBuild = async function ({
276
280
  errorParams,
277
281
  logs,
278
282
  debug,
283
+ systemLog,
279
284
  verbose,
280
285
  timers: timersA,
281
286
  sendStatus,
@@ -325,6 +330,7 @@ const runAndReportBuild = async function ({
325
330
  errorParams,
326
331
  logs,
327
332
  debug,
333
+ systemLog,
328
334
  verbose,
329
335
  timers,
330
336
  sendStatus,
@@ -365,6 +371,7 @@ const runAndReportBuild = async function ({
365
371
  errorParams,
366
372
  logs,
367
373
  debug,
374
+ systemLog,
368
375
  verbose,
369
376
  timers,
370
377
  sendStatus,
@@ -457,6 +464,7 @@ const initAndRunBuild = async function ({
457
464
  errorParams,
458
465
  logs,
459
466
  debug,
467
+ systemLog,
460
468
  verbose,
461
469
  sendStatus,
462
470
  saveConfig,
@@ -528,6 +536,7 @@ const initAndRunBuild = async function ({
528
536
  errorParams,
529
537
  logs,
530
538
  debug,
539
+ systemLog,
531
540
  verbose,
532
541
  saveConfig,
533
542
  timers: timersB,
@@ -581,6 +590,7 @@ const runBuild = async function ({
581
590
  errorParams,
582
591
  logs,
583
592
  debug,
593
+ systemLog,
584
594
  verbose,
585
595
  saveConfig,
586
596
  timers,
@@ -634,6 +644,7 @@ const runBuild = async function ({
634
644
  configOpts,
635
645
  logs,
636
646
  debug,
647
+ systemLog,
637
648
  verbose,
638
649
  saveConfig,
639
650
  timers: timersA,
@@ -3,7 +3,15 @@
3
3
  // them consistent
4
4
  export const normalizeGroupingMessage = function (message, type) {
5
5
  const messageA = removeDependenciesLogs(message, type)
6
- return NORMALIZE_REGEXPS.reduce(normalizeMessage, messageA)
6
+ const messageB = NORMALIZE_REGEXPS.reduce(normalizeMessage, messageA)
7
+
8
+ // If this is a functions bundling error, we'll use additional normalization
9
+ // rules to group errors more aggressively.
10
+ if (type === 'functionsBundling') {
11
+ return FUNCTIONS_BUNDLING_REGEXPS.reduce(normalizeMessage, messageB)
12
+ }
13
+
14
+ return messageB
7
15
  }
8
16
 
9
17
  // Discard debug/info installation information
@@ -32,9 +40,9 @@ const normalizeMessage = function (message, [regExp, replacement]) {
32
40
 
33
41
  const NORMALIZE_REGEXPS = [
34
42
  // Base64 URL
35
- [/(data:[^;]+;base64),[\w+/-]+/g, 'dataURI'],
43
+ [/(data:[^;]+;base64),[\w+/-=]+/g, 'dataURI'],
36
44
  // File paths
37
- [/(["'`, ]|^)([^"'`, \n]*[/\\][^"'`, \n]*)(["'`, ]|$)/gm, '$1/file/path$3'],
45
+ [/(["'`, ]|^)([^"'`, \n]*[/\\][^"'`, \n]*)(?=["'`, ]|$)/gm, '$1/file/path'],
38
46
  // Semantic versions
39
47
  [/\d+\.\d+\.\d+(-\d+)?/g, '1.0.0'],
40
48
  [/version "[^"]+"/g, 'version "1.0.0"'],
@@ -45,7 +53,7 @@ const NORMALIZE_REGEXPS = [
45
53
  // Numbers, e.g. number of issues/problems
46
54
  [/\d+/g, '0'],
47
55
  // Hexadecimal strings
48
- [/[\da-fA-F]{6,}/g, 'hex'],
56
+ [/[\da-fA-F-]{6,}/g, 'hex'],
49
57
  // On unknown inputs, we print the inputs
50
58
  [/(does not accept any inputs but you specified: ).*/, '$1'],
51
59
  [/(Unknown inputs for plugin).*/, '$1'],
@@ -75,3 +83,14 @@ const NORMALIZE_REGEXPS = [
75
83
  // Multiple empty lines
76
84
  [/^\s*$/gm, ''],
77
85
  ]
86
+
87
+ const FUNCTIONS_BUNDLING_REGEXPS = [
88
+ // String literals and identifiers
89
+ [/"([^"]+)"/g, '""'],
90
+ [/'([^']+)'/g, "''"],
91
+ [/`([^`]+)`/g, '``'],
92
+
93
+ // Rust crates
94
+ [/(?:Downloaded \S+ v[\d.]+\s*)+/gm, 'Downloaded crates'],
95
+ [/(?:Compiling \S+ v[\d.]+\s*)+/gm, 'Compiled crates'],
96
+ ]
@@ -21,7 +21,7 @@ export const reportBuildError = async function ({ error, errorMonitor, childEnv,
21
21
  const { errorInfo, type, severity, title, group = title } = parseErrorInfo(error)
22
22
  const severityA = getSeverity(severity, errorInfo)
23
23
  const groupA = getGroup(group, errorInfo)
24
- const groupingHash = getGroupingHash(groupA, error, type)
24
+ const groupingHash = getGroupingHash(groupA, error, type, errorInfo)
25
25
  const metadata = getMetadata(errorInfo, childEnv, groupingHash)
26
26
  const app = getApp()
27
27
  const eventProps = getEventProps({ severity: severityA, group: groupA, groupingHash, metadata, app })
@@ -52,7 +52,12 @@ const getGroup = function (group, errorInfo) {
52
52
  return group(errorInfo)
53
53
  }
54
54
 
55
- const getGroupingHash = function (group, error, type) {
55
+ const getGroupingHash = function (group, error, type, errorInfo = {}) {
56
+ // If the error has a `normalizedMessage`, we use it as the grouping hash.
57
+ if (errorInfo.normalizedMessage) {
58
+ return errorInfo.normalizedMessage
59
+ }
60
+
56
61
  const message = error instanceof Error && typeof error.message === 'string' ? error.message : String(error)
57
62
  const messageA = normalizeGroupingMessage(message, type)
58
63
  return `${group}\n${messageA}`
@@ -23,8 +23,12 @@ const getBuildCommandLocation = function ({ buildCommand, buildCommandOrigin })
23
23
  ${buildCommand}`
24
24
  }
25
25
 
26
- const getFunctionsBundlingLocation = function ({ functionName }) {
27
- return `While bundling Function "${functionName}"`
26
+ const getFunctionsBundlingLocation = function ({ functionName, functionType }) {
27
+ if (functionType === 'edge') {
28
+ return 'While bundling edge function'
29
+ }
30
+
31
+ return `While bundling function "${functionName}"`
28
32
  }
29
33
 
30
34
  const getCoreStepLocation = function ({ coreStepName }) {
package/src/error/type.js CHANGED
@@ -81,7 +81,14 @@ const TYPES = {
81
81
 
82
82
  // User error during Functions bundling
83
83
  functionsBundling: {
84
- title: ({ location: { functionName } }) => `Bundling of Function "${functionName}" failed`,
84
+ title: ({ location: { functionName, functionType } }) => {
85
+ if (functionType === 'edge') {
86
+ return 'Bundling of edge function failed'
87
+ }
88
+
89
+ return `Bundling of function "${functionName}" failed`
90
+ },
91
+ group: ({ location: { functionType = 'serverless' } }) => `Bundling of ${functionType} function failed`,
85
92
  stackType: 'none',
86
93
  locationType: 'functionsBundling',
87
94
  severity: 'info',
package/src/log/logger.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createWriteStream } from 'fs'
2
+
1
3
  import figures from 'figures'
2
4
  import indentString from 'indent-string'
3
5
 
@@ -105,3 +107,36 @@ export const logErrorSubHeader = function (logs, string, opts) {
105
107
  export const logWarningSubHeader = function (logs, string, opts) {
106
108
  logSubHeader(logs, string, { color: THEME.warningSubHeader, ...opts })
107
109
  }
110
+
111
+ // Combines an array of elements into a single string, separated by a space,
112
+ // and with basic serialization of non-string types
113
+ const reduceLogLines = function (lines) {
114
+ return lines
115
+ .map((input) => {
116
+ if (typeof input === 'object') {
117
+ return JSON.stringify(input)
118
+ }
119
+
120
+ return input.toString()
121
+ })
122
+ .join(' ')
123
+ }
124
+
125
+ // Builds a function for logging data to the system logger (i.e. hidden from
126
+ // the user-facing build logs)
127
+ export const getSystemLogger = function (logs, debug, systemLogFile) {
128
+ // If there's not a file descriptor (or it's set to 0, matching stdout), we
129
+ // send debug lines to the normal logger. The same happens if the `debug`
130
+ // flag is used, since that means we want to print logs to stdout.
131
+ if (!systemLogFile || debug) {
132
+ return (...args) => log(logs, reduceLogLines(args))
133
+ }
134
+
135
+ const fileDescriptor = createWriteStream(null, { fd: systemLogFile })
136
+
137
+ fileDescriptor.on('error', () => {
138
+ logError(logs, 'Could not write to system log file')
139
+ })
140
+
141
+ return (...args) => fileDescriptor.write(reduceLogLines(args))
142
+ }
@@ -6,18 +6,19 @@ import { addErrorInfo } from '../../error/info.js'
6
6
  // Report status information to the UI
7
7
  export const show = function (runState, showArgs) {
8
8
  validateShowArgs(showArgs)
9
- const { title, summary, text } = removeEmptyStrings(showArgs)
10
- runState.status = { state: 'success', title, summary, text }
9
+ const { title, summary, text, extraData } = removeEmptyStrings(showArgs)
10
+ runState.status = { state: 'success', title, summary, text, extraData }
11
11
  }
12
12
 
13
13
  // Validate arguments of `utils.status.show()`
14
14
  const validateShowArgs = function (showArgs) {
15
15
  try {
16
16
  validateShowArgsObject(showArgs)
17
- const { title, summary, text, ...otherArgs } = showArgs
17
+ const { title, summary, text, extraData, ...otherArgs } = showArgs
18
18
  validateShowArgsKeys(otherArgs)
19
19
  Object.entries({ title, summary, text }).forEach(validateStringArg)
20
20
  validateShowArgsSummary(summary)
21
+ validateShowArgsExtraData(extraData)
21
22
  } catch (error) {
22
23
  error.message = `utils.status.show() ${error.message}`
23
24
  addErrorInfo(error, { type: 'pluginValidation' })
@@ -54,6 +55,12 @@ const validateShowArgsSummary = function (summary) {
54
55
  }
55
56
  }
56
57
 
58
+ const validateShowArgsExtraData = function (extraData) {
59
+ if (extraData !== undefined && Array.isArray(extraData) === false) {
60
+ throw new TypeError('provided extra data must be an array')
61
+ }
62
+ }
63
+
57
64
  const removeEmptyStrings = function (showArgs) {
58
65
  return mapObj(showArgs, removeEmptyString)
59
66
  }
@@ -6,12 +6,15 @@ import { pathExists } from 'path-exists'
6
6
 
7
7
  import { logFunctionsToBundle } from '../../log/messages/core_steps.js'
8
8
 
9
+ import { tagBundlingError } from './lib/error.js'
9
10
  import { parseManifest } from './lib/internal_manifest.js'
11
+ import { validateEdgeFunctionsManifest } from './validate_manifest/validate_edge_functions_manifest.js'
10
12
 
11
13
  // TODO: Replace this with a custom cache directory.
12
14
  const DENO_CLI_CACHE_DIRECTORY = '.netlify/plugins/deno-cli'
13
15
  const IMPORT_MAP_FILENAME = 'edge-functions-import-map.json'
14
16
 
17
+ // eslint-disable-next-line max-statements
15
18
  const coreStep = async function ({
16
19
  buildDir,
17
20
  constants: {
@@ -21,6 +24,7 @@ const coreStep = async function ({
21
24
  IS_LOCAL: isRunningLocally,
22
25
  },
23
26
  debug,
27
+ systemLog,
24
28
  featureFlags,
25
29
  logs,
26
30
  netlifyConfig,
@@ -45,13 +49,24 @@ const coreStep = async function ({
45
49
  // Edge Bundler expects the dist directory to exist.
46
50
  await fs.mkdir(distPath, { recursive: true })
47
51
 
48
- await bundle(sourcePaths, distPath, declarations, {
49
- cacheDirectory,
50
- debug,
51
- distImportMapPath,
52
- featureFlags,
53
- importMaps: [importMap].filter(Boolean),
54
- })
52
+ try {
53
+ const { manifest } = await bundle(sourcePaths, distPath, declarations, {
54
+ basePath: buildDir,
55
+ cacheDirectory,
56
+ debug,
57
+ distImportMapPath,
58
+ featureFlags,
59
+ importMaps: [importMap].filter(Boolean),
60
+ })
61
+
62
+ systemLog('Edge Functions manifest:', manifest)
63
+ } catch (error) {
64
+ tagBundlingError(error)
65
+
66
+ throw error
67
+ }
68
+
69
+ await validateEdgeFunctionsManifest({ buildDir, constants: { EDGE_FUNCTIONS_DIST: distDirectory } })
55
70
 
56
71
  return {}
57
72
  }
@@ -0,0 +1,21 @@
1
+ import { CUSTOM_ERROR_KEY, getErrorInfo, isBuildError } from '../../../error/info.js'
2
+
3
+ // If we have a custom error tagged with `functionsBundling` (which happens if
4
+ // there is an issue with user code), we tag it as coming from an edge function
5
+ // so that we can adjust the downstream error messages accordingly.
6
+ export const tagBundlingError = (error) => {
7
+ if (!isBuildError(error)) {
8
+ return
9
+ }
10
+
11
+ const [errorInfo = {}] = getErrorInfo(error)
12
+
13
+ if (errorInfo.type !== 'functionsBundling') {
14
+ return
15
+ }
16
+
17
+ error[CUSTOM_ERROR_KEY].location = {
18
+ ...error[CUSTOM_ERROR_KEY].location,
19
+ functionType: 'edge',
20
+ }
21
+ }
@@ -0,0 +1,89 @@
1
+ import { promises as fs } from 'fs'
2
+ import { join, resolve } from 'path'
3
+
4
+ import Ajv from 'ajv'
5
+ import ajvErrors from 'ajv-errors'
6
+
7
+ import { addErrorInfo } from '../../../error/info.js'
8
+
9
+ const ajv = new Ajv({ allErrors: true })
10
+ ajvErrors(ajv)
11
+
12
+ // regex pattern for manifest route pattern
13
+ // checks if the pattern string starts with ^ and ends with $
14
+ // we define this format in edge-bundler:
15
+ // https://github.com/netlify/edge-bundler/blob/main/src/manifest.ts#L66
16
+ const normalizedPatternRegex = /^\^.*\$$/
17
+ ajv.addFormat('regexPattern', {
18
+ async: true,
19
+ validate: (data) => normalizedPatternRegex.test(data),
20
+ })
21
+
22
+ const bundlesSchema = {
23
+ $async: true,
24
+ type: 'object',
25
+ required: ['asset', 'format'],
26
+ properties: {
27
+ asset: { type: 'string' },
28
+ format: { type: 'string' },
29
+ },
30
+ additionalProperties: false,
31
+ }
32
+
33
+ const routesSchema = {
34
+ $async: true,
35
+ type: 'object',
36
+ required: ['function', 'pattern'],
37
+ properties: {
38
+ function: { type: 'string' },
39
+ pattern: { type: 'string', format: 'regexPattern', errorMessage: `must match format ${normalizedPatternRegex}` },
40
+ },
41
+ additionalProperties: false,
42
+ }
43
+
44
+ const edgeManifestSchema = {
45
+ $async: true,
46
+ type: 'object',
47
+ required: ['bundles', 'routes', 'bundler_version'],
48
+ properties: {
49
+ bundles: {
50
+ type: 'array',
51
+ items: bundlesSchema,
52
+ },
53
+ routes: {
54
+ type: 'array',
55
+ items: routesSchema,
56
+ },
57
+ bundler_version: { type: 'string' },
58
+ },
59
+ additionalProperties: false,
60
+ errorMessage: "Couldn't validate Edge Functions manifest.json",
61
+ }
62
+
63
+ const validateManifest = async (manifestData) => {
64
+ const validate = ajv.compile(edgeManifestSchema)
65
+
66
+ await validate(manifestData)
67
+ }
68
+
69
+ export const validateEdgeFunctionsManifest = async function ({
70
+ buildDir,
71
+ constants: { EDGE_FUNCTIONS_DIST: distDirectory },
72
+ }) {
73
+ try {
74
+ const edgeFunctionsDistPath = resolve(buildDir, distDirectory)
75
+ const manifestPath = join(edgeFunctionsDistPath, 'manifest.json')
76
+ const data = await fs.readFile(manifestPath)
77
+ const manifestData = JSON.parse(data)
78
+
79
+ await validateManifest(manifestData)
80
+ } catch (error) {
81
+ const isValidationErr = error instanceof Ajv.ValidationError
82
+ const parsedErr = isValidationErr ? error.errors : error
83
+
84
+ addErrorInfo(parsedErr, { type: 'coreStep' })
85
+ throw new Error(isValidationErr ? JSON.stringify(parsedErr, null, 2) : parsedErr)
86
+ }
87
+
88
+ return {}
89
+ }
@@ -104,7 +104,7 @@ const sendApiStatuses = async function ({
104
104
 
105
105
  const sendApiStatus = async function ({
106
106
  api,
107
- status: { packageName, version, state, event, title, summary, text },
107
+ status: { packageName, version, state, event, title, summary, text, extraData },
108
108
  childEnv,
109
109
  mode,
110
110
  netlifyConfig,
@@ -117,7 +117,16 @@ const sendApiStatus = async function ({
117
117
  try {
118
118
  await api.createPluginRun({
119
119
  deploy_id: deployId,
120
- body: { package: packageName, version, state, reporting_event: event, title, summary, text },
120
+ body: {
121
+ package: packageName,
122
+ version,
123
+ state,
124
+ reporting_event: event,
125
+ title,
126
+ summary,
127
+ text,
128
+ extra_data: extraData,
129
+ },
121
130
  })
122
131
  // Bitballoon API randomly fails with 502.
123
132
  // Builds should be successful when this API call fails, but we still want
@@ -27,6 +27,7 @@ export const fireCoreStep = async function ({
27
27
  redirectsPath,
28
28
  featureFlags,
29
29
  debug,
30
+ systemLog,
30
31
  saveConfig,
31
32
  }) {
32
33
  try {
@@ -54,6 +55,7 @@ export const fireCoreStep = async function ({
54
55
  redirectsPath,
55
56
  featureFlags,
56
57
  debug,
58
+ systemLog,
57
59
  saveConfig,
58
60
  })
59
61
  const {
@@ -48,6 +48,7 @@ export const runStep = async function ({
48
48
  redirectsPath,
49
49
  logs,
50
50
  debug,
51
+ systemLog,
51
52
  verbose,
52
53
  saveConfig,
53
54
  timers,
@@ -109,6 +110,7 @@ export const runStep = async function ({
109
110
  error,
110
111
  logs,
111
112
  debug,
113
+ systemLog,
112
114
  verbose,
113
115
  saveConfig,
114
116
  timers,
@@ -140,6 +142,7 @@ export const runStep = async function ({
140
142
  redirectsPath: redirectsPathA,
141
143
  logs,
142
144
  debug,
145
+ systemLog,
143
146
  timers: timersA,
144
147
  durationNs,
145
148
  testOpts,
@@ -237,6 +240,7 @@ const tFireStep = function ({
237
240
  error,
238
241
  logs,
239
242
  debug,
243
+ systemLog,
240
244
  verbose,
241
245
  saveConfig,
242
246
  errorParams,
@@ -271,6 +275,7 @@ const tFireStep = function ({
271
275
  redirectsPath,
272
276
  featureFlags,
273
277
  debug,
278
+ systemLog,
274
279
  saveConfig,
275
280
  })
276
281
  }
@@ -294,6 +299,7 @@ const tFireStep = function ({
294
299
  error,
295
300
  logs,
296
301
  debug,
302
+ systemLog,
297
303
  verbose,
298
304
  })
299
305
  }
@@ -34,6 +34,7 @@ export const runSteps = async function ({
34
34
  configOpts,
35
35
  logs,
36
36
  debug,
37
+ systemLog,
37
38
  verbose,
38
39
  saveConfig,
39
40
  timers,
@@ -127,6 +128,7 @@ export const runSteps = async function ({
127
128
  redirectsPath: redirectsPathA,
128
129
  logs,
129
130
  debug,
131
+ systemLog,
130
132
  verbose,
131
133
  saveConfig,
132
134
  timers: timersA,