Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/lib/edge-functions/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { LocalState, ServerSettings } from '../../utils/types.js'
import { getBootstrapURL } from './bootstrap.js'
import { DIST_IMPORT_MAP_PATH, EDGE_FUNCTIONS_SERVE_FOLDER } from './consts.js'
import { getFeatureFlagsHeader, getInvocationMetadataHeader, headers } from './headers.js'
import { EdgeFunctionsRegistry } from './registry.js'
import { EdgeFunctionsRegistryImpl } from './registry.js'

export type EdgeFunctionDeclaration = bundler.Declaration

Expand Down Expand Up @@ -250,7 +250,7 @@ const prepareServer = async ({
rootPath: repositoryRoot,
servePath,
})
const registry = new EdgeFunctionsRegistry({
const registry = new EdgeFunctionsRegistryImpl({
aiGatewayContext,
bundler,
command,
Expand Down
69 changes: 64 additions & 5 deletions src/lib/edge-functions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,26 @@ function traverseLocalDependencies(
})
}

export class EdgeFunctionsRegistry {
/** Public contract for EdgeFunctionsRegistry - consumers should use this type */
export interface EdgeFunctionsRegistry {
initialize(): Promise<void>
matchURLPath(
urlPath: string,
method: string,
headers: Record<string, string | string[] | undefined>,
): { functionNames: string[]; invocationMetadata: unknown }
}

export class EdgeFunctionsRegistryImpl implements EdgeFunctionsRegistry {
public importMapFromDeployConfig?: string

private aiGatewayContext?: AIGatewayContext | null
private buildError: Error | null = null

/** @internal Exposed for testing - not part of the public EdgeFunctionsRegistry interface */
public buildPending = false
/** @internal Exposed for testing - not part of the public EdgeFunctionsRegistry interface */
public 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 @@ -155,8 +170,8 @@ export class EdgeFunctionsRegistry {
this.projectDir = projectDir

this.importMapFromTOML = importMapFromTOML
this.declarationsFromTOML = EdgeFunctionsRegistry.getDeclarationsFromTOML(config)
this.env = EdgeFunctionsRegistry.getEnvironmentVariables(env)
this.declarationsFromTOML = EdgeFunctionsRegistryImpl.getDeclarationsFromTOML(config)
this.env = EdgeFunctionsRegistryImpl.getEnvironmentVariables(env)

this.initialScan = this.doInitialScan()

Expand All @@ -181,7 +196,51 @@ export class EdgeFunctionsRegistry {
return [...this.internalFunctions, ...this.userFunctions]
}

private async build() {
/**
* Triggers a build of edge functions with coalescing behavior.
*
* 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.
*
* @internal Exposed for testing - not part of the public EdgeFunctionsRegistry interface
*/
public 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.
if (this.buildPromise) {
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
}
}

/** @internal Exposed for testing - not part of the public EdgeFunctionsRegistry interface */
public async doBuild(): Promise<{ warnings: Record<string, string[]> }> {
const warnings: Record<string, string[]> = {}

try {
Expand Down Expand Up @@ -643,7 +702,7 @@ export class EdgeFunctionsRegistry {
onChange: async () => {
const newConfig = await this.getUpdatedConfig()

this.declarationsFromTOML = EdgeFunctionsRegistry.getDeclarationsFromTOML(newConfig)
this.declarationsFromTOML = EdgeFunctionsRegistryImpl.getDeclarationsFromTOML(newConfig)

await this.checkForAddedOrDeletedFunctions()
},
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
63 changes: 63 additions & 0 deletions tests/unit/lib/edge-functions/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test, vi } from 'vitest'

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

/**
* Tests for EdgeFunctionsRegistryImpl.build() coalescing behavior.
*
* The build(), buildPending, buildPromise, and doBuild members are public on
* the implementation class (but not on the EdgeFunctionsRegistry interface),
* allowing direct unit testing of the coalescing logic.
*/

describe('EdgeFunctionsRegistryImpl.build() coalescing', () => {
const createMockRegistry = () => {
const state = { buildCount: 0, shouldFail: false }

// Create instance with prototype chain for build() method
const registry = Object.create(EdgeFunctionsRegistryImpl.prototype) as EdgeFunctionsRegistryImpl

// Initialize properties needed for build()
registry.buildPending = false
registry.buildPromise = null
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()

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
await registry.build()
expect(state.buildCount).toBe(3)
})

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

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