diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2d96fd2..eeb671645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,4 @@ - chore: drop support for Node 18 and below (minimum supported version is now Node 20) - feat: Add requiresAPI function to allow declaring Google Cloud API dependencies in code. - fix(v1): Call onInit for schedule.onRun functions (#1801) +- feat: Add support to declare lifecycle hooks in functions. (#1915) diff --git a/eslint.config.js b/eslint.config.js index 2b77805fd..9670d4e96 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,6 +19,7 @@ module.exports = [ "v1/", "v2/", "logger/", + "lifecycle/", "dist/", "spec/fixtures/", "scripts/**/*.js", diff --git a/lifecycle/index.js b/lifecycle/index.js new file mode 100644 index 000000000..eef88c583 --- /dev/null +++ b/lifecycle/index.js @@ -0,0 +1,26 @@ +// The MIT License (MIT) +// +// Copyright (c) 2026 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// This file is not part of the firebase-functions SDK. It is used to silence the +// imports eslint plugin until it can understand import paths defined by node +// package exports. +// For more information, see github.com/import-js/eslint-plugin-import/issues/1810 diff --git a/package-lock.json b/package-lock.json index ed1749205..737d986fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4731,9 +4731,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4751,9 +4748,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4771,9 +4765,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4791,9 +4782,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 237d0769a..3e841f51e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,11 @@ "./lib/esm/logger/compat.mjs" ], "exports": { + "./lifecycle": { + "types": "./lib/lifecycle/index.d.ts", + "import": "./lib/esm/lifecycle/index.mjs", + "require": "./lib/lifecycle/index.js" + }, "./logger/compat": { "types": "./lib/logger/compat.d.ts", "import": "./lib/esm/logger/compat.mjs", @@ -346,6 +351,9 @@ }, "typesVersions": { "*": { + "lifecycle": [ + "lib/lifecycle/index" + ], "logger": [ "lib/logger/index" ], diff --git a/spec/lifecycle/lifecycle.spec.ts b/spec/lifecycle/lifecycle.spec.ts new file mode 100644 index 000000000..b5358e2a8 --- /dev/null +++ b/spec/lifecycle/lifecycle.spec.ts @@ -0,0 +1,63 @@ +import { expect } from "chai"; +import { + afterInstall, + afterUpdate, + clearDeclaredLifecycleHooks, + declaredLifecycleHooks, +} from "../../src/lifecycle"; + +describe("lifecycle", () => { + afterEach(() => { + clearDeclaredLifecycleHooks(); + }); + + describe("afterInstall", () => { + it("registers an afterInstall lifecycle action", () => { + afterInstall({ + task: { + function: "runInitialSetup", + body: { seedData: true }, + }, + }); + + expect(declaredLifecycleHooks.afterInstall).to.deep.equal({ + task: { + function: "runInitialSetup", + body: { seedData: true }, + }, + }); + }); + + it("throws an error if afterInstall is called more than once", () => { + afterInstall({ task: { function: "fn1" } }); + expect(() => afterInstall({ task: { function: "fn2" } })).to.throw( + "Only one afterInstall lifecycle hook is allowed per codebase." + ); + }); + }); + + describe("afterUpdate", () => { + it("registers an afterUpdate lifecycle action", () => { + afterUpdate({ + call: { + function: "migrateSchema", + params: { version: 2 }, + }, + }); + + expect(declaredLifecycleHooks.afterUpdate).to.deep.equal({ + call: { + function: "migrateSchema", + params: { version: 2 }, + }, + }); + }); + + it("throws an error if afterUpdate is called more than once", () => { + afterUpdate({ call: { function: "fn1" } }); + expect(() => afterUpdate({ call: { function: "fn2" } })).to.throw( + "Only one afterUpdate lifecycle hook is allowed per codebase." + ); + }); + }); +}); diff --git a/spec/runtime/loader.spec.ts b/spec/runtime/loader.spec.ts index 9070c995f..65b7713a3 100644 --- a/spec/runtime/loader.spec.ts +++ b/spec/runtime/loader.spec.ts @@ -11,6 +11,7 @@ import { } from "../../src/runtime/manifest"; import { clearParams } from "../../src/params"; import { clearGlobalRequiredAPIs } from "../../src/common/api"; +import { afterInstall, afterUpdate, clearDeclaredLifecycleHooks } from "../../src/lifecycle"; import { MINIMAL_V1_ENDPOINT, MINIMAL_V2_ENDPOINT } from "../fixtures"; import { MINIMAL_SCHEDULE_TRIGGER, MINIMIAL_TASK_QUEUE_TRIGGER } from "../v1/providers/fixtures"; import { BooleanParam, IntParam, StringParam } from "../../src/params/types"; @@ -344,6 +345,7 @@ describe("loadStack", () => { process.env.GCLOUD_PROJECT = prev; clearGlobalRequiredAPIs(); clearParams(); + clearDeclaredLifecycleHooks(); // Purge the require cache for fixture modules so that when a file is loaded // a second time via absolute path, it re-executes and successfully runs its global side-effects. for (const key of Object.keys(require.cache)) { @@ -508,4 +510,93 @@ describe("loadStack", () => { }); } }); + + describe("loadStack with lifecycleHooks", () => { + it("captures top-level afterInstall and afterUpdate calls into ManifestStack.lifecycleHooks", async () => { + afterInstall({ + task: { + function: "runInitialSetup", + body: { seedData: "default_catalog" }, + }, + }); + afterUpdate({ + call: { + function: "migrateSchema", + params: { targetVersion: "v2.4" }, + }, + }); + + const stack = await loader.loadStack("./spec/fixtures/sources/commonjs"); + expect(stack.lifecycleHooks).to.deep.equal({ + afterInstall: { + task: { + function: "runInitialSetup", + body: { seedData: "default_catalog" }, + }, + }, + afterUpdate: { + call: { + function: "migrateSchema", + params: { targetVersion: "v2.4" }, + }, + }, + }); + }); + + it("captures http lifecycle hooks with function and url into ManifestStack.lifecycleHooks", async () => { + afterInstall({ + http: { + function: "seedDatabase", + method: "POST", + headers: { "X-API-Key": "my-key" }, + body: { force: true }, + }, + }); + afterUpdate({ + http: { + url: "https://example.com/webhook", + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + }); + + const stack = await loader.loadStack("./spec/fixtures/sources/commonjs"); + expect(stack.lifecycleHooks).to.deep.equal({ + afterInstall: { + http: { + function: "seedDatabase", + method: "POST", + headers: { "X-API-Key": "my-key" }, + body: { force: true }, + }, + }, + afterUpdate: { + http: { + url: "https://example.com/webhook", + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + }, + }); + }); + + it("preserves falsy but valid JSON values (like 0, false, empty string) in hook payloads", async () => { + afterInstall({ + task: { + function: "runInitialSetup", + body: { force: false, code: 0, tag: "" }, + }, + }); + + const stack = await loader.loadStack("./spec/fixtures/sources/commonjs"); + expect(stack.lifecycleHooks).to.deep.equal({ + afterInstall: { + task: { + function: "runInitialSetup", + body: { force: false, code: 0, tag: "" }, + }, + }, + }); + }); + }); }); diff --git a/src/lifecycle/index.ts b/src/lifecycle/index.ts new file mode 100644 index 000000000..5ae09ff80 --- /dev/null +++ b/src/lifecycle/index.ts @@ -0,0 +1,98 @@ +// The MIT License (MIT) +// +// Copyright (c) 2026 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export interface TaskAction { + function: string; + body?: Record; +} + +export interface CallAction { + function: string; + params?: Record; +} + +export type HttpAction = ({ function: string } | { url: string }) & { + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + body?: unknown; +}; + +export type LifecycleAction = + | { task: TaskAction; call?: never; http?: never } + | { call: CallAction; task?: never; http?: never } + | { http: HttpAction; task?: never; call?: never }; + +const majorVersion = + // @ts-expect-error __FIREBASE_FUNCTIONS_MAJOR_VERSION__ is injected at build time + typeof __FIREBASE_FUNCTIONS_MAJOR_VERSION__ !== "undefined" + ? // @ts-expect-error __FIREBASE_FUNCTIONS_MAJOR_VERSION__ is injected at build time + __FIREBASE_FUNCTIONS_MAJOR_VERSION__ + : "0"; + +const GLOBAL_SYMBOL = Symbol.for(`firebase-functions:lifecycle:declaredHooks:v${majorVersion}`); +const globalSymbols = globalThis as unknown as Record>; + +if (!globalSymbols[GLOBAL_SYMBOL]) { + globalSymbols[GLOBAL_SYMBOL] = {}; +} + +/** + * Singleton dictionary of declared lifecycle hooks. + * @internal + */ +export const declaredLifecycleHooks: Record = globalSymbols[GLOBAL_SYMBOL]; + +/** + * Registers an action to be executed automatically post-deployment when resources in this codebase + * are installed for the initial time. + * + * @param action The lifecycle action to execute. + */ +export function afterInstall(action: LifecycleAction): void { + if (declaredLifecycleHooks.afterInstall) { + throw new Error("Only one afterInstall lifecycle hook is allowed per codebase."); + } + declaredLifecycleHooks.afterInstall = action; +} + +/** + * Registers an action to be executed automatically post-deployment when resources in this codebase + * are updated. + * + * @param action The lifecycle action to execute. + */ +export function afterUpdate(action: LifecycleAction): void { + if (declaredLifecycleHooks.afterUpdate) { + throw new Error("Only one afterUpdate lifecycle hook is allowed per codebase."); + } + declaredLifecycleHooks.afterUpdate = action; +} + +/** + * Helper to clear declared lifecycle hooks (primarily for testing). + * @internal + */ +export function clearDeclaredLifecycleHooks(): void { + for (const key of Object.keys(declaredLifecycleHooks)) { + delete declaredLifecycleHooks[key]; + } +} diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index 9a6be3708..b3ac8c3e9 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -32,6 +32,7 @@ import { import * as params from "../params"; import { declaredRoles } from "../security/roles"; import { getGlobalRequiredAPIs, clearGlobalRequiredAPIs } from "../common/api"; +import { declaredLifecycleHooks, clearDeclaredLifecycleHooks } from "../lifecycle"; /** * Dynamically load import function to prevent TypeScript from @@ -209,5 +210,10 @@ export async function loadStack(functionsDir: string): Promise { if (declaredRoles.size > 0) { stack.requiredRoles = Array.from(declaredRoles); } + + if (Object.keys(declaredLifecycleHooks).length > 0) { + stack.lifecycleHooks = { ...declaredLifecycleHooks }; + } + clearDeclaredLifecycleHooks(); return stack; } diff --git a/src/runtime/manifest.ts b/src/runtime/manifest.ts index 4f356aa6e..9d50fbbde 100644 --- a/src/runtime/manifest.ts +++ b/src/runtime/manifest.ts @@ -136,6 +136,29 @@ export interface ManifestRequiredAPI { reason: string; } +/** + * A definition of a lifecycle action as appears in the Manifest. + * + * @alpha + */ +export interface ManifestLifecycleAction { + task?: { + function: string; + body?: Record; + }; + call?: { + function: string; + params?: Record; + }; + http?: { + function?: string; + url?: string | Expression; + method?: string; + headers?: Record; + body?: unknown; + }; +} + /** * A definition of a function/extension deployment as appears in the Manifest. * @alpha @@ -147,6 +170,7 @@ export interface ManifestStack { endpoints: Record; extensions?: Record; requiredRoles?: string[]; + lifecycleHooks?: Record; } /** @@ -170,6 +194,9 @@ export function stackToWire(stack: ManifestStack): Record { } }; traverse(wireStack.endpoints); + if (wireStack.lifecycleHooks) { + traverse(wireStack.lifecycleHooks); + } return wireStack; } diff --git a/src/v2/index.ts b/src/v2/index.ts index 288d32b9a..73b5149fe 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -83,6 +83,8 @@ export { traceContext } from "../common/trace"; import * as params from "../params"; export { params }; +export { afterInstall, afterUpdate } from "../lifecycle"; + // NOTE: Required to support the Functions Emulator which monkey patches `functions.config()` // TODO(danielylee): Remove in next major release. export { config } from "../v1/config";