diff --git a/package-lock.json b/package-lock.json index 22671d5dc12..e5bdb105437 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@fastify/static": "7.0.4", + "@netlify/ai": "0.2.1", "@netlify/api": "14.0.4", "@netlify/blobs": "10.0.10", "@netlify/build": "35.1.6", @@ -18,7 +19,7 @@ "@netlify/config": "24.0.3", "@netlify/dev-utils": "4.1.3", "@netlify/edge-bundler": "14.5.4", - "@netlify/edge-functions-bootstrap": "2.14.0", + "@netlify/edge-functions": "2.17.4", "@netlify/headers-parser": "9.0.2", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", @@ -114,7 +115,7 @@ "@bugsnag/js": "8.4.0", "@eslint/compat": "1.3.2", "@eslint/js": "9.24.0", - "@netlify/edge-functions": "2.17.4", + "@netlify/edge-functions-bootstrap": "2.14.0", "@netlify/functions": "3.0.4", "@netlify/types": "2.0.3", "@sindresorhus/slugify": "2.2.1", @@ -176,6 +177,55 @@ "node": ">=20.12.2" } }, + "../primitives/packages/ai-gateway": { + "name": "@netlify/ai-gateway", + "version": "1.0.0", + "extraneous": true, + "dependencies": { + "@netlify/api": "^14.0.4" + }, + "devDependencies": { + "@types/node": "20.14.15", + "npm-run-all2": "^7.0.2", + "tsup": "8.5.0", + "typescript": "5.5.4", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20.6.1" + }, + "peerDependencies": { + "@netlify/api": ">=14.0.0" + } + }, + "../primitives/packages/dev": { + "name": "@netlify/dev", + "version": "4.5.9", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@netlify/blobs": "10.0.10", + "@netlify/config": "^23.2.0", + "@netlify/dev-utils": "4.1.3", + "@netlify/edge-functions": "2.17.4", + "@netlify/functions": "4.2.5", + "@netlify/headers": "2.0.11", + "@netlify/images": "1.2.7", + "@netlify/redirects": "3.0.12", + "@netlify/runtime": "4.0.15", + "@netlify/static": "3.0.10", + "ulid": "^3.0.0" + }, + "devDependencies": { + "@netlify/api": "^14.0.4", + "@netlify/types": "2.0.3", + "tsup": "^8.0.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2006,6 +2056,20 @@ "node": ">=18" } }, + "node_modules/@netlify/ai": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.2.1.tgz", + "integrity": "sha512-pc30UjYtmoP9XyY6b+xyD/Xh3RYtuc3VcboKU0Ojdv3fX27NUEy3ZLYlmhHB+8E1zVHhyHsoBHqTt/He/YuhXw==", + "dependencies": { + "@netlify/api": "^14.0.4" + }, + "engines": { + "node": ">=20.6.1" + }, + "peerDependencies": { + "@netlify/api": ">=14.0.0" + } + }, "node_modules/@netlify/api": { "version": "14.0.4", "resolved": "https://registry.npmjs.org/@netlify/api/-/api-14.0.4.tgz", @@ -2795,7 +2859,6 @@ "version": "2.17.4", "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.17.4.tgz", "integrity": "sha512-r8cmsTxlF7TUAmVCplS14H+HQewhfqKaCiVshAr4rlSdCfH1QqygMPWFAxgWNhLcgFQOVYH1+uT0fUZOTlVoiA==", - "dev": true, "dependencies": { "@netlify/dev-utils": "4.1.3", "@netlify/edge-bundler": "^14.5.2", @@ -2811,13 +2874,13 @@ "node_modules/@netlify/edge-functions-bootstrap": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/@netlify/edge-functions-bootstrap/-/edge-functions-bootstrap-2.14.0.tgz", - "integrity": "sha512-Fs1cQ+XKfKr2OxrAvmX+S46CJmrysxBdCUCTk/wwcCZikrDvsYUFG7FTquUl4JfAf9taYYyW/tPv35gKOKS8BQ==" + "integrity": "sha512-Fs1cQ+XKfKr2OxrAvmX+S46CJmrysxBdCUCTk/wwcCZikrDvsYUFG7FTquUl4JfAf9taYYyW/tPv35gKOKS8BQ==", + "dev": true }, "node_modules/@netlify/edge-functions/node_modules/@netlify/dev-utils": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.1.3.tgz", "integrity": "sha512-Cc8XNyKNVPWmRJAMVD8VICdYvVxZ66uoVdDzSyhrctw0cT7hW3NAlXF/xoLFK7uOV1xejah/Qt+2MPCJn32mqg==", - "dev": true, "dependencies": { "@whatwg-node/server": "^0.10.0", "ansis": "^4.1.0", @@ -2842,14 +2905,12 @@ "node_modules/@netlify/edge-functions/node_modules/@netlify/edge-functions-bootstrap": { "version": "2.16.3", "resolved": "https://registry.npmjs.org/@netlify/edge-functions-bootstrap/-/edge-functions-bootstrap-2.16.3.tgz", - "integrity": "sha512-ae/bJpeAnHZK8ailEKfcV4smmkbCAORpVyU3WgYJwvKp94NOXzo+OWQZbCNIbcEMq2q7luRdmu1KeREh4D5Qgg==", - "dev": true + "integrity": "sha512-ae/bJpeAnHZK8ailEKfcV4smmkbCAORpVyU3WgYJwvKp94NOXzo+OWQZbCNIbcEMq2q7luRdmu1KeREh4D5Qgg==" }, "node_modules/@netlify/edge-functions/node_modules/get-port": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", - "dev": true, "engines": { "node": ">=16" }, @@ -3437,7 +3498,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.0.3.tgz", "integrity": "sha512-OcV8ivKTdsyANqVSQzbusOA7FVtE9s6zwxNCGR/aNnQaVxMUgm93UzKgfR7cZ1nnQNZHAbjd0dKJKaAUqrzbMw==", - "dev": true, "engines": { "node": "^18.14.0 || >=20" } @@ -20558,6 +20618,14 @@ "strict-event-emitter": "^0.5.1" } }, + "@netlify/ai": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.2.1.tgz", + "integrity": "sha512-pc30UjYtmoP9XyY6b+xyD/Xh3RYtuc3VcboKU0Ojdv3fX27NUEy3ZLYlmhHB+8E1zVHhyHsoBHqTt/He/YuhXw==", + "requires": { + "@netlify/api": "^14.0.4" + } + }, "@netlify/api": { "version": "14.0.4", "resolved": "https://registry.npmjs.org/@netlify/api/-/api-14.0.4.tgz", @@ -21085,7 +21153,6 @@ "version": "2.17.4", "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.17.4.tgz", "integrity": "sha512-r8cmsTxlF7TUAmVCplS14H+HQewhfqKaCiVshAr4rlSdCfH1QqygMPWFAxgWNhLcgFQOVYH1+uT0fUZOTlVoiA==", - "dev": true, "requires": { "@netlify/dev-utils": "4.1.3", "@netlify/edge-bundler": "^14.5.2", @@ -21099,7 +21166,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.1.3.tgz", "integrity": "sha512-Cc8XNyKNVPWmRJAMVD8VICdYvVxZ66uoVdDzSyhrctw0cT7hW3NAlXF/xoLFK7uOV1xejah/Qt+2MPCJn32mqg==", - "dev": true, "requires": { "@whatwg-node/server": "^0.10.0", "ansis": "^4.1.0", @@ -21121,21 +21187,20 @@ "@netlify/edge-functions-bootstrap": { "version": "2.16.3", "resolved": "https://registry.npmjs.org/@netlify/edge-functions-bootstrap/-/edge-functions-bootstrap-2.16.3.tgz", - "integrity": "sha512-ae/bJpeAnHZK8ailEKfcV4smmkbCAORpVyU3WgYJwvKp94NOXzo+OWQZbCNIbcEMq2q7luRdmu1KeREh4D5Qgg==", - "dev": true + "integrity": "sha512-ae/bJpeAnHZK8ailEKfcV4smmkbCAORpVyU3WgYJwvKp94NOXzo+OWQZbCNIbcEMq2q7luRdmu1KeREh4D5Qgg==" }, "get-port": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", - "dev": true + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==" } } }, "@netlify/edge-functions-bootstrap": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/@netlify/edge-functions-bootstrap/-/edge-functions-bootstrap-2.14.0.tgz", - "integrity": "sha512-Fs1cQ+XKfKr2OxrAvmX+S46CJmrysxBdCUCTk/wwcCZikrDvsYUFG7FTquUl4JfAf9taYYyW/tPv35gKOKS8BQ==" + "integrity": "sha512-Fs1cQ+XKfKr2OxrAvmX+S46CJmrysxBdCUCTk/wwcCZikrDvsYUFG7FTquUl4JfAf9taYYyW/tPv35gKOKS8BQ==", + "dev": true }, "@netlify/functions": { "version": "3.0.4", @@ -21470,8 +21535,7 @@ "@netlify/types": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.0.3.tgz", - "integrity": "sha512-OcV8ivKTdsyANqVSQzbusOA7FVtE9s6zwxNCGR/aNnQaVxMUgm93UzKgfR7cZ1nnQNZHAbjd0dKJKaAUqrzbMw==", - "dev": true + "integrity": "sha512-OcV8ivKTdsyANqVSQzbusOA7FVtE9s6zwxNCGR/aNnQaVxMUgm93UzKgfR7cZ1nnQNZHAbjd0dKJKaAUqrzbMw==" }, "@netlify/zip-it-and-ship-it": { "version": "14.1.7", diff --git a/package.json b/package.json index f1f890f7a12..1f9651f5d4d 100644 --- a/package.json +++ b/package.json @@ -62,10 +62,11 @@ "@netlify/blobs": "10.0.10", "@netlify/build": "35.1.6", "@netlify/build-info": "10.0.7", + "@netlify/ai": "0.2.1", "@netlify/config": "24.0.3", "@netlify/dev-utils": "4.1.3", "@netlify/edge-bundler": "14.5.4", - "@netlify/edge-functions-bootstrap": "2.14.0", + "@netlify/edge-functions": "2.17.4", "@netlify/headers-parser": "9.0.2", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", @@ -157,7 +158,7 @@ "@bugsnag/js": "8.4.0", "@eslint/compat": "1.3.2", "@eslint/js": "9.24.0", - "@netlify/edge-functions": "2.17.4", + "@netlify/edge-functions-bootstrap": "2.14.0", "@netlify/functions": "3.0.4", "@netlify/types": "2.0.3", "@sindresorhus/slugify": "2.2.1", diff --git a/src/commands/dev/dev.ts b/src/commands/dev/dev.ts index 6102e652b38..66f463c9574 100644 --- a/src/commands/dev/dev.ts +++ b/src/commands/dev/dev.ts @@ -19,6 +19,8 @@ import { netlifyCommand, } from '../../utils/command-helpers.js' import detectServerSettings, { getConfigWithPlugins } from '../../utils/detect-server-settings.js' +import { parseAIGatewayContext, setupAIGateway } from '@netlify/ai/bootstrap' + import { UNLINKED_SITE_MOCK_ID, getDotEnvVariables, getSiteInformation, injectEnvVariables } from '../../utils/dev.js' import { getEnvelopeEnv } from '../../utils/env/index.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' @@ -143,8 +145,6 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { } env = await getDotEnvVariables({ devConfig, env, site }) - injectEnvVariables(env) - await promptEditorHelper({ chalk, config, log, NETLIFYDEVLOG, repositoryRoot, state }) const { accountId, addonsUrls, capabilities, siteUrl, timeouts } = await getSiteInformation({ // inherited from base command --offline @@ -155,6 +155,14 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { siteInfo, }) + if (!options.offline && !options.offlineEnv) { + await setupAIGateway({ api, env, siteID: site.id, siteURL: siteUrl }) + } + + injectEnvVariables(env) + + await promptEditorHelper({ chalk, config, log, NETLIFYDEVLOG, repositoryRoot, state }) + let settings: ServerSettings try { settings = await detectServerSettings(devConfig, options, command) @@ -204,7 +212,11 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { // FIXME(serhalp): `applyMutations` is `(any, any) => any)`. Add types in `@netlify/config`. const mutatedConfig: typeof config = applyMutations(config, configMutations) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const aiGatewayContext = parseAIGatewayContext(env.AI_GATEWAY?.value) + const functionsRegistry = await startFunctionsServer({ + aiGatewayContext, blobsContext, command, config: mutatedConfig, diff --git a/src/commands/functions/functions-serve.ts b/src/commands/functions/functions-serve.ts index 8ddd388fc43..57a11134de6 100644 --- a/src/commands/functions/functions-serve.ts +++ b/src/commands/functions/functions-serve.ts @@ -2,6 +2,8 @@ import { join } from 'path' import { OptionValues } from 'commander' +import { parseAIGatewayContext, setupAIGateway } from '@netlify/ai/bootstrap' + import { getBlobsContextWithEdgeAccess } from '../../lib/blobs/blobs.js' import { startFunctionsServer } from '../../lib/functions/server.js' import { printBanner } from '../../utils/dev-server-banner.js' @@ -28,7 +30,6 @@ export const functionsServe = async (options: OptionValues, command: BaseCommand env.NETLIFY_DEV = { sources: ['internal'], value: 'true' } env = await getDotEnvVariables({ devConfig: { ...config.dev }, env, site }) - injectEnvVariables(env) const { accountId, capabilities, siteUrl, timeouts } = await getSiteInformation({ offline: options.offline, @@ -37,6 +38,12 @@ export const functionsServe = async (options: OptionValues, command: BaseCommand siteInfo, }) + if (!options.offline) { + await setupAIGateway({ api, env, siteID: site.id, siteURL: siteUrl }) + } + + injectEnvVariables(env) + const functionsPort = await acquirePort({ configuredPort: options.port || config.dev?.functionsPort, defaultPort: DEFAULT_PORT, @@ -49,8 +56,11 @@ export const functionsServe = async (options: OptionValues, command: BaseCommand siteID: site.id ?? UNLINKED_SITE_MOCK_ID, }) + const aiGatewayContext = parseAIGatewayContext(env.AI_GATEWAY?.value) + await startFunctionsServer({ loadDistFunctions: process.env.NETLIFY_FUNCTIONS_SERVE_LOAD_DIST_FUNCTIONS === 'true', + aiGatewayContext, blobsContext, config, debug: options.debug, diff --git a/src/lib/functions/netlify-function.ts b/src/lib/functions/netlify-function.ts index 4d348f96fde..7c91c98bc47 100644 --- a/src/lib/functions/netlify-function.ts +++ b/src/lib/functions/netlify-function.ts @@ -10,6 +10,7 @@ import semver from 'semver' import { logAndThrowError, type NormalizedCachedConfigConfig } from '../../utils/command-helpers.js' import { BACKGROUND } from '../../utils/functions/get-functions.js' import { type BlobsContextWithEdgeAccess, getBlobsEventProperty } from '../blobs/blobs.js' +import type { AIGatewayContext } from '@netlify/ai/bootstrap' import type { ServerSettings } from '../../utils/types.js' import type { BaseBuildResult, InvokeFunctionResult, Runtime } from './runtimes/index.js' @@ -42,6 +43,7 @@ const getNextRun = function (schedule: string) { } export default class NetlifyFunction { + private readonly aiGatewayContext?: AIGatewayContext | null private readonly blobsContext: BlobsContextWithEdgeAccess private readonly config: NormalizedCachedConfigConfig private readonly directory?: string @@ -74,6 +76,7 @@ export default class NetlifyFunction { private srcFiles = new Set() constructor({ + aiGatewayContext, blobsContext, config, directory, @@ -87,6 +90,7 @@ export default class NetlifyFunction { timeoutBackground, timeoutSynchronous, }: { + aiGatewayContext?: AIGatewayContext | null blobsContext: BlobsContextWithEdgeAccess config: NormalizedCachedConfigConfig directory?: string @@ -101,6 +105,7 @@ export default class NetlifyFunction { timeoutBackground?: number timeoutSynchronous?: number }) { + this.aiGatewayContext = aiGatewayContext this.blobsContext = blobsContext this.config = config this.directory = directory @@ -284,6 +289,11 @@ export default class NetlifyFunction { event.blobs = Buffer.from(payload).toString('base64') } + if (this.aiGatewayContext) { + const payload = JSON.stringify(this.aiGatewayContext) + event.aiGateway = Buffer.from(payload).toString('base64') + } + try { const result = await this.runtime.invokeFunction({ context, diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts index 8c367c375e3..53927db23fa 100644 --- a/src/lib/functions/registry.ts +++ b/src/lib/functions/registry.ts @@ -23,6 +23,7 @@ import { INTERNAL_FUNCTIONS_FOLDER, SERVE_FUNCTIONS_FOLDER } from '../../utils/f import type { BlobsContextWithEdgeAccess } from '../blobs/blobs.js' import { BACKGROUND_FUNCTIONS_WARNING } from '../log.js' import { getPathInProject } from '../settings.js' +import type { AIGatewayContext } from '@netlify/ai/bootstrap' import type { ServerSettings } from '../../utils/types.js' import NetlifyFunction from './netlify-function.js' @@ -67,6 +68,11 @@ export class FunctionsRegistry { */ private blobsContext: BlobsContextWithEdgeAccess + /** + * Context object for Netlify AI Gateway + */ + private aiGatewayContext?: AIGatewayContext | null + private buildCommandCache?: MemoizeCache> private capabilities: { backgroundFunctions?: boolean @@ -84,6 +90,7 @@ export class FunctionsRegistry { private timeouts: { backgroundFunctions: number; syncFunctions: number } constructor({ + aiGatewayContext, blobsContext, capabilities, config, @@ -97,6 +104,7 @@ export class FunctionsRegistry { settings, timeouts, }: { + aiGatewayContext?: AIGatewayContext | null blobsContext: BlobsContextWithEdgeAccess buildCache?: Record capabilities: { @@ -124,6 +132,7 @@ export class FunctionsRegistry { this.timeouts = timeouts this.settings = settings this.blobsContext = blobsContext + this.aiGatewayContext = aiGatewayContext /** * An object to be shared among all functions in the registry. It can be @@ -547,6 +556,7 @@ export class FunctionsRegistry { } const func = new NetlifyFunction({ + aiGatewayContext: this.aiGatewayContext, blobsContext: this.blobsContext, config: this.config, mainFile, diff --git a/src/lib/functions/server.ts b/src/lib/functions/server.ts index faa5d2a78c0..3a415db19d0 100644 --- a/src/lib/functions/server.ts +++ b/src/lib/functions/server.ts @@ -24,6 +24,7 @@ import { NFFunctionName, NFFunctionRoute } from '../../utils/headers.js' import type { BlobsContextWithEdgeAccess } from '../blobs/blobs.js' import { headers as efHeaders } from '../edge-functions/headers.js' import { getGeoLocation } from '../geo-location.js' +import type { AIGatewayContext } from '@netlify/ai/bootstrap' import type { LocalState, ServerSettings, SiteInfo } from '../../utils/types.js' import { handleBackgroundFunction, handleBackgroundFunctionResult } from './background.js' @@ -299,6 +300,7 @@ const getFunctionsServer = (options: GetFunctionsServerOptions) => { export const startFunctionsServer = async ( options: { + aiGatewayContext?: AIGatewayContext | null blobsContext: BlobsContextWithEdgeAccess command: BaseCommand config: NormalizedCachedConfigConfig @@ -316,6 +318,7 @@ export const startFunctionsServer = async ( } & Omit, ): Promise => { const { + aiGatewayContext, blobsContext, capabilities, command, @@ -378,6 +381,7 @@ export const startFunctionsServer = async ( } const functionsRegistry = new FunctionsRegistry({ + aiGatewayContext, blobsContext, capabilities, config, diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 4de6efce2fd..198037a18af 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -122,6 +122,7 @@ const BACKGROUND_FUNCTION_TIMEOUT = 900 * @param {*} config.siteInfo * @returns */ + // @ts-expect-error TS(7031) FIXME: Binding element 'api' implicitly has an 'any' type... Remove this comment to see the full error message export const getSiteInformation = async ({ api, offline, site, siteInfo }) => { if (site.id && !offline) { diff --git a/tests/integration/commands/dev/ai-gateway.test.ts b/tests/integration/commands/dev/ai-gateway.test.ts new file mode 100644 index 00000000000..8ca61d85ae8 --- /dev/null +++ b/tests/integration/commands/dev/ai-gateway.test.ts @@ -0,0 +1,273 @@ +import { describe, test } from 'vitest' + +import { withDevServer } from '../../utils/dev-server.js' +import { withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { + assertAIGatewayValue, + createAIGatewayCheckFunction, + createAIGatewayTestData, + createMockApiFailureRoutes, +} from '../../utils/ai-gateway-helpers.js' + +describe.concurrent('AI Gateway Integration', () => { + test('should setup AI Gateway environment when site is linked and online', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo, aiGatewayToken, routes } = createAIGatewayTestData() + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withMockApi(routes, async ({ apiUrl }) => { + await withDevServer( + { + cwd: builder.directory, + offline: false, + env: { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + }, + }, + async (server) => { + const response = await fetch(`${server.url}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean; aiGatewayValue: string | null } + + t.expect(response.status).toBe(200) + assertAIGatewayValue(t, result, aiGatewayToken.token, `${siteInfo.ssl_url}/.netlify/ai`) + }, + ) + }) + }) + }) + + test('should not setup AI Gateway when site is unlinked', async (t) => { + await withSiteBuilder(t, async (builder) => { + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withDevServer( + { + cwd: builder.directory, + offline: false, + env: { + NETLIFY_AUTH_TOKEN: 'fake-token', + AI_GATEWAY: undefined, + }, + }, + async (server) => { + const response = await fetch(`${server.url}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean } + + t.expect(response.status).toBe(200) + t.expect(result.hasAIGateway).toBe(false) + }, + ) + }) + }) + + test('should not setup AI Gateway when offline', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo } = createAIGatewayTestData() + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withDevServer( + { + cwd: builder.directory, + offline: true, + env: { + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + AI_GATEWAY: undefined, + }, + }, + async (server) => { + const response = await fetch(`${server.url}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean } + + t.expect(response.status).toBe(200) + t.expect(result.hasAIGateway).toBe(false) + }, + ) + }) + }) + + test('should not setup AI Gateway when no siteUrl', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo: baseSiteInfo } = createAIGatewayTestData() + const siteInfo = { ...baseSiteInfo, ssl_url: null } + const checkFunction = createAIGatewayCheckFunction() + + const routes = [ + { path: 'sites/test-site-id', response: siteInfo }, + { path: 'sites/test-site-id/service-instances', response: [] }, + { path: 'accounts', response: [{ slug: siteInfo.account_slug }] }, + { path: 'accounts/test-account/env', response: [] }, + ] + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withMockApi(routes, async ({ apiUrl }) => { + await withDevServer( + { + cwd: builder.directory, + offline: false, + env: { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + AI_GATEWAY: undefined, + }, + }, + async (server) => { + const response = await fetch(`${server.url}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean } + + t.expect(response.status).toBe(200) + t.expect(result.hasAIGateway).toBe(false) + }, + ) + }) + }) + }) + + test('should handle AI Gateway API failures gracefully', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo } = createAIGatewayTestData() + const routes = createMockApiFailureRoutes(siteInfo) + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withMockApi(routes, async ({ apiUrl }) => { + await withDevServer( + { + cwd: builder.directory, + offline: false, + env: { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + AI_GATEWAY: undefined, + }, + }, + async (server) => { + const response = await fetch(`${server.url}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean } + + t.expect(response.status).toBe(200) + t.expect(result.hasAIGateway).toBe(false) + }, + ) + }) + }) + }) + + test('should work with V2 functions', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo, aiGatewayToken, routes } = createAIGatewayTestData() + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withMockApi(routes, async ({ apiUrl }) => { + await withDevServer( + { + cwd: builder.directory, + offline: false, + env: { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + }, + }, + async (server) => { + const response = await fetch(`${server.url}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean; aiGatewayValue: string | null } + + t.expect(response.status).toBe(200) + assertAIGatewayValue(t, result, aiGatewayToken.token, `${siteInfo.ssl_url}/.netlify/ai`) + }, + ) + }) + }) + }) + + test('should work with staging environment URLs', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo: baseSiteInfo, aiGatewayToken: baseToken } = createAIGatewayTestData() + const siteInfo = { ...baseSiteInfo, ssl_url: 'https://test-site--staging.netlify.app' } + const aiGatewayToken = { ...baseToken, token: 'ai-gateway-token-staging' } + const checkFunction = createAIGatewayCheckFunction() + + const routes = [ + { path: 'sites/test-site-id', response: siteInfo }, + { path: 'sites/test-site-id/service-instances', response: [] }, + { path: 'accounts', response: [{ slug: siteInfo.account_slug }] }, + { path: 'accounts/test-account/env', response: [] }, + { path: 'sites/test-site-id/ai-gateway/token', response: aiGatewayToken }, + ] + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withMockApi(routes, async ({ apiUrl }) => { + await withDevServer( + { + cwd: builder.directory, + offline: false, + env: { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + }, + }, + async (server) => { + const response = await fetch(`${server.url}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean; aiGatewayValue: string | null } + + t.expect(response.status).toBe(200) + assertAIGatewayValue(t, result, aiGatewayToken.token, `${siteInfo.ssl_url}/.netlify/ai`) + }, + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/functions-serve/functions-serve.test.ts b/tests/integration/commands/functions-serve/functions-serve.test.ts index d18f00fe775..0cf84a8d711 100644 --- a/tests/integration/commands/functions-serve/functions-serve.test.ts +++ b/tests/integration/commands/functions-serve/functions-serve.test.ts @@ -7,8 +7,14 @@ import { describe, test } from 'vitest' import waitPort from 'wait-port' import { cliPath } from '../../utils/cli-path.js' +import { withMockApi } from '../../utils/mock-api.js' import { type SiteBuilder, withSiteBuilder } from '../../utils/site-builder.js' import { InvokeFunctionResult } from '../../../../src/lib/functions/runtimes/index.js' +import { + assertAIGatewayValue, + createAIGatewayCheckFunction, + createAIGatewayTestData, +} from '../../utils/ai-gateway-helpers.js' const DEFAULT_PORT = 9999 const SERVE_TIMEOUT = 180_000 @@ -18,10 +24,12 @@ const withFunctionsServer = async ( args = [], builder, port = DEFAULT_PORT, + env = {}, }: { args?: string[] builder: SiteBuilder port?: number + env?: NodeJS.ProcessEnv }, testHandler: () => Promise, ) => { @@ -29,6 +37,7 @@ const withFunctionsServer = async ( try { ps = execa(cliPath, ['functions:serve', ...args], { cwd: builder.directory, + env: { ...process.env, ...env }, }) ps.stdout?.on('data', (data: Buffer) => { @@ -193,4 +202,110 @@ describe.concurrent('functions:serve command', () => { }) }) }) + + test('should inject AI Gateway when linked site and online', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo, aiGatewayToken, routes } = createAIGatewayTestData() + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withMockApi(routes, async ({ apiUrl }) => { + const port = await getPort() + await withFunctionsServer( + { + builder, + args: ['--port', port.toString()], + port, + env: { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + }, + }, + async () => { + const response = await fetch(`http://localhost:${port.toString()}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean; aiGatewayValue: string | null } + + t.expect(response.status).toBe(200) + assertAIGatewayValue(t, result, aiGatewayToken.token, `${siteInfo.ssl_url}/.netlify/ai`) + }, + ) + }) + }) + }) + + test('should not inject AI Gateway when site is unlinked in functions serve', async (t) => { + await withSiteBuilder(t, async (builder) => { + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + const port = await getPort() + await withFunctionsServer( + { + builder, + args: ['--port', port.toString()], + port, + env: { + NETLIFY_AUTH_TOKEN: 'fake-token', + }, + }, + async () => { + const response = await fetch(`http://localhost:${port.toString()}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean } + + t.expect(response.status).toBe(200) + t.expect(result.hasAIGateway).toBe(false) + }, + ) + }) + }) + + test('should inject AI Gateway for V2 functions in functions serve', async (t) => { + await withSiteBuilder(t, async (builder) => { + const { siteInfo, aiGatewayToken, routes } = createAIGatewayTestData() + const checkFunction = createAIGatewayCheckFunction() + + await builder + .withContentFile({ + path: checkFunction.path, + content: checkFunction.content, + }) + .build() + + await withMockApi(routes, async ({ apiUrl }) => { + const port = await getPort() + await withFunctionsServer( + { + builder, + args: ['--port', port.toString()], + port, + env: { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: siteInfo.id, + NETLIFY_AUTH_TOKEN: 'fake-token', + }, + }, + async () => { + const response = await fetch(`http://localhost:${port.toString()}${checkFunction.urlPath}`) + const result = (await response.json()) as { hasAIGateway: boolean; aiGatewayValue: string | null } + + t.expect(response.status).toBe(200) + assertAIGatewayValue(t, result, aiGatewayToken.token, `${siteInfo.ssl_url}/.netlify/ai`) + }, + ) + }) + }) + }) }) diff --git a/tests/integration/utils/ai-gateway-helpers.ts b/tests/integration/utils/ai-gateway-helpers.ts new file mode 100644 index 00000000000..ad4a6e67b20 --- /dev/null +++ b/tests/integration/utils/ai-gateway-helpers.ts @@ -0,0 +1,85 @@ +import { TestContext } from 'vitest' + +export const createAIGatewayTestData = () => { + const siteInfo = { + account_slug: 'test-account', + id: 'test-site-id', + name: 'site-name', + ssl_url: 'https://test-site.netlify.app', + } + + const aiGatewayToken = { + token: 'ai-gateway-token-123', + url: 'https://api.netlify.com/.netlify/ai/', + } + + const routes = [ + { path: 'sites/test-site-id', response: siteInfo }, + { path: 'sites/test-site-id/service-instances', response: [] }, + { path: 'accounts', response: [{ slug: siteInfo.account_slug }] }, + { path: 'accounts/test-account/env', response: [] }, + { path: 'sites/test-site-id/ai-gateway/token', response: aiGatewayToken }, + { path: 'ai-gateway/providers', response: { providers: {} } }, + ] + + return { siteInfo, aiGatewayToken, routes } +} + +type V2Function = { + path: string + content: string + urlPath: string +} + +export function createAIGatewayCheckFunction(): V2Function { + return { + path: 'netlify/functions/check-ai-gateway.js', + content: `export default () => { + return new Response( + JSON.stringify({ + hasAIGateway: !!process.env.AI_GATEWAY, + aiGatewayValue: process.env.AI_GATEWAY || null, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) +} +export const config = { path: "/check-ai-gateway" }`, + urlPath: '/check-ai-gateway', + } +} + +export const assertAIGatewayValue = ( + t: TestContext, + result: { hasAIGateway: boolean; aiGatewayValue: string | null }, + expectedToken: string, + expectedUrl: string, +) => { + t.expect(result.hasAIGateway).toBe(true) + t.expect(result.aiGatewayValue).toBeDefined() + + if (result.aiGatewayValue) { + const decodedPayload = JSON.parse(Buffer.from(result.aiGatewayValue, 'base64').toString()) as { + token: string + url: string + } + t.expect(decodedPayload).toHaveProperty('token', expectedToken) + t.expect(decodedPayload).toHaveProperty('url', expectedUrl) + } +} + +export const createMockApiFailureRoutes = (siteInfo: { + account_slug: string + id: string + name: string + ssl_url: string | null +}) => [ + { path: 'sites/test-site-id', response: siteInfo }, + { path: 'sites/test-site-id/service-instances', response: [] }, + { path: 'accounts', response: [{ slug: siteInfo.account_slug }] }, + { path: 'accounts/test-account/env', response: [] }, + { path: 'sites/test-site-id/ai-gateway/token', status: 404, response: { message: 'Not Found' } }, + { path: 'ai-gateway/providers', status: 404, response: { message: 'Not Found' } }, +]