diff --git a/.eslintrc.json b/.eslintrc.json index e4e6458..ca66313 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -38,7 +38,8 @@ ], "no-return-await": "off", "@typescript-eslint/return-await": "error", - "eqeqeq": "error" + "eqeqeq": "error", + "@typescript-eslint/no-empty-function": "off" }, "ignorePatterns": [ "**/*.js", diff --git a/src/InvocationModel.ts b/src/InvocationModel.ts index 1ddb0b5..5b45991 100644 --- a/src/InvocationModel.ts +++ b/src/InvocationModel.ts @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { FunctionHandler } from '@azure/functions'; import * as coreTypes from '@azure/functions-core'; import { CoreInvocationContext, @@ -79,10 +78,13 @@ export class InvocationModel implements coreTypes.InvocationModel { return { context, inputs }; } - async invokeFunction(context: InvocationContext, inputs: unknown[], handler: FunctionHandler): Promise { + async invokeFunction( + context: InvocationContext, + inputs: unknown[], + handler: coreTypes.FunctionCallback + ): Promise { try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return await Promise.resolve(handler(inputs[0], context)); + return await Promise.resolve(handler(...inputs, context)); } finally { this.#isDone = true; } diff --git a/src/app.ts b/src/app.ts index 7c90e80..ec68452 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,19 +29,9 @@ import { toRpcDuration } from './converters/toRpcDuration'; import * as output from './output'; import * as trigger from './trigger'; import { isTrigger } from './utils/isTrigger'; +import { tryGetCoreApiLazy } from './utils/tryGetCoreApiLazy'; -let coreApi: typeof coreTypes | undefined | null; -function tryGetCoreApiLazy(): typeof coreTypes | null { - if (coreApi === undefined) { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - coreApi = require('@azure/functions-core'); - } catch { - coreApi = null; - } - } - return coreApi; -} +export * as hook from './hooks/registerHook'; class ProgrammingModel implements coreTypes.ProgrammingModel { name = '@azure/functions'; diff --git a/src/hooks/AppStartContext.ts b/src/hooks/AppStartContext.ts new file mode 100644 index 0000000..dda2b77 --- /dev/null +++ b/src/hooks/AppStartContext.ts @@ -0,0 +1,7 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { HookContext } from './HookContext'; + +export class AppStartContext extends HookContext implements types.AppStartContext {} diff --git a/src/hooks/AppTerminateContext.ts b/src/hooks/AppTerminateContext.ts new file mode 100644 index 0000000..84e3694 --- /dev/null +++ b/src/hooks/AppTerminateContext.ts @@ -0,0 +1,7 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { HookContext } from './HookContext'; + +export class AppTerminateContext extends HookContext implements types.AppTerminateContext {} diff --git a/src/hooks/HookContext.ts b/src/hooks/HookContext.ts new file mode 100644 index 0000000..c2794ca --- /dev/null +++ b/src/hooks/HookContext.ts @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { ReadOnlyError } from '../errors'; +import { nonNullProp } from '../utils/nonNull'; + +export class HookContext implements types.HookContext { + #init: types.HookContextInit; + + constructor(init?: types.HookContextInit) { + this.#init = init ?? {}; + this.#init.hookData ??= {}; + } + + get hookData(): Record { + return nonNullProp(this.#init, 'hookData'); + } + + set hookData(_value: unknown) { + throw new ReadOnlyError('hookData'); + } +} diff --git a/src/hooks/InvocationHookContext.ts b/src/hooks/InvocationHookContext.ts new file mode 100644 index 0000000..75eefdd --- /dev/null +++ b/src/hooks/InvocationHookContext.ts @@ -0,0 +1,35 @@ +// 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 { nonNullProp } from '../utils/nonNull'; +import { HookContext } from './HookContext'; + +export class InvocationHookContext extends HookContext implements types.InvocationHookContext { + #init: types.InvocationHookContextInit; + + constructor(init?: types.InvocationHookContextInit) { + super(init); + this.#init = init ?? {}; + this.#init.inputs ??= []; + this.#init.invocationContext ??= new InvocationContext(); + } + + get invocationContext(): types.InvocationContext { + return nonNullProp(this.#init, 'invocationContext'); + } + + set invocationContext(_value: types.InvocationContext) { + throw new ReadOnlyError('invocationContext'); + } + + get inputs(): unknown[] { + return nonNullProp(this.#init, 'inputs'); + } + + set inputs(value: unknown[]) { + this.#init.inputs = value; + } +} diff --git a/src/hooks/PostInvocationContext.ts b/src/hooks/PostInvocationContext.ts new file mode 100644 index 0000000..889532f --- /dev/null +++ b/src/hooks/PostInvocationContext.ts @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { InvocationHookContext } from './InvocationHookContext'; + +export class PostInvocationContext extends InvocationHookContext implements types.PostInvocationContext { + #init: types.PostInvocationContextInit; + + constructor(init?: types.PostInvocationContextInit) { + super(init); + this.#init = init ?? {}; + } + + get result(): unknown { + return this.#init.result; + } + + set result(value: unknown) { + this.#init.result = value; + } + + get error(): unknown { + return this.#init.error; + } + + set error(value: unknown) { + this.#init.error = value; + } +} diff --git a/src/hooks/PreInvocationContext.ts b/src/hooks/PreInvocationContext.ts new file mode 100644 index 0000000..5a7f0be --- /dev/null +++ b/src/hooks/PreInvocationContext.ts @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as types from '@azure/functions'; +import { nonNullProp } from '../utils/nonNull'; +import { InvocationHookContext } from './InvocationHookContext'; + +export class PreInvocationContext extends InvocationHookContext implements types.PreInvocationContext { + #init: types.PreInvocationContextInit; + + constructor(init?: types.PreInvocationContextInit) { + super(init); + this.#init = init ?? {}; + this.#init.functionCallback ??= () => {}; + } + + get functionHandler(): types.FunctionHandler { + return nonNullProp(this.#init, 'functionCallback'); + } + + set functionHandler(value: types.FunctionHandler) { + this.#init.functionCallback = value; + } +} diff --git a/src/hooks/registerHook.ts b/src/hooks/registerHook.ts new file mode 100644 index 0000000..446b928 --- /dev/null +++ b/src/hooks/registerHook.ts @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AppStartHandler, AppTerminateHandler, PostInvocationHandler, PreInvocationHandler } from '@azure/functions'; +import * as coreTypes from '@azure/functions-core'; +import { Disposable } from '../utils/Disposable'; +import { tryGetCoreApiLazy } from '../utils/tryGetCoreApiLazy'; +import { AppStartContext } from './AppStartContext'; +import { AppTerminateContext } from './AppTerminateContext'; +import { PostInvocationContext } from './PostInvocationContext'; +import { PreInvocationContext } from './PreInvocationContext'; + +function registerHook(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.warn( + `WARNING: Skipping call to dispose ${hookName} hook because the "@azure/functions" package is in test mode.` + ); + }); + } else { + return coreApi.registerHook(hookName, callback); + } +} + +export function appStart(handler: AppStartHandler): Disposable { + return registerHook('appStart', (coreContext) => { + return handler(new AppStartContext(coreContext)); + }); +} + +export function appTerminate(handler: AppTerminateHandler): Disposable { + return registerHook('appTerminate', (coreContext) => { + return handler(new AppTerminateContext(coreContext)); + }); +} + +export function preInvocation(handler: PreInvocationHandler): Disposable { + return registerHook('preInvocation', (coreContext) => { + return handler(new PreInvocationContext(coreContext)); + }); +} + +export function postInvocation(handler: PostInvocationHandler): Disposable { + return registerHook('postInvocation', (coreContext) => { + return handler(new PostInvocationContext(coreContext)); + }); +} diff --git a/src/index.ts b/src/index.ts index b06ab88..45df5b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,17 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. +export { InvocationContext } from './InvocationContext'; export * as app from './app'; +export { AppStartContext } from './hooks/AppStartContext'; +export { AppTerminateContext } from './hooks/AppTerminateContext'; +export { HookContext } from './hooks/HookContext'; +export { InvocationHookContext } from './hooks/InvocationHookContext'; +export { PostInvocationContext } from './hooks/PostInvocationContext'; +export { PreInvocationContext } from './hooks/PreInvocationContext'; export { HttpRequest } from './http/HttpRequest'; export { HttpResponse } from './http/HttpResponse'; export * as input from './input'; -export { InvocationContext } from './InvocationContext'; export * as output from './output'; export * as trigger from './trigger'; +export { Disposable } from './utils/Disposable'; diff --git a/src/utils/Disposable.ts b/src/utils/Disposable.ts new file mode 100644 index 0000000..913a080 --- /dev/null +++ b/src/utils/Disposable.ts @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +/** + * Based off of VS Code + * https://github.com/microsoft/vscode/blob/7bed4ce3e9f5059b5fc638c348f064edabcce5d2/src/vs/workbench/api/common/extHostTypes.ts#L65 + */ +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 (typeof this.#callOnDispose === 'function') { + this.#callOnDispose(); + this.#callOnDispose = undefined; + } + } +} diff --git a/src/utils/tryGetCoreApiLazy.ts b/src/utils/tryGetCoreApiLazy.ts new file mode 100644 index 0000000..b42c523 --- /dev/null +++ b/src/utils/tryGetCoreApiLazy.ts @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as coreTypes from '@azure/functions-core'; + +let coreApi: typeof coreTypes | undefined | null; +export function tryGetCoreApiLazy(): typeof coreTypes | null { + if (coreApi === undefined) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + coreApi = require('@azure/functions-core'); + } catch { + coreApi = null; + } + } + return coreApi; +} diff --git a/test/hooks.test.ts b/test/hooks.test.ts new file mode 100644 index 0000000..ca582a9 --- /dev/null +++ b/test/hooks.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import 'mocha'; +import { + AppStartContext, + AppTerminateContext, + HookContext, + InvocationContext, + InvocationHookContext, + PostInvocationContext, + PreInvocationContext, + app, +} from '../src/index'; + +describe('hooks', () => { + it("register doesn't throw error in unit test mode", () => { + app.hook.appStart(() => {}); + app.hook.appTerminate(() => {}); + app.hook.postInvocation(() => {}); + const registeredHook = app.hook.preInvocation(() => {}); + registeredHook.dispose(); + }); + + it('AppTerminateContext', () => { + const context = new AppTerminateContext(); + validateHookContext(context); + }); + + it('AppStartContext', () => { + const context = new AppStartContext(); + validateHookContext(context); + }); + + it('PreInvocationContext', () => { + const context = new PreInvocationContext(); + validateInvocationHookContext(context); + expect(typeof context.functionHandler).to.equal('function'); + + const updatedFunc = () => { + console.log('changed'); + }; + context.functionHandler = updatedFunc; + expect(context.functionHandler).to.equal(updatedFunc); + }); + + it('PostInvocationContext', () => { + const context = new PostInvocationContext(); + validateInvocationHookContext(context); + expect(context.error).to.equal(undefined); + expect(context.result).to.equal(undefined); + + const newError = new Error('test1'); + context.error = newError; + context.result = 'test2'; + expect(context.error).to.equal(newError); + expect(context.result).to.equal('test2'); + }); + + function validateInvocationHookContext(context: InvocationHookContext): void { + validateHookContext(context); + expect(context.inputs).to.deep.equal([]); + expect(context.invocationContext).to.deep.equal(new InvocationContext()); + + expect(() => { + context.invocationContext = {}; + }).to.throw(); + context.inputs = ['change']; + expect(context.inputs).to.deep.equal(['change']); + } + + function validateHookContext(context: HookContext) { + expect(context.hookData).to.deep.equal({}); + expect(() => { + context.hookData = {}; + }).to.throw(); + } +}); diff --git a/types-core/index.d.ts b/types-core/index.d.ts index 3c7f965..20d118b 100644 --- a/types-core/index.d.ts +++ b/types-core/index.d.ts @@ -278,7 +278,7 @@ declare module '@azure/functions-core' { inputs: unknown[]; } - type FunctionCallback = (context: unknown, ...inputs: unknown[]) => unknown; + type FunctionCallback = (...args: unknown[]) => unknown; // #region rpc types interface RpcFunctionMetadata { diff --git a/types/app.d.ts b/types/app.d.ts index dffcda9..e318e3c 100644 --- a/types/app.d.ts +++ b/types/app.d.ts @@ -163,3 +163,5 @@ export function warmup(name: string, options: WarmupFunctionOptions): void; * @param options Configuration options describing the inputs, outputs, and handler for this function */ export function generic(name: string, options: GenericFunctionOptions): void; + +export * as hook from './hooks/registerHook'; diff --git a/types/hooks/HookContext.d.ts b/types/hooks/HookContext.d.ts new file mode 100644 index 0000000..9ef8e66 --- /dev/null +++ b/types/hooks/HookContext.d.ts @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +/** + * 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); + + /** + * The recommended place to store and share data between hooks in the same scope (app-level vs invocation-level). + * You should use a unique property name so that it doesn't conflict with other hooks' data. + * This object is readonly. You may modify it, but attempting to overwrite it will throw an error + */ + readonly hookData: Record; +} + +/** + * Base interface for objects passed to HookContext constructors. + * For testing purposes only. + */ +export interface HookContextInit { + hookData?: Record; +} diff --git a/types/hooks/appHooks.d.ts b/types/hooks/appHooks.d.ts new file mode 100644 index 0000000..a4e5185 --- /dev/null +++ b/types/hooks/appHooks.d.ts @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { HookContext, HookContextInit } from './HookContext'; + +/** + * Handler for app start hooks + */ +export type AppStartHandler = (context: AppStartContext) => void | Promise; + +/** + * Context on a function app during app startup. + */ +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); +} + +/** + * Handler for app terminate hooks + */ +export type AppTerminateHandler = (context: AppTerminateContext) => void | Promise; + +/** + * Context on a function app during app termination. + */ +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 AppStartContext constructors. + * For testing purposes only + */ +export interface AppStartContextInit extends HookContextInit {} + +/** + * Object passed to AppTerminateContext constructors. + * For testing purposes only + */ +export interface AppTerminateContextInit extends HookContextInit {} diff --git a/types/hooks/invocationHooks.d.ts b/types/hooks/invocationHooks.d.ts new file mode 100644 index 0000000..03aafd3 --- /dev/null +++ b/types/hooks/invocationHooks.d.ts @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { FunctionHandler } from '../index'; +import { InvocationContext } from '../InvocationContext'; +import { HookContext, HookContextInit } from './HookContext'; + +/** + * Handler for pre-invocation hooks. + */ +export type PreInvocationHandler = (context: PreInvocationContext) => void | Promise; + +/** + * Context on a function before it executes. + */ +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 + */ + inputs: unknown[]; + + /** + * The function handler for this specific invocation. Changes to this value _will_ affect the function itself + */ + functionHandler: FunctionHandler; +} + +/** + * Handler for post-invocation hooks + */ +export type PostInvocationHandler = (context: PostInvocationContext) => void | Promise; + +/** + * Context on a function after it executes. + */ +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. + */ + inputs: unknown[]; + + /** + * The result of the function. Changes to this value _will_ affect the overall result of the function + */ + result: unknown; + + /** + * The error thrown by the function, or null/undefined if there is no error. Changes to this value _will_ affect the overall result of the function + */ + error: unknown; +} + +/** + * Base class for all invocation hook context objects + */ +export declare 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; +} + +/** + * Object passed to InvocationHookContext constructors. + * For testing purposes only + */ +export interface InvocationHookContextInit extends HookContextInit { + inputs?: unknown[]; + + invocationContext?: InvocationContext; +} + +/** + * Object passed to PreInvocationContext constructors. + * For testing purposes only + */ +export interface PreInvocationContextInit extends InvocationHookContextInit { + functionCallback?: FunctionHandler; +} + +/** + * Object passed to PostInvocationContext constructors. + * For testing purposes only + */ +export interface PostInvocationContextInit extends InvocationHookContextInit { + result?: unknown; + + error?: unknown; +} diff --git a/types/hooks/registerHook.d.ts b/types/hooks/registerHook.d.ts new file mode 100644 index 0000000..8c60e51 --- /dev/null +++ b/types/hooks/registerHook.d.ts @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { Disposable } from '../index'; +import { AppStartHandler, AppTerminateHandler } from './appHooks'; +import { PostInvocationHandler, PreInvocationHandler } from './invocationHooks'; + +/** + * Register a hook to be run at the start of your application + * + * @param handler the handler for the hook + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function appStart(handler: AppStartHandler): Disposable; + +/** + * Register a hook to be run during graceful shutdown of your application. + * This hook will not be executed if your application is terminated forcefully. + * Hooks have a limited time to execute during the termination grace period. + * + * @param handler the handler for the hook + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function appTerminate(handler: AppTerminateHandler): Disposable; + +/** + * Register a hook to be run before a function is invoked. + * + * @param handler the handler for the hook + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function preInvocation(handler: PreInvocationHandler): Disposable; + +/** + * Register a hook to be run after a function is invoked. + * + * @param handler the handler for the hook + * @returns a `Disposable` object that can be used to unregister the hook + */ +export function postInvocation(handler: PostInvocationHandler): Disposable; diff --git a/types/index.d.ts b/types/index.d.ts index 5cb8843..6657c6f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -11,6 +11,9 @@ export * from './cosmosDB.v4'; export * from './eventGrid'; export * from './eventHub'; export * from './generic'; +export * from './hooks/HookContext'; +export * from './hooks/appHooks'; +export * from './hooks/invocationHooks'; export * from './http'; export * as input from './input'; export * as output from './output'; @@ -168,3 +171,29 @@ export interface Duration { seconds?: number; milliseconds?: number; } + +/** + * 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; +}