Skip to content

Add basic hooks support (v4) #115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Licensed under the MIT License.

import {
AppStartHandler,
AppTerminateHandler,
CosmosDBFunctionOptions,
CosmosDBTrigger,
EventGridFunctionOptions,
Expand All @@ -11,6 +13,9 @@ import {
HttpHandler,
HttpMethod,
HttpMethodFunctionOptions,
InvocationContext,
PostInvocationHandler,
PreInvocationHandler,
ServiceBusQueueFunctionOptions,
ServiceBusTopicFunctionOptions,
StorageBlobFunctionOptions,
Expand All @@ -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';
Expand Down Expand Up @@ -280,3 +290,72 @@ export function generic(name: string, options: FunctionOptions): void {
coreApi.registerFunction({ name, bindings }, <FunctionCallback>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);
}
16 changes: 16 additions & 0 deletions src/hooks/AppStartContext.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
12 changes: 12 additions & 0 deletions src/hooks/AppTerminateContext.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
35 changes: 35 additions & 0 deletions src/hooks/Disposable.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
35 changes: 35 additions & 0 deletions src/hooks/HookContext.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
25 changes: 25 additions & 0 deletions src/hooks/InvocationHookContext.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
61 changes: 61 additions & 0 deletions src/hooks/PostInvocationContext.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
44 changes: 44 additions & 0 deletions src/hooks/PreInvocationContext.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
37 changes: 37 additions & 0 deletions types/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaning towards this organization:

app.hook.start
app.hook.terminate
app.hook.preInvocation
app.hook.postInvocation

I'm not a big of "on". I feel like we could've easily done that for the trigger registrations (app.onHttp, app.onTimer), and I don't want to be inconsistent and start using "on" for hooks. That being said, I still like grouping all the hooks together to make the Intellisense cleaner. Rather than group them with the "on" prefix, I think a more explicit "hook" group would make sense.

It's a bit weird to have hook on app when input/output/trigger aren't on app, but I feel like it's okay because the latter don't actually register anything.


/**
* 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;
Loading