diff --git a/package-lock.json b/package-lock.json index 285c9771d70..f43da004138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,6 @@ "ansi-escapes": "7.3.0", "ansi-to-html": "0.7.2", "ascii-table": "0.0.9", - "backoff": "2.5.0", "boxen": "8.0.1", "chalk": "5.6.2", "chokidar": "4.0.3", @@ -123,7 +122,6 @@ "@sindresorhus/slugify": "3.0.0", "@tsconfig/node20": "20.1.0", "@tsconfig/recommended": "1.0.13", - "@types/backoff": "2.5.5", "@types/content-type": "1.1.9", "@types/debug": "4.1.12", "@types/envinfo": "7.8.4", @@ -6403,7 +6401,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6417,7 +6414,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6431,7 +6427,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6445,7 +6440,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6459,7 +6453,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6473,7 +6466,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6487,7 +6479,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6501,7 +6492,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6515,7 +6505,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6529,7 +6518,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6543,7 +6531,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6557,7 +6544,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6571,7 +6557,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6585,7 +6570,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6599,7 +6583,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6613,7 +6596,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6627,7 +6609,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6641,7 +6622,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6655,7 +6635,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6669,7 +6648,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6683,7 +6661,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6697,7 +6674,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6711,7 +6687,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6725,7 +6700,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6739,7 +6713,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6860,14 +6833,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/backoff": { - "version": "2.5.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "dev": true, @@ -8891,16 +8856,6 @@ } } }, - "node_modules/backoff": { - "version": "2.5.0", - "license": "MIT", - "dependencies": { - "precond": "0.2" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "license": "MIT" @@ -12537,7 +12492,6 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -16435,12 +16389,6 @@ "node": ">=18" } }, - "node_modules/precond": { - "version": "0.2.3", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, diff --git a/package.json b/package.json index 9f7b25e7661..0fbb593d716 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "ansi-escapes": "7.3.0", "ansi-to-html": "0.7.2", "ascii-table": "0.0.9", - "backoff": "2.5.0", "boxen": "8.0.1", "chalk": "5.6.2", "chokidar": "4.0.3", @@ -164,7 +163,6 @@ "@sindresorhus/slugify": "3.0.0", "@tsconfig/node20": "20.1.0", "@tsconfig/recommended": "1.0.13", - "@types/backoff": "2.5.5", "@types/content-type": "1.1.9", "@types/debug": "4.1.12", "@types/envinfo": "7.8.4", diff --git a/src/commands/database/legacy/utils.ts b/src/commands/database/legacy/utils.ts index 56a7461060e..f565272d2fa 100644 --- a/src/commands/database/legacy/utils.ts +++ b/src/commands/database/legacy/utils.ts @@ -1,4 +1,3 @@ -import { createRequire } from 'module' import { join } from 'path' import fsPromises from 'fs/promises' @@ -17,8 +16,8 @@ type PackageJSON = { } export function getPackageJSON(directory: string) { - const require = createRequire(join(directory, 'package.json')) - const packageJson = require('./package.json') as unknown + const packageJsonPath = join(directory, 'package.json') + const packageJson: unknown = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) if (typeof packageJson !== 'object' || packageJson === null) { throw new Error('Failed to load package.json') } diff --git a/src/commands/functions/functions-create.ts b/src/commands/functions/functions-create.ts index 42d5da8eded..61f9df3bff4 100644 --- a/src/commands/functions/functions-create.ts +++ b/src/commands/functions/functions-create.ts @@ -1,7 +1,6 @@ import cp from 'child_process' import fs from 'fs' import { mkdir, readdir, unlink } from 'fs/promises' -import { createRequire } from 'module' import path, { dirname, join, relative } from 'path' import process from 'process' import { fileURLToPath, pathToFileURL } from 'url' @@ -30,7 +29,7 @@ import execa from '../../utils/execa.js' import { readRepoURL, validateRepoURL } from '../../utils/read-repo-url.js' import BaseCommand from '../base-command.js' -const require = createRequire(import.meta.url) +const readJsonFile = (filePath: string): unknown => JSON.parse(fs.readFileSync(filePath, 'utf8')) const templatesDir = path.resolve(dirname(fileURLToPath(import.meta.url)), '../../../functions-templates') @@ -443,7 +442,9 @@ const getNpmInstallPackages = (existingPackages = {}, neededPackages = {}) => */ // @ts-expect-error TS(7031) FIXME: Binding element 'functionPackageJson' implicitly h... Remove this comment to see the full error message const installDeps = async ({ functionPackageJson, functionPath, functionsDir }) => { - const { dependencies: functionDependencies, devDependencies: functionDevDependencies } = require(functionPackageJson) + const { dependencies: functionDependencies, devDependencies: functionDevDependencies } = readJsonFile( + functionPackageJson, + ) as { dependencies?: Record; devDependencies?: Record } const sitePackageJson = await findUp('package.json', { cwd: functionsDir }) const npmInstallFlags = ['--no-audit', '--no-fund'] @@ -456,7 +457,10 @@ const installDeps = async ({ functionPackageJson, functionPath, functionsDir }) return } - const { dependencies: siteDependencies, devDependencies: siteDevDependencies } = require(sitePackageJson) + const { dependencies: siteDependencies, devDependencies: siteDevDependencies } = readJsonFile(sitePackageJson) as { + dependencies?: Record + devDependencies?: Record + } const dependencies = getNpmInstallPackages(siteDependencies, functionDependencies) const devDependencies = getNpmInstallPackages(siteDevDependencies, functionDevDependencies) const npmInstallPath = path.dirname(sitePackageJson) diff --git a/src/commands/functions/functions-invoke.ts b/src/commands/functions/functions-invoke.ts index 04a4788a9b4..aba65e43791 100644 --- a/src/commands/functions/functions-invoke.ts +++ b/src/commands/functions/functions-invoke.ts @@ -1,6 +1,6 @@ import fs from 'fs' -import { createRequire } from 'module' import path from 'path' +import { pathToFileURL } from 'url' import { OptionValues } from 'commander' import inquirer from 'inquirer' @@ -10,8 +10,6 @@ import { APIError, NETLIFYDEVWARN, chalk, logAndThrowError, exit } from '../../u import { BACKGROUND, CLOCKWORK_USERAGENT, getFunctions } from '../../utils/functions/index.js' import BaseCommand from '../base-command.js' -const require = createRequire(import.meta.url) - // https://docs.netlify.com/functions/trigger-on-events/ const events = [ 'deploy-building', @@ -64,20 +62,22 @@ const formatQstring = function (querystring) { * @param {string} workingDir */ // @ts-expect-error TS(7006) FIXME: Parameter 'payloadString' implicitly has an 'any' ... Remove this comment to see the full error message -const processPayloadFromFlag = function (payloadString, workingDir) { +export const processPayloadFromFlag = async function (payloadString, workingDir) { if (payloadString) { // case 1: jsonstring - let payload = tryParseJSON(payloadString) - if (payload) return payload - // case 2: jsonpath + const parsedJson = tryParseJSON(payloadString) + if (parsedJson) return parsedJson + // case 2: file path to a JSON or JS module const payloadpath = path.join(workingDir, payloadString) const pathexists = fs.existsSync(payloadpath) if (pathexists) { try { + if (payloadpath.endsWith('.json')) { + return JSON.parse(fs.readFileSync(payloadpath, 'utf8')) + } // there is code execution potential here - - payload = require(payloadpath) - return payload + const imported = (await import(pathToFileURL(payloadpath).href)) as { default?: unknown } + return imported.default ?? imported } catch (error_) { console.error(error_) } @@ -219,7 +219,7 @@ export const functionsInvoke = async (nameArgument: string, options: OptionValue // } } } - const payload = processPayloadFromFlag(options.payload, command.workingDir) + const payload = await processPayloadFromFlag(options.payload, command.workingDir) body = { ...body, ...payload } try { diff --git a/src/utils/deploy/upload-files.ts b/src/utils/deploy/upload-files.ts index 7244c96ceb0..e090422c3d3 100644 --- a/src/utils/deploy/upload-files.ts +++ b/src/utils/deploy/upload-files.ts @@ -1,6 +1,5 @@ import fs from 'fs' -import backoff from 'backoff' import pMap from 'p-map' import { UPLOAD_INITIAL_DELAY, UPLOAD_MAX_DELAY, UPLOAD_RANDOM_FACTOR } from './constants.js' @@ -40,9 +39,8 @@ const uploadFiles = async (api, deployId, uploadList, { concurrentUpload, maxRet break } case 'function': { - // @ts-expect-error TS(7006) FIXME: Parameter 'retryCount' implicitly has an 'any' typ... Remove this comment to see the full error message - response = await retryUpload((retryCount) => { - const params = { + response = await retryUpload((retryCount: number) => { + const params: Record = { body: readStreamCtor, deployId, invocationMode, @@ -52,7 +50,6 @@ const uploadFiles = async (api, deployId, uploadList, { concurrentUpload, maxRet } if (retryCount > 0) { - // @ts-expect-error TS(2339) FIXME: Property 'xNfRetryCount' does not exist on type '{... Remove this comment to see the full error message params.xNfRetryCount = retryCount } @@ -80,61 +77,53 @@ const uploadFiles = async (api, deployId, uploadList, { concurrentUpload, maxRet return results } -// @ts-expect-error TS(7006) FIXME: Parameter 'uploadFn' implicitly has an 'any' type. -const retryUpload = (uploadFn, maxRetry) => +const retryUpload = (uploadFn: (retryCount: number) => Promise, maxRetry: number) => new Promise((resolve, reject) => { - // @ts-expect-error TS(7034) FIXME: Variable 'lastError' implicitly has type 'any' in ... Remove this comment to see the full error message - let lastError - - const fibonacciBackoff = backoff.fibonacci({ - randomisationFactor: UPLOAD_RANDOM_FACTOR, - initialDelay: UPLOAD_INITIAL_DELAY, - maxDelay: UPLOAD_MAX_DELAY, - }) + let lastError: unknown + let retryCount = 0 + let previousDelay = 0 + let nextDelay = UPLOAD_INITIAL_DELAY + + const scheduleNextAttempt = () => { + const baseDelay = Math.min(nextDelay, UPLOAD_MAX_DELAY) + nextDelay = previousDelay + baseDelay + previousDelay = baseDelay + const jitteredDelay = Math.round(baseDelay * (1 + Math.random() * UPLOAD_RANDOM_FACTOR)) + setTimeout(() => { + void tryUpload() + }, jitteredDelay) + } - const tryUpload = async (retryIndex = -1) => { + const tryUpload = async () => { try { - const results = await uploadFn(retryIndex + 1) - - resolve(results) + const result = await uploadFn(retryCount) + resolve(result) return } catch (error) { lastError = error + const status = (error as { status?: number } | null)?.status + const name = (error as { name?: string } | null)?.name - // We don't need to retry for 400 or 422 errors - // @ts-expect-error TS(2571) FIXME: Object is of type 'unknown'. - if (error.status === 400 || error.status === 422) { + if (status === 400 || status === 422) { reject(error) return } - // observed errors: 408, 401 (4** swallowed), 502 - // @ts-expect-error TS(2571) FIXME: Object is of type 'unknown'. - if (error.status > 400 || error.name === 'FetchError') { - fibonacciBackoff.backoff() + if ((typeof status === 'number' && status > 400) || name === 'FetchError') { + retryCount += 1 + if (retryCount > maxRetry) { + reject(lastError) + return + } + scheduleNextAttempt() return } + reject(error) - return } } - fibonacciBackoff.failAfter(maxRetry) - - fibonacciBackoff.on('backoff', () => { - // Do something when backoff starts, e.g. show to the - // user the delay before next reconnection attempt. - }) - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - fibonacciBackoff.on('ready', tryUpload) - - fibonacciBackoff.on('fail', () => { - // @ts-expect-error TS(7005) FIXME: Variable 'lastError' implicitly has an 'any' type. - reject(lastError) - }) - - tryUpload() + void tryUpload() }) export default uploadFiles diff --git a/tests/unit/commands/database/legacy/utils.test.ts b/tests/unit/commands/database/legacy/utils.test.ts new file mode 100644 index 00000000000..072243fbabf --- /dev/null +++ b/tests/unit/commands/database/legacy/utils.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { getPackageJSON } from '../../../../../src/commands/database/legacy/utils.js' + +describe('getPackageJSON', () => { + let workDir: string + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'netlify-cli-getpkgjson-')) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + const writePackageJson = (contents: unknown) => { + writeFileSync(join(workDir, 'package.json'), typeof contents === 'string' ? contents : JSON.stringify(contents)) + } + + test('reads and parses a valid package.json', () => { + writePackageJson({ + name: 'my-app', + version: '1.0.0', + dependencies: { foo: '^1.0.0' }, + devDependencies: { bar: '^2.0.0' }, + scripts: { build: 'tsc' }, + }) + + expect(getPackageJSON(workDir)).toEqual({ + name: 'my-app', + version: '1.0.0', + dependencies: { foo: '^1.0.0' }, + devDependencies: { bar: '^2.0.0' }, + scripts: { build: 'tsc' }, + }) + }) + + test('accepts a package.json without optional fields', () => { + writePackageJson({ name: 'bare' }) + expect(getPackageJSON(workDir)).toEqual({ name: 'bare' }) + }) + + test('throws when package.json is missing', () => { + expect(() => getPackageJSON(workDir)).toThrow() + }) + + test('throws when package.json is not valid JSON', () => { + writePackageJson('{ not json') + expect(() => getPackageJSON(workDir)).toThrow() + }) + + test('throws when the parsed value is not an object', () => { + writePackageJson('"just a string"') + expect(() => getPackageJSON(workDir)).toThrow('Failed to load package.json') + }) + + test('throws when dependencies is not an object', () => { + writePackageJson({ dependencies: 'oops' }) + expect(() => getPackageJSON(workDir)).toThrow('Expected object at package.json#dependencies, got string') + }) + + test('throws when devDependencies is not an object', () => { + writePackageJson({ devDependencies: 42 }) + expect(() => getPackageJSON(workDir)).toThrow('Expected object at package.json#devDependencies, got number') + }) + + test('throws when scripts is not an object', () => { + writePackageJson({ scripts: true }) + expect(() => getPackageJSON(workDir)).toThrow('Expected object at package.json#scripts, got boolean') + }) +}) diff --git a/tests/unit/commands/functions/functions-invoke.test.ts b/tests/unit/commands/functions/functions-invoke.test.ts new file mode 100644 index 00000000000..1aa51071845 --- /dev/null +++ b/tests/unit/commands/functions/functions-invoke.test.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { processPayloadFromFlag } from '../../../../src/commands/functions/functions-invoke.js' + +describe('processPayloadFromFlag', () => { + let workDir: string + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'netlify-cli-processpayload-')) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + test('returns undefined when no payload string is provided', async () => { + await expect(processPayloadFromFlag(undefined, workDir)).resolves.toBeUndefined() + await expect(processPayloadFromFlag('', workDir)).resolves.toBeUndefined() + }) + + test('parses an inline JSON object string', async () => { + await expect(processPayloadFromFlag('{"hello":"world","n":1}', workDir)).resolves.toEqual({ + hello: 'world', + n: 1, + }) + }) + + test('loads a .json payload from a file path', async () => { + const fileName = 'payload.json' + writeFileSync(join(workDir, fileName), JSON.stringify({ from: 'file', arr: [1, 2, 3] })) + + await expect(processPayloadFromFlag(fileName, workDir)).resolves.toEqual({ from: 'file', arr: [1, 2, 3] }) + }) + + test('loads an ESM .mjs payload via dynamic import and unwraps the default export', async () => { + const fileName = 'payload.mjs' + writeFileSync(join(workDir, fileName), `export default { source: 'mjs-default', n: 7 }\n`) + + await expect(processPayloadFromFlag(fileName, workDir)).resolves.toEqual({ source: 'mjs-default', n: 7 }) + }) + + test('loads a CJS .cjs payload via dynamic import (module.exports is exposed under default)', async () => { + const fileName = 'payload.cjs' + writeFileSync(join(workDir, fileName), `module.exports = { source: 'cjs', n: 9 }\n`) + + await expect(processPayloadFromFlag(fileName, workDir)).resolves.toEqual({ source: 'cjs', n: 9 }) + }) + + test('returns false when the referenced path does not exist and the string is not valid JSON', async () => { + await expect(processPayloadFromFlag('does-not-exist.json', workDir)).resolves.toBe(false) + }) + + test('logs and returns false when an imported JS payload throws at load time', async () => { + const fileName = 'broken.mjs' + writeFileSync(join(workDir, fileName), `throw new Error('boom')\n`) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await expect(processPayloadFromFlag(fileName, workDir)).resolves.toBe(false) + expect(errorSpy).toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/tests/unit/utils/gh-auth.test.ts b/tests/unit/utils/gh-auth.test.ts index 1186308ee8a..ba80b3abdcf 100644 --- a/tests/unit/utils/gh-auth.test.ts +++ b/tests/unit/utils/gh-auth.test.ts @@ -1,4 +1,3 @@ -import { fibonacci } from 'backoff' import fetch from 'node-fetch' import { afterAll, describe, expect, test, vi } from 'vitest' @@ -11,6 +10,26 @@ vi.mock('../../../src/utils/open-browser.js', () => ({ default: vi.fn(() => Promise.resolve()), })) +const waitForBrowserOpen = () => + new Promise((resolve, reject) => { + const maxAttempts = 10 + const pollIntervalMs = 200 + let attempts = 0 + const check = () => { + if (openBrowser.mock.calls.length > 0) { + resolve() + return + } + attempts += 1 + if (attempts >= maxAttempts) { + reject(new Error('Timed out waiting for browser to be opened')) + return + } + setTimeout(check, pollIntervalMs) + } + check() + }) + describe('gh-auth', () => { afterAll(() => { vi.restoreAllMocks() @@ -18,22 +37,7 @@ describe('gh-auth', () => { test('should check if the authWithNetlify is working', async () => { const promise = authWithNetlify() - // wait for server to be started - await new Promise((resolve, reject) => { - const fibonacciBackoff = fibonacci() - const check = () => { - if (openBrowser.mock.calls.length === 0) { - fibonacciBackoff.backoff() - } else { - resolve() - } - } - - fibonacciBackoff.failAfter(10) - fibonacciBackoff.on('ready', check) - fibonacciBackoff.on('fail', reject) - check() - }) + await waitForBrowserOpen() const params = new URLSearchParams([ ['user', 'spongebob'],