Skip to content
12 changes: 1 addition & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/dev-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
},
"author": "Netlify Inc.",
"devDependencies": {
"@netlify/types": "2.0.1",
"@netlify/types": "2.0.2",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^18.19.110",
"@types/parse-gitignore": "^1.0.2",
Expand Down
55 changes: 55 additions & 0 deletions packages/dev/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -997,5 +997,60 @@ describe('Handling requests', () => {

await fixture.destroy()
})

test('Function timeout configuration respects site settings', async () => {
const fixture = new Fixture()
.withFile(
'netlify.toml',
`[build]
publish = "public"
`,
)
.withFile('netlify/functions/hello.mjs', `export default async () => new Response("Hello from function");`)
.withStateFile({ siteId: 'site_id' })

const siteInfoWithTimeouts = {
id: 'site_id',
name: 'site-name',
account_slug: 'test-account',
build_settings: { env: {} },
functions_timeout: 60, // 60 seconds timeout
functions_config: { timeout: 45 }, // This should be ignored in favor of functions_timeout
}

const routesWithTimeouts = [
{ path: 'sites/site_id', response: siteInfoWithTimeouts },
{ path: 'sites/site_id/service-instances', response: [] },
{
path: 'accounts',
response: [{ slug: siteInfoWithTimeouts.account_slug }],
},
{
path: 'accounts/test-account/env',
response: [],
},
]

const directory = await fixture.create()

await withMockApi(routesWithTimeouts, async (context) => {
const dev = new NetlifyDev({
apiURL: context.apiUrl,
apiToken: 'token',
projectRoot: directory,
})

await dev.start()

// We can't directly test the timeout values being used, but we can test that
// the NetlifyDev starts successfully with site configuration, which validates
// that the timeout logic is properly implemented
expect(dev.siteIsLinked).toBe(true)

await dev.stop()
})

await fixture.destroy()
})
})
})
38 changes: 37 additions & 1 deletion packages/dev/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { resolveConfig } from '@netlify/config'
import { ensureNetlifyIgnore, getAPIToken, mockLocation, LocalState, type Logger, HTTPServer } from '@netlify/dev-utils'
import { EdgeFunctionsHandler } from '@netlify/edge-functions/dev'
import { FunctionsHandler } from '@netlify/functions/dev'
import { SYNCHRONOUS_FUNCTION_TIMEOUT, BACKGROUND_FUNCTION_TIMEOUT } from '@netlify/functions'
import { HeadersHandler, type HeadersCollector } from '@netlify/headers'
import { ImageHandler } from '@netlify/images'
import { RedirectsHandler } from '@netlify/redirects'
import { StaticHandler } from '@netlify/static'
import type { SiteConfig } from '@netlify/types'

import { InjectedEnvironmentVariable, injectEnvVariables } from './lib/env.js'
import { isDirectory, isFile } from './lib/fs.js'
Expand Down Expand Up @@ -120,6 +122,38 @@ interface NetlifyDevOptions extends Features {

const notFoundHandler = async () => new Response('Not found', { status: 404 })

/**
* Get the effective function timeout considering site-specific configuration
*/
const getFunctionTimeout = (siteConfig: SiteConfig | undefined, isBackground = false): number => {
// Check for site-specific timeout configuration
const siteTimeout = siteConfig?.functionsTimeout ?? siteConfig?.functionsConfig?.timeout

if (siteTimeout !== undefined) {
return siteTimeout
}

// Use default timeout based on function type
return isBackground ? BACKGROUND_FUNCTION_TIMEOUT : SYNCHRONOUS_FUNCTION_TIMEOUT
}

/**
* Get timeout configuration for functions
*/
const getFunctionTimeouts = (config: Config | undefined): { syncFunctions: number; backgroundFunctions: number } => {
const siteConfig: SiteConfig | undefined = config?.siteInfo
? {
functionsTimeout: config.siteInfo.functions_timeout,
functionsConfig: config.siteInfo.functions_config,
}
: undefined

return {
syncFunctions: getFunctionTimeout(siteConfig, false),
backgroundFunctions: getFunctionTimeout(siteConfig, true),
}
}

type Config = Awaited<ReturnType<typeof resolveConfig>>

interface HandleOptions {
Expand Down Expand Up @@ -505,14 +539,16 @@ export class NetlifyDev {
this.#config?.config.functionsDirectory ?? path.join(this.#projectRoot, 'netlify/functions')
const userFunctionsPathExists = await isDirectory(userFunctionsPath)

const timeouts = getFunctionTimeouts(this.#config)

this.#functionsHandler = new FunctionsHandler({
config: this.#config,
destPath: this.#functionsServePath,
geolocation: mockLocation,
projectRoot: this.#projectRoot,
settings: {},
siteId: this.#siteID,
timeouts: {},
timeouts,
userFunctionsPath: userFunctionsPathExists ? userFunctionsPath : undefined,
})
}
Expand Down
1 change: 1 addition & 0 deletions packages/functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"@netlify/blobs": "10.0.6",
"@netlify/dev-utils": "4.0.0",
"@netlify/serverless-functions-api": "2.1.3",
"@netlify/types": "2.0.2",
"@netlify/zip-it-and-ship-it": "^14.1.0",
"cron-parser": "^4.9.0",
"decache": "^4.6.2",
Expand Down
10 changes: 10 additions & 0 deletions packages/functions/src/lib/consts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, test } from 'vitest'

import { BACKGROUND_FUNCTION_TIMEOUT, SYNCHRONOUS_FUNCTION_TIMEOUT } from './consts.js'

describe('Function timeout constants', () => {
test('exports correct timeout values', () => {
expect(SYNCHRONOUS_FUNCTION_TIMEOUT).toBe(30)
expect(BACKGROUND_FUNCTION_TIMEOUT).toBe(900)
})
})
21 changes: 20 additions & 1 deletion packages/functions/src/lib/consts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import type { SiteConfig } from '@netlify/types'

const BUILDER_FUNCTIONS_FLAG = true
const HTTP_STATUS_METHOD_NOT_ALLOWED = 405
const HTTP_STATUS_OK = 200
const METADATA_VERSION = 1

export { BUILDER_FUNCTIONS_FLAG, HTTP_STATUS_METHOD_NOT_ALLOWED, HTTP_STATUS_OK, METADATA_VERSION }
/**
* Default timeout for synchronous functions in seconds
*/
const SYNCHRONOUS_FUNCTION_TIMEOUT = 30

/**
* Default timeout for background functions in seconds
*/
const BACKGROUND_FUNCTION_TIMEOUT = 900

export {
BUILDER_FUNCTIONS_FLAG,
HTTP_STATUS_METHOD_NOT_ALLOWED,
HTTP_STATUS_OK,
METADATA_VERSION,
SYNCHRONOUS_FUNCTION_TIMEOUT,
BACKGROUND_FUNCTION_TIMEOUT,
}
68 changes: 68 additions & 0 deletions packages/types/src/lib/context/site.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, expectTypeOf, test } from 'vitest'

import type { Site, SiteConfig } from './site.js'

describe('Site types', () => {
test('Site interface accepts all optional properties', () => {
const site: Site = {}
expect(site).toBeDefined()

const siteWithProps: Site = {
id: 'site-id',
name: 'My Site',
url: 'https://example.com',
}
expect(siteWithProps.id).toBe('site-id')
expect(siteWithProps.name).toBe('My Site')
expect(siteWithProps.url).toBe('https://example.com')
})

test('SiteConfig interface accepts optional timeout properties', () => {
const config: SiteConfig = {}
expect(config).toBeDefined()

const configWithFunctionsTimeout: SiteConfig = {
functionsTimeout: 60,
}
expect(configWithFunctionsTimeout.functionsTimeout).toBe(60)

const configWithFunctionsConfig: SiteConfig = {
functionsConfig: {
timeout: 45,
},
}
expect(configWithFunctionsConfig.functionsConfig?.timeout).toBe(45)

const configWithBoth: SiteConfig = {
functionsTimeout: 60,
functionsConfig: {
timeout: 45,
},
}
expect(configWithBoth.functionsTimeout).toBe(60)
expect(configWithBoth.functionsConfig?.timeout).toBe(45)
})

test('timeout values have correct types', () => {
const config: SiteConfig = {
functionsTimeout: 30,
functionsConfig: {
timeout: 900,
},
}

expect(config.functionsTimeout).toBe(30)
expect(config.functionsConfig?.timeout).toBe(900)

expectTypeOf(config.functionsTimeout).toEqualTypeOf<number | undefined>()
expectTypeOf(config.functionsConfig).toEqualTypeOf<{ timeout?: number } | undefined>()

if (config.functionsTimeout) {
expectTypeOf(config.functionsTimeout).toBeNumber()
}

if (config.functionsConfig?.timeout) {
expectTypeOf(config.functionsConfig.timeout).toBeNumber()
}
})
})
16 changes: 16 additions & 0 deletions packages/types/src/lib/context/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,19 @@ export interface Site {
name?: string
url?: string
}

/**
* Site configuration for function timeout options
*/
export interface SiteConfig {
/**
* Site-specific function timeout in seconds
*/
functionsTimeout?: number
/**
* Function-specific timeout configuration
*/
functionsConfig?: {
timeout?: number
}
}
1 change: 1 addition & 0 deletions packages/types/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export type { Context } from './lib/context/context.js'
export type { Cookie } from './lib/context/cookies.js'
export type { EnvironmentVariables } from './lib/environment-variables.js'
export type { NetlifyGlobal } from './lib/globals.js'
export type { Site, SiteConfig } from './lib/context/site.js'