diff --git a/package.json b/package.json index 39772d3ff58..d80fae36a5d 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,6 @@ "jwt-decode": "4.0.0", "lambda-local": "2.2.0", "locate-path": "8.0.0", - "lodash": "4.18.1", "log-update": "7.2.0", "maxstache": "1.0.7", "maxstache-stream": "1.0.4", @@ -176,7 +175,6 @@ "@types/inquirer": "9.0.9", "@types/inquirer-autocomplete-prompt": "3.0.3", "@types/jsonwebtoken": "9.0.10", - "@types/lodash": "4.17.24", "@types/lodash.shuffle": "4.2.9", "@types/multiparty": "4.2.1", "@types/node": "22.18.11", diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index d3b40a27b6e..07e026c90cf 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -14,8 +14,7 @@ import debug from 'debug' import { findUp } from 'find-up' import inquirer from 'inquirer' import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt' -import merge from 'lodash/merge.js' -import pick from 'lodash/pick.js' +import { deepMerge, pick } from '../utils/object-utilities.js' import { getAgent } from '../lib/http-agent.js' import { @@ -191,7 +190,7 @@ export function storeToken( globalConfig: Awaited>, { userId, name, email, accessToken }: { userId: string; name?: string; email?: string; accessToken: string }, ) { - const userData = merge(globalConfig.get(`users.${userId}`), { + const userData = deepMerge(globalConfig.get(`users.${userId}`), { id: userId, name, email, diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 6d845206f79..8f312bc28c8 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -7,8 +7,6 @@ import { stdin, stdout } from 'process' import type { NetlifyAPI } from '@netlify/api' import { type NetlifyConfig, type OnPostBuild, runCoreSteps } from '@netlify/build' import inquirer from 'inquirer' -import isEmpty from 'lodash/isEmpty.js' -import isObject from 'lodash/isObject.js' import { parseAllHeaders } from '@netlify/headers-parser' import { parseAllRedirects } from '@netlify/redirect-parser' import prettyjson from 'prettyjson' @@ -45,6 +43,7 @@ import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js' import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js' import { getEnvelopeEnv } from '../../utils/env/index.js' import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js' +import { isEmpty } from '../../utils/object-utilities.js' import openBrowser from '../../utils/open-browser.js' import { isInteractive } from '../../utils/scripted-commands.js' import { resolveTeamForNonInteractive } from '../../utils/team.js' @@ -359,7 +358,11 @@ const generateDeployCommand = ( // @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 }) => { - if (isObject(siteData.published_deploy) && siteData.published_deploy.locked) { + if ( + typeof siteData.published_deploy === 'object' && + siteData.published_deploy !== null && + siteData.published_deploy.locked + ) { log(`\n${NETLIFYDEVERR} Deployments are "locked" for production context of this project\n`) const overrideCommand = generateDeployCommand({ ...options, prodIfUnlocked: true, prod: false }, [], command) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index f43d22718ba..76f53310b0e 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -1,6 +1,6 @@ import { OptionValues } from 'commander' import inquirer from 'inquirer' -import isEmpty from 'lodash/isEmpty.js' +import { isEmpty } from '../../utils/object-utilities.js' import { chalk, exit, log, netlifyCommand } from '../../utils/command-helpers.js' import getRepoData from '../../utils/get-repo-data.js' diff --git a/src/commands/link/link.ts b/src/commands/link/link.ts index 97c45c7f62f..eba5a942202 100644 --- a/src/commands/link/link.ts +++ b/src/commands/link/link.ts @@ -1,7 +1,7 @@ import assert from 'node:assert' import inquirer from 'inquirer' -import isEmpty from 'lodash/isEmpty.js' +import { isEmpty } from '../../utils/object-utilities.js' import type { NetlifyAPI } from '@netlify/api' import { listSites } from '../../lib/api.js' diff --git a/src/commands/sites/sites-create.ts b/src/commands/sites/sites-create.ts index 69e4352560f..6f65fea1ebf 100644 --- a/src/commands/sites/sites-create.ts +++ b/src/commands/sites/sites-create.ts @@ -1,6 +1,6 @@ import type { OptionValues } from 'commander' import inquirer from 'inquirer' -import pick from 'lodash/pick.js' +import { pick } from '../../utils/object-utilities.js' import prettyjson from 'prettyjson' import { chalk, logAndThrowError, log, logJson, warn, type APIError } from '../../utils/command-helpers.js' diff --git a/src/lib/extensions.ts b/src/lib/extensions.ts index e93600253f6..08d48f07fbb 100644 --- a/src/lib/extensions.ts +++ b/src/lib/extensions.ts @@ -1,5 +1,5 @@ import type { Project } from '@netlify/build-info' -import isEmpty from 'lodash/isEmpty.js' +import { isEmpty } from '../utils/object-utilities.js' import type { NetlifySite } from '../commands/types.js' import type { SiteInfo } from '../utils/types.js' diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 8f929be9270..f41b1276b21 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -2,7 +2,7 @@ import process from 'process' import type { NetlifyAPI } from '@netlify/api' import getPort from 'get-port' -import isEmpty from 'lodash/isEmpty.js' +import { isEmpty } from './object-utilities.js' import { supportsBackgroundFunctions } from '../lib/account.js' diff --git a/src/utils/object-utilities.ts b/src/utils/object-utilities.ts new file mode 100644 index 00000000000..741abebdf2d --- /dev/null +++ b/src/utils/object-utilities.ts @@ -0,0 +1,102 @@ +/** + * Checks whether a value is effectively empty. + * + * A value is considered empty when it is `null`, `undefined`, or an object + * with no own enumerable keys. Intended as a minimal stand-in for the handful + * of `lodash/isEmpty` call sites that only ever receive plain objects or + * nullish values (e.g. API response payloads that may be absent). + * + * @param obj - The object to check, or a nullish value. + * @returns `true` if the value is nullish or has no own enumerable keys. + */ +export const isEmpty = (obj: object | null | undefined): boolean => obj == null || Object.keys(obj).length === 0 + +/** + * Returns a new object containing only the specified keys from the source. + * + * Mirrors the shape of `lodash/pick` but is intentionally loosely typed so it + * can accept string keys that are not declared on the source type. This is + * pragmatic for CLI output filtering of API responses whose generated types + * sometimes omit fields the server actually returns. + * + * Keys that are not present on the source are silently skipped. + * + * @param obj - The source object to pick properties from. + * @param keys - The property names to include in the result. + * @returns A new object with only the requested keys that exist on the source. + */ +export const pick = (obj: T, keys: readonly string[]): Partial => + Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, (obj as Record)[k]])) as Partial + +/** + * Recursively merges the properties of `source` into a copy of `target`. + * + * Behavior matches `lodash/merge` in the ways the existing call sites rely on: + * - `undefined` source values are skipped, preserving any existing value in + * the target. This lets callers pass partial patch objects without having + * to strip keys that happen to be `undefined` (e.g. `storeToken` in + * `base-command.ts` passes `{ github: { user: undefined, token: undefined } }` + * and relies on the existing GitHub auth fields being preserved). + * - `null` source values DO overwrite -- only `undefined` is treated as + * "no-op". + * - Only plain (non-array) objects are merged recursively; arrays and + * primitive values from `source` overwrite the corresponding value in + * `target`. + * + * Neither argument is mutated. + * + * @param target - The base object to merge into. `undefined` is treated as `{}`. + * @param source - The object whose properties take precedence over `target`. + * @returns A new object containing the combined properties. + */ +export function deepMerge>( + target: T | undefined, + source: Record, +): T { + const result: Record = { ...(target ?? {}) } + for (const key of Object.keys(source)) { + const sourceVal = source[key] + if (sourceVal === undefined) continue + const targetVal = result[key] + if ( + targetVal != null && + sourceVal != null && + typeof targetVal === 'object' && + typeof sourceVal === 'object' && + !Array.isArray(targetVal) && + !Array.isArray(sourceVal) + ) { + result[key] = deepMerge(targetVal as Record, sourceVal as Record) + } else { + result[key] = sourceVal + } + } + return result as T +} + +/** + * Wraps a function so that it runs at most once per `intervalMs` milliseconds. + * + * Uses leading-edge invocation: the first call runs immediately, and any calls + * that arrive within `intervalMs` of the previous invocation are dropped. + * There is no trailing call -- this is intentional for fire-and-forget + * side-effect callers (e.g. rate-limited activity pings) where losing the + * final invocation is acceptable. + * + * @param fn - The function to throttle. + * @param intervalMs - Minimum time between invocations, in milliseconds. + * @returns A throttled wrapper around `fn` with the same arguments signature. + */ +export function throttle( + fn: (...args: Args) => void, + intervalMs: number, +): (...args: Args) => void { + let lastCall = 0 + return (...args: Args) => { + const now = Date.now() + if (now - lastCall >= intervalMs) { + lastCall = now + fn(...args) + } + } +} diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 96e5e04a014..3603f31a57d 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -25,7 +25,7 @@ import httpProxy from 'http-proxy' import { createProxyMiddleware } from 'http-proxy-middleware' import { jwtDecode } from 'jwt-decode' import { locatePath } from 'locate-path' -import throttle from 'lodash/throttle.js' +import { throttle } from './object-utilities.js' import type { Match } from 'netlify-redirector' import pFilter from 'p-filter' diff --git a/tests/unit/utils/object-utilities.test.ts b/tests/unit/utils/object-utilities.test.ts new file mode 100644 index 00000000000..1e3e9311867 --- /dev/null +++ b/tests/unit/utils/object-utilities.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { deepMerge, isEmpty, pick, throttle } from '../../../src/utils/object-utilities.js' + +describe('isEmpty', () => { + test('returns true for null', () => { + expect(isEmpty(null)).toBe(true) + }) + + test('returns true for undefined', () => { + expect(isEmpty(undefined)).toBe(true) + }) + + test('returns true for an empty object', () => { + expect(isEmpty({})).toBe(true) + }) + + test('returns false for an object with properties', () => { + expect(isEmpty({ a: 1 })).toBe(false) + }) + + test('returns false for an object with falsy values', () => { + expect(isEmpty({ a: null })).toBe(false) + expect(isEmpty({ a: undefined })).toBe(false) + expect(isEmpty({ a: 0 })).toBe(false) + expect(isEmpty({ a: '' })).toBe(false) + }) + + test('returns true for an empty array', () => { + expect(isEmpty([])).toBe(true) + }) + + test('returns false for a non-empty array', () => { + expect(isEmpty([1])).toBe(false) + }) +}) + +describe('pick', () => { + test('returns a new object containing only the requested keys', () => { + const source = { a: 1, b: 2, c: 3 } + expect(pick(source, ['a', 'c'])).toEqual({ a: 1, c: 3 }) + }) + + test('skips keys that are not present on the source', () => { + const source = { a: 1, b: 2 } + expect(pick(source, ['a', 'missing'])).toEqual({ a: 1 }) + }) + + test('returns an empty object when no keys are requested', () => { + expect(pick({ a: 1 }, [])).toEqual({}) + }) + + test('returns an empty object when no requested keys exist on the source', () => { + expect(pick({ a: 1 }, ['b', 'c'])).toEqual({}) + }) + + test('does not mutate the source object', () => { + const source = { a: 1, b: 2 } + pick(source, ['a']) + expect(source).toEqual({ a: 1, b: 2 }) + }) + + test('preserves falsy values', () => { + const source = { a: 0, b: false, c: null, d: '', e: 1 } + expect(pick(source, ['a', 'b', 'c', 'd'])).toEqual({ a: 0, b: false, c: null, d: '' }) + }) + + test('accepts extra string keys not declared on the source type', () => { + const source: { id: string } = { id: 'abc' } + const sourceWithExtras = { ...source, undeclared: 'extra' } as { id: string } + expect(pick(sourceWithExtras, ['id', 'undeclared'])).toEqual({ id: 'abc', undeclared: 'extra' }) + }) +}) + +describe('deepMerge', () => { + test('treats undefined target as an empty object', () => { + expect(deepMerge>(undefined, { a: 1 })).toEqual({ a: 1 }) + }) + + test('merges top-level keys from the source into the target', () => { + expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }) + }) + + test('source values overwrite target values at the same key', () => { + expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }) + }) + + test('recursively merges nested plain objects', () => { + const target = { auth: { token: 'old', provider: 'github' } } + const source = { auth: { token: 'new' } } + expect(deepMerge(target, source)).toEqual({ auth: { token: 'new', provider: 'github' } }) + }) + + test('overwrites arrays instead of merging them', () => { + const target = { list: [1, 2, 3] } + const source = { list: [4] } + expect(deepMerge(target, source)).toEqual({ list: [4] }) + }) + + test('overwrites target values with null from source', () => { + const target = { a: 1 } + const source = { a: null } + expect(deepMerge(target, source)).toEqual({ a: null }) + }) + + test('skips undefined source values to preserve existing target values (lodash.merge parity)', () => { + const target = { auth: { token: 'existing', github: { user: 'octocat', token: 'gh-token' } } } + const source = { auth: { github: { user: undefined, token: undefined } } } + expect(deepMerge(target, source)).toEqual({ + auth: { token: 'existing', github: { user: 'octocat', token: 'gh-token' } }, + }) + }) + + test('skips top-level undefined source values', () => { + expect(deepMerge({ a: 1, b: 2 }, { a: undefined, b: 3 })).toEqual({ a: 1, b: 3 }) + }) + + test('does not mutate the target object', () => { + const target = { auth: { token: 'old' } } + deepMerge(target, { auth: { token: 'new' } }) + expect(target).toEqual({ auth: { token: 'old' } }) + }) + + test('does not mutate the source object', () => { + const source = { auth: { token: 'new' } } + deepMerge({ auth: { token: 'old' } }, source) + expect(source).toEqual({ auth: { token: 'new' } }) + }) +}) + +describe('throttle', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('invokes the underlying function immediately on first call', () => { + const fn = vi.fn() + const throttled = throttle(fn, 100) + + throttled() + + expect(fn).toHaveBeenCalledTimes(1) + }) + + test('drops calls that arrive within the throttle interval and schedules no trailing invocation', () => { + const fn = vi.fn() + const throttled = throttle(fn, 100) + + throttled() + vi.advanceTimersByTime(50) + throttled() + throttled() + + expect(fn).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(200) + + expect(fn).toHaveBeenCalledTimes(1) + }) + + test('allows a subsequent call after the interval has elapsed', () => { + const fn = vi.fn() + const throttled = throttle(fn, 100) + + throttled() + vi.advanceTimersByTime(100) + throttled() + + expect(fn).toHaveBeenCalledTimes(2) + }) + + test('forwards all arguments to the underlying function', () => { + const fn = vi.fn() + const throttled = throttle(fn, 100) + + throttled('a', 1, { x: true }) + + expect(fn).toHaveBeenCalledWith('a', 1, { x: true }) + }) +})