From 74f1ee5a3864ad0ba8c5fed9814ce03e1d9f9fd5 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 8 Oct 2026 14:02:45 -0400 Subject: [PATCH] refactor(deploy): fix types in deploy command and utils Replace the `$TSFixMe`/`any` parameters and `@ts-expect-error` markers in the deploy command and `src/utils/deploy/` with real types: Remaining gaps in upstream typings are marked with specific FIXME casts or suppressions. Runtime behaviour is unchanged. --- eslint-suppressions.json | 95 +--------- src/commands/deploy/deploy.ts | 173 +++++++++++-------- src/lib/spinner.ts | 10 +- src/utils/deploy/deploy-site.ts | 114 ++++++------ src/utils/deploy/drop-api.ts | 36 ++-- src/utils/deploy/file.ts | 7 +- src/utils/deploy/hash-config.ts | 12 +- src/utils/deploy/hash-files.ts | 21 +-- src/utils/deploy/hash-fns.ts | 61 ++++--- src/utils/deploy/hasher-segments.ts | 25 ++- src/utils/deploy/process-files.ts | 1 + src/utils/deploy/types.ts | 14 ++ src/utils/deploy/upload-files.ts | 24 ++- src/utils/deploy/upload-source-zip.ts | 12 +- src/utils/deploy/util.ts | 54 +++--- tests/unit/utils/deploy/hash-files.test.ts | 1 - tests/unit/utils/deploy/hash-fns.test.ts | 3 +- tests/unit/utils/deploy/upload-files.test.ts | 2 +- 18 files changed, 335 insertions(+), 330 deletions(-) create mode 100644 src/utils/deploy/types.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 753b3245b9c..a6cf561216c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -106,9 +106,6 @@ } }, "src/commands/deploy/deploy.ts": { - "@typescript-eslint/ban-ts-comment": { - "count": 2 - }, "@typescript-eslint/no-dynamic-delete": { "count": 2 }, @@ -116,28 +113,13 @@ "count": 7 }, "@typescript-eslint/no-unnecessary-condition": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-argument": { - "count": 13 - }, - "@typescript-eslint/no-unsafe-assignment": { - "count": 63 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 56 - }, - "@typescript-eslint/no-unsafe-return": { - "count": 1 + "count": 4 }, "@typescript-eslint/prefer-nullish-coalescing": { - "count": 13 + "count": 9 }, "@typescript-eslint/restrict-template-expressions": { - "count": 15 + "count": 9 } }, "src/commands/dev/dev.ts": { @@ -737,11 +719,6 @@ "count": 2 } }, - "src/lib/spinner.ts": { - "@typescript-eslint/no-unnecessary-condition": { - "count": 1 - } - }, "src/recipes/ai-context/context.ts": { "no-empty": { "count": 1 @@ -847,21 +824,9 @@ "@typescript-eslint/await-thenable": { "count": 1 }, - "@typescript-eslint/no-confusing-void-expression": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-argument": { - "count": 6 - }, "@typescript-eslint/no-unsafe-assignment": { - "count": 28 - }, - "@typescript-eslint/no-unsafe-call": { "count": 1 }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 11 - }, "@typescript-eslint/restrict-template-expressions": { "count": 1 } @@ -876,32 +841,10 @@ "count": 1 } }, - "src/utils/deploy/hash-config.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "src/utils/deploy/hash-files.ts": { - "@typescript-eslint/no-unsafe-argument": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-assignment": { - "count": 4 - } - }, "src/utils/deploy/hash-fns.ts": { "@typescript-eslint/no-unnecessary-condition": { "count": 20 }, - "@typescript-eslint/no-unsafe-argument": { - "count": 3 - }, - "@typescript-eslint/no-unsafe-assignment": { - "count": 10 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 4 - }, "@typescript-eslint/prefer-nullish-coalescing": { "count": 9 } @@ -909,24 +852,9 @@ "src/utils/deploy/hasher-segments.ts": { "@typescript-eslint/no-misused-promises": { "count": 1 - }, - "@typescript-eslint/no-unsafe-argument": { - "count": 5 - }, - "@typescript-eslint/no-unsafe-assignment": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 13 - }, - "@typescript-eslint/restrict-template-expressions": { - "count": 1 } }, "src/utils/deploy/upload-files.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 1 - }, "@typescript-eslint/prefer-promise-reject-errors": { "count": 3 }, @@ -940,26 +868,11 @@ } }, "src/utils/deploy/util.ts": { - "@typescript-eslint/no-unsafe-argument": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-assignment": { - "count": 12 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 3 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 9 - }, - "@typescript-eslint/no-unsafe-return": { - "count": 2 - }, "@typescript-eslint/prefer-nullish-coalescing": { "count": 2 }, "@typescript-eslint/restrict-template-expressions": { - "count": 3 + "count": 1 } }, "src/utils/detect-server-settings.ts": { diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index ee95378bad1..6558e96d908 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'crypto' -import { type Stats } from 'fs' +import type { Stats } from 'fs' import { stat } from 'fs/promises' import { basename, resolve } from 'path' import { stdin, stdout } from 'process' @@ -21,7 +21,7 @@ import { } from '../../lib/build.js' import { getBootstrapURL } from '../../lib/edge-functions/bootstrap.js' import { featureFlags as edgeFunctionsFeatureFlags } from '../../lib/edge-functions/consts.js' -import { normalizeFunctionsConfig } from '../../lib/functions/config.js' +import { type NormalizedFunctionsConfig, normalizeFunctionsConfig } from '../../lib/functions/config.js' import { BACKGROUND_FUNCTIONS_WARNING } from '../../lib/log.js' import { type Spinner, startSpinner, stopSpinner } from '../../lib/spinner.js' import { detectFrameworkSettings, getDefaultConfig } from '../../utils/build-info.js' @@ -56,25 +56,38 @@ import { isInteractive } from '../../utils/scripted-commands.js' import { resolveTeamForNonInteractive } from '../../utils/team.js' import { type DropApiError, - type UploadListItem, getDropToken, createDropDeploy, uploadDropFiles, waitForDropDeploy, } from '../../utils/deploy/drop-api.js' +import type { DeployConfig } from '../../utils/deploy/types.js' import { getUploadList } from '../../utils/deploy/util.js' import hashFiles from '../../utils/deploy/hash-files.js' import { deployFileNormalizer, getEdgeFunctionsDistPathIfExists } from '../../utils/deploy/process-files.js' import type BaseCommand from '../base-command.js' import { link } from '../link/link.js' import { sitesCreate } from '../sites/sites-create.js' -import type { $TSFixMe } from '../types.js' -import { SiteInfo } from '../../utils/types.js' +import type { NetlifyOptions, NetlifySite } from '../types.js' +import type { SiteInfo } from '../../utils/types.js' import type { DeployOptionValues } from './option_values.js' import boxen from 'boxen' import terminalLink from 'terminal-link' import { anyEdgeFunctionsDirectoryExists } from '../../lib/edge-functions/get-directories.js' +// FIXME: `site.root` is typed as optional, but it is always set to the build directory by the time a deploy runs +type DeploySite = NetlifySite & { root: string } + +// FIXME(@netlify/api): `SiteInfo['build_settings']` is missing `functions_dir` +type DeploySiteData = { build_settings?: SiteInfo['build_settings'] & { functions_dir?: string } } | undefined + +// FIXME(@netlify/api): the `createSiteDeploy` types omit the source zip fields and make `id` optional +type CreatedDeploy = Omit>, 'id'> & { + id: string + source_zip_upload_url?: string + source_zip_filename?: string +} + const triggerDeploy = async ({ api, options, @@ -120,10 +133,10 @@ const getDeployFolder = async ({ siteData, }: { command: BaseCommand - config: $TSFixMe + config: DeployConfig options: DeployOptionValues - site: $TSFixMe - siteData: $TSFixMe + site: DeploySite + siteData: DeploySiteData }): Promise => { let deployFolder: string | undefined // if the `--dir .` flag is provided we should resolve it to the working directory. @@ -199,10 +212,10 @@ const getFunctionsFolder = ({ siteData, workingDir, }: { - config: $TSFixMe + config: DeployConfig options: DeployOptionValues - site: $TSFixMe - siteData: $TSFixMe + site: DeploySite + siteData: DeploySiteData /** The process working directory where the build command is executed */ workingDir: string }): string | undefined => { @@ -260,20 +273,13 @@ const validateFolders = async ({ return { deployFolderStat, functionsFolderStat } } -/** - * @param {object} config - * @param {string} config.deployFolder - * @param {*} config.site - * @returns - */ -// @ts-expect-error TS(7031) FIXME: Binding element 'deployFolder' implicitly has an '... Remove this comment to see the full error message -const getDeployFilesFilter = ({ deployFolder, site }) => { +const getDeployFilesFilter = ({ deployFolder, site }: { deployFolder: string; site: DeploySite }) => { // site.root === deployFolder can happen when users run `netlify deploy --dir .` // in that specific case we don't want to publish the repo node_modules // when site.root !== deployFolder the behaviour matches our buildbot const skipNodeModules = site.root === deployFolder - return (filename: string) => { + return (filename: string | null | undefined) => { if (filename == null) { return false } @@ -369,10 +375,20 @@ const generateDeployCommand = ( return parts.join(' ') } -// @ts-expect-error TS(7031) FIXME: Binding element 'api' implicitly has an 'any' type... Remove this comment to see the full error message -const prepareProductionDeploy = async ({ api, siteData, options, command }) => { +const prepareProductionDeploy = async ({ + api, + siteData, + options, + command, +}: { + api: NetlifyAPI + siteData: SiteInfo + options: DeployOptionValues + command: BaseCommand +}) => { if ( typeof siteData.published_deploy === 'object' && + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- FIXME: a non-object `published_deploy` must not count as locked siteData.published_deploy !== null && siteData.published_deploy.locked ) { @@ -446,7 +462,7 @@ const reportDeployError = ({ } const deployProgressCb = function () { - const spinnersByType: Record = {} + const spinnersByType: Partial> = {} return (event: DeployEvent) => { switch (event.phase) { case 'start': { @@ -468,7 +484,8 @@ const deployProgressCb = function () { return case 'stop': default: { - spinnersByType[event.type].success(event.msg) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- FIXME: a `stop` event is assumed to follow a `start` event of the same type + spinnersByType[event.type]!.success(event.msg) delete spinnersByType[event.type] } } @@ -506,7 +523,7 @@ const uploadDeployBlobs = async ({ // We log our own progress so we don't want this as well. Plus, this logs much of the same // information as the build that (likely) came before this as part of the deploy build. quiet: options.debug ?? true, - // @ts-expect-error(serhalp) -- Untyped in `@netlify/build` + // @ts-expect-error FIXME(@netlify/build): the `cachedConfig` flag is typed `Record`, rejecting `CachedConfig` cachedConfig, packagePath, deployId, @@ -532,43 +549,43 @@ const uploadDeployBlobs = async ({ } const runDeploy = async ({ - // @ts-expect-error TS(7031) FIXME: Binding element 'alias' implicitly has an 'any' ty... Remove this comment to see the full error message alias, - // @ts-expect-error TS(7031) FIXME: Binding element 'api' implicitly has an 'any' type... Remove this comment to see the full error message api, command, - // @ts-expect-error TS(7031) FIXME: Binding element 'config' implicitly has an 'any' t... Remove this comment to see the full error message config, - // @ts-expect-error TS(7031) FIXME: Binding element 'deployFolder' implicitly has an '... Remove this comment to see the full error message deployFolder, - // @ts-expect-error TS(7031) FIXME: Binding element 'deployTimeout' implicitly has an ... Remove this comment to see the full error message deployTimeout, - // @ts-expect-error TS(7031) FIXME: Binding element 'deployToProduction' implicitly ha... Remove this comment to see the full error message deployToProduction, - // @ts-expect-error TS(7031) FIXME: Binding element 'functionsConfig' implicitly has a... Remove this comment to see the full error message functionsConfig, functionsFolder, - // @ts-expect-error TS(7031) FIXME: Binding element 'options' implicitly has an 'a... Remove this comment to see the full error message options, - // @ts-expect-error TS(7031) FIXME: Binding element 'packagePath' implicitly has an 'a... Remove this comment to see the full error message packagePath, - // @ts-expect-error TS(7031) FIXME: Binding element 'silent' implicitly has an 'any' t... Remove this comment to see the full error message silent, - // @ts-expect-error TS(7031) FIXME: Binding element 'site' implicitly has an 'any' typ... Remove this comment to see the full error message site, - // @ts-expect-error TS(7031) FIXME: Binding element 'siteData' implicitly has an 'any'... Remove this comment to see the full error message siteData, - // @ts-expect-error TS(7031) FIXME: Binding element 'siteId' implicitly has an 'any' t... Remove this comment to see the full error message siteId, - // @ts-expect-error TS(7031) FIXME: Binding element 'skipFunctionsCache' implicitly ha... Remove this comment to see the full error message skipFunctionsCache, - // @ts-expect-error TS(7031) FIXME: Binding element 'title' implicitly has an 'any' ty... Remove this comment to see the full error message title, deployId: existingDeployId, }: { - functionsFolder?: string + alias: string | undefined + api: NetlifyAPI command: BaseCommand - deployId?: string + config: DeployConfig + deployFolder: string + deployId?: string | undefined + deployTimeout: number + deployToProduction: boolean + functionsConfig: NormalizedFunctionsConfig + functionsFolder?: string | undefined + options: DeployOptionValues + packagePath: string | undefined + silent: boolean + site: DeploySite + siteData: SiteInfo + siteId: string + skipFunctionsCache: boolean + title: string | undefined }): Promise<{ siteId: string siteName: string @@ -600,8 +617,12 @@ const runDeploy = async ({ ...getDeploySourceFields(), } - const createDeployResponse = await api.createSiteDeploy({ siteId, title, body: createDeployBody }) - deployId = createDeployResponse.id as string + const createDeployResponse = (await api.createSiteDeploy({ + siteId, + title, + body: createDeployBody, + })) as CreatedDeploy + deployId = createDeployResponse.id if ( options.uploadSourceZip && @@ -629,13 +650,16 @@ const runDeploy = async ({ command.netlify.frameworksAPIPaths.functions.path, functionsFolder, ].filter((folder): folder is string => Boolean(folder)) - const manifestPath = skipFunctionsCache ? null : await getFunctionsManifestPath({ base: site.root, packagePath }) + const manifestPath = skipFunctionsCache + ? undefined + : await getFunctionsManifestPath({ base: site.root, packagePath }) const serverManifestPath = await getServerManifestPath({ base: site.root, packagePath }) const redirectsPath = `${deployFolder}/_redirects` const headersPath = `${deployFolder}/_headers` const { redirects } = await parseAllRedirects({ + // @ts-expect-error FIXME(@netlify/redirect-parser): `configRedirects` is typed `string[]` but takes redirect objects configRedirects: config.redirects, redirectsFiles: [redirectsPath], minimal: true, @@ -644,6 +668,7 @@ const runDeploy = async ({ config.redirects = redirects const { headers } = await parseAllHeaders({ + // @ts-expect-error FIXME(@netlify/headers-parser): `MinimalHeader` rejects the array values `NetlifyConfig['headers']` allows configHeaders: config.headers, headersFiles: [headersPath], minimal: true, @@ -660,7 +685,6 @@ const runDeploy = async ({ }) results = await deploySite(command, api, siteId, deployFolder, { - // @ts-expect-error FIXME config, fnDir: functionDirectories, functionsConfig, @@ -672,7 +696,7 @@ const runDeploy = async ({ deployId, filter: getDeployFilesFilter({ site, deployFolder }), workingDir: command.workingDir, - manifestPath, + manifestPath: manifestPath ?? undefined, packagePath, serverEnabled: Boolean(siteData?.feature_flags?.netlify_build_server_standalone), serverManifestPath: serverManifestPath ?? undefined, @@ -801,10 +825,8 @@ const bundleEdgeFunctions = async (options: DeployOptionValues, command: BaseCom // We log our own progress so we don't want this as well. Plus, this logs much of the same // information as the build that (likely) came before this as part of the deploy build. quiet: options.debug ?? true, - // (cachedConfig type error hides this one, but it still is valid) @ts-expect-error FIXME(serhalp): This is missing from the `runCoreSteps` type in @netlify/build edgeFunctionsBootstrapURL: await getBootstrapURL(), - // @ts-expect-error 'CachedConfig' is not assignable to type 'Record'. - // Index signature for type 'string' is missing in type 'CachedConfig'. + // @ts-expect-error FIXME(@netlify/build): `cachedConfig` is typed `Record` and `edgeFunctionsBootstrapURL` is missing from the flags cachedConfig: command.netlify.cachedConfig, }) @@ -941,12 +963,16 @@ const prepAndRunDeploy = async ({ workingDir, deployId, }: { - options: DeployOptionValues + api: NetlifyAPI command: BaseCommand + config: DeployConfig + deployToProduction: boolean + options: DeployOptionValues + site: DeploySite + siteData: SiteInfo + siteId: string workingDir: string deployId?: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FIXME(serhalp) - [key: string]: any }) => { const alias = options.alias || options.branch // if a context is passed besides dev, we need to pull env vars from that specific context @@ -1006,7 +1032,6 @@ const prepAndRunDeploy = async ({ }) const results = await runDeploy({ - // @ts-expect-error FIXME alias, api, command, @@ -1019,7 +1044,7 @@ const prepAndRunDeploy = async ({ functionsFolder: functionsFolderStat && functionsFolder, options, packagePath: command.workspacePackage, - silent: options.json || options.silent, + silent: options.json || Boolean(options.silent), site, siteData, siteId, @@ -1031,7 +1056,7 @@ const prepAndRunDeploy = async ({ return results } -const createSiteWithFlags = async (options: DeployOptionValues, command: BaseCommand, site: $TSFixMe) => { +const createSiteWithFlags = async (options: DeployOptionValues, command: BaseCommand, site: NetlifySite) => { const { accounts } = command.netlify const siteName = typeof options.createSite === 'string' ? options.createSite : undefined @@ -1060,24 +1085,26 @@ const createSiteWithFlags = async (options: DeployOptionValues, command: BaseCom } try { - const siteData = await api.createSiteInTeam({ + // FIXME(@netlify/api): the `site` response type makes every field optional, unlike `SiteInfo` + const siteData = (await api.createSiteInTeam({ accountSlug: options.team, body, - }) + })) as SiteInfo site.id = siteData.id - return siteData as SiteInfo + return siteData } catch (error_) { if ((error_ as APIError).status === 422 && siteName) { const suffix = randomBytes(4).toString('hex') const suffixedName = `${siteName.trim()}-${suffix}` log(`Site name "${siteName}" is taken. Retrying with "${suffixedName}"...`) try { - const siteData = await api.createSiteInTeam({ + // FIXME(@netlify/api): the `site` response type makes every field optional, unlike `SiteInfo` + const siteData = (await api.createSiteInTeam({ accountSlug: options.team, body: { name: suffixedName }, - }) + })) as SiteInfo site.id = siteData.id - return siteData as SiteInfo + return siteData } catch (retryError) { return logAndThrowError( `Failed to create site "${suffixedName}": ${(retryError as APIError).status}: ${ @@ -1093,7 +1120,7 @@ const createSiteWithFlags = async (options: DeployOptionValues, command: BaseCom } } -const promptForSiteAction = async (options: DeployOptionValues, command: BaseCommand, site: $TSFixMe) => { +const promptForSiteAction = async (options: DeployOptionValues, command: BaseCommand, site: NetlifySite) => { log("This folder isn't linked to a project yet") const { accounts } = command.netlify @@ -1128,7 +1155,7 @@ const promptForSiteAction = async (options: DeployOptionValues, command: BaseCom const ensureSiteExists = async ( options: DeployOptionValues, command: BaseCommand, - site: $TSFixMe, + site: NetlifySite, siteInfo: SiteInfo, ): Promise => { const hasSiteData = (site.id || options.site) && !isEmpty(siteInfo) @@ -1155,7 +1182,7 @@ const ensureSiteExists = async ( const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand) => { const { workingDir } = command - const { site, config } = command.netlify + const { site, config } = command.netlify as NetlifyOptions & { site: DeploySite } const dirHasFiles = async (dir: string | undefined): Promise => { if (!dir) return false @@ -1223,7 +1250,7 @@ const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand const filter = getDeployFilesFilter({ site, deployFolder }) const { files, filesShaMap } = await hashFiles({ concurrentHash: DEFAULT_CONCURRENT_HASH, - directories: [deployFolder, edgeFunctionsDistPath].filter(Boolean), + directories: [deployFolder, edgeFunctionsDistPath].filter((dir): dir is string => Boolean(dir)), filter, normalizer: deployFileNormalizer.bind(null, workingDir), statusCb: options.json ? () => {} : deployProgressCb(), @@ -1263,7 +1290,7 @@ const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand throw error } - const uploadList = getUploadList(deployInfo.required, filesShaMap) as UploadListItem[] + const uploadList = getUploadList(deployInfo.required, filesShaMap) if (uploadList.length > 0) { await uploadDropFiles(dropApiOptions, deployInfo.deploy_id, uploadList, dropToken, { @@ -1280,7 +1307,7 @@ const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand site.id = deployInfo.id - const siteUrl = (deploy.ssl_url || deploy.url || `https://${deployInfo.subdomain}.netlify.app`) as string + const siteUrl = deploy.ssl_url || deploy.url || `https://${deployInfo.subdomain}.netlify.app` const isPasswordProtected = !options.createdVia || options.createdVia === 'drop' const claimUrl = `https://app.netlify.com/drop/${deployInfo.subdomain}#drop_token=${dropToken}` @@ -1393,15 +1420,11 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) ...getDeploySourceFields(), } - // TODO: Type this properly in `@netlify/api`. const deployMetadata = (await api.createSiteDeploy({ siteId, title: options.message, body: createDeployBody, - })) as Awaited> & { - source_zip_upload_url?: string - source_zip_filename?: string - } + })) as CreatedDeploy const deployId = deployMetadata.id || '' const skewProtectionToken = deployMetadata.skew_protection_token let sourceZipFileName: string | undefined @@ -1434,7 +1457,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) options, workingDir, api, - site, + site: site as DeploySite, config: netlifyConfig, siteData, siteId, @@ -1464,7 +1487,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) options, workingDir, api, - site, + site: site as DeploySite, config: command.netlify.config, siteData, siteId, diff --git a/src/lib/spinner.ts b/src/lib/spinner.ts index 0866c8b6e02..b753d2d6ce6 100644 --- a/src/lib/spinner.ts +++ b/src/lib/spinner.ts @@ -39,7 +39,15 @@ export const startSpinner = ({ text }: { text: string }): Spinner => { /** * Stops the spinner with the following text */ -export const stopSpinner = ({ error, spinner, text }: { error?: boolean; spinner: Spinner; text?: string }) => { +export const stopSpinner = ({ + error, + spinner, + text, +}: { + error?: boolean + spinner: Spinner | undefined + text?: string +}) => { if (!spinner) { return } diff --git a/src/utils/deploy/deploy-site.ts b/src/utils/deploy/deploy-site.ts index 4e34d89fdd9..dc3ec2c1a45 100644 --- a/src/utils/deploy/deploy-site.ts +++ b/src/utils/deploy/deploy-site.ts @@ -1,10 +1,11 @@ import { rm } from 'fs/promises' +import type { NetlifyAPI } from '@netlify/api' import { getVersion as getNetlifyBuildVersion } from '@netlify/build' +import type { Config as FunctionsConfig } from '@netlify/zip-it-and-ship-it' import cleanDeep from 'clean-deep' -import BaseCommand from '../../commands/base-command.js' -import { type $TSFixMe } from '../../commands/types.js' +import type BaseCommand from '../../commands/base-command.js' import { warn } from '../command-helpers.js' import { @@ -26,13 +27,18 @@ import { isEdgeFunctionFile, } from './process-files.js' import uploadFiles from './upload-files.js' -import { getUploadList, pluralize, waitForDeploy, waitForDiff } from './util.js' -import type { DeployEvent } from './status-cb.js' +import { type Deploy, getUploadList, pluralize, waitForDeploy, waitForDiff } from './util.js' +import type { File } from './file.js' +import type { DeployEvent, StatusCallback } from './status-cb.js' +import type { DeployConfig } from './types.js' import type { DeployEnvironmentVariable } from '../env/deploy-env-vars.js' import { temporaryDirectory } from '../temporary-file.js' export type { DeployEvent } +// FIXME(@netlify/api): every `deploy` field is optional, even those always set once a deploy is diffed +type DiffedDeploy = Deploy & Required> + const buildStatsString = (possibleParts: (string | false | undefined)[]) => { const parts = possibleParts.filter(Boolean) @@ -43,43 +49,59 @@ const buildStatsString = (possibleParts: (string | false | undefined)[]) => { return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` } +export interface DeploySiteOptions { + assetType?: 'file' | undefined + branch?: string + concurrentHash?: number + concurrentUpload?: number + /** The site configuration, uploaded as the deploy's `netlify.toml` */ + config: DeployConfig + deployId: string + deployTimeout?: number + draft?: boolean + environment?: DeployEnvironmentVariable[] + filter: (filename: string) => boolean + fnDir?: string[] + functionsConfig?: FunctionsConfig | undefined + hashAlgorithm?: string + manifestPath?: string | undefined + maxRetry?: number + packagePath?: string | undefined + serverEnabled?: boolean + serverManifestPath?: string | undefined + siteRoot?: string | undefined + skipFunctionsCache?: boolean | undefined + statusCb?: StatusCallback + syncFileLimit?: number + tmpDir?: string + workingDir: string +} + export const deploySite = async ( command: BaseCommand, - api: $TSFixMe, - // @ts-expect-error TS(7006) FIXME: Parameter 'siteId' implicitly has an 'any' type. - siteId, - // @ts-expect-error TS(7006) FIXME: Parameter 'dir' implicitly has an 'any' type. - dir, + api: NetlifyAPI, + siteId: string, + dir: string, { - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message assetType, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message branch, concurrentHash = DEFAULT_CONCURRENT_HASH, concurrentUpload = DEFAULT_CONCURRENT_UPLOAD, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message config, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message deployId, deployTimeout = DEFAULT_DEPLOY_TIMEOUT, draft = false, environment, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message filter, fnDir = [], - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message functionsConfig, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message hashAlgorithm, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message manifestPath, maxRetry = DEFAULT_MAX_RETRY, packagePath, serverEnabled, serverManifestPath, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message siteRoot, - // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message skipFunctionsCache, statusCb = () => { /* default to noop */ @@ -87,22 +109,7 @@ export const deploySite = async ( syncFileLimit = DEFAULT_SYNC_LIMIT, tmpDir = temporaryDirectory(), workingDir, - }: { - concurrentHash?: number - concurrentUpload?: number - deployTimeout?: number - draft?: boolean - environment?: DeployEnvironmentVariable[] - maxRetry?: number - packagePath?: string - serverEnabled?: boolean - serverManifestPath?: string - statusCb?: (status: DeployEvent) => void - syncFileLimit?: number - tmpDir?: string - fnDir?: string[] - workingDir: string - }, + }: DeploySiteOptions, ) => { statusCb({ type: 'hashing', @@ -122,7 +129,9 @@ export const deploySite = async ( hashFiles({ assetType, concurrentHash, - directories: [dir, edgeFunctionsDistPath, deployConfigPath, dbMigrationsDistPath].filter(Boolean), + directories: [dir, edgeFunctionsDistPath, deployConfigPath, dbMigrationsDistPath].filter((d): d is string => + Boolean(d), + ), filter, hashAlgorithm, normalizer: deployFileNormalizer.bind(null, workingDir), @@ -146,7 +155,7 @@ export const deploySite = async ( ]) const files = { ...staticFiles, [configFile.normalizedPath]: configFile.hash } - const filesShaMap = { ...staticShaMap, [configFile.hash]: [configFile] } + const filesShaMap: Record = { ...staticShaMap, [configFile.hash]: [configFile] } const edgeFunctionsCount = Object.keys(files).filter(isEdgeFunctionFile).length const filesCount = Object.keys(files).length - edgeFunctionsCount @@ -169,9 +178,7 @@ export const deploySite = async ( } if (functionsWithNativeModules.length !== 0) { - const functionsWithNativeModulesMessage = functionsWithNativeModules - .map(({ name }: { name: string }) => `- ${name}`) - .join('\n') + const functionsWithNativeModulesMessage = functionsWithNativeModules.map(({ name }) => `- ${name}`).join('\n') warn(`Modules with native dependencies\n ${functionsWithNativeModulesMessage} @@ -193,8 +200,7 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) const packageFrameworks = command.project.frameworks.get(command.workspacePackage ?? '') const primaryFramework = packageFrameworks?.[0] - // @ts-expect-error TS(2349) This expression is not callable - const cleanedParams = cleanDeep({ + const params = { siteId, deploy_id: deployId, body: { @@ -211,16 +217,23 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) framework_version: primaryFramework?.detected.package?.version?.toString() ?? 'unknown', build_version: getNetlifyBuildVersion(), }, - }) + } + const cleanedParams: Partial> & { body: Partial } = + // @ts-expect-error FIXME(clean-deep): typings declare an ES `export default` but the package is CommonJS + cleanDeep(params) // cleanDeep deeply strips keys with empty strings, but empty strings are valid environment // variable values--a user can use an empty string to e.g. unset a variable only for a deploy. // This would result in payloads with a missing `value` key, which the API would reject. const deployParams = environment?.length ? { ...cleanedParams, body: { ...cleanedParams.body, environment } } : cleanedParams - let deploy = await api.updateSiteDeploy(deployParams) + // FIXME(@netlify/api): `id` and `required` are optional on the updated deploy + let deploy = (await api.updateSiteDeploy( + // @ts-expect-error FIXME(@netlify/api): `functions_config` rejects zip-it-and-ship-it's `BuildData`, route `methods` and `traffic_rules` strings + deployParams, + )) as DiffedDeploy - if (deployParams.body.async) deploy = await waitForDiff(api, deploy.id, siteId, deployTimeout) + if (deployParams.body.async) deploy = (await waitForDiff(api, deploy.id, siteId, deployTimeout)) as DiffedDeploy const { required: requiredFiles, @@ -231,8 +244,8 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) const newStats = buildStatsString([ requiredFiles.length > 0 && pluralize(requiredFiles.length, 'file'), - (requiredFns?.length ?? 0) > 0 && pluralize(requiredFns.length, 'function'), - (requiredEdgeFns?.length ?? 0) > 0 && pluralize(requiredEdgeFns.length, 'edge function'), + requiredFns != null && requiredFns.length > 0 && pluralize(requiredFns.length, 'function'), + requiredEdgeFns != null && requiredEdgeFns.length > 0 && pluralize(requiredEdgeFns.length, 'edge function'), (requiredServer?.length ?? 0) > 0 && 'a server', ]) @@ -255,7 +268,7 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) msg: 'Waiting for deploy to go live...', phase: 'start', }) - deploy = await waitForDeploy(api, deployId, siteId, deployTimeout) + const readyDeploy = await waitForDeploy(api, deployId, siteId, deployTimeout) statusCb({ type: 'wait-for-deploy', @@ -265,10 +278,9 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) await rm(tmpDir, { force: true, recursive: true }) - const deployManifest = { + return { deployId, - deploy, + deploy: readyDeploy, uploadList, } - return deployManifest } diff --git a/src/utils/deploy/drop-api.ts b/src/utils/deploy/drop-api.ts index 1a8ae9dc8e6..097304c3e31 100644 --- a/src/utils/deploy/drop-api.ts +++ b/src/utils/deploy/drop-api.ts @@ -79,10 +79,6 @@ export const createDropDeploy = async ( return (await response.json()) as DropDeployInfo } -interface UploadError extends Error { - status?: number -} - // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const uploadDropFile = async ( { apiBase, userAgent }: DropApiOptions, @@ -91,22 +87,20 @@ export const uploadDropFile = async ( body: fs.ReadStream | Buffer, token: string, ): Promise => { - // Node.js fetch needs `duplex: 'half'` for streaming bodies which isn't in standard RequestInit - /* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}` - const response: Response = await fetch(`${apiBase}/deploys/${deployId}/files${encodeURI(normalizedFilePath)}`, { + const response = await fetch(`${apiBase}/deploys/${deployId}/files${encodeURI(normalizedFilePath)}`, { method: 'PUT', headers: makeHeaders(userAgent, { 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}`, }), - body: body as any, + body, + // Node.js fetch requires this for streaming bodies duplex: 'half', - } as any) - /* eslint-enable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ + }) if (!response.ok) { - const error: UploadError = new Error( + const error: DropApiError = new Error( `Failed to upload file ${filePath}: ${String(response.status)} ${response.statusText}`, ) error.status = response.status @@ -114,14 +108,21 @@ export const uploadDropFile = async ( } } +interface DropSiteDeploy { + state?: string + error_message?: string + ssl_url?: string + url?: string +} + // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const waitForDropDeploy = async ( { apiBase, userAgent }: DropApiOptions, siteId: string, deployId: string, timeout: number = DEFAULT_DEPLOY_TIMEOUT, -): Promise> => { - let deploy: Record | undefined +): Promise => { + let deploy: DropSiteDeploy | undefined const checkDeploy = async (): Promise => { const response = await fetch(`${apiBase}/sites/${siteId}/deploys/${deployId}`, { @@ -132,13 +133,14 @@ export const waitForDropDeploy = async ( return false } - const data = (await response.json()) as Record + const data = (await response.json()) as DropSiteDeploy if (data.state === 'ready') { deploy = data return true } if (data.state === 'error') { - throw new Error((data.error_message as string) || `Deploy ${deployId} had an error`) + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty error message falls back to the generic one + throw new Error(data.error_message || `Deploy ${deployId} had an error`) } return false } @@ -216,14 +218,14 @@ export const uploadDropFiles = async ( phase: 'progress', }) - let lastError: UploadError | undefined + let lastError: DropApiError | undefined for (let attempt = 0; attempt <= maxRetry; attempt++) { try { const body = fs.createReadStream(fileObj.filepath) await uploadDropFile(apiOptions, deployId, fileObj.normalizedPath, body, token) return } catch (error) { - lastError = error as UploadError + lastError = error as DropApiError if (lastError.status === 400 || lastError.status === 422) { throw error } diff --git a/src/utils/deploy/file.ts b/src/utils/deploy/file.ts index f1c7b192427..d020d3edf5b 100644 --- a/src/utils/deploy/file.ts +++ b/src/utils/deploy/file.ts @@ -2,8 +2,9 @@ import walker from 'folder-walker' export type OriginalFile = walker.Entry -export type File = walker.Entry & { - hash: string - assetType: string +export type HashedFile = OriginalFile & { hash: string } + +export type File = HashedFile & { + assetType: 'file' normalizedPath: string } diff --git a/src/utils/deploy/hash-config.ts b/src/utils/deploy/hash-config.ts index 9234fed9681..f2d68b44662 100644 --- a/src/utils/deploy/hash-config.ts +++ b/src/utils/deploy/hash-config.ts @@ -2,8 +2,10 @@ import { createHash } from 'node:crypto' import tomlify from 'tomlify-j0.4' -// @ts-expect-error TS(7031) FIXME: Binding element 'config' implicitly has an 'any' t... Remove this comment to see the full error message -export const hashConfig = ({ config }) => { +import type { DeployConfig } from './types.js' +import type { InlineUploadFile } from './upload-files.js' + +export const hashConfig = ({ config }: { config: DeployConfig | undefined }): InlineUploadFile & { hash: string } => { if (!config) throw new Error('Missing config option') const configString = serializeToml(config) @@ -17,14 +19,12 @@ export const hashConfig = ({ config }) => { } } -// @ts-expect-error TS(7006) FIXME: Parameter 'object' implicitly has an 'any' type. -export const serializeToml = function (object) { +export const serializeToml = function (object: DeployConfig) { return tomlify.toToml(object, { space: 2, replace: replaceTomlValue }) } // `tomlify-j0.4` serializes integers as floats, e.g. `200.0`. // This is a problem with `redirects[*].status`. -// @ts-expect-error TS(7006) FIXME: Parameter 'key' implicitly has an 'any' type. -const replaceTomlValue = function (key, value) { +const replaceTomlValue = function (_key: string, value: unknown) { return Number.isInteger(value) ? String(value) : false } diff --git a/src/utils/deploy/hash-files.ts b/src/utils/deploy/hash-files.ts index 38d89ca8225..dd56700812c 100644 --- a/src/utils/deploy/hash-files.ts +++ b/src/utils/deploy/hash-files.ts @@ -2,8 +2,9 @@ import { pipeline } from 'stream/promises' import walker from 'folder-walker' +import type { File } from './file.js' import { fileFilterCtor, fileNormalizerCtor, hasherCtor, manifestCollectorCtor } from './hasher-segments.js' -import { $TSFixMe } from '../../commands/types.js' +import type { StatusCallback } from './status-cb.js' const hashFiles = async ({ assetType = 'file', @@ -14,14 +15,14 @@ const hashFiles = async ({ normalizer, statusCb, }: { - assetType?: string | undefined - concurrentHash: $TSFixMe - directories: $TSFixMe - filter: $TSFixMe + assetType?: 'file' | undefined + concurrentHash: number + directories: string[] + filter: ((filename: string) => boolean) | undefined hashAlgorithm?: string | undefined - normalizer?: $TSFixMe - statusCb: $TSFixMe -}): Promise<{ files: Record; filesShaMap: Record }> => { + normalizer?: (file: File) => File + statusCb: StatusCallback +}): Promise<{ files: Record; filesShaMap: Record }> => { if (!filter) throw new Error('Missing filter function option') const fileStream = walker(directories, { filter }) @@ -31,9 +32,9 @@ const hashFiles = async ({ // Written to by manifestCollector // normalizedPath: hash (wanted by deploy API) - const files = {} + const files: Record = {} // hash: [fileObj, fileObj, fileObj] - const filesShaMap = {} + const filesShaMap: Record = {} const manifestCollector = manifestCollectorCtor(files, filesShaMap, { statusCb }) await pipeline([fileStream, fileFilter, hasher, fileNormalizer, manifestCollector]) diff --git a/src/utils/deploy/hash-fns.ts b/src/utils/deploy/hash-fns.ts index a75d41148b2..01c56291a0d 100644 --- a/src/utils/deploy/hash-fns.ts +++ b/src/utils/deploy/hash-fns.ts @@ -8,17 +8,17 @@ import { findServerEntry, zipFunctions, zipServer, + type Config as FunctionsConfig, type FunctionResult, type TrafficRules, } from '@netlify/zip-it-and-ship-it' -import BaseCommand from '../../commands/base-command.js' -import { $TSFixMe } from '../../commands/types.js' +import type BaseCommand from '../../commands/base-command.js' import { INTERNAL_FUNCTIONS_FOLDER } from '../functions/functions.js' import { hasherCtor, manifestCollectorCtor } from './hasher-segments.js' import type { StatusCallback } from './status-cb.js' -import type { ServerUploadFile } from './upload-files.js' +import type { FunctionUploadFile, ServerUploadFile } from './upload-files.js' // Maximum age of functions manifest (2 minutes). const MANIFEST_FILE_TTL = 12e4 @@ -28,6 +28,8 @@ interface ServerBundle { region?: string } +type HashedFunctionFile = FunctionUploadFile & { hash: string; relname: string } + const getFunctionZips = async ({ command, directories, @@ -40,13 +42,13 @@ const getFunctionZips = async ({ }: { command: BaseCommand directories: string[] - functionsConfig?: $TSFixMe - manifestPath: $TSFixMe - rootDir: $TSFixMe + functionsConfig?: FunctionsConfig | undefined + manifestPath?: string | undefined + rootDir?: string | undefined skipFunctionsCache?: boolean | undefined - statusCb: $TSFixMe - tmpDir: $TSFixMe -}): Promise<(FunctionResult & { buildData?: unknown })[]> => { + statusCb: StatusCallback + tmpDir: string +}): Promise => { statusCb({ type: 'functions-manifest', msg: 'Looking for a functions cache...', @@ -56,7 +58,7 @@ const getFunctionZips = async ({ if (manifestPath) { try { const { functions, timestamp } = JSON.parse(await readFile(manifestPath, 'utf-8')) as { - functions: (FunctionResult & { buildData?: unknown })[] + functions: FunctionResult[] timestamp: number } const manifestAge = Date.now() - timestamp @@ -161,6 +163,19 @@ const trafficRulesConfig = (trafficRules?: TrafficRules) => { } } +interface FunctionConfigPayload { + display_name: FunctionResult['displayName'] + excluded_routes: FunctionResult['excludedRoutes'] + generator: FunctionResult['generator'] + memory: FunctionResult['memory'] + region: FunctionResult['region'] + routes: FunctionResult['routes'] + build_data: FunctionResult['buildData'] + priority: FunctionResult['priority'] + traffic_rules: ReturnType + vcpu: FunctionResult['vcpu'] +} + const hashFns = async ( command: BaseCommand, directories: string[], @@ -178,7 +193,7 @@ const hashFns = async ( tmpDir, }: { concurrentHash?: number - functionsConfig?: $TSFixMe + functionsConfig?: FunctionsConfig | undefined hashAlgorithm?: string | undefined manifestPath?: string | undefined packagePath?: string | undefined @@ -186,16 +201,16 @@ const hashFns = async ( serverEnabled?: boolean | undefined serverManifestPath?: string | undefined skipFunctionsCache?: boolean | undefined - statusCb: $TSFixMe - tmpDir: $TSFixMe + statusCb: StatusCallback + tmpDir?: string | undefined }, ): Promise<{ functionSchedules?: { name: string; cron: string }[] | undefined functions: Record - functionsWithNativeModules: $TSFixMe[] - shaMap?: Record | undefined - fnShaMap?: Record | undefined - fnConfig?: Record | undefined + functionsWithNativeModules: FunctionResult[] + shaMap?: Record | undefined + fnShaMap?: Record | undefined + fnConfig?: Record | undefined server?: { sha: string; region?: string } | undefined serverShaMap?: Record | undefined }> => { @@ -281,7 +296,7 @@ const hashFns = async ( func.vcpu, ), ) - .reduce( + .reduce>( (funcs, curr) => ({ ...funcs, [curr.name]: { @@ -301,7 +316,7 @@ const hashFns = async ( ) const functionSchedules = functionZips .map(({ name, schedule }) => schedule && { name, cron: schedule }) - .filter((schedule) => schedule !== '' && schedule !== undefined) + .filter((schedule): schedule is { name: string; cron: string } => schedule !== '' && schedule !== undefined) const functionsWithNativeModules = functionZips.filter( ({ nativeNodeModules }) => nativeNodeModules !== undefined && Object.keys(nativeNodeModules).length !== 0, ) @@ -312,9 +327,9 @@ const hashFns = async ( // Written to by manifestCollector // normalizedPath: hash (wanted by deploy API) - const functions = {} + const functions: Record = {} // hash: [fileObj, fileObj, fileObj] - const fnShaMap = {} + const fnShaMap: Record = {} const manifestCollector = manifestCollectorCtor(functions, fnShaMap, { statusCb }) await pipeline([functionStream, hasher, manifestCollector]) @@ -334,7 +349,7 @@ const hashServer = async ( hashAlgorithm, statusCb, tmpDir, - }: { concurrentHash?: number; hashAlgorithm?: string; statusCb: StatusCallback; tmpDir: string }, + }: { concurrentHash?: number; hashAlgorithm: string; statusCb: StatusCallback; tmpDir: string }, ): Promise<{ server?: { sha: string; region?: string }; serverShaMap?: Record }> => { if (!serverBundle) { return {} @@ -352,7 +367,7 @@ const hashServer = async ( } const servers: Record = {} - const serverShaMap: Record = {} + const serverShaMap: Record = {} await pipeline([ Readable.from([fileObj]), diff --git a/src/utils/deploy/hasher-segments.ts b/src/utils/deploy/hasher-segments.ts index dc8bd5103d2..897169a4afb 100644 --- a/src/utils/deploy/hasher-segments.ts +++ b/src/utils/deploy/hasher-segments.ts @@ -5,7 +5,7 @@ import { pipeline } from 'node:stream/promises' import transform from 'parallel-transform' -import type { File, OriginalFile } from './file.js' +import type { File, HashedFile, OriginalFile } from './file.js' import { normalizePath } from './util.js' import type { StatusCallback } from './status-cb.js' @@ -18,10 +18,9 @@ const hashFile = async (filePath: string, algorithm: string) => { // a parallel transform stream segment ctor that hashes fileObj's created by folder-walker // TODO: use promises instead of callbacks -// @ts-expect-error TS(7031) FIXME: Binding element 'concurrentHash' implicitly has an... Remove this comment to see the full error message -export const hasherCtor = ({ concurrentHash, hashAlgorithm }) => { +export const hasherCtor = ({ concurrentHash, hashAlgorithm }: { concurrentHash?: number; hashAlgorithm: string }) => { if (!concurrentHash) throw new Error('Missing required opts') - return transform(concurrentHash, { objectMode: true }, async (fileObj, cb) => { + return transform(concurrentHash, { objectMode: true }, async (fileObj: { filepath: string }, cb) => { try { const hash = await hashFile(fileObj.filepath, hashAlgorithm) // insert hash and asset type to file obj @@ -39,13 +38,13 @@ export const fileNormalizerCtor = ({ assetType, normalizer: normalizeFunction, }: { - assetType: string - normalizer?: (file: OriginalFile) => File + assetType: 'file' + normalizer?: (file: File) => File }) => { return new Transform({ objectMode: true, - transform(fileObj, _, callback) { - const normalizedFile = { ...fileObj, assetType, normalizedPath: normalizePath(fileObj.relname) } + transform(fileObj: HashedFile, _, callback) { + const normalizedFile: File = { ...fileObj, assetType, normalizedPath: normalizePath(fileObj.relname) } const result = normalizeFunction !== undefined ? normalizeFunction(normalizedFile) : normalizedFile @@ -57,14 +56,14 @@ export const fileNormalizerCtor = ({ } // A writable stream segment ctor that normalizes file paths, and writes shaMap's -export const manifestCollectorCtor = ( - filesObj: Record, - shaMap: Record, +export const manifestCollectorCtor = ( + filesObj: Record, + shaMap: Record, { statusCb }: { statusCb: StatusCallback }, ) => { return new Writable({ objectMode: true, - write(fileObj, _encoding, callback) { + write(fileObj: T, _encoding, callback) { filesObj[fileObj.normalizedPath] = fileObj.hash // Maintain hash to fileObj mapping @@ -88,7 +87,7 @@ export const manifestCollectorCtor = ( export const fileFilterCtor = () => new Transform({ objectMode: true, - transform(fileObj, _, callback) { + transform(fileObj: OriginalFile, _, callback) { if (fileObj.type === 'file') { this.push(fileObj) } diff --git a/src/utils/deploy/process-files.ts b/src/utils/deploy/process-files.ts index 540069ac744..2d0c67247ff 100644 --- a/src/utils/deploy/process-files.ts +++ b/src/utils/deploy/process-files.ts @@ -59,6 +59,7 @@ const getDirectoryIfExists = async (directoryPath: string) => { } catch { // no-op } + return undefined } export const isEdgeFunctionFile = (filePath: string) => filePath.startsWith(`${PUBLIC_URL_PATH}/`) diff --git a/src/utils/deploy/types.ts b/src/utils/deploy/types.ts new file mode 100644 index 00000000000..b7acc1928f1 --- /dev/null +++ b/src/utils/deploy/types.ts @@ -0,0 +1,14 @@ +import type { NetlifyConfig } from '@netlify/build' +import type { MinimalHeader } from '@netlify/headers-parser' + +/** + * The parts of the resolved configuration a deploy reads and uploads. Satisfied both by the CLI's cached config and + * by the `NetlifyConfig` that `@netlify/build` hands to the deploy handler. + */ +export interface DeployConfig { + build: { base: string; publish?: string } + functions?: NetlifyConfig['functions'] + functionsDirectory?: string + headers?: NetlifyConfig['headers'] | MinimalHeader[] + redirects?: unknown[] +} diff --git a/src/utils/deploy/upload-files.ts b/src/utils/deploy/upload-files.ts index 046f6c7642f..9bee2c632ec 100644 --- a/src/utils/deploy/upload-files.ts +++ b/src/utils/deploy/upload-files.ts @@ -24,13 +24,21 @@ type UploadDeployServerParams = WithRetryCount { const { body, filepath, normalizedPath } = fileObj - const readStreamCtor = () => body ?? fs.createReadStream(filepath) + // FIXME(@netlify/api): octet-stream `body` is typed as `ReadStream` only, but an inline string body works too + const readStreamCtor = () => (body ?? fs.createReadStream(filepath)) as fs.ReadStream statusCb({ type: 'upload', @@ -217,14 +231,14 @@ const retryUpload = (uploadFn: (retryCount: number) => Promise, maxRetry: // user the delay before next reconnection attempt. }) - // eslint-disable-next-line @typescript-eslint/no-misused-promises + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- FIXME: the listener's promise is discarded fibonacciBackoff.on('ready', tryUpload) fibonacciBackoff.on('fail', () => { reject(lastError) }) - tryUpload() + void tryUpload() }) export default uploadFiles diff --git a/src/utils/deploy/upload-source-zip.ts b/src/utils/deploy/upload-source-zip.ts index 2865f436a07..4bfc45d0275 100644 --- a/src/utils/deploy/upload-source-zip.ts +++ b/src/utils/deploy/upload-source-zip.ts @@ -40,6 +40,8 @@ const DEFAULT_IGNORE_PATTERNS = [ '.temp*', ] +const isExecaError = (error: unknown): error is ExecaError => error instanceof Error && 'command' in error + const createSourceZip = async ({ sourceDir, filename, @@ -79,13 +81,9 @@ const createSourceZip = async ({ cwd: sourceDir, stdio: ['ignore', 'pipe', 'pipe'], }) - } catch (_baseErr) { - let message = 'zip command failed' - if (_baseErr instanceof Error && 'command' in _baseErr) { - const baseErr = _baseErr as ExecaError - message = `${baseErr.shortMessage}\n\n${baseErr.all ?? ''}` - } - throw new Error(message, { cause: _baseErr }) + } catch (error) { + const message = isExecaError(error) ? `${error.shortMessage}\n\n${error.all ?? ''}` : 'zip command failed' + throw new Error(message, { cause: error }) } return zipPath diff --git a/src/utils/deploy/util.ts b/src/utils/deploy/util.ts index 70532b93caa..9f80d958b97 100644 --- a/src/utils/deploy/util.ts +++ b/src/utils/deploy/util.ts @@ -1,10 +1,20 @@ import { sep } from 'path' +import type { NetlifyAPI } from '@netlify/api' import pWaitFor from 'p-wait-for' import { DEPLOY_POLL } from './constants.js' export const pluralize = (amount: number, noun: string): string => `${amount} ${noun}${amount === 1 ? '' : 's'}` +export type Deploy = Awaited> + +// FIXME(@netlify/api): every `deploy` field is optional, even those always set once a deploy is ready +export type ReadyDeploy = Deploy & + Required> + +interface DeployStateError extends Error { + deploy?: Deploy +} // normalize windows paths to unix paths export const normalizePath = (relname: string): string => { @@ -15,19 +25,19 @@ export const normalizePath = (relname: string): string => { } // poll an async deployId until its done diffing -// @ts-expect-error TS(7006) FIXME: Parameter 'api' implicitly has an 'any' type. -export const waitForDiff = async (api, deployId, siteId, timeout) => { - // capture ready deploy during poll - let deploy - +export const waitForDiff = async ( + api: Pick, + deployId: string, + siteId: string, + timeout: number, +): Promise => { const loadDeploy = async () => { const siteDeploy = await api.getSiteDeploy({ siteId, deployId }) switch (siteDeploy.state) { // https://github.com/netlify/bitballoon/blob/master/app/models/deploy.rb#L21-L33 case 'error': { - const deployError = new Error(siteDeploy.error_message || `Deploy ${deployId} had an error`) - // @ts-expect-error TS(2339) FIXME: Property 'deploy' does not exist on type 'Error'. + const deployError: DeployStateError = new Error(siteDeploy.error_message || `Deploy ${deployId} had an error`) deployError.deploy = siteDeploy throw deployError } @@ -35,8 +45,7 @@ export const waitForDiff = async (api, deployId, siteId, timeout) => { case 'uploading': case 'uploaded': case 'ready': { - deploy = siteDeploy - return true + return pWaitFor.resolveWith(siteDeploy) } case 'preparing': default: { @@ -45,7 +54,7 @@ export const waitForDiff = async (api, deployId, siteId, timeout) => { } } - await pWaitFor(loadDeploy, { + const deploy = await pWaitFor(loadDeploy, { interval: DEPLOY_POLL, timeout: { milliseconds: timeout, @@ -57,24 +66,23 @@ export const waitForDiff = async (api, deployId, siteId, timeout) => { } // Poll a deployId until its ready -// @ts-expect-error TS(7006) FIXME: Parameter 'api' implicitly has an 'any' type. -export const waitForDeploy = async (api, deployId, siteId, timeout) => { - // capture ready deploy during poll - let deploy - +export const waitForDeploy = async ( + api: Pick, + deployId: string, + siteId: string, + timeout: number, +): Promise => { const loadDeploy = async () => { const siteDeploy = await api.getSiteDeploy({ siteId, deployId }) switch (siteDeploy.state) { // https://github.com/netlify/bitballoon/blob/master/app/models/deploy.rb#L21-L33 case 'error': { - const deployError = new Error(siteDeploy.error_message || `Deploy ${deployId} had an error`) - // @ts-expect-error TS(2339) FIXME: Property 'deploy' does not exist on type 'Error'. + const deployError: DeployStateError = new Error(siteDeploy.error_message || `Deploy ${deployId} had an error`) deployError.deploy = siteDeploy throw deployError } case 'ready': { - deploy = siteDeploy - return true + return pWaitFor.resolveWith(siteDeploy) } case 'preparing': case 'prepared': @@ -86,7 +94,7 @@ export const waitForDeploy = async (api, deployId, siteId, timeout) => { } } - await pWaitFor(loadDeploy, { + const deploy = await pWaitFor(loadDeploy, { interval: DEPLOY_POLL, timeout: { milliseconds: timeout, @@ -94,13 +102,11 @@ export const waitForDeploy = async (api, deployId, siteId, timeout) => { }, }) - return deploy + return deploy as ReadyDeploy } // Transform the fileShaMap and fnShaMap into a generic shaMap that file-uploader.js can use -// @ts-expect-error TS(7006) FIXME: Parameter 'required' implicitly has an 'any' type. -export const getUploadList = (required, shaMap) => { +export const getUploadList = (required: string[] | undefined, shaMap: Record | undefined): T[] => { if (!required || !shaMap) return [] - // @ts-expect-error TS(7006) FIXME: Parameter 'sha' implicitly has an 'any' type. return required.flatMap((sha) => shaMap[sha]) } diff --git a/tests/unit/utils/deploy/hash-files.test.ts b/tests/unit/utils/deploy/hash-files.test.ts index 0382e9c891b..a46e0dc1873 100644 --- a/tests/unit/utils/deploy/hash-files.test.ts +++ b/tests/unit/utils/deploy/hash-files.test.ts @@ -31,7 +31,6 @@ test('Hashes files in a folder', async (t) => { const fileObjArray = filesShaMap[sha] fileObjArray.forEach((fileObj) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(fileObj.normalizedPath).toBe(filePath) }) }) diff --git a/tests/unit/utils/deploy/hash-fns.test.ts b/tests/unit/utils/deploy/hash-fns.test.ts index 1eef6ec8a09..1798bb0e3f3 100644 --- a/tests/unit/utils/deploy/hash-fns.test.ts +++ b/tests/unit/utils/deploy/hash-fns.test.ts @@ -41,7 +41,6 @@ test('Hashes files in a folder', async (t) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- FIXME const functionsObjArray = fnShaMap![sha] functionsObjArray.forEach((fileObj) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- FIXME expect(fileObj.normalizedPath).toBe(functionPath) }) }) @@ -67,7 +66,7 @@ test('Populates build_data.bootstrapVersion for v2 functions on direct-zip path' expect(fnConfig).toBeDefined() // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- covered by expectation above - const helloConfig = fnConfig!.hello as { build_data?: { bootstrapVersion?: string; runtimeAPIVersion?: number } } + const helloConfig = fnConfig!.hello expect(helloConfig).toBeDefined() expect(helloConfig.build_data).toBeDefined() expect(helloConfig.build_data?.runtimeAPIVersion).toBe(2) diff --git a/tests/unit/utils/deploy/upload-files.test.ts b/tests/unit/utils/deploy/upload-files.test.ts index 27e70aaf9f4..48c0ec2d482 100644 --- a/tests/unit/utils/deploy/upload-files.test.ts +++ b/tests/unit/utils/deploy/upload-files.test.ts @@ -138,7 +138,7 @@ test('Uploads a Netlify Server addressed by its digest, and retries it', async ( filepath: 'server.tgz', normalizedPath: 'server', hash: codeSha, - } as unknown as UploadFile, + }, ] await uploadFiles(mockApi, deployId, files, { concurrentUpload: 1, maxRetry: 3, statusCb: () => {} })