Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 2 additions & 3 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -191,7 +190,7 @@ export function storeToken(
globalConfig: Awaited<ReturnType<typeof getGlobalConfigStore>>,
{ 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,
Expand Down
5 changes: 2 additions & 3 deletions src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ 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 { isEmpty } from '../../utils/object-utilities.js'
import { parseAllHeaders } from '@netlify/headers-parser'
import { parseAllRedirects } from '@netlify/redirect-parser'
import prettyjson from 'prettyjson'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -359,7 +358,7 @@ 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)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/init/init.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/link/link.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sites/sites-create.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/lib/extensions.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
2 changes: 1 addition & 1 deletion src/utils/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
90 changes: 90 additions & 0 deletions src/utils/object-utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* 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 = <T extends object>(obj: T, keys: readonly string[]): Partial<T> =>
Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, (obj as Record<string, unknown>)[k]])) as Partial<T>

/**
* Recursively merges the properties of `source` into a copy of `target`.
*
* Only plain (non-array) objects are merged recursively; arrays and primitive
* values from `source` overwrite the corresponding value in `target`. Neither
* argument is mutated. Behavior is intentionally narrow: this is not a full
* replacement for `lodash/merge`, only enough to cover the existing call sites
* that merge shallow-nested config objects.
*
* @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<T extends Record<string, unknown>>(target: T | undefined, source: Record<string, unknown>): T {
const result: Record<string, unknown> = { ...(target ?? {}) }
for (const key of Object.keys(source)) {
const targetVal = result[key]
const sourceVal = source[key]
if (
targetVal != null &&
sourceVal != null &&
typeof targetVal === 'object' &&
typeof sourceVal === 'object' &&
!Array.isArray(targetVal) &&
!Array.isArray(sourceVal)
) {
result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>)
} else {
result[key] = sourceVal
}
}
return result as T
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* 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<Args extends unknown[]>(
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)
}
}
}
Comment on lines +1 to +102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Prettier --check is failing on this file.

The CI Format job reports formatting issues. Please run npm run format (or equivalent) before pushing so prettier --check passes.

🧰 Tools
🪛 GitHub Actions: Format

[warning] 1-1: Prettier --check reported formatting/style issues in this file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/object-utilities.ts` around lines 1 - 90, Prettier check failed for
this file; run the project formatter and commit the changes so formatting
matches CI. Run the repo's format script (e.g. npm run format or prettier
--write) on src/utils/object-utilities.ts, then review and stage the updated
file (covering exports isEmpty, pick, deepMerge, throttle) and push the
formatted version so prettier --check passes in CI.

2 changes: 1 addition & 1 deletion src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
168 changes: 168 additions & 0 deletions tests/unit/utils/object-utilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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<Record<string, unknown>>(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 or undefined from source', () => {
const target = { a: 1, b: 2 }
const source = { a: null, b: undefined }
expect(deepMerge(target, source)).toEqual({ a: null, b: undefined })
})

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', () => {
const fn = vi.fn()
const throttled = throttle(fn, 100)

throttled()
vi.advanceTimersByTime(50)
throttled()
throttled()

expect(fn).toHaveBeenCalledTimes(1)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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 })
})
})
Loading