diff --git a/src/app.ts b/src/app.ts index 48728da..6544fc5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,8 @@ // Licensed under the MIT License. import { + AppStartHandler, + AppTerminateHandler, CosmosDBFunctionOptions, CosmosDBTrigger, EventGridFunctionOptions, @@ -11,6 +13,9 @@ import { HttpHandler, HttpMethod, HttpMethodFunctionOptions, + InvocationContext, + PostInvocationHandler, + PreInvocationHandler, ServiceBusQueueFunctionOptions, ServiceBusTopicFunctionOptions, StorageBlobFunctionOptions, @@ -21,6 +26,11 @@ import * as coreTypes from '@azure/functions-core'; import { CoreInvocationContext, FunctionCallback } from '@azure/functions-core'; import { InvocationModel } from './InvocationModel'; import { returnBindingKey, version } from './constants'; +import { AppStartContext } from './hooks/AppStartContext'; +import { AppTerminateContext } from './hooks/AppTerminateContext'; +import { Disposable } from './hooks/Disposable'; +import { PostInvocationContext } from './hooks/PostInvocationContext'; +import { PreInvocationContext } from './hooks/PreInvocationContext'; import * as output from './output'; import * as trigger from './trigger'; import { isTrigger } from './utils/isTrigger'; @@ -280,3 +290,72 @@ export function generic(name: string, options: FunctionOptions): void { coreApi.registerFunction({ name, bindings }, options.handler); } } + +function coreRegisterHook(hookName: string, callback: coreTypes.HookCallback): coreTypes.Disposable { + const coreApi = tryGetCoreApiLazy(); + if (!coreApi) { + console.warn( + `WARNING: Skipping call to register ${hookName} hook because the "@azure/functions" package is in test mode.` + ); + return new Disposable(() => { + console.log( + `WARNING: Skipping call to dispose ${hookName} hook because the "@azure/functions" package is in test mode.` + ); + }); + } else { + return coreApi.registerHook(hookName, callback); + } +} + +export function onStart(handler: AppStartHandler): Disposable { + const coreCallback: coreTypes.AppStartCallback = (coreContext: coreTypes.AppStartContext) => { + const context = new AppStartContext(coreContext); + return handler(context); + }; + return coreRegisterHook('appStart', coreCallback as coreTypes.HookCallback); +} + +export function onTerminate(handler: AppTerminateHandler): Disposable { + const coreCallback: coreTypes.AppTerminateCallback = (coreContext: coreTypes.AppTerminateContext) => { + const context = new AppTerminateContext(coreContext); + return handler(context); + }; + return coreRegisterHook('appTerminate', coreCallback as coreTypes.HookCallback); +} + +export function onPreInvocation(handler: PreInvocationHandler): Disposable { + const coreCallback: coreTypes.PreInvocationCallback = (coreContext: coreTypes.PreInvocationContext) => { + const invocationContext = coreContext.invocationContext as InvocationContext; + const preInvocContext = new PreInvocationContext({ + // spreading here is necessary to pass hookData and appHookData objects + ...coreContext, + functionHandler: coreContext.functionCallback, + args: coreContext.inputs, + invocationContext, + coreContext, + }); + return handler(preInvocContext); + }; + + return coreRegisterHook('preInvocation', coreCallback as coreTypes.HookCallback); +} + +export function onPostInvocation(handler: PostInvocationHandler): Disposable { + const coreCallback: coreTypes.PostInvocationCallback = (coreContext: coreTypes.PostInvocationContext) => { + const invocationContext = coreContext.invocationContext as InvocationContext; + const postInvocContext = new PostInvocationContext({ + // spreading here is necessary to pass hookData and appHookData objects + ...coreContext, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + result: coreContext.result, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + errorResult: coreContext.error, + args: coreContext.inputs, + invocationContext, + coreContext, + }); + return handler(postInvocContext); + }; + + return coreRegisterHook('postInvocation', coreCallback as coreTypes.HookCallback); +} diff --git a/src/hooks/AppStartContext.ts b/src/hooks/AppStartContext.ts new file mode 100644 index 0000000..b89c3a6 --- /dev/null +++ b/src/hooks/AppStartContext.ts @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { AppStartContextInit } from '@azure/functions'; +import { HookContext } from './HookContext'; + +export class AppStartContext extends HookContext implements types.AppStartContext { + functionAppDirectory: string; + + constructor(init?: AppStartContextInit) { + super(init); + init = init || {}; + this.functionAppDirectory = init.functionAppDirectory || 'unknown'; + } +} diff --git a/src/hooks/AppTerminateContext.ts b/src/hooks/AppTerminateContext.ts new file mode 100644 index 0000000..87c9014 --- /dev/null +++ b/src/hooks/AppTerminateContext.ts @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { AppTerminateContextInit } from '@azure/functions'; +import { HookContext } from './HookContext'; + +export class AppTerminateContext extends HookContext implements types.AppTerminateContext { + constructor(init?: AppTerminateContextInit) { + super(init); + } +} diff --git a/src/hooks/Disposable.ts b/src/hooks/Disposable.ts new file mode 100644 index 0000000..b159103 --- /dev/null +++ b/src/hooks/Disposable.ts @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +/** + * Based off of the Node worker + * https://github.com/Azure/azure-functions-nodejs-worker/blob/bf28d9c5ad4ed22c5e42c082471d16108abee140/src/Disposable.ts + */ +export class Disposable { + static from(...inDisposables: { dispose(): any }[]): Disposable { + let disposables: ReadonlyArray<{ dispose(): any }> | undefined = inDisposables; + return new Disposable(function () { + if (disposables) { + for (const disposable of disposables) { + if (disposable && typeof disposable.dispose === 'function') { + disposable.dispose(); + } + } + disposables = undefined; + } + }); + } + + #callOnDispose?: () => any; + + constructor(callOnDispose: () => any) { + this.#callOnDispose = callOnDispose; + } + + dispose(): any { + if (this.#callOnDispose instanceof Function) { + this.#callOnDispose(); + this.#callOnDispose = undefined; + } + } +} diff --git a/src/hooks/HookContext.ts b/src/hooks/HookContext.ts new file mode 100644 index 0000000..5473c0e --- /dev/null +++ b/src/hooks/HookContext.ts @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { HookContextInit, HookData } from '@azure/functions'; + +export class HookContext implements types.HookContext { + #hookData: HookData; + #appHookData: HookData; + + constructor(init?: HookContextInit) { + init = init || {}; + + // It's important to use the original objects passed in init (no copies or clones) + // because modifications to these objects are persisted by the worker + this.#hookData = init.hookData || {}; + this.#appHookData = init.appHookData || {}; + } + + get(propertyName: string): unknown { + return this.#hookData[propertyName]; + } + + set(propertyName: string, value: unknown): void { + this.#hookData[propertyName] = value; + } + + getGlobal(propertyName: string): unknown { + return this.#appHookData[propertyName]; + } + + setGlobal(propertyName: string, value: unknown): void { + this.#appHookData[propertyName] = value; + } +} diff --git a/src/hooks/InvocationHookContext.ts b/src/hooks/InvocationHookContext.ts new file mode 100644 index 0000000..d3aaa01 --- /dev/null +++ b/src/hooks/InvocationHookContext.ts @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { InvocationContext } from '../InvocationContext'; +import { ReadOnlyError } from '../errors'; +import { HookContext } from './HookContext'; + +export abstract class InvocationHookContext extends HookContext implements types.InvocationHookContext { + #invocationContext: types.InvocationContext; + + constructor(init?: types.InvocationHookContextInit) { + super(init); + init = init || {}; + this.#invocationContext = init.invocationContext || new InvocationContext({}); + } + + get invocationContext(): types.InvocationContext { + return this.#invocationContext; + } + + set invocationContext(_value: types.InvocationContext) { + throw new ReadOnlyError('invocationContext'); + } +} diff --git a/src/hooks/PostInvocationContext.ts b/src/hooks/PostInvocationContext.ts new file mode 100644 index 0000000..c1fa4c4 --- /dev/null +++ b/src/hooks/PostInvocationContext.ts @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { PostInvocationCoreContext } from '@azure/functions'; +import { InvocationHookContext } from './InvocationHookContext'; + +export class PostInvocationContext extends InvocationHookContext implements types.PostInvocationContext { + #coreCtx: PostInvocationCoreContext; + + constructor(init?: types.PostInvocationContextInit) { + super(init); + init = init || {}; + if (!init.coreContext) { + init.coreContext = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + result: typeof init.result === 'undefined' ? null : init.errorResult, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + error: typeof init.errorResult === 'undefined' ? null : init.errorResult, + inputs: init.args || [], + }; + } + + this.#coreCtx = init.coreContext; + } + + get args(): any[] { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.#coreCtx.inputs; + } + + set args(value: any[]) { + // it's important to use the core context inputs + // since changes to this array are persisted by the worker + this.#coreCtx.inputs = value; + } + + get result(): any { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.#coreCtx.result; + } + + set result(value: any) { + // it's important to use the core context result + // since changes to this value are persisted by the worker + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.#coreCtx.result = value; + } + + get errorResult(): any { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.#coreCtx.error; + } + + set errorResult(value: any) { + // it's important to use the core context result + // since changes to this value are persisted by the worker + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.#coreCtx.error = value; + } +} diff --git a/src/hooks/PreInvocationContext.ts b/src/hooks/PreInvocationContext.ts new file mode 100644 index 0000000..d9a916b --- /dev/null +++ b/src/hooks/PreInvocationContext.ts @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { FunctionHandler, PreInvocationContextInit, PreInvocationCoreContext } from '@azure/functions'; +import { InvocationHookContext } from './InvocationHookContext'; + +export class PreInvocationContext extends InvocationHookContext implements types.PreInvocationContext { + #coreCtx: PreInvocationCoreContext; + + constructor(init?: PreInvocationContextInit) { + super(init); + init = init || {}; + if (!init.coreContext) { + init.coreContext = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + functionCallback: init.functionHandler || (() => {}), + inputs: init.args || [], + }; + } + this.#coreCtx = init.coreContext; + } + + get functionHandler(): FunctionHandler { + return this.#coreCtx.functionCallback; + } + + set functionHandler(value: FunctionHandler) { + // it's important to use the core context functionCallback + // since changes to this value are persisted by the worker + this.#coreCtx.functionCallback = value; + } + + get args(): any[] { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.#coreCtx.inputs; + } + + set args(value: any[]) { + // it's important to use the core context inputs + // since changes to this array are persisted by the worker + this.#coreCtx.inputs = value; + } +} diff --git a/types/app.d.ts b/types/app.d.ts index 41dfdba..636e422 100644 --- a/types/app.d.ts +++ b/types/app.d.ts @@ -4,6 +4,7 @@ import { CosmosDBFunctionOptions } from './cosmosDB'; import { EventGridFunctionOptions } from './eventGrid'; import { EventHubFunctionOptions } from './eventHub'; +import { AppStartHandler, AppTerminateHandler, Disposable, PostInvocationHandler, PreInvocationHandler } from './hooks'; import { HttpFunctionOptions, HttpHandler, HttpMethodFunctionOptions } from './http'; import { FunctionOptions } from './index'; import { ServiceBusQueueFunctionOptions, ServiceBusTopicFunctionOptions } from './serviceBus'; @@ -150,3 +151,39 @@ export function cosmosDB(name: string, options: CosmosDBFunctionOptions): void; * @param options Configuration options describing the inputs, outputs, and handler for this function */ export function generic(name: string, options: FunctionOptions): void; + +/** + * Register a hook on the `appStart` event, executed at the start of your application + * + * @param handler the handler for the event + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function onStart(handler: AppStartHandler): Disposable; + +/** + * Register a hook on the `appTerminate` event, executed during graceful shutdown of your application + * This hook will not be executed if your application is terminated forcefully + * Please note that all `appTerminate` hooks must finish execution in 10 seconds or less, or they will be terminated. + + * @param handler the handler for the event + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function onTerminate(handler: AppTerminateHandler): Disposable; + +/** + * Register a hook to be run right _before_ a function is invoked. + * This hook will be executed for all functions in your app. + * + * @param handler the handler for the event + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function onPreInvocation(handler: PreInvocationHandler): Disposable; + +/** + * Register a hook to be run right _after_ a function is invoked. + * This hook will be executed for all functions in your app. + * + * @param handler the handler for the event + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function onPostInvocation(handler: PostInvocationHandler): Disposable; diff --git a/types/hooks.d.ts b/types/hooks.d.ts new file mode 100644 index 0000000..f46bf50 --- /dev/null +++ b/types/hooks.d.ts @@ -0,0 +1,328 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { FunctionHandler } from '.'; +import { InvocationContext } from './InvocationContext'; + +/** + * Represents a type which can release resources, such as event listening or a timer. + */ +export declare class Disposable { + /** + * Combine many disposable-likes into one. You can use this method when having objects with a dispose function which aren't instances of `Disposable`. + * + * @param disposableLikes Objects that have at least a `dispose`-function member. Note that asynchronous dispose-functions aren't awaited. + * @return Returns a new disposable which, upon dispose, will dispose all provided disposables. + */ + static from(...disposableLikes: { dispose: () => any }[]): Disposable; + + /** + * Creates a new disposable that calls the provided function on dispose. + * *Note* that an asynchronous function is not awaited. + * + * @param callOnDispose Function that disposes something. + */ + constructor(callOnDispose: () => any); + + /** + * Dispose this object. + */ + dispose(): any; +} + +/** + * A generic handler for hooks + */ +export type HookHandler = (context: HookContext) => void | Promise; + +/** + * Base class for all hook context objects + */ +export declare class HookContext { + /** + * For testing purposes only. This will always be constructed for you when run in the context of the Azure Functions runtime + */ + constructor(init?: HookContextInit); + + /** + * Set a `propertyName` to some `value` to be shared with other hooks in the same scope (app-level vs invocation-level) + * @param propertyName The name of the property to set + * @param value The value to set + */ + set(propertyName: string, value: unknown): void; + + /** + * Get the value of a `propertyName` shared with other hooks in the same scope (app-level vs invocation-level) + * @param propertyName the name of the property to get + */ + get(propertyName: string): unknown; + + /** + * Set a `propertyName` to some `value` to be shared across scopes for all hooks + * + * @param propertyName The name of the property to set + * @param value The value to set + */ + setGlobal(propertyName: string, value: unknown): void; + + /** + * Get the value of a `propertyName` shared across scopes for all hooks + * @param propertyName The name of the property to get + */ + getGlobal(propertyName: string): unknown; +} + +/** + * Object used to pass data between hooks + */ +export type HookData = { [key: string]: any }; + +/** + * Base interface for objects passed to HookContext constructors. + * For testing purposes only. + */ +export interface HookContextInit { + /** + * This object will be used to persist values between hooks + * of the same level (invocation-level vs app-level) + * + * Defaults to empty object if not specified + */ + hookData?: HookData; + + /** + * This object will be used to persist global values + * + * Defaults to empty object if not specified + */ + appHookData?: HookData; +} + +/** + * Handler for app start hooks + */ +export type AppStartHandler = (context: AppStartContext) => void | Promise; + +/** + * Context on a function app that is about to be started + * This object will be passed to all app start hooks + */ +export declare class AppStartContext extends HookContext { + /** + * For testing purposes only. This will always be constructed for you when run in the context of the Azure Functions runtime + */ + constructor(init?: AppStartContextInit); + + /** + * Absolute directory of the function app + */ + functionAppDirectory: string; +} + +/** + * Object passed to AppStartContext constructors. + * For testing purposes only + */ +export interface AppStartContextInit extends HookContextInit { + /** + * Defaults to 'unknown' + */ + functionAppDirectory?: string; +} + +/** + * Handler for app terminate hooks + */ +export type AppTerminateHandler = (context: AppTerminateContext) => void | Promise; + +/** + * Context on a function app that is about to be terminated + * This object will be passed to all app terminate hooks + */ +export declare class AppTerminateContext extends HookContext { + /** + * For testing purposes only. This will always be constructed for you when run in the context of the Azure Functions runtime + */ + constructor(init?: AppTerminateContextInit); +} + +/** + * Object passed to AppTerminateContext constructors. + * For testing purposes only + */ +export interface AppTerminateContextInit extends HookContextInit {} + +/** + * Handler for pre-invocation hooks + */ +export type PreInvocationHandler = (context: PreInvocationContext) => void | Promise; + +/** + * Base class for all invocation hook context objects (pre-invocation and post-invocation) + */ +export declare abstract class InvocationHookContext extends HookContext { + /** + * For testing purposes only. This will always be constructed for you when run in the context of the Azure Functions runtime + */ + constructor(init?: InvocationHookContextInit); + + /** + * The context object passed to the function + * This object is readonly. You may modify it, but attempting to overwrite it will throw an error + */ + readonly invocationContext: InvocationContext; +} + +/** + * Base interface passed to invocation hook context constructors. + * For testing purposes only + */ +interface InvocationHookContextInit extends HookContextInit { + /** + * Defaults to new InvocationContext with default values if not specified + */ + invocationContext?: InvocationContext; +} + +/** + * Context on a function that is about to be executed + * This object will be passed to all pre-invocation hooks + */ +export declare class PreInvocationContext extends InvocationHookContext { + /** + * For testing purposes only. This will always be constructed for you when run in the context of the Azure Functions runtime + */ + constructor(init?: PreInvocationContextInit); + + /** + * The arguments passed to this specific invocation. + * Changes to this array _will_ affect the inputs passed to your function + */ + args: any[]; + + /** + * The function handler for this specific invocation. Changes to this value _will_ affect the function itself + */ + functionHandler: FunctionHandler; +} + +/** + * Object passed to PreInvocationContext constructors. + * For testing purposes only + */ +export interface PreInvocationContextInit extends InvocationHookContextInit { + /** + * Defaults to empty array if not specified + */ + args?: any[]; + + /** + * Defaults to an empty function if not specified + */ + functionHandler?: FunctionHandler; + + /** + * This is set by the Azure Functions runtime. You should not set this yourself + */ + coreContext?: PreInvocationCoreContext; +} + +/** + * Context for pre-invocation hooks coming from the Core API + * This object is relevant only for hooks that are registered using the Core API + * You should not construct this object yourself + */ +export interface PreInvocationCoreContext { + /** + * The input values for this specific invocation. + * Changes to this array _will_ affect the inputs passed to your function + */ + inputs: any[]; + + /** + * The function callback for this specific invocation. + * Changes to this value _will_ affect the function itself + */ + functionCallback: FunctionHandler; +} + +/** + * Handler for post-invocation hooks + */ +export type PostInvocationHandler = (context: PostInvocationContext) => void | Promise; + +/** + * Context on a function that has just executed + * This object will be passed to all post invocation hooks + */ +export declare class PostInvocationContext extends InvocationHookContext { + /** + * For testing purposes only. This will always be constructed for you when run in the context of the Azure Functions runtime + */ + constructor(init?: PostInvocationContextInit); + + /** + * The arguments passed to this specific invocation. + */ + args: any[]; + + /** + * The result of the function, or null if there is no result. Changes to this value _will_ affect the overall result of the function + */ + result: any; + + /** + * The error thrown by the function, or null if there is no error. Changes to this value _will_ affect the overall result of the function + */ + errorResult: any; +} + +/** + * Object passed to PostInvocationContext constructors. + * For testing purposes only + */ +export interface PostInvocationContextInit extends InvocationHookContextInit { + /** + * Defaults to empty array if not specified + */ + args?: any[]; + + /** + * Defaults to `null` if not specified + */ + result?: any; + + /** + * Defaults to `null` if not specified + */ + errorResult?: any; + + /** + * This is set automatically by the Azure Functions runtime. You should not set this yourself + */ + coreContext?: PostInvocationCoreContext; +} + +/** + * Context for Post Invocation hooks coming from the Core API + * This object is relevant only for hooks that are registered using the Core API + * You should not construct this object yourself + */ +interface PostInvocationCoreContext { + /** + * The input values for this specific invocation + */ + inputs: any[]; + + /** + * The result of the function, or null if there is no result. + * Changes to this value _will_ affect the overall result of the function + */ + result: any; + + /** + * The error for the function, or null if there is no error. + * Changes to this value _will_ affect the overall result of the function + */ + error: any; +} diff --git a/types/index.d.ts b/types/index.d.ts index 647a2b5..0a1aa2d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -11,6 +11,7 @@ export * from './cosmosDB.v4'; export * from './eventGrid'; export * from './eventHub'; export * from './generic'; +export * from './hooks'; export * from './http'; export * as input from './input'; export * as output from './output';