From 9489ef4ecdf119dbded5ada23f6e3a0e12458c31 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Wed, 24 Jun 2026 23:54:22 +0000 Subject: [PATCH 1/5] feature: functions can now declare lifecycle hooks --- package.json | 8 +++ spec/lifecycle/lifecycle.spec.ts | 63 +++++++++++++++++++ spec/runtime/loader.spec.ts | 68 ++++++++++++++++++++ src/lifecycle/index.ts | 104 +++++++++++++++++++++++++++++++ src/runtime/loader.ts | 39 ++++++++++++ src/runtime/manifest.ts | 26 ++++++++ 6 files changed, 308 insertions(+) create mode 100644 spec/lifecycle/lifecycle.spec.ts create mode 100644 src/lifecycle/index.ts 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..b73bceabc 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,70 @@ 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", + body: { force: true }, + }, + }); + afterUpdate({ + http: { + url: "https://example.com/webhook", + method: "POST", + }, + }); + + const stack = await loader.loadStack("./spec/fixtures/sources/commonjs"); + expect(stack.lifecycleHooks).to.deep.equal({ + afterInstall: { + http: { + function: "seedDatabase", + method: "POST", + body: { force: true }, + }, + }, + afterUpdate: { + http: { + url: "https://example.com/webhook", + method: "POST", + }, + }, + }); + }); + }); }); diff --git a/src/lifecycle/index.ts b/src/lifecycle/index.ts new file mode 100644 index 000000000..67a7d13a3 --- /dev/null +++ b/src/lifecycle/index.ts @@ -0,0 +1,104 @@ +// 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. + +import { Expression } from "../params"; + +export type TargetFunction = string | Expression; + +export interface TaskAction { + function: TargetFunction; + body?: Record; +} + +export interface CallAction { + function: TargetFunction; + params?: Record; +} + +export interface HttpAction { + function?: TargetFunction; + url?: string | Expression; + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: unknown; +} + +export interface LifecycleAction { + task?: TaskAction; + call?: CallAction; + http?: HttpAction; +} + +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..47f921a02 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -25,6 +25,7 @@ import * as url from "url"; import { ManifestEndpoint, ManifestExtension, + ManifestLifecycleAction, ManifestRequiredAPI, ManifestStack, } from "./manifest"; @@ -32,6 +33,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 +211,42 @@ export async function loadStack(functionsDir: string): Promise { if (declaredRoles.size > 0) { stack.requiredRoles = Array.from(declaredRoles); } + + const hooks = Object.entries(declaredLifecycleHooks); + if (hooks.length > 0) { + stack.lifecycleHooks = {}; + for (const [event, action] of hooks) { + const specAction: ManifestLifecycleAction = {}; + if (action.task) { + specAction.task = { + function: action.task.function, + ...(action.task.body && { body: action.task.body }), + }; + } + if (action.call) { + specAction.call = { + function: action.call.function, + ...(action.call.params && { params: action.call.params }), + }; + } + if (action.http) { + if ("function" in action.http && action.http.function) { + specAction.http = { + function: action.http.function, + ...(action.http.method && { method: action.http.method }), + ...(action.http.body && { body: action.http.body }), + }; + } else if ("url" in action.http && action.http.url) { + specAction.http = { + url: action.http.url, + ...(action.http.method && { method: action.http.method }), + ...(action.http.body && { body: action.http.body }), + }; + } + } + stack.lifecycleHooks[event] = specAction; + } + } + clearDeclaredLifecycleHooks(); return stack; } diff --git a/src/runtime/manifest.ts b/src/runtime/manifest.ts index 4f356aa6e..9db4116e2 100644 --- a/src/runtime/manifest.ts +++ b/src/runtime/manifest.ts @@ -136,6 +136,28 @@ export interface ManifestRequiredAPI { reason: string; } +/** + * A definition of a lifecycle action as appears in the Manifest. + * + * @alpha + */ +export interface ManifestLifecycleAction { + task?: { + function: string | Expression; + body?: Record; + }; + call?: { + function: string | Expression; + params?: Record; + }; + http?: { + function?: string | Expression; + url?: string | Expression; + method?: string; + body?: unknown; + }; +} + /** * A definition of a function/extension deployment as appears in the Manifest. * @alpha @@ -147,6 +169,7 @@ export interface ManifestStack { endpoints: Record; extensions?: Record; requiredRoles?: string[]; + lifecycleHooks?: Record; } /** @@ -170,6 +193,9 @@ export function stackToWire(stack: ManifestStack): Record { } }; traverse(wireStack.endpoints); + if (wireStack.lifecycleHooks) { + traverse(wireStack.lifecycleHooks); + } return wireStack; } From 49e9d1bfe352a0f25e6c9fdabc3db75c0c9acbc5 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Thu, 25 Jun 2026 20:15:40 +0000 Subject: [PATCH 2/5] refactor(lifecycle): improve type safety with union types and preserve falsy body/params in loader --- spec/runtime/loader.spec.ts | 19 +++++++++++++++++++ src/lifecycle/index.ts | 28 +++++++++++++++++----------- src/runtime/loader.ts | 23 ++++++++--------------- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/spec/runtime/loader.spec.ts b/spec/runtime/loader.spec.ts index b73bceabc..e535f0ec9 100644 --- a/spec/runtime/loader.spec.ts +++ b/spec/runtime/loader.spec.ts @@ -575,5 +575,24 @@ describe("loadStack", () => { }, }); }); + + 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 index 67a7d13a3..9dfd36e5e 100644 --- a/src/lifecycle/index.ts +++ b/src/lifecycle/index.ts @@ -34,18 +34,24 @@ export interface CallAction { params?: Record; } -export interface HttpAction { - function?: TargetFunction; - url?: string | Expression; - method?: "GET" | "POST" | "PUT" | "DELETE"; - body?: unknown; -} +export type HttpAction = + | { + function: TargetFunction; + url?: never; + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: unknown; + } + | { + url: string | Expression; + function?: never; + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: unknown; + }; -export interface LifecycleAction { - task?: TaskAction; - call?: CallAction; - http?: HttpAction; -} +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 diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index 47f921a02..a31b9b32f 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -220,29 +220,22 @@ export async function loadStack(functionsDir: string): Promise { if (action.task) { specAction.task = { function: action.task.function, - ...(action.task.body && { body: action.task.body }), + ...(action.task.body !== undefined && { body: action.task.body }), }; } if (action.call) { specAction.call = { function: action.call.function, - ...(action.call.params && { params: action.call.params }), + ...(action.call.params !== undefined && { params: action.call.params }), }; } if (action.http) { - if ("function" in action.http && action.http.function) { - specAction.http = { - function: action.http.function, - ...(action.http.method && { method: action.http.method }), - ...(action.http.body && { body: action.http.body }), - }; - } else if ("url" in action.http && action.http.url) { - specAction.http = { - url: action.http.url, - ...(action.http.method && { method: action.http.method }), - ...(action.http.body && { body: action.http.body }), - }; - } + specAction.http = { + ...(action.http.function && { function: action.http.function }), + ...(action.http.url && { url: action.http.url }), + ...(action.http.method && { method: action.http.method }), + ...(action.http.body !== undefined && { body: action.http.body }), + }; } stack.lifecycleHooks[event] = specAction; } From 55db1e18e6cf4df908fa5c725433b3b5bcc1c1ef Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Thu, 25 Jun 2026 21:55:42 +0000 Subject: [PATCH 3/5] Add headers to HttpAction --- spec/runtime/loader.spec.ts | 4 ++++ src/lifecycle/index.ts | 2 ++ src/runtime/loader.ts | 1 + src/runtime/manifest.ts | 1 + 4 files changed, 8 insertions(+) diff --git a/spec/runtime/loader.spec.ts b/spec/runtime/loader.spec.ts index e535f0ec9..65b7713a3 100644 --- a/spec/runtime/loader.spec.ts +++ b/spec/runtime/loader.spec.ts @@ -548,6 +548,7 @@ describe("loadStack", () => { http: { function: "seedDatabase", method: "POST", + headers: { "X-API-Key": "my-key" }, body: { force: true }, }, }); @@ -555,6 +556,7 @@ describe("loadStack", () => { http: { url: "https://example.com/webhook", method: "POST", + headers: { "Content-Type": "application/json" }, }, }); @@ -564,6 +566,7 @@ describe("loadStack", () => { http: { function: "seedDatabase", method: "POST", + headers: { "X-API-Key": "my-key" }, body: { force: true }, }, }, @@ -571,6 +574,7 @@ describe("loadStack", () => { http: { url: "https://example.com/webhook", method: "POST", + headers: { "Content-Type": "application/json" }, }, }, }); diff --git a/src/lifecycle/index.ts b/src/lifecycle/index.ts index 9dfd36e5e..db17152c3 100644 --- a/src/lifecycle/index.ts +++ b/src/lifecycle/index.ts @@ -39,12 +39,14 @@ export type HttpAction = function: TargetFunction; url?: never; method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record>; body?: unknown; } | { url: string | Expression; function?: never; method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record>; body?: unknown; }; diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index a31b9b32f..3793f01cb 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -234,6 +234,7 @@ export async function loadStack(functionsDir: string): Promise { ...(action.http.function && { function: action.http.function }), ...(action.http.url && { url: action.http.url }), ...(action.http.method && { method: action.http.method }), + ...(action.http.headers && { headers: action.http.headers }), ...(action.http.body !== undefined && { body: action.http.body }), }; } diff --git a/src/runtime/manifest.ts b/src/runtime/manifest.ts index 9db4116e2..b74f1a65f 100644 --- a/src/runtime/manifest.ts +++ b/src/runtime/manifest.ts @@ -154,6 +154,7 @@ export interface ManifestLifecycleAction { function?: string | Expression; url?: string | Expression; method?: string; + headers?: Record>; body?: unknown; }; } From 9af04644b991866b081a9aad9903585b2bfa80e0 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 17:34:34 +0000 Subject: [PATCH 4/5] Address comments * Clean up use of Expression * Directly load from the manifest for hooks * Clean up HttpAction def * Add lifecycle to index.ts * Add empty index.js file for eslint --- eslint.config.js | 1 + lifecycle/index.js | 26 ++++++++++++++++++++++++++ package-lock.json | 12 ------------ src/lifecycle/index.ts | 28 +++++++--------------------- src/runtime/loader.ts | 31 ++----------------------------- src/runtime/manifest.ts | 8 ++++---- src/v2/index.ts | 2 ++ 7 files changed, 42 insertions(+), 66 deletions(-) create mode 100644 lifecycle/index.js 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/src/lifecycle/index.ts b/src/lifecycle/index.ts index db17152c3..5ae09ff80 100644 --- a/src/lifecycle/index.ts +++ b/src/lifecycle/index.ts @@ -20,35 +20,21 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { Expression } from "../params"; - -export type TargetFunction = string | Expression; - export interface TaskAction { - function: TargetFunction; + function: string; body?: Record; } export interface CallAction { - function: TargetFunction; + function: string; params?: Record; } -export type HttpAction = - | { - function: TargetFunction; - url?: never; - method?: "GET" | "POST" | "PUT" | "DELETE"; - headers?: Record>; - body?: unknown; - } - | { - url: string | Expression; - function?: never; - method?: "GET" | "POST" | "PUT" | "DELETE"; - headers?: Record>; - body?: unknown; - }; +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 } diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index 3793f01cb..b3ac8c3e9 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -25,7 +25,6 @@ import * as url from "url"; import { ManifestEndpoint, ManifestExtension, - ManifestLifecycleAction, ManifestRequiredAPI, ManifestStack, } from "./manifest"; @@ -212,34 +211,8 @@ export async function loadStack(functionsDir: string): Promise { stack.requiredRoles = Array.from(declaredRoles); } - const hooks = Object.entries(declaredLifecycleHooks); - if (hooks.length > 0) { - stack.lifecycleHooks = {}; - for (const [event, action] of hooks) { - const specAction: ManifestLifecycleAction = {}; - if (action.task) { - specAction.task = { - function: action.task.function, - ...(action.task.body !== undefined && { body: action.task.body }), - }; - } - if (action.call) { - specAction.call = { - function: action.call.function, - ...(action.call.params !== undefined && { params: action.call.params }), - }; - } - if (action.http) { - specAction.http = { - ...(action.http.function && { function: action.http.function }), - ...(action.http.url && { url: action.http.url }), - ...(action.http.method && { method: action.http.method }), - ...(action.http.headers && { headers: action.http.headers }), - ...(action.http.body !== undefined && { body: action.http.body }), - }; - } - stack.lifecycleHooks[event] = specAction; - } + 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 b74f1a65f..9d50fbbde 100644 --- a/src/runtime/manifest.ts +++ b/src/runtime/manifest.ts @@ -143,18 +143,18 @@ export interface ManifestRequiredAPI { */ export interface ManifestLifecycleAction { task?: { - function: string | Expression; + function: string; body?: Record; }; call?: { - function: string | Expression; + function: string; params?: Record; }; http?: { - function?: string | Expression; + function?: string; url?: string | Expression; method?: string; - headers?: Record>; + headers?: Record; body?: unknown; }; } 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"; From fd8840d953ab09e1120d886a033d45b4c5336cba Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 17:43:53 +0000 Subject: [PATCH 5/5] Add CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)