Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
41 changes: 40 additions & 1 deletion src/lib/edge-functions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export class EdgeFunctionsRegistry {

private aiGatewayContext?: AIGatewayContext | null
private buildError: Error | null = null
private buildPending = false
private buildPromise: Promise<{ warnings: Record<string, string[]> }> | null = null
private bundler: typeof import('@netlify/edge-bundler')
private configPath: string
private importMapFromTOML?: string
Expand Down Expand Up @@ -181,7 +183,44 @@ export class EdgeFunctionsRegistry {
return [...this.internalFunctions, ...this.userFunctions]
}

private async build() {
// Note: We intentionally don't use @netlify/dev-utils memoize() here because
// it has a 300ms debounce and fire-and-forget logic. Edge function build
// needs callers to receive the latest build result
private async build(): Promise<{ warnings: Record<string, string[]> }> {
// If a build is already in progress, mark that we need another build
// and return the current build's promise. The running build will
// trigger a rebuild when it completes if buildPending is true.
Comment on lines +190 to +192
Copy link
Member

Choose a reason for hiding this comment

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

πŸ‘€ Instead of reimplementing this, we could reuse the existing battle-tested helper we already use for this same purpose for (non-Edge) Functions! Check it out:

.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea thank you!

Copy link
Contributor Author

@jaredm563 jaredm563 Jan 24, 2026

Choose a reason for hiding this comment

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

So I looked into implementing that and unfortunately if build A fails while build B is pending, the caller of build A gets the error but build B runs without anyone waiting for it. I'll add a comment explaining why there is a custom implementation

if (this.buildPromise) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can we add tests here for this method which will ensure this tests fine?

this.build() // resolved firstly
this.build() // resolved together with the next
this.build() // resolved together with the previous

Copy link
Member

Choose a reason for hiding this comment

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

^ and the test should verify that subsequent calls trigger an actual new build!

this.buildPending = true
return this.buildPromise
}

this.buildPending = false
this.buildPromise = this.doBuild()

try {
const result = await this.buildPromise
this.buildPromise = null

// If another build was requested while we were building, run it now
if (this.buildPending) {
return await this.build()
}

return result
} catch (error) {
this.buildPromise = null

// If another build was requested while we were building, run it now
if (this.buildPending) {
return await this.build()
}

throw error
}
}

private async doBuild(): Promise<{ warnings: Record<string, string[]> }> {
const warnings: Record<string, string[]> = {}

try {
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/commands/init/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ describe.concurrent('commands/init', () => {
const initQuestions = [
{
question: 'Yes, create and deploy project manually',
answer: answerWithValue(CONFIRM),
answer: CONFIRM, // List selection only needs one CONFIRM, not answerWithValue
},
{ question: 'Team: (Use arrow keys)', answer: CONFIRM },
{
Expand All @@ -227,7 +227,7 @@ describe.concurrent('commands/init', () => {
},
{
question: `Do you want to configure build settings? We'll suggest settings for your project automatically`,
answer: answerWithValue(CONFIRM),
answer: CONFIRM, // Confirm prompt only needs one CONFIRM
},
{
question: 'Your build command (hugo build/yarn run build/etc)',
Expand Down
26 changes: 16 additions & 10 deletions tests/integration/utils/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface DevServer {
port: number
errorBuffer: Buffer[]
outputBuffer: Buffer[]
waitForLogMatching(match: string): Promise<void>
waitForLogMatching(match: string, timeoutMs?: number): Promise<void>
output: string
error: string
close(): Promise<void>
Expand Down Expand Up @@ -137,16 +137,22 @@ const startServer = async ({
port,
errorBuffer,
outputBuffer,
waitForLogMatching(match: string) {
return new Promise<void>((resolveWait) => {
const listener = (stdoutData: string) => {
if (stdoutData.includes(match)) {
ps.removeListener('data', listener)
resolveWait()
waitForLogMatching(match: string, timeoutMs = 30_000) {
return pTimeout(
new Promise<void>((resolveWait) => {
const listener = (stdoutData: string) => {
if (stdoutData.includes(match)) {
ps.stdout!.removeListener('data', listener)
resolveWait()
}
}
}
ps.stdout!.on('data', listener)
})
ps.stdout!.on('data', listener)
}),
{
milliseconds: timeoutMs,
message: `Timed out waiting for log matching "${match}".\nOutput so far:\n${outputBuffer.join('')}`,
},
)
},
get output() {
// these are getters so we do the actual joining as late as possible as the array might still get
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/lib/edge-functions/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test, vi } from 'vitest'

import { EdgeFunctionsRegistry } from '../../../../src/lib/edge-functions/registry.js'

/**
* Tests for EdgeFunctionsRegistry.build() coalescing behavior.
*/
describe('EdgeFunctionsRegistry.build() coalescing', () => {
const createMockRegistry = () => {
const state = { buildCount: 0, shouldFail: false }

// Create instance with minimal mocked dependencies
const registry = Object.create(EdgeFunctionsRegistry.prototype) as InstanceType<typeof EdgeFunctionsRegistry>

// Initialize only the properties needed for build()
// @ts-expect-error -- accessing private members for testing
registry.buildPending = false
// @ts-expect-error -- accessing private members for testing
registry.buildPromise = null
// @ts-expect-error -- accessing private members for testing
registry.doBuild = vi.fn(async () => {
state.buildCount++
await new Promise((resolve) => setTimeout(resolve, 10))
if (state.shouldFail) {
state.shouldFail = false
throw new Error('Build failed')
}
return { warnings: {} }
})

return { registry, state }
}

test('concurrent calls coalesce into fewer builds', async () => {
const { registry, state } = createMockRegistry()

// @ts-expect-error -- accessing private method for testing
const results = await Promise.all([registry.build(), registry.build(), registry.build()])

expect(results).toHaveLength(3)
for (const r of results) {
expect(r).toEqual({ warnings: {} })
}
// First build + one rebuild for pending = 2 total
expect(state.buildCount).toBe(2)

// Subsequent call after all concurrent calls complete triggers a NEW build
// @ts-expect-error -- accessing private method for testing
await registry.build()
expect(state.buildCount).toBe(3)
})

test('retries pending build on failure', async () => {
const { registry, state } = createMockRegistry()
state.shouldFail = true

// @ts-expect-error -- accessing private method for testing
const [result1, result2] = await Promise.allSettled([registry.build(), registry.build()])

expect(result1.status).toBe('fulfilled') // First call gets retry result
expect(result2.status).toBe('rejected') // Concurrent call gets the original failure
expect(state.buildCount).toBe(2)
})
})
Loading