diff --git a/src/commands/dev/dev.js b/src/commands/dev/dev.js index 7beb34bcffb..d13e5d2827f 100644 --- a/src/commands/dev/dev.js +++ b/src/commands/dev/dev.js @@ -11,8 +11,18 @@ const stripAnsiCc = require('strip-ansi-control-characters') const waitPort = require('wait-port') const { startFunctionsServer } = require('../../lib/functions/server') -const { OneGraphCliClient, startOneGraphCLISession } = require('../../lib/one-graph/cli-client') -const { getNetlifyGraphConfig } = require('../../lib/one-graph/cli-netlify-graph') +const { + OneGraphCliClient, + loadCLISession, + persistNewOperationsDocForSession, + startOneGraphCLISession, +} = require('../../lib/one-graph/cli-client') +const { + defaultExampleOperationsDoc, + getGraphEditUrlBySiteId, + getNetlifyGraphConfig, + readGraphQLOperationsSourceFile, +} = require('../../lib/one-graph/cli-netlify-graph') const { NETLIFYDEV, NETLIFYDEVERR, @@ -349,9 +359,29 @@ const dev = async (options, command) => { await OneGraphCliClient.ensureAppForSite(netlifyToken, site.id) const netlifyGraphConfig = await getNetlifyGraphConfig({ command, options, settings }) - log(`Starting Netlify Graph session, to edit your library run \`netlify graph:edit\` in another tab`) + let graphqlDocument = readGraphQLOperationsSourceFile(netlifyGraphConfig) + + if (!graphqlDocument || graphqlDocument.trim().length === 0) { + graphqlDocument = defaultExampleOperationsDoc + } + + await startOneGraphCLISession({ netlifyGraphConfig, netlifyToken, site, state }) + + // Should be created by startOneGraphCLISession + const oneGraphSessionId = loadCLISession(state) - startOneGraphCLISession({ netlifyGraphConfig, netlifyToken, site, state }) + await persistNewOperationsDocForSession({ + netlifyToken, + oneGraphSessionId, + operationsDoc: graphqlDocument, + siteId: site.id, + }) + + const graphEditUrl = getGraphEditUrlBySiteId({ siteId: site.id, oneGraphSessionId }) + + log( + `Starting Netlify Graph session, to edit your library visit ${graphEditUrl} or run \`netlify graph:edit\` in another tab`, + ) } printBanner({ url }) diff --git a/src/commands/graph/graph-edit.js b/src/commands/graph/graph-edit.js index f779eded761..5f3eb9f498b 100644 --- a/src/commands/graph/graph-edit.js +++ b/src/commands/graph/graph-edit.js @@ -3,7 +3,7 @@ const gitRepoInfo = require('git-repo-info') const { OneGraphCliClient, generateSessionName, loadCLISession } = require('../../lib/one-graph/cli-client') const { defaultExampleOperationsDoc, - getGraphEditUrlBySiteName, + getGraphEditUrlBySiteId, getNetlifyGraphConfig, readGraphQLOperationsSourceFile, } = require('../../lib/one-graph/cli-netlify-graph') @@ -19,7 +19,7 @@ const { createCLISession, createPersistedQuery, ensureAppForSite, updateCLISessi * @returns */ const graphEdit = async (options, command) => { - const { api, site, siteInfo, state } = command.netlify + const { site, state } = command.netlify const siteId = site.id if (!site.id) { @@ -60,17 +60,7 @@ const graphEdit = async (options, command) => { await updateCLISessionMetadata(netlifyToken, siteId, oneGraphSessionId, { docId: persistedDoc.id }) - let siteName = siteInfo.name - - if (!siteName) { - const siteData = await api.getSite({ siteId }) - siteName = siteData.name - if (!siteName) { - error(`No site name found for siteId ${siteId}`) - } - } - - const graphEditUrl = getGraphEditUrlBySiteName({ siteName, oneGraphSessionId }) + const graphEditUrl = getGraphEditUrlBySiteId({ siteId, oneGraphSessionId }) await openBrowser({ url: graphEditUrl }) } diff --git a/src/lib/one-graph/cli-client.js b/src/lib/one-graph/cli-client.js index c29ca62f919..feadd72081e 100644 --- a/src/lib/one-graph/cli-client.js +++ b/src/lib/one-graph/cli-client.js @@ -1,13 +1,15 @@ /* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable fp/no-loops */ +const crypto = require('crypto') const os = require('os') +const path = require('path') +const gitRepoInfo = require('git-repo-info') const { GraphQL, InternalConsole, OneGraphClient } = require('netlify-onegraph-internal') const { NetlifyGraph } = require('netlify-onegraph-internal') const { chalk, error, log, warn } = require('../../utils') - -const { createCLISession, createPersistedQuery, ensureAppForSite, updateCLISessionMetadata } = OneGraphClient +const { watchDebounced } = require('../functions/watcher') const { generateFunctionsFile, @@ -19,6 +21,7 @@ const { const { parse } = GraphQL const { defaultExampleOperationsDoc, extractFunctionsFromOperationDoc } = NetlifyGraph +const { createCLISession, createPersistedQuery, ensureAppForSite, updateCLISessionMetadata } = OneGraphClient const internalConsole = { log, @@ -27,6 +30,9 @@ const internalConsole = { debug: console.debug, } +const witnessedIncomingDocumentHashes = [] + +// Keep track of which document hashes we've received from the server so we can ignore events from the filesystem based on them InternalConsole.registerConsole(internalConsole) /** @@ -108,6 +114,26 @@ const monitorCLISessionEvents = (input) => { return close } +/** + * Monitor the operations document for changes + * @param {object} input + * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events + * @param {function} input.onAdd A callback function to handle when the operations document is added + * @param {function} input.onChange A callback function to handle when the operations document is changed + * @param {function} input.onUnlink A callback function to handle when the operations document is unlinked + * @returns {Promise} + */ +const monitorOperationFile = async ({ netlifyGraphConfig, onAdd, onChange, onUnlink }) => { + const filePath = path.resolve(...netlifyGraphConfig.graphQLOperationsSourceFilename) + const newWatcher = await watchDebounced([filePath], { + onAdd, + onChange, + onUnlink, + }) + + return newWatcher +} + /** * Fetch the schema for a site, and regenerate all of the downstream files * @param {object} input @@ -146,7 +172,44 @@ const refetchAndGenerateFromOneGraph = async (input) => { } /** - * + * Regenerate the function library based on the current operations document on disk + * @param {object} input + * @param {string} input.schema The GraphQL schema to use when generating code + * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events + * @returns + */ +const regenerateFunctionsFileFromOperationsFile = (input) => { + const { netlifyGraphConfig, schema } = input + + const appOperationsDoc = readGraphQLOperationsSourceFile(netlifyGraphConfig) + + const hash = quickHash(appOperationsDoc) + + if (witnessedIncomingDocumentHashes.includes(hash)) { + // We've already seen this document, so don't regenerate + return + } + + const parsedDoc = parse(appOperationsDoc, { + noLocation: true, + }) + const { fragments, functions } = extractFunctionsFromOperationDoc(parsedDoc) + generateFunctionsFile({ netlifyGraphConfig, schema, operationsDoc: appOperationsDoc, functions, fragments }) +} + +/** + * Compute a md5 hash of a string + * @param {string} input String to compute a quick md5 hash for + * @returns hex digest of the input string + */ +const quickHash = (input) => { + const hashSum = crypto.createHash('md5') + hashSum.update(input) + return hashSum.digest('hex') +} + +/** + * Fetch a persisted operations doc by its id, write it to the system, and regenerate the library * @param {object} input * @param {string} input.siteId The site id to query against * @param {string} input.netlifyToken The (typically netlify) access token that is used for authentication, if any @@ -155,7 +218,7 @@ const refetchAndGenerateFromOneGraph = async (input) => { * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events * @returns */ -const updateGraphQLOperationsFile = async (input) => { +const updateGraphQLOperationsFileFromPersistedDoc = async (input) => { const { docId, netlifyGraphConfig, netlifyToken, schema, siteId } = input const persistedDoc = await OneGraphClient.fetchPersistedQuery(netlifyToken, siteId, docId) if (!persistedDoc) { @@ -166,12 +229,17 @@ const updateGraphQLOperationsFile = async (input) => { const doc = persistedDoc.query writeGraphQLOperationsSourceFile(netlifyGraphConfig, doc) - const appOperationsDoc = readGraphQLOperationsSourceFile(netlifyGraphConfig) - const parsedDoc = parse(appOperationsDoc, { - noLocation: true, - }) - const { fragments, functions } = extractFunctionsFromOperationDoc(parsedDoc) - generateFunctionsFile({ netlifyGraphConfig, schema, operationsDoc: appOperationsDoc, functions, fragments }) + regenerateFunctionsFileFromOperationsFile({ netlifyGraphConfig, schema }) + + const hash = quickHash(doc) + + const relevantHasLength = 10 + + if (witnessedIncomingDocumentHashes.length > relevantHasLength) { + witnessedIncomingDocumentHashes.shift() + } + + witnessedIncomingDocumentHashes.push(hash) } const handleCliSessionEvent = async ({ event, netlifyGraphConfig, netlifyToken, schema, siteId }) => { @@ -184,7 +252,13 @@ const handleCliSessionEvent = async ({ event, netlifyGraphConfig, netlifyToken, await generateHandler(netlifyGraphConfig, schema, payload.operationId, payload) break case 'OneGraphNetlifyCliSessionPersistedLibraryUpdatedEvent': - await updateGraphQLOperationsFile({ netlifyToken, docId: payload.docId, netlifyGraphConfig, schema, siteId }) + await updateGraphQLOperationsFileFromPersistedDoc({ + netlifyToken, + docId: payload.docId, + netlifyGraphConfig, + schema, + siteId, + }) break default: { warn(`Unrecognized event received, you may need to upgrade your CLI version`, __typename, payload) @@ -193,6 +267,24 @@ const handleCliSessionEvent = async ({ event, netlifyGraphConfig, netlifyToken, } } +const persistNewOperationsDocForSession = async ({ netlifyToken, oneGraphSessionId, operationsDoc, siteId }) => { + const { branch } = gitRepoInfo() + + const payload = { + appId: siteId, + description: 'Temporary snapshot of local queries', + document: operationsDoc, + tags: ['netlify-cli', `session:${oneGraphSessionId}`, `git-branch:${branch}`, `local-change`], + } + const persistedDoc = await createPersistedQuery(netlifyToken, payload) + const newMetadata = await { docId: persistedDoc.id } + const result = await OneGraphClient.updateCLISessionMetadata(netlifyToken, siteId, oneGraphSessionId, newMetadata) + + if (result.errors) { + warn('Unable to update session metadata with updated operations doc', result.errors) + } +} + /** * Load the CLI session id from the local state * @param {state} state @@ -201,7 +293,7 @@ const handleCliSessionEvent = async ({ event, netlifyGraphConfig, netlifyToken, const loadCLISession = (state) => state.get('oneGraphSessionId') /** - * Idemponentially save the CLI session id to the local state and start monitoring for CLI events and upstream schema changes + * Idemponentially save the CLI session id to the local state and start monitoring for CLI events, upstream schema changes, and local operation file changes * @param {object} input * @param {string} input.netlifyToken The (typically netlify) access token that is used for authentication, if any * @param {NetlifyGraphConfig} input.netlifyGraphConfig A standalone config object that contains all the information necessary for Netlify Graph to process events @@ -223,6 +315,32 @@ const startOneGraphCLISession = async (input) => { const enabledServices = [] const schema = await OneGraphClient.fetchOneGraphSchema(site.id, enabledServices) + monitorOperationFile({ + netlifyGraphConfig, + onChange: async (filePath) => { + log('NetlifyGraph operation file changed at', filePath, 'updating function library...') + regenerateFunctionsFileFromOperationsFile({ netlifyGraphConfig, schema }) + const newOperationsDoc = readGraphQLOperationsSourceFile(netlifyGraphConfig) + await persistNewOperationsDocForSession({ + netlifyToken, + oneGraphSessionId, + operationsDoc: newOperationsDoc, + siteId: site.id, + }) + }, + onAdd: async (filePath) => { + log('NetlifyGraph operation file created at', filePath, 'creating function library...') + regenerateFunctionsFileFromOperationsFile({ netlifyGraphConfig, schema }) + const newOperationsDoc = readGraphQLOperationsSourceFile(netlifyGraphConfig) + await persistNewOperationsDocForSession({ + netlifyToken, + oneGraphSessionId, + operationsDoc: newOperationsDoc, + siteId: site.id, + }) + }, + }) + monitorCLISessionEvents({ appId: site.id, netlifyToken, @@ -273,6 +391,7 @@ module.exports = { generateSessionName, loadCLISession, monitorCLISessionEvents, + persistNewOperationsDocForSession, refetchAndGenerateFromOneGraph, startOneGraphCLISession, } diff --git a/src/lib/one-graph/cli-netlify-graph.js b/src/lib/one-graph/cli-netlify-graph.js index ae411a90480..960b566c956 100644 --- a/src/lib/one-graph/cli-netlify-graph.js +++ b/src/lib/one-graph/cli-netlify-graph.js @@ -4,7 +4,7 @@ const process = require('process') const { GraphQL, InternalConsole, NetlifyGraph } = require('netlify-onegraph-internal') -const { detectServerSettings, error, getFunctionsDir, log, warn } = require('../../utils') +const { detectServerSettings, error, execa, getFunctionsDir, log, warn } = require('../../utils') const { printSchema } = GraphQL @@ -241,6 +241,31 @@ const ensureFunctionsPath = (netlifyGraphConfig) => { fs.mkdirSync(fullPath, { recursive: true }) } +let disablePrettierDueToPreviousError = false + +const runPrettier = async (filePath) => { + if (disablePrettierDueToPreviousError) { + return + } + + const command = `prettier --write ${filePath}` + try { + const commandProcess = execa.command(command, { + preferLocal: true, + // windowsHide needs to be false for child process to terminate properly on Windows + windowsHide: false, + }) + + await commandProcess + } catch (prettierError) { + if (!disablePrettierDueToPreviousError) { + disablePrettierDueToPreviousError = true + warn(prettierError) + warn("Error while running prettier, make sure you have installed it globally with 'npm i -g prettier'") + } + } +} + /** * Generate a library file with type definitions for a given NetlifyGraphConfig, operationsDoc, and schema, writing them to the filesystem * @param {object} context @@ -267,6 +292,8 @@ const generateFunctionsFile = ({ fragments, functions, netlifyGraphConfig, opera typeDefinitionsSource, 'utf8', ) + runPrettier(path.resolve(...netlifyGraphConfig.netlifyGraphImplementationFilename)) + runPrettier(path.resolve(...netlifyGraphConfig.netlifyGraphTypeDefinitionsFilename)) } /** @@ -384,6 +411,7 @@ const generateHandler = (netlifyGraphConfig, schema, operationId, handlerOptions const absoluteFilename = path.resolve(...filenameArr) fs.writeFileSync(absoluteFilename, content) + runPrettier(absoluteFilename) }) } @@ -405,6 +433,21 @@ const getGraphEditUrlBySiteName = ({ oneGraphSessionId, siteName }) => { return url } +/** + * Get a url to the Netlify Graph UI for the current session by a site's id + * @param {object} options + * @param {string} options.siteId The name of the site as used in the Netlify UI url scheme + * @param {string} options.oneGraphSessionId The oneGraph session id to use when generating the graph-edit link + * @returns {string} The url to the Netlify Graph UI for the current session + */ +const getGraphEditUrlBySiteId = ({ oneGraphSessionId, siteId }) => { + const host = process.env.NETLIFY_APP_HOST || 'app.netlify.com' + // http because app.netlify.com will redirect to https, and localhost will still work for development + const url = `http://${host}/site-redirect/${siteId}/graph/explorer?cliSessionId=${oneGraphSessionId}` + + return url +} + module.exports = { buildSchema, defaultExampleOperationsDoc: NetlifyGraph.defaultExampleOperationsDoc, @@ -413,6 +456,7 @@ module.exports = { generateFunctionsFile, generateHandlerSource: NetlifyGraph.generateHandlerSource, generateHandler, + getGraphEditUrlBySiteId, getGraphEditUrlBySiteName, getNetlifyGraphConfig, parse,