diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 53b0224557a..7e626ce3d55 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -301,6 +301,9 @@ }, "FunctionConfig": { "anyOf": [ + { + "$ref": "#/definitions/KitFunctionConfig" + }, { "$ref": "#/definitions/LocalFunctionConfig" }, @@ -909,6 +912,93 @@ }, "type": "object" }, + "KitFunctionConfig": { + "additionalProperties": false, + "properties": { + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "instances": { + "additionalProperties": { + "type": "string" + }, + "description": "Dictionary mapping instance IDs to their configuration directories", + "type": "object" + }, + "kit": { + "description": "Unique identifier for the functions kit (peer to codebase)", + "type": "string" + }, + "postdeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "predeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "runtime": { + "enum": [ + "dart3", + "nodejs18", + "nodejs20", + "nodejs22", + "nodejs24", + "python310", + "python311", + "python312", + "python313", + "python314" + ], + "type": "string" + }, + "source": { + "description": "Local directory containing the kit source code.", + "type": "string" + }, + "sourcePackage": { + "additionalProperties": false, + "description": "Package details when resolved from a package repository.", + "properties": { + "id": { + "description": "Package identifier (e.g., \"@firebase-functions-kits/firestore-bigquery-export\")", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "instances", + "kit", + "source" + ], + "type": "object" + }, "LocalFunctionConfig": { "additionalProperties": false, "properties": { @@ -1647,6 +1737,9 @@ }, "functions": { "anyOf": [ + { + "$ref": "#/definitions/KitFunctionConfig" + }, { "$ref": "#/definitions/LocalFunctionConfig" }, diff --git a/src/deploy/functions/functionsDeployHelper.spec.ts b/src/deploy/functions/functionsDeployHelper.spec.ts index 68c33e73d84..07b8922f7c5 100644 --- a/src/deploy/functions/functionsDeployHelper.spec.ts +++ b/src/deploy/functions/functionsDeployHelper.spec.ts @@ -5,6 +5,7 @@ import * as helper from "./functionsDeployHelper"; import { Options } from "../../options"; import { DEFAULT_CODEBASE, ValidatedConfig } from "../../functions/projectConfig"; import { EndpointFilter, parseFunctionSelector } from "./functionsDeployHelper"; +import * as experiments from "../../experiments"; describe("functionsDeployHelper", () => { const ENDPOINT: backend.Endpoint = { @@ -321,6 +322,21 @@ describe("functionsDeployHelper", () => { .be.undefined; }); + it("should create codebase filter when selector matches kit instance ID", () => { + experiments.setEnabled("kits", true); + const config = [ + { + kit: "my-kit", + source: "kits/my-kit", + instances: { "inst-1": "cfg1", "inst-2": "cfg2" }, + }, + ] as ValidatedConfig; + + const filters = helper.getEndpointFilters({ only: "functions:inst-1" }, config); + expect(filters).to.deep.equal([{ codebase: "inst-1" }]); + experiments.setEnabled("kits", null); + }); + it("should create only codebase filter when selector matches codebase name", () => { const config: ValidatedConfig = [ { source: "functions", codebase: DEFAULT_CODEBASE }, @@ -398,6 +414,24 @@ describe("functionsDeployHelper", () => { ]; expect(helper.targetCodebases(config, filters)).to.have.members(["default", "foobar"]); }); + + it("returns kit instance IDs as targeted codebases", () => { + experiments.setEnabled("kits", true); + const kitConfig: ValidatedConfig = [ + { + kit: "my-kit", + source: "kits/my-kit", + instances: { "inst-1": "c1", "inst-2": "c2" }, + } as ValidatedConfig[number], + { + source: "foo", + codebase: "default", + }, + ]; + const filters: EndpointFilter[] = [{ codebase: "inst-1" }]; + expect(helper.targetCodebases(kitConfig, filters)).to.have.members(["inst-1"]); + experiments.setEnabled("kits", null); + }); }); describe("groupEndpointsByCodebase", () => { diff --git a/src/deploy/functions/functionsDeployHelper.ts b/src/deploy/functions/functionsDeployHelper.ts index c32d7a1e062..77a8ee0fce4 100644 --- a/src/deploy/functions/functionsDeployHelper.ts +++ b/src/deploy/functions/functionsDeployHelper.ts @@ -1,5 +1,5 @@ import * as backend from "./backend"; -import { DEFAULT_CODEBASE, ValidatedConfig } from "../../functions/projectConfig"; +import { DEFAULT_CODEBASE, ValidatedConfig, isKitConfig } from "../../functions/projectConfig"; import { assertExhaustive } from "../../functional"; export interface EndpointFilter { @@ -54,23 +54,36 @@ export function endpointMatchesFilter(endpoint: backend.Endpoint, filter: Endpoi return true; } +/** + * Returns all codebase names and kit instance IDs defined in the configuration. + */ +export function getCodebasesFromConfig(config: ValidatedConfig): string[] { + return [ + ...new Set(config.flatMap((c) => (isKitConfig(c) ? Object.keys(c.instances) : [c.codebase]))), + ]; +} + /** * Returns list of filters after parsing selector. */ export function parseFunctionSelector(selector: string, config: ValidatedConfig): EndpointFilter[] { const fragments = selector.split(":"); + const target = fragments[0]; + + // Check if target matches a known codebase name or kit instance ID + const codebaseNames = getCodebasesFromConfig(config); + + if (codebaseNames.includes(target)) { + return [ + { + codebase: target, + ...(fragments.length > 1 ? { idChunks: fragments[1].split(/[-.]/) } : {}), + }, + ]; + } + if (fragments.length < 2) { - // This is a plain selector w/o codebase prefix (e.g. "abc" not "abc:efg") . - // This could mean 2 things: - // - // 1. Codebase selector (i.e. "abc" refers to a codebase). - // 2. Id filter for the DEFAULT codebase (i.e. "abc" refers to a function in the default codebase). - const codebaseNames = config.map((c) => c.codebase); - if (codebaseNames.includes(fragments[0])) { - // It's a known codebase name - return [{ codebase: fragments[0] }]; - } - // It's not a codebase name, assume it is a function id in default codebase + // It's not a codebase or kit instance name, assume it is a function id in default codebase return [{ codebase: DEFAULT_CODEBASE, idChunks: fragments[0].split(/[-.]/) }]; } return [ @@ -158,26 +171,20 @@ export function getFunctionLabel(fn: backend.TargetIds & { codebase?: string }): * Returns list of codebases specified in firebase.json filtered by --only filters if present. */ export function targetCodebases(config: ValidatedConfig, filters?: EndpointFilter[]): string[] { - const codebasesFromConfig = [...new Set(Object.values(config).map((c) => c.codebase))]; + const codebasesFromConfig = getCodebasesFromConfig(config); if (!filters) { return [...codebasesFromConfig]; } const codebasesFromFilters = [ - ...new Set(filters.map((f) => f.codebase).filter((c) => c !== undefined)), + ...new Set(filters.map((f) => f.codebase).filter((c): c is string => c !== undefined)), ]; if (codebasesFromFilters.length === 0) { return [...codebasesFromConfig]; } - const intersections: string[] = []; - for (const codebase of codebasesFromConfig) { - if (codebasesFromFilters.includes(codebase)) { - intersections.push(codebase); - } - } - return intersections; + return codebasesFromConfig.filter((codebase) => codebasesFromFilters.includes(codebase)); } /** diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 06b845d87a1..9e67266b430 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as build from "./build"; import * as prepare from "./prepare"; +import * as experiments from "../../experiments"; import * as runtimes from "./runtimes"; import * as backend from "./backend"; import * as ensureApiEnabled from "../../ensureApiEnabled"; @@ -102,6 +103,52 @@ describe("prepare", () => { expect(Object.keys(builds.codebase.endpoints)).to.deep.equal(["my-prefix-test"]); }); + it("should automatically apply the kit instance ID as the prefix for kit function builds", async () => { + experiments.setEnabled("kits", true); + discoverBuildStub.callsFake(() => + Promise.resolve( + build.of({ + test: { + platform: "gcfv2", + entryPoint: "test", + project: "project", + runtime: latest("nodejs"), + httpsTrigger: {}, + }, + }), + ), + ); + try { + const config: ValidatedConfig = [ + { + kit: "my-kit", + sourcePackage: { id: "@firebase-functions-kits/my-kit" }, + source: "source", + instances: { + "inst-alpha": "config/inst-alpha", + "inst-beta": "config/inst-beta", + }, + runtime: "nodejs22", + }, + ]; + const options = { + config: { + path: (p: string) => p, + }, + projectId: "project", + } as unknown as Options; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = {}; + + const builds = await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig); + + expect(Object.keys(builds["inst-alpha"].endpoints)).to.deep.equal(["kit-inst-alpha-test"]); + expect(Object.keys(builds["inst-beta"].endpoints)).to.deep.equal(["kit-inst-beta-test"]); + } finally { + experiments.setEnabled("kits", null); + } + }); + it("should preserve runtime from codebase config", async () => { const config: ValidatedConfig = [ { source: "source", codebase: "codebase", runtime: "nodejs20" }, diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index d43006a19e3..6f7b72270f5 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -47,6 +47,8 @@ import { ValidatedConfig, requireLocal, shouldUseRuntimeConfig, + isKitConfig, + addKitPrefix, } from "../../functions/projectConfig"; import { AUTH_BLOCKING_EVENTS } from "../../functions/events/v1"; import { generateServiceIdentity } from "../../gcp/serviceusage"; @@ -240,7 +242,12 @@ export async function prepare( // ===Phase 1. Load codebases from source with optional runtime config. let runtimeConfig: Record = { firebase: firebaseConfig }; - const targetedCodebaseConfigs = context.config.filter((cfg) => codebases.includes(cfg.codebase)); + const targetedCodebaseConfigs = context.config.filter((cfg) => { + if (isKitConfig(cfg)) { + return cfg.instances && Object.keys(cfg.instances).some((inst) => codebases.includes(inst)); + } + return cfg.codebase && codebases.includes(cfg.codebase); + }); // Load runtime config if API is enabled and at least one targeted codebase uses it if (checkAPIsEnabled[1] && targetedCodebaseConfigs.some(shouldUseRuntimeConfig)) { @@ -282,7 +289,11 @@ export async function prepare( projectId: projectId, projectAlias: options.projectAlias, }; - proto.convertIfPresent(userEnvOpt, localCfg, "configDir", (cd) => options.config.path(cd)); + if (isKitConfig(localCfg) && codebase in localCfg.instances) { + userEnvOpt.configDir = options.config.path(localCfg.instances[codebase]); + } else { + proto.convertIfPresent(userEnvOpt, localCfg, "configDir", (cd) => options.config.path(cd)); + } const rawUserEnvs = functionsEnv.loadUserEnvs(userEnvOpt); const { userEnvs: userEnvs, secretRefs: secretRefs } = partitionUserEnvs(rawUserEnvs); @@ -780,7 +791,10 @@ export async function loadCodebases( GOOGLE_CLOUD_QUOTA_PROJECT: projectId, }); discoveredBuild.runtime = codebaseConfig.runtime; - build.applyPrefix(discoveredBuild, codebaseConfig.prefix || ""); + const prefix = isKitConfig(codebaseConfig) + ? addKitPrefix(codebase) + : codebaseConfig.prefix || ""; + build.applyPrefix(discoveredBuild, prefix); wantBuilds[codebase] = discoveredBuild; } return wantBuilds; diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 493dc0834db..50e9ee3e628 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -2,7 +2,6 @@ import * as clc from "colorette"; import * as fs from "fs"; import * as path from "path"; import * as fsConfig from "../firestore/fsConfig"; -import * as proto from "../gcp/proto"; import { logger } from "../logger"; import { trackEmulator, trackGA4 } from "../track"; @@ -43,7 +42,12 @@ import { getProjectDefaultAccount } from "../auth"; import { Options } from "../options"; import { ParsedTriggerDefinition } from "./functionsEmulatorShared"; import { ExtensionsEmulator } from "./extensionsEmulator"; -import { normalizeAndValidate, requireLocal } from "../functions/projectConfig"; +import { + isKitConfig, + addKitPrefix, + normalizeAndValidate, + requireLocal, +} from "../functions/projectConfig"; import { requiresJava } from "./downloadableEmulators"; import { prepareFrameworks } from "../frameworks"; import * as experiments from "../experiments"; @@ -542,22 +546,40 @@ export async function startAll( `Cannot load functions from ${functionsDir} because it has invalid runtime ${runtime as string}`, ); } - const backend: EmulatableBackend = { - functionsDir, - runtime, - codebase: localCfg.codebase, - prefix: localCfg.prefix, - env: { - ...options.extDevEnv, - }, - secretEnv: [], // CF3 secrets are bound to specific functions, so we'll get them during trigger discovery. - // TODO(b/213335255): predefinedTriggers and nodeMajorVersion are here to support ext:dev:emulators:* commands. - // Ideally, we should handle that case via ExtensionEmulator. - predefinedTriggers: options.extDevTriggers as ParsedTriggerDefinition[] | undefined, - ignore: localCfg.ignore, - }; - proto.convertIfPresent(backend, localCfg, "configDir", (cd) => path.join(projectDir, cd)); - emulatableBackends.push(backend); + + if (isKitConfig(localCfg)) { + for (const [instanceId, configDir] of Object.entries(localCfg.instances)) { + const backend: EmulatableBackend = { + functionsDir, + runtime, + codebase: instanceId, + prefix: addKitPrefix(instanceId), + configDir: path.join(projectDir, configDir), + env: { + ...options.extDevEnv, + }, + secretEnv: [], + predefinedTriggers: options.extDevTriggers as ParsedTriggerDefinition[] | undefined, + ignore: localCfg.ignore, + }; + emulatableBackends.push(backend); + } + } else { + const backend: EmulatableBackend = { + functionsDir, + runtime, + codebase: localCfg.codebase, + ...(localCfg.prefix ? { prefix: localCfg.prefix } : {}), + ...(localCfg.configDir ? { configDir: path.join(projectDir, localCfg.configDir) } : {}), + env: { + ...options.extDevEnv, + }, + secretEnv: [], + predefinedTriggers: options.extDevTriggers as ParsedTriggerDefinition[] | undefined, + ignore: localCfg.ignore, + }; + emulatableBackends.push(backend); + } } } diff --git a/src/experiments.ts b/src/experiments.ts index 89ce6b433ee..7274013159f 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -90,6 +90,12 @@ export const ALL_EXPERIMENTS = experiments({ public: true, default: false, }, + kits: { + shortDescription: "Enable Functions Kits.", + fullDescription: "Adds support for Function Kits.", + public: false, + default: false, + }, // Emulator experiments emulatoruisnapshot: { diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 1e03fbc962d..3cfebbbbabb 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -169,16 +169,25 @@ export type DatabaseConfig = DatabaseSingle | DatabaseMultiple; export type FirestoreConfig = FirestoreSingle | FirestoreMultiple; +/** + * Base configuration options common to all Cloud Functions configurations. + */ type FunctionConfigBase = { - // Optional: Directory containing the .env files for this codebase. - // Defaults to the same directory as source if not specified. - configDir?: string; // Optional: List of glob patterns for files and directories to ignore during deployment. // Uses gitignore-style syntax. Commonly includes node_modules, .git, etc. ignore?: string[]; // Optional: The Node.js/Python runtime version to use for Cloud Functions. // Example: "nodejs20", "python312". Must be a supported runtime version. runtime?: ActiveRuntime; +} & Deployable; + +/** + * Base configuration options specific to codebase-based Cloud Functions configurations. + */ +type CodebaseFunctionConfigBase = FunctionConfigBase & { + // Optional: Directory containing the .env files for this codebase. + // Defaults to the same directory as source if not specified. + configDir?: string; // Optional: A unique identifier for this functions codebase when using multiple codebases. // Must be unique across all codebases in firebase.json. codebase?: string; @@ -186,9 +195,34 @@ type FunctionConfigBase = { // Must start with a lowercase letter; may contain lowercase letters, numbers, and dashes; // cannot start or end with a dash; maximum length 30 characters. prefix?: string; -} & Deployable; +}; + +export type KitSourcePackage = { + /** Package identifier (e.g., "@firebase-functions-kits/firestore-bigquery-export") */ + id: string; +}; + +export type KitFunctionConfig = FunctionConfigBase & { + /** Unique identifier for the functions kit (peer to codebase) */ + kit: string; + /** Package details when resolved from a package repository. */ + sourcePackage?: KitSourcePackage; + /** Local directory containing the kit source code. */ + source: string; + /** Dictionary mapping instance IDs to their configuration directories */ + // eslint-disable-next-line @typescript-eslint/ban-types + instances: { [instanceId: string]: string }; + /** codebase cannot be used in a kit stanza */ + codebase?: never; + /** remoteSource cannot be used in a kit stanza */ + remoteSource?: never; + /** prefix cannot be used in a kit stanza */ + prefix?: never; + /** configDir cannot be used in a kit stanza */ + configDir?: never; +}; -export type LocalFunctionConfig = FunctionConfigBase & { +export type LocalFunctionConfig = CodebaseFunctionConfigBase & { // Directory containing the Cloud Functions source code. source: string; // Optional: When true, prevents the Firebase CLI from fetching and including legacy @@ -197,9 +231,11 @@ export type LocalFunctionConfig = FunctionConfigBase & { disallowLegacyRuntimeConfig?: boolean; // Forbid remoteSource when local source is provided remoteSource?: never; + // Forbid kit when local source is provided + kit?: never; }; -export type RemoteFunctionConfig = FunctionConfigBase & { +export type RemoteFunctionConfig = CodebaseFunctionConfigBase & { // Deploy functions from a remote Git repository. remoteSource: { // The URL of the Git repository. @@ -213,9 +249,11 @@ export type RemoteFunctionConfig = FunctionConfigBase & { runtime: ActiveRuntime; // Forbid local source when remoteSource is provided source?: never; + // Forbid kit when remoteSource is provided + kit?: never; }; -export type FunctionConfig = LocalFunctionConfig | RemoteFunctionConfig; +export type FunctionConfig = LocalFunctionConfig | RemoteFunctionConfig | KitFunctionConfig; export type FunctionsConfig = FunctionConfig | FunctionConfig[]; diff --git a/src/functions/projectConfig.spec.ts b/src/functions/projectConfig.spec.ts index 107442222dc..45bc2225643 100644 --- a/src/functions/projectConfig.spec.ts +++ b/src/functions/projectConfig.spec.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; import * as projectConfig from "./projectConfig"; +import * as experiments from "../experiments"; import { FirebaseError } from "../error"; const TEST_CONFIG_0 = { source: "foo" }; @@ -237,6 +238,226 @@ describe("projectConfig", () => { expect(projectConfig.validate(config)).to.deep.equal(config); }); }); + + describe("kit", () => { + beforeEach(() => { + experiments.setEnabled("kits", true); + }); + + afterEach(() => { + experiments.setEnabled("kits", null); + }); + + const VALID_KIT_CONFIG = { + kit: "firestore-bigquery-export", + sourcePackage: { + id: "@firebase-functions-kits/firestore-bigquery-export", + }, + instances: { + "firestore-bigquery-export": "config/bq-instance-1", + }, + source: "functions/kits/bigquery-export", + runtime: "nodejs22" as const, + }; + + it("fails validation if kits experiment is disabled", () => { + experiments.setEnabled("kits", false); + const config: projectConfig.NormalizedConfig = [VALID_KIT_CONFIG]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /Cannot use functions kits because the experiment .*kits.* is not enabled/, + ); + }); + + it("passes validation for a valid kit config", () => { + const config: projectConfig.NormalizedConfig = [VALID_KIT_CONFIG]; + expect(projectConfig.validate(config)).to.deep.equal([VALID_KIT_CONFIG]); + }); + + it("fails validation if both kit and codebase are specified", () => { + const config = [{ ...VALID_KIT_CONFIG, codebase: "my-codebase" }]; + // @ts-expect-error Should not specify both kit and codebase + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /Cannot specify both 'kit' and 'codebase'/, + ); + }); + + it("fails validation if both kit and remoteSource are specified", () => { + const config = [ + { + ...VALID_KIT_CONFIG, + remoteSource: { repository: "repo", ref: "main" }, + }, + ]; + // @ts-expect-error Should not specify both kit and remoteSource + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /Cannot specify both 'kit' and 'remoteSource'/, + ); + }); + + it("fails validation if kit is missing source", () => { + const config = [{ kit: "firestore-bigquery-export", instances: { inst1: "path" } }]; + // @ts-expect-error kit requires source + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /Must specify 'source' in a functions kit config/, + ); + }); + + it("fails validation if kit is missing instances", () => { + const config = [ + { kit: "firestore-bigquery-export", source: "functions/kits/bigquery-export" }, + ]; + // @ts-expect-error kit requires instances + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /Must specify 'instances' as an object mapping instance IDs to configuration paths/, + ); + }); + + it("fails validation if instances is not an object", () => { + const config = [ + { + kit: "firestore-bigquery-export", + source: "functions/kits/bigquery-export", + instances: ["invalid-array-instance"], + }, + ]; + // @ts-expect-error instances must be an object + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /Must specify 'instances' as an object mapping instance IDs to configuration paths/, + ); + }); + + it("fails validation for duplicate kit names", () => { + const config: projectConfig.NormalizedConfig = [ + VALID_KIT_CONFIG, + { ...VALID_KIT_CONFIG, source: "functions/kits/bigquery-export-2" }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions.kit must be unique but 'firestore-bigquery-export' was used more than once/, + ); + }); + + it("fails validation if prefix is specified in a kit config", () => { + const config = [{ ...VALID_KIT_CONFIG, prefix: "my-prefix" }]; + // @ts-expect-error Should not specify prefix in kit config + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /Cannot specify 'prefix' in a functions kit config/, + ); + }); + + it("fails validation if kit instance IDs are duplicated across kits", () => { + const config: projectConfig.NormalizedConfig = [ + VALID_KIT_CONFIG, + { + kit: "another-kit", + source: "functions/kits/another", + instances: { + "firestore-bigquery-export": "config/bq-instance-2", + }, + }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions kit instance ID must be unique across all kits, but 'firestore-bigquery-export' was used more than once/, + ); + }); + + it("fails validation given invalid kit name", () => { + const config = [{ ...VALID_KIT_CONFIG, kit: "Invalid-Kit-Name!" }]; + expect(() => projectConfig.validate(config as any)).to.throw( + FirebaseError, + /Invalid kit name/, + ); + }); + + it("fails validation given long kit name (>40 chars)", () => { + const config = [{ ...VALID_KIT_CONFIG, kit: "a".repeat(41) }]; + expect(() => projectConfig.validate(config as any)).to.throw( + FirebaseError, + /Invalid kit name/, + ); + }); + + it("fails validation given invalid instance ID format", () => { + const config = [ + { + ...VALID_KIT_CONFIG, + instances: { "Invalid_Instance!": "config/path" }, + }, + ]; + expect(() => projectConfig.validate(config as any)).to.throw( + FirebaseError, + /Invalid kit instance ID/, + ); + }); + + it("fails validation if instance ID starts with a dash", () => { + const config = [ + { + ...VALID_KIT_CONFIG, + instances: { "-invalid-instance": "config/path" }, + }, + ]; + expect(() => projectConfig.validate(config as any)).to.throw( + FirebaseError, + /Invalid kit instance ID.*cannot start or end with a dash/, + ); + }); + + it("fails validation if instance ID ends with a dash", () => { + const config = [ + { + ...VALID_KIT_CONFIG, + instances: { "invalid-instance-": "config/path" }, + }, + ]; + expect(() => projectConfig.validate(config as any)).to.throw( + FirebaseError, + /Invalid kit instance ID.*cannot start or end with a dash/, + ); + }); + + it("fails validation if kit instance IDs are duplicated across kits", () => { + const config: projectConfig.NormalizedConfig = [ + VALID_KIT_CONFIG, + { + kit: "another-kit", + source: "functions/kits/another", + instances: { + "firestore-bigquery-export": "config/bq-instance-2", + }, + }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions kit instance ID must be unique across all kits, but 'firestore-bigquery-export' was used more than once/, + ); + }); + + it("fails validation if codebase name conflicts with a kit instance ID", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "functions", codebase: "bq-instance-1" }, + { + kit: "my-kit", + source: "functions/kits/my-kit", + instances: { + "bq-instance-1": "config/bq-instance-1", + }, + }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions codebase name and kit instance ID must be mutually exclusive, but 'bq-instance-1' was used as both a codebase name and a kit instance ID/, + ); + }); + }); }); describe("normalizeAndValidate", () => { @@ -285,20 +506,44 @@ describe("projectConfig", () => { }); }); - describe("isLocalConfig/isRemoteConfig", () => { + describe("isLocalConfig/isRemoteConfig/isKitConfig", () => { + beforeEach(() => { + experiments.setEnabled("kits", true); + }); + + afterEach(() => { + experiments.setEnabled("kits", null); + }); + const localCfg = { source: "local" }; const remoteCfg = { remoteSource: { repository: "repo", ref: "main" }, runtime: "nodejs20" as const, }; + const kitCfg = { + kit: "my-kit", + source: "kits/my-kit", + instances: { + "my-kit": "kits/my-kit", + }, + }; + it("isLocalConfig narrow correctly", () => { const local = projectConfig.validate([localCfg])[0]; const remote = projectConfig.validate([remoteCfg])[0]; + const kit = projectConfig.validate([kitCfg])[0]; expect(projectConfig.isLocalConfig(local)).to.equal(true); expect(projectConfig.isRemoteConfig(local)).to.equal(false); + expect(projectConfig.isKitConfig(local)).to.equal(false); + expect(projectConfig.isLocalConfig(remote)).to.equal(false); expect(projectConfig.isRemoteConfig(remote)).to.equal(true); + expect(projectConfig.isKitConfig(remote)).to.equal(false); + + expect(projectConfig.isLocalConfig(kit)).to.equal(false); + expect(projectConfig.isRemoteConfig(kit)).to.equal(false); + expect(projectConfig.isKitConfig(kit)).to.equal(true); }); it("isRemoteConfig narrow correctly", () => { @@ -353,6 +598,28 @@ describe("projectConfig", () => { ])[0]; expect(projectConfig.resolveConfigDir(cfg)).to.be.undefined; }); + + it("returns instance configDir for kit configs when instanceId is provided", () => { + experiments.setEnabled("kits", true); + try { + const cfg = projectConfig.validate([ + { + kit: "my-kit", + sourcePackage: { id: "@firebase-functions-kits/my-kit" }, + source: "kit-source", + instances: { + "inst-alpha": "config/inst-alpha", + "inst-beta": "config/inst-beta", + }, + }, + ])[0]; + expect(projectConfig.resolveConfigDir(cfg, "inst-alpha")).to.equal("config/inst-alpha"); + expect(projectConfig.resolveConfigDir(cfg, "inst-beta")).to.equal("config/inst-beta"); + expect(projectConfig.resolveConfigDir(cfg)).to.be.undefined; + } finally { + experiments.setEnabled("kits", null); + } + }); }); describe("shouldUseRuntimeConfig", () => { @@ -399,4 +666,39 @@ describe("projectConfig", () => { }); } }); + + describe("configForCodebase", () => { + beforeEach(() => { + experiments.setEnabled("kits", true); + }); + + afterEach(() => { + experiments.setEnabled("kits", null); + }); + + it("returns config for standard codebase name", () => { + const config = projectConfig.validate([{ codebase: "my-codebase", source: "functions" }]); + expect(projectConfig.configForCodebase(config, "my-codebase")).to.deep.equal(config[0]); + }); + + it("returns config for kit instance ID", () => { + const config = projectConfig.validate([ + { + kit: "my-kit", + source: "kits/my-kit", + instances: { "instance-1": "cfg1", "instance-2": "cfg2" }, + }, + ]); + expect(projectConfig.configForCodebase(config, "instance-1")).to.deep.equal(config[0]); + expect(projectConfig.configForCodebase(config, "instance-2")).to.deep.equal(config[0]); + }); + + it("throws if codebase or instance ID is not found", () => { + const config = projectConfig.validate([{ codebase: "my-codebase", source: "functions" }]); + expect(() => projectConfig.configForCodebase(config, "unknown")).to.throw( + FirebaseError, + /No functions config found for codebase or kit instance unknown/, + ); + }); + }); }); diff --git a/src/functions/projectConfig.ts b/src/functions/projectConfig.ts index a9da9e0dafe..d13c9f8c9e6 100644 --- a/src/functions/projectConfig.ts +++ b/src/functions/projectConfig.ts @@ -1,12 +1,13 @@ -import { FunctionsConfig, FunctionConfig } from "../firebaseConfig"; +import { FunctionsConfig, FunctionConfig, KitSourcePackage } from "../firebaseConfig"; import { FirebaseError } from "../error"; import type { ActiveRuntime } from "../deploy/functions/runtimes/supported/types"; +import * as experiments from "../experiments"; export type NormalizedConfig = [FunctionConfig, ...FunctionConfig[]]; -// Stronger validated variants: local vs remote. +// Stronger validated variants: local vs remote vs kit. type FunctionConfigCommon = Omit< FunctionConfig, - "source" | "remoteSource" | "codebase" | "runtime" + "source" | "remoteSource" | "codebase" | "runtime" | "kit" | "sourcePackage" | "instances" >; export type ValidatedLocalSingle = FunctionConfigCommon & { @@ -16,6 +17,7 @@ export type ValidatedLocalSingle = FunctionConfigCommon & { runtime?: ActiveRuntime; remoteSource?: never; disallowLegacyRuntimeConfig?: boolean; + kit?: never; }; export type ValidatedRemoteSingle = FunctionConfigCommon & { @@ -24,9 +26,21 @@ export type ValidatedRemoteSingle = FunctionConfigCommon & { runtime: ActiveRuntime; codebase: string; source?: never; + kit?: never; }; -export type ValidatedSingle = ValidatedLocalSingle | ValidatedRemoteSingle; +export type ValidatedKitSingle = FunctionConfigCommon & { + kit: string; + sourcePackage?: KitSourcePackage; + source: string; + instances: Record; + codebase?: never; + runtime?: ActiveRuntime; + remoteSource?: never; + prefix?: never; +}; + +export type ValidatedSingle = ValidatedLocalSingle | ValidatedRemoteSingle | ValidatedKitSingle; export type ValidatedConfig = [ValidatedSingle, ...ValidatedSingle[]]; export const DEFAULT_CODEBASE = "default"; @@ -61,6 +75,18 @@ export function validateCodebase(codebase: string): void { } } +/** + * Check that the kit name is 40 characters or less and only contains allowed characters. + */ +export function validateKit(kit: string): void { + if (kit.length === 0 || kit.length > 40 || !/^[a-z0-9_-]+$/.test(kit)) { + throw new FirebaseError( + "Invalid kit name. Kit name must be 40 characters or less and " + + "can contain only lowercase letters, numeric characters, underscores, and dashes.", + ); + } +} + /** * Check that the prefix contains only allowed characters. */ @@ -77,6 +103,40 @@ export function validatePrefix(prefix: string): void { } function validateSingle(config: FunctionConfig): ValidatedSingle { + if ("kit" in config) { + experiments.assertEnabled("kits", "use functions kits"); + if (!config.kit) { + throw new FirebaseError("Must specify 'kit' name in a functions kit config."); + } + validateKit(config.kit); + if (config.codebase) { + throw new FirebaseError( + "Cannot specify both 'kit' and 'codebase' in a single functions config.", + ); + } + if (config.remoteSource) { + throw new FirebaseError( + "Cannot specify both 'kit' and 'remoteSource' in a single functions config.", + ); + } + if (config.prefix) { + throw new FirebaseError("Cannot specify 'prefix' in a functions kit config."); + } + if (!config.source) { + throw new FirebaseError("Must specify 'source' in a functions kit config."); + } + if ( + !config.instances || + typeof config.instances !== "object" || + Array.isArray(config.instances) + ) { + throw new FirebaseError( + "Must specify 'instances' as an object mapping instance IDs to configuration paths in a functions kit config.", + ); + } + return config as ValidatedKitSingle; + } + const { source, remoteSource, runtime, codebase: providedCodebase, ...rest } = config; // Exactly one of source or remoteSource must be specified @@ -102,7 +162,7 @@ function validateSingle(config: FunctionConfig): ValidatedSingle { ...commonConfig, source, ...(runtime ? { runtime } : {}), - }; + } as ValidatedLocalSingle; } else if (remoteSource) { if (!remoteSource.repository || !remoteSource.ref) { throw new FirebaseError("remoteSource requires 'repository' and 'ref' to be specified."); @@ -117,7 +177,7 @@ function validateSingle(config: FunctionConfig): ValidatedSingle { ...commonConfig, remoteSource, runtime, - }; + } as ValidatedRemoteSingle; } // Unreachable due to XOR guard @@ -138,12 +198,14 @@ export function assertUnique( } for (const single of config) { const value = single[property]; - if (values.has(value)) { - throw new FirebaseError( - `functions.${property} must be unique but '${value}' was used more than once.`, - ); + if (value !== undefined) { + if (values.has(value)) { + throw new FirebaseError( + `functions.${String(property)} must be unique but '${String(value)}' was used more than once.`, + ); + } + values.add(value); } - values.add(value); } } @@ -177,13 +239,64 @@ function assertUniqueSourcePrefixPair(config: ValidatedConfig): void { } } +/** + * Validate each instance ID format and ensure instance IDs are unique across all kit stanzas in the project. + */ +export function validateKitInstances( + instances: Record, + allProjectInstanceIds: Set, +): void { + for (const instanceId of Object.keys(instances)) { + if ( + instanceId.length === 0 || + instanceId.length > 40 || + !/^[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?$/.test(instanceId) + ) { + throw new FirebaseError( + `Invalid kit instance ID '${instanceId}'. Instance ID must be 40 characters or less, ` + + "can contain only lowercase letters, numeric characters, underscores, and dashes, and cannot start or end with a dash.", + ); + } + if (allProjectInstanceIds.has(instanceId)) { + throw new FirebaseError( + `functions kit instance ID must be unique across all kits, but '${instanceId}' was used more than once.`, + ); + } + allProjectInstanceIds.add(instanceId); + } +} + +function assertUniqueKitInstancesAndCodebases(config: ValidatedConfig): void { + const codebases = new Set(); + const instanceIds = new Set(); + + for (const c of config) { + if ("codebase" in c && c.codebase) { + codebases.add(c.codebase); + } + if ("instances" in c && c.instances) { + validateKitInstances(c.instances, instanceIds); + } + } + + for (const instanceId of instanceIds) { + if (codebases.has(instanceId)) { + throw new FirebaseError( + `functions codebase name and kit instance ID must be mutually exclusive, but '${instanceId}' was used as both a codebase name and a kit instance ID.`, + ); + } + } +} + /** * Validate functions config. */ export function validate(config: NormalizedConfig): ValidatedConfig { const validated = config.map((cfg) => validateSingle(cfg)) as ValidatedConfig; assertUnique(validated, "codebase"); + assertUnique(validated, "kit"); assertUniqueSourcePrefixPair(validated); + assertUniqueKitInstancesAndCodebases(validated); return validated; } @@ -197,24 +310,47 @@ export function normalizeAndValidate(config?: FunctionsConfig): ValidatedConfig } /** - * Return functions config for given codebase. + * Return functions config for given codebase or kit instance. */ -export function configForCodebase(config: ValidatedConfig, codebase: string): ValidatedSingle { - const codebaseCfg = config.find((c) => c.codebase === codebase); +export function configForCodebase( + config: ValidatedConfig, + codebaseOrInstance: string, +): ValidatedSingle { + const codebaseCfg = config.find((c) => { + if (isKitConfig(c)) { + return c.instances && codebaseOrInstance in c.instances; + } + return c.codebase === codebaseOrInstance; + }); if (!codebaseCfg) { - throw new FirebaseError(`No functions config found for codebase ${codebase}`); + throw new FirebaseError( + `No functions config found for codebase or kit instance ${codebaseOrInstance}`, + ); } return codebaseCfg; } /** Returns true if the codebase uses a local source. */ export function isLocalConfig(c: ValidatedSingle): c is ValidatedLocalSingle { - return (c as ValidatedLocalSingle).source !== undefined; + return "source" in c && !("kit" in c); } /** Returns true if the codebase uses a remote source. */ export function isRemoteConfig(c: ValidatedSingle): c is ValidatedRemoteSingle { - return (c as ValidatedRemoteSingle).remoteSource !== undefined; + return "remoteSource" in c; +} + +/** Returns true if the config uses a functions kit. */ +export function isKitConfig(c: ValidatedSingle): c is ValidatedKitSingle { + return "kit" in c; +} + +/** Prefix added to function names for Functions Kit instances. */ +export const KIT_PREFIX = "kit-"; + +/** Format the function prefix for a kit instance. */ +export function addKitPrefix(instanceId: string): string { + return `${KIT_PREFIX}${instanceId}`; } /** @@ -222,8 +358,20 @@ export function isRemoteConfig(c: ValidatedSingle): c is ValidatedRemoteSingle { * @param c The validated functions config entry. * @param purpose Optional message to use in the error. */ -export function requireLocal(c: ValidatedSingle, purpose?: string): ValidatedLocalSingle { - if (!isLocalConfig(c)) { +export function requireLocal(c: ValidatedKitSingle, purpose?: string): ValidatedKitSingle; +export function requireLocal( + c: ValidatedLocalSingle | ValidatedRemoteSingle, + purpose?: string, +): ValidatedLocalSingle; +export function requireLocal( + c: ValidatedSingle, + purpose?: string, +): ValidatedLocalSingle | ValidatedKitSingle; +export function requireLocal( + c: ValidatedSingle, + purpose?: string, +): ValidatedLocalSingle | ValidatedKitSingle { + if (isRemoteConfig(c)) { const msg = purpose ?? "This operation requires a local functions source directory, but the codebase is configured with a remote source."; @@ -234,11 +382,15 @@ export function requireLocal(c: ValidatedSingle, purpose?: string): ValidatedLoc /** * Resolve the directory used for .env files. + * - Kit: returns `instances[instanceId]` if instanceId provided and present, otherwise `undefined`. * - Local: returns `configDir` if set, otherwise `source`. * - Remote: returns `configDir` if set, otherwise `undefined`. */ -export function resolveConfigDir(c: ValidatedSingle): string | undefined { - return c.configDir || c.source; +export function resolveConfigDir(c: ValidatedSingle, instanceId?: string): string | undefined { + if (isKitConfig(c)) { + return instanceId ? c.instances[instanceId] : undefined; + } + return c.configDir || (isLocalConfig(c) ? c.source : undefined); } /** diff --git a/src/serve/functions.ts b/src/serve/functions.ts index ee0a0fa9f6e..85ccd3e4a2a 100644 --- a/src/serve/functions.ts +++ b/src/serve/functions.ts @@ -34,13 +34,31 @@ export class FunctionsServer { "Remote sources are not supported in the Functions emulator.", ); const functionsDir = path.join(options.config.projectDir, localCfg.source); - backends.push({ - functionsDir, - codebase: localCfg.codebase, - runtime: localCfg.runtime, - env: {}, - secretEnv: [], - }); + + if (projectConfig.isKitConfig(localCfg)) { + for (const [instanceId, configDir] of Object.entries(localCfg.instances)) { + backends.push({ + functionsDir, + codebase: instanceId, + prefix: projectConfig.addKitPrefix(instanceId), + configDir: path.join(options.config.projectDir, configDir), + runtime: localCfg.runtime, + env: {}, + secretEnv: [], + }); + } + } else { + backends.push({ + functionsDir, + codebase: localCfg.codebase, + ...(localCfg.configDir + ? { configDir: path.join(options.config.projectDir, localCfg.configDir) } + : {}), + runtime: localCfg.runtime, + env: {}, + secretEnv: [], + }); + } } this.backends = backends;