netlify-cli 8.12.0 → 8.14.0-scheduled-functions

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.
@@ -32,6 +32,7 @@ class NetlifyFunction {
32
32
  // Determines whether this is a background function based on the function
33
33
  // name.
34
34
  this.isBackground = name.endsWith(BACKGROUND_SUFFIX)
35
+ this.schedule = null
35
36
 
36
37
  // List of the function's source files. This starts out as an empty set
37
38
  // and will get populated on every build.
@@ -44,6 +45,12 @@ class NetlifyFunction {
44
45
  return /^[A-Za-z0-9_-]+$/.test(this.name)
45
46
  }
46
47
 
48
+ async isScheduled() {
49
+ await this.buildQueue
50
+
51
+ return Boolean(this.schedule)
52
+ }
53
+
47
54
  // The `build` method transforms source files into invocable functions. Its
48
55
  // return value is an object with:
49
56
  //
@@ -61,12 +68,13 @@ class NetlifyFunction {
61
68
  this.buildQueue = buildFunction({ cache })
62
69
 
63
70
  try {
64
- const { srcFiles, ...buildData } = await this.buildQueue
71
+ const { schedule, srcFiles, ...buildData } = await this.buildQueue
65
72
  const srcFilesSet = new Set(srcFiles)
66
73
  const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet)
67
74
 
68
75
  this.buildData = buildData
69
76
  this.srcFiles = srcFilesSet
77
+ this.schedule = schedule
70
78
 
71
79
  return { srcFilesDiff }
72
80
  } catch (error) {
@@ -1,7 +1,7 @@
1
1
  const { mkdir, writeFile } = require('fs').promises
2
2
  const path = require('path')
3
3
 
4
- const { zipFunction } = require('@netlify/zip-it-and-ship-it')
4
+ const { listFunction, zipFunction } = require('@netlify/zip-it-and-ship-it')
5
5
  const decache = require('decache')
6
6
  const readPkgUp = require('read-pkg-up')
7
7
  const sourceMapSupport = require('source-map-support')
@@ -35,7 +35,11 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr
35
35
  // root of the functions directory (e.g. `functions/my-func.js`). In
36
36
  // this case, we use `mainFile` as the function path of `zipFunction`.
37
37
  const entryPath = functionDirectory === directory ? func.mainFile : functionDirectory
38
- const { inputs, path: functionPath } = await memoizedBuild({
38
+ const {
39
+ inputs,
40
+ path: functionPath,
41
+ schedule,
42
+ } = await memoizedBuild({
39
43
  cache,
40
44
  cacheKey: `zisi-${entryPath}`,
41
45
  command: () => zipFunction(entryPath, targetDirectory, zipOptions),
@@ -56,7 +60,19 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr
56
60
 
57
61
  clearFunctionsCache(targetDirectory)
58
62
 
59
- return { buildPath, srcFiles }
63
+ return { buildPath, srcFiles, schedule }
64
+ }
65
+
66
+ /**
67
+ * @param {string} mainFile
68
+ */
69
+ const parseForSchedule = async ({ config, mainFile, projectRoot }) => {
70
+ const listedFunction = await listFunction(mainFile, {
71
+ config: netlifyConfigToZisiConfig({ config, projectRoot }),
72
+ parseISC: true,
73
+ })
74
+
75
+ return listedFunction && listedFunction.schedule
60
76
  }
61
77
 
62
78
  // Clears the cache for any files inside the directory from which functions are
@@ -79,10 +95,11 @@ const getTargetDirectory = async ({ errorExit }) => {
79
95
  return targetDirectory
80
96
  }
81
97
 
98
+ const netlifyConfigToZisiConfig = ({ config, projectRoot }) =>
99
+ addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig: config.functions, projectRoot }))
100
+
82
101
  module.exports = async ({ config, directory, errorExit, func, projectRoot }) => {
83
- const functionsConfig = addFunctionsConfigDefaults(
84
- normalizeFunctionsConfig({ functionsConfig: config.functions, projectRoot }),
85
- )
102
+ const functionsConfig = netlifyConfigToZisiConfig({ config, projectRoot })
86
103
 
87
104
  const packageJson = await readPkgUp(func.mainFile)
88
105
  const hasTypeModule = packageJson && packageJson.packageJson.type === 'module'
@@ -115,3 +132,5 @@ module.exports = async ({ config, directory, errorExit, func, projectRoot }) =>
115
132
  target: targetDirectory,
116
133
  }
117
134
  }
135
+
136
+ module.exports.parseForSchedule = parseForSchedule
@@ -46,8 +46,9 @@ const getBuildFunction = async ({ config, directory, errorExit, func, projectRoo
46
46
  // main file otherwise.
47
47
  const functionDirectory = dirname(func.mainFile)
48
48
  const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory]
49
+ const schedule = await detectZisiBuilder.parseForSchedule({ mainFile: func.mainFile, config, projectRoot })
49
50
 
50
- return () => ({ srcFiles })
51
+ return () => ({ schedule, srcFiles })
51
52
  }
52
53
 
53
54
  const invokeFunction = async ({ context, event, func, timeout }) => {
@@ -0,0 +1,78 @@
1
+ const ansi2html = require('ansi2html')
2
+
3
+ const { CLOCKWORK_USERAGENT } = require('../../utils')
4
+
5
+ const { formatLambdaError } = require('./utils')
6
+
7
+ const handleScheduledFunction = ({ error, request, response, result }) => {
8
+ const acceptsHtml = request.headers.accept && request.headers.accept.includes('text/html')
9
+ const paragraph = (text) => {
10
+ text = text.trim()
11
+
12
+ if (acceptsHtml) {
13
+ return ansi2html(`<p>${text}</p>`)
14
+ }
15
+
16
+ text = text
17
+ .replaceAll('<pre><code>', '```\n')
18
+ .replaceAll('</code></pre>', '\n```')
19
+ .replaceAll(`<code>`, '`')
20
+ .replaceAll(`</code>`, '`')
21
+
22
+ return `${text}\n\n`
23
+ }
24
+
25
+ const isSimulatedRequest = request.headers['user-agent'] === CLOCKWORK_USERAGENT
26
+
27
+ let message = ''
28
+
29
+ if (!isSimulatedRequest) {
30
+ message += paragraph(`
31
+ You performed an HTTP request to <code>${request.path}</code>, which is a scheduled function.
32
+ You can do this to test your functions locally, but it won't work in production.
33
+ `)
34
+ }
35
+
36
+ if (error) {
37
+ message += paragraph(`
38
+ There was an error during execution of your scheduled function:
39
+
40
+ <pre><code>${formatLambdaError(error)}</code></pre>`)
41
+ }
42
+
43
+ if (result) {
44
+ const { statusCode } = result
45
+ if (statusCode >= 500) {
46
+ message += paragraph(`
47
+ Your function returned a status code of <code>${statusCode}</code>.
48
+ At the moment, Netlify does nothing about that. In the future, there might be a retry mechanism based on this.
49
+ `)
50
+ }
51
+
52
+ const allowedKeys = new Set(['statusCode'])
53
+ const returnedKeys = Object.keys(result)
54
+ const ignoredKeys = returnedKeys.filter((key) => !allowedKeys.has(key))
55
+
56
+ if (ignoredKeys.length !== 0) {
57
+ message += paragraph(
58
+ `Your function returned ${ignoredKeys
59
+ .map((key) => `<code>${key}</code>`)
60
+ .join(', ')}. Is this an accident? It won't be interpreted by Netlify.`,
61
+ )
62
+ }
63
+ }
64
+
65
+ response.status(error ? 500 : 200)
66
+ if (acceptsHtml) {
67
+ response.set('Content-Type', 'text/html')
68
+ response.send(
69
+ `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">\n
70
+ ${message}`,
71
+ )
72
+ } else {
73
+ response.set('Content-Type', 'text/plain')
74
+ response.send(message)
75
+ }
76
+ }
77
+
78
+ module.exports = { handleScheduledFunction }
@@ -6,6 +6,7 @@ const { NETLIFYDEVERR, NETLIFYDEVLOG, error: errorExit, getInternalFunctionsDir,
6
6
  const { handleBackgroundFunction, handleBackgroundFunctionResult } = require('./background')
7
7
  const { createFormSubmissionHandler } = require('./form-submissions-handler')
8
8
  const { FunctionsRegistry } = require('./registry')
9
+ const { handleScheduledFunction } = require('./scheduled')
9
10
  const { handleSynchronousFunction } = require('./synchronous')
10
11
  const { shouldBase64Encode } = require('./utils')
11
12
 
@@ -105,6 +106,15 @@ const createHandler = function ({ functionsRegistry }) {
105
106
  const { error } = await func.invoke(event, clientContext)
106
107
 
107
108
  handleBackgroundFunctionResult(functionName, error)
109
+ } else if (await func.isScheduled()) {
110
+ const { error, result } = await func.invoke(event, clientContext)
111
+
112
+ handleScheduledFunction({
113
+ error,
114
+ result,
115
+ request,
116
+ response,
117
+ })
108
118
  } else {
109
119
  const { error, result } = await func.invoke(event, clientContext)
110
120
 
@@ -0,0 +1,278 @@
1
+ /* eslint-disable eslint-comments/disable-enable-pair */
2
+ /* eslint-disable fp/no-loops */
3
+ const os = require('os')
4
+
5
+ const { GraphQL, InternalConsole, OneGraphClient } = require('netlify-onegraph-internal')
6
+ const { NetlifyGraph } = require('netlify-onegraph-internal')
7
+
8
+ const { chalk, error, log, warn } = require('../../utils')
9
+
10
+ const { createCLISession, createPersistedQuery, ensureAppForSite, updateCLISessionMetadata } = OneGraphClient
11
+
12
+ const {
13
+ generateFunctionsFile,
14
+ generateHandler,
15
+ readGraphQLOperationsSourceFile,
16
+ writeGraphQLOperationsSourceFile,
17
+ writeGraphQLSchemaFile,
18
+ } = require('./cli-netlify-graph')
19
+
20
+ const { parse } = GraphQL
21
+ const { defaultExampleOperationsDoc, extractFunctionsFromOperationDoc } = NetlifyGraph
22
+
23
+ const internalConsole = {
24
+ log,
25
+ warn,
26
+ error,
27
+ debug: console.debug,
28
+ }
29
+
30
+ InternalConsole.registerConsole(internalConsole)
31
+
32
+ /**
33
+ * Start polling for CLI events for a given session to process locally
34
+ * @param {object} input
35
+ * @param {string} input.appId The app to query against, typically the siteId
36
+ * @param {string} input.netlifyToken The (typically netlify) access token that is used for authentication, if any
37
+ * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events
38
+ * @param {function} input.onClose A function to call when the polling loop is closed
39
+ * @param {function} input.onError A function to call when an error occurs
40
+ * @param {function} input.onEvents A function to call when CLI events are received and need to be processed
41
+ * @param {string} input.sessionId The session id to monitor CLI events for
42
+ * @param {state} input.state A function to call to set/get the current state of the local Netlify project
43
+ * @returns
44
+ */
45
+ const monitorCLISessionEvents = (input) => {
46
+ const { appId, netlifyGraphConfig, netlifyToken, onClose, onError, onEvents, sessionId, state } = input
47
+
48
+ const frequency = 5000
49
+ let shouldClose = false
50
+
51
+ const enabledServiceWatcher = async (innerNetlifyToken, siteId) => {
52
+ const enabledServices = state.get('oneGraphEnabledServices') || ['onegraph']
53
+ const enabledServicesInfo = await OneGraphClient.fetchEnabledServices(innerNetlifyToken, siteId)
54
+ if (!enabledServicesInfo) {
55
+ warn('Unable to fetch enabled services for site for code generation')
56
+ return
57
+ }
58
+ const newEnabledServices = enabledServicesInfo.map((service) => service.service)
59
+ const enabledServicesCompareKey = enabledServices.sort().join(',')
60
+ const newEnabledServicesCompareKey = newEnabledServices.sort().join(',')
61
+
62
+ if (enabledServicesCompareKey !== newEnabledServicesCompareKey) {
63
+ log(
64
+ `${chalk.magenta(
65
+ 'Reloading',
66
+ )} Netlify Graph schema..., ${enabledServicesCompareKey} => ${newEnabledServicesCompareKey}`,
67
+ )
68
+ await refetchAndGenerateFromOneGraph({ netlifyGraphConfig, state, netlifyToken: innerNetlifyToken, siteId })
69
+ log(`${chalk.green('Reloaded')} Netlify Graph schema and regenerated functions`)
70
+ }
71
+ }
72
+
73
+ const close = () => {
74
+ shouldClose = true
75
+ }
76
+
77
+ let handle
78
+
79
+ const helper = async () => {
80
+ if (shouldClose) {
81
+ clearTimeout(handle)
82
+ onClose()
83
+ }
84
+
85
+ const next = await OneGraphClient.fetchCliSessionEvents({ appId, authToken: netlifyToken, sessionId })
86
+
87
+ if (next.errors) {
88
+ next.errors.forEach((fetchEventError) => {
89
+ onError(fetchEventError)
90
+ })
91
+ }
92
+
93
+ const { events } = next
94
+
95
+ if (events.length !== 0) {
96
+ const ackIds = await onEvents(events)
97
+ await OneGraphClient.ackCLISessionEvents({ appId, authToken: netlifyToken, sessionId, eventIds: ackIds })
98
+ }
99
+
100
+ await enabledServiceWatcher(netlifyToken, appId)
101
+
102
+ handle = setTimeout(helper, frequency)
103
+ }
104
+
105
+ // Fire immediately to start rather than waiting the initial `frequency`
106
+ helper()
107
+
108
+ return close
109
+ }
110
+
111
+ /**
112
+ * Fetch the schema for a site, and regenerate all of the downstream files
113
+ * @param {object} input
114
+ * @param {string} input.siteId The id of the site to query against
115
+ * @param {string} input.netlifyToken The (typically netlify) access token that is used for authentication, if any
116
+ * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events
117
+ * @param {state} input.state A function to call to set/get the current state of the local Netlify project
118
+ * @returns {Promise<void>}
119
+ */
120
+ const refetchAndGenerateFromOneGraph = async (input) => {
121
+ const { netlifyGraphConfig, netlifyToken, siteId, state } = input
122
+ await OneGraphClient.ensureAppForSite(netlifyToken, siteId)
123
+
124
+ const enabledServicesInfo = await OneGraphClient.fetchEnabledServices(netlifyToken, siteId)
125
+ if (!enabledServicesInfo) {
126
+ warn('Unable to fetch enabled services for site for code generation')
127
+ return
128
+ }
129
+
130
+ const enabledServices = enabledServicesInfo
131
+ .map((service) => service.service)
132
+ .sort((aString, bString) => aString.localeCompare(bString))
133
+ const schema = await OneGraphClient.fetchOneGraphSchema(siteId, enabledServices)
134
+
135
+ let currentOperationsDoc = readGraphQLOperationsSourceFile(netlifyGraphConfig)
136
+ if (currentOperationsDoc.trim().length === 0) {
137
+ currentOperationsDoc = defaultExampleOperationsDoc
138
+ }
139
+
140
+ const parsedDoc = parse(currentOperationsDoc)
141
+ const operations = extractFunctionsFromOperationDoc(parsedDoc)
142
+
143
+ generateFunctionsFile(netlifyGraphConfig, schema, currentOperationsDoc, operations)
144
+ writeGraphQLSchemaFile(netlifyGraphConfig, schema)
145
+ state.set('oneGraphEnabledServices', enabledServices)
146
+ }
147
+
148
+ /**
149
+ *
150
+ * @param {object} input
151
+ * @param {string} input.siteId The site id to query against
152
+ * @param {string} input.netlifyToken The (typically netlify) access token that is used for authentication, if any
153
+ * @param {string} input.docId The GraphQL operations document id to fetch
154
+ * @param {string} input.schema The GraphQL schema to use when generating code
155
+ * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events
156
+ * @returns
157
+ */
158
+ const updateGraphQLOperationsFile = async (input) => {
159
+ const { docId, netlifyGraphConfig, netlifyToken, schema, siteId } = input
160
+ const persistedDoc = await OneGraphClient.fetchPersistedQuery(netlifyToken, siteId, docId)
161
+ if (!persistedDoc) {
162
+ warn('No persisted doc found for:', docId)
163
+ return
164
+ }
165
+
166
+ const doc = persistedDoc.query
167
+
168
+ writeGraphQLOperationsSourceFile(netlifyGraphConfig, doc)
169
+ const appOperationsDoc = readGraphQLOperationsSourceFile(netlifyGraphConfig)
170
+ const parsedDoc = parse(appOperationsDoc, {
171
+ noLocation: true,
172
+ })
173
+ const operations = extractFunctionsFromOperationDoc(parsedDoc)
174
+ generateFunctionsFile(netlifyGraphConfig, schema, appOperationsDoc, operations)
175
+ }
176
+
177
+ const handleCliSessionEvent = async ({ event, netlifyGraphConfig, netlifyToken, schema, siteId }) => {
178
+ const { __typename, payload } = await event
179
+ switch (__typename) {
180
+ case 'OneGraphNetlifyCliSessionTestEvent':
181
+ await handleCliSessionEvent({ netlifyToken, event: payload, netlifyGraphConfig, schema, siteId })
182
+ break
183
+ case 'OneGraphNetlifyCliSessionGenerateHandlerEvent':
184
+ await generateHandler(netlifyGraphConfig, schema, payload.operationId, payload)
185
+ break
186
+ case 'OneGraphNetlifyCliSessionPersistedLibraryUpdatedEvent':
187
+ await updateGraphQLOperationsFile({ netlifyToken, docId: payload.docId, netlifyGraphConfig, schema, siteId })
188
+ break
189
+ default: {
190
+ warn(`Unrecognized event received, you may need to upgrade your CLI version`, __typename, payload)
191
+ break
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Load the CLI session id from the local state
198
+ * @param {state} state
199
+ * @returns
200
+ */
201
+ const loadCLISession = (state) => state.get('oneGraphSessionId')
202
+
203
+ /**
204
+ * Idemponentially save the CLI session id to the local state and start monitoring for CLI events and upstream schema changes
205
+ * @param {object} input
206
+ * @param {string} input.netlifyToken The (typically netlify) access token that is used for authentication, if any
207
+ * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events
208
+ * @param {state} input.state A function to call to set/get the current state of the local Netlify project
209
+ * @param {site} input.site The site object
210
+ */
211
+ const startOneGraphCLISession = async (input) => {
212
+ const { netlifyGraphConfig, netlifyToken, site, state } = input
213
+ OneGraphClient.ensureAppForSite(netlifyToken, site.id)
214
+ let oneGraphSessionId = loadCLISession(state)
215
+ if (!oneGraphSessionId) {
216
+ const sessionName = generateSessionName()
217
+ const sessionMetadata = {}
218
+ const oneGraphSession = await OneGraphClient.createCLISession(netlifyToken, site.id, sessionName, sessionMetadata)
219
+ state.set('oneGraphSessionId', oneGraphSession.id)
220
+ oneGraphSessionId = state.get('oneGraphSessionId')
221
+ }
222
+
223
+ const enabledServices = []
224
+ const schema = await OneGraphClient.fetchOneGraphSchema(site.id, enabledServices)
225
+
226
+ monitorCLISessionEvents({
227
+ appId: site.id,
228
+ netlifyToken,
229
+ netlifyGraphConfig,
230
+ sessionId: oneGraphSessionId,
231
+ state,
232
+ onEvents: async (events) => {
233
+ for (const event of events) {
234
+ const eventName = OneGraphClient.friendlyEventName(event)
235
+ log(`${chalk.magenta('Handling')} Netlify Graph: ${eventName}...`)
236
+ await handleCliSessionEvent({ netlifyToken, event, netlifyGraphConfig, schema, siteId: site.id })
237
+ log(`${chalk.green('Finished handling')} Netlify Graph: ${eventName}...`)
238
+ }
239
+ return events.map((event) => event.id)
240
+ },
241
+ onError: (fetchEventError) => {
242
+ error(`Netlify Graph upstream error: ${fetchEventError}`)
243
+ },
244
+ onClose: () => {
245
+ log('Netlify Graph upstream closed')
246
+ },
247
+ })
248
+ }
249
+
250
+ /**
251
+ * Generate a session name that can be identified as belonging to the current checkout
252
+ * @returns {string} The name of the session to create
253
+ */
254
+ const generateSessionName = () => {
255
+ const userInfo = os.userInfo({ encoding: 'utf-8' })
256
+ const sessionName = `${userInfo.username}-${Date.now()}`
257
+ log(`Generated Netlify Graph session name: ${sessionName}`)
258
+ return sessionName
259
+ }
260
+
261
+ const OneGraphCliClient = {
262
+ ackCLISessionEvents: OneGraphClient.ackCLISessionEvents,
263
+ createCLISession,
264
+ createPersistedQuery,
265
+ fetchCliSessionEvents: OneGraphClient.fetchCliSessionEvents,
266
+ ensureAppForSite,
267
+ updateCLISessionMetadata,
268
+ }
269
+
270
+ module.exports = {
271
+ OneGraphCliClient,
272
+ handleCliSessionEvent,
273
+ generateSessionName,
274
+ loadCLISession,
275
+ monitorCLISessionEvents,
276
+ refetchAndGenerateFromOneGraph,
277
+ startOneGraphCLISession,
278
+ }