Skip to content

Commit 3c9383f

Browse files
authored
Add pre and post invocation hooks (#548)
1 parent b14b52e commit 3c9383f

File tree

10 files changed

+469
-152
lines changed

10 files changed

+469
-152
lines changed

.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@typescript-eslint/restrict-template-expressions": "off",
4444
"@typescript-eslint/unbound-method": "off",
4545
"no-empty": "off",
46+
"prefer-const": ["error", { "destructuring": "all" }],
4647
"prefer-rest-params": "off",
4748
"prefer-spread": "off"
4849
},

src/Disposable.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
/**
5+
* Based off of VS Code
6+
* https://github.com/microsoft/vscode/blob/a64e8e5673a44e5b9c2d493666bde684bd5a135c/src/vs/workbench/api/common/extHostTypes.ts#L32
7+
*/
8+
export class Disposable {
9+
static from(...inDisposables: { dispose(): any }[]): Disposable {
10+
let disposables: ReadonlyArray<{ dispose(): any }> | undefined = inDisposables;
11+
return new Disposable(function () {
12+
if (disposables) {
13+
for (const disposable of disposables) {
14+
if (disposable && typeof disposable.dispose === 'function') {
15+
disposable.dispose();
16+
}
17+
}
18+
disposables = undefined;
19+
}
20+
});
21+
}
22+
23+
#callOnDispose?: () => any;
24+
25+
constructor(callOnDispose: () => any) {
26+
this.#callOnDispose = callOnDispose;
27+
}
28+
29+
dispose(): any {
30+
if (this.#callOnDispose instanceof Function) {
31+
this.#callOnDispose();
32+
this.#callOnDispose = undefined;
33+
}
34+
}
35+
}

src/Worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import * as parseArgs from 'minimist';
55
import { FunctionLoader } from './FunctionLoader';
66
import { CreateGrpcEventStream } from './GrpcClient';
7+
import { setupCoreModule } from './setupCoreModule';
78
import { setupEventStream } from './setupEventStream';
89
import { ensureErrorType } from './utils/ensureErrorType';
910
import { InternalException } from './utils/InternalException';
@@ -42,6 +43,7 @@ export function startNodeWorker(args) {
4243

4344
const channel = new WorkerChannel(eventStream, new FunctionLoader());
4445
setupEventStream(workerId, channel);
46+
setupCoreModule(channel);
4547

4648
eventStream.write({
4749
requestId: requestId,

src/WorkerChannel.ts

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { Context } from '@azure/functions';
4+
import { HookCallback, HookContext } from '@azure/functions-core';
55
import { readJson } from 'fs-extra';
66
import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc';
7+
import { Disposable } from './Disposable';
78
import { IFunctionLoader } from './FunctionLoader';
89
import { IEventStream } from './GrpcClient';
910
import { ensureErrorType } from './utils/ensureErrorType';
1011
import path = require('path');
1112
import LogLevel = rpc.RpcLog.Level;
1213
import LogCategory = rpc.RpcLog.RpcLogCategory;
1314

14-
type InvocationRequestBefore = (context: Context, userFn: Function) => Function;
15-
type InvocationRequestAfter = (context: Context) => void;
16-
1715
export interface PackageJson {
1816
type?: string;
1917
}
@@ -22,15 +20,13 @@ export class WorkerChannel {
2220
public eventStream: IEventStream;
2321
public functionLoader: IFunctionLoader;
2422
public packageJson: PackageJson;
25-
private _invocationRequestBefore: InvocationRequestBefore[];
26-
private _invocationRequestAfter: InvocationRequestAfter[];
23+
#preInvocationHooks: HookCallback[] = [];
24+
#postInvocationHooks: HookCallback[] = [];
2725

2826
constructor(eventStream: IEventStream, functionLoader: IFunctionLoader) {
2927
this.eventStream = eventStream;
3028
this.functionLoader = functionLoader;
3129
this.packageJson = {};
32-
this._invocationRequestBefore = [];
33-
this._invocationRequestAfter = [];
3430
}
3531

3632
/**
@@ -44,32 +40,32 @@ export class WorkerChannel {
4440
});
4541
}
4642

47-
/**
48-
* Register a patching function to be run before User Function is executed.
49-
* Hook should return a patched version of User Function.
50-
*/
51-
public registerBeforeInvocationRequest(beforeCb: InvocationRequestBefore): void {
52-
this._invocationRequestBefore.push(beforeCb);
53-
}
54-
55-
/**
56-
* Register a function to be run after User Function resolves.
57-
*/
58-
public registerAfterInvocationRequest(afterCb: InvocationRequestAfter): void {
59-
this._invocationRequestAfter.push(afterCb);
43+
public registerHook(hookName: string, callback: HookCallback): Disposable {
44+
const hooks = this.#getHooks(hookName);
45+
hooks.push(callback);
46+
return new Disposable(() => {
47+
const index = hooks.indexOf(callback);
48+
if (index > -1) {
49+
hooks.splice(index, 1);
50+
}
51+
});
6052
}
6153

62-
public runInvocationRequestBefore(context: Context, userFunction: Function): Function {
63-
let wrappedFunction = userFunction;
64-
for (const before of this._invocationRequestBefore) {
65-
wrappedFunction = before(context, wrappedFunction);
54+
public async executeHooks(hookName: string, context: HookContext): Promise<void> {
55+
const callbacks = this.#getHooks(hookName);
56+
for (const callback of callbacks) {
57+
await callback(context);
6658
}
67-
return wrappedFunction;
6859
}
6960

70-
public runInvocationRequestAfter(context: Context) {
71-
for (const after of this._invocationRequestAfter) {
72-
after(context);
61+
#getHooks(hookName: string): HookCallback[] {
62+
switch (hookName) {
63+
case 'preInvocation':
64+
return this.#preInvocationHooks;
65+
case 'postInvocation':
66+
return this.#postInvocationHooks;
67+
default:
68+
throw new RangeError(`Unrecognized hook "${hookName}"`);
7369
}
7470
}
7571

src/eventHandlers/invocationRequest.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
import { HookData, PostInvocationContext, PreInvocationContext } from '@azure/functions-core';
45
import { format } from 'util';
56
import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc';
67
import { CreateContextAndInputs } from '../Context';
@@ -67,7 +68,7 @@ export async function invocationRequest(channel: WorkerChannel, requestId: strin
6768
isDone = true;
6869
}
6970

70-
const { context, inputs, doneEmitter } = CreateContextAndInputs(info, msg, userLog);
71+
let { context, inputs, doneEmitter } = CreateContextAndInputs(info, msg, userLog);
7172
try {
7273
const legacyDoneTask = new Promise((resolve, reject) => {
7374
doneEmitter.on('done', (err?: unknown, result?: any) => {
@@ -80,8 +81,13 @@ export async function invocationRequest(channel: WorkerChannel, requestId: strin
8081
});
8182
});
8283

83-
let userFunction = channel.functionLoader.getFunc(nonNullProp(msg, 'functionId'));
84-
userFunction = channel.runInvocationRequestBefore(context, userFunction);
84+
const hookData: HookData = {};
85+
const userFunction = channel.functionLoader.getFunc(nonNullProp(msg, 'functionId'));
86+
const preInvocContext: PreInvocationContext = { hookData, invocationContext: context, inputs };
87+
88+
await channel.executeHooks('preInvocation', preInvocContext);
89+
inputs = preInvocContext.inputs;
90+
8591
let rawResult = userFunction(context, ...inputs);
8692
resultIsPromise = rawResult && typeof rawResult.then === 'function';
8793
let resultTask: Promise<any>;
@@ -95,7 +101,24 @@ export async function invocationRequest(channel: WorkerChannel, requestId: strin
95101
resultTask = legacyDoneTask;
96102
}
97103

98-
const result = await resultTask;
104+
const postInvocContext: PostInvocationContext = {
105+
hookData,
106+
invocationContext: context,
107+
inputs,
108+
result: null,
109+
error: null,
110+
};
111+
try {
112+
postInvocContext.result = await resultTask;
113+
} catch (err) {
114+
postInvocContext.error = err;
115+
}
116+
await channel.executeHooks('postInvocation', postInvocContext);
117+
118+
if (isError(postInvocContext.error)) {
119+
throw postInvocContext.error;
120+
}
121+
const result = postInvocContext.result;
99122

100123
// Allow HTTP response from context.res if HTTP response is not defined from the context.bindings object
101124
if (info.httpOutputName && context.res && context.bindings[info.httpOutputName] === undefined) {
@@ -164,6 +187,4 @@ export async function invocationRequest(channel: WorkerChannel, requestId: strin
164187
requestId: requestId,
165188
invocationResponse: response,
166189
});
167-
168-
channel.runInvocationRequestAfter(context);
169190
}

src/setupCoreModule.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { HookCallback } from '@azure/functions-core';
5+
import { Disposable } from './Disposable';
6+
import { WorkerChannel } from './WorkerChannel';
7+
import Module = require('module');
8+
9+
/**
10+
* Intercepts the default "require" method so that we can provide our own "built-in" module
11+
* This module is essentially the publicly accessible API for our worker
12+
* This module is available to users only at runtime, not as an installable npm package
13+
*/
14+
export function setupCoreModule(channel: WorkerChannel): void {
15+
const coreApi = {
16+
registerHook: (hookName: string, callback: HookCallback) => channel.registerHook(hookName, callback),
17+
Disposable,
18+
};
19+
20+
Module.prototype.require = new Proxy(Module.prototype.require, {
21+
apply(target, thisArg, argArray) {
22+
if (argArray[0] === '@azure/functions-core') {
23+
return coreApi;
24+
} else {
25+
return Reflect.apply(target, thisArg, argArray);
26+
}
27+
},
28+
});
29+
}

test/eventHandlers/beforeEventHandlerSuite.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import * as sinon from 'sinon';
55
import { FunctionLoader } from '../../src/FunctionLoader';
6+
import { setupCoreModule } from '../../src/setupCoreModule';
67
import { setupEventStream } from '../../src/setupEventStream';
78
import { WorkerChannel } from '../../src/WorkerChannel';
89
import { TestEventStream } from './TestEventStream';
@@ -12,5 +13,6 @@ export function beforeEventHandlerSuite() {
1213
const loader = sinon.createStubInstance<FunctionLoader>(FunctionLoader);
1314
const channel = new WorkerChannel(stream, loader);
1415
setupEventStream('workerId', channel);
16+
setupCoreModule(channel);
1517
return { stream, loader, channel };
1618
}

0 commit comments

Comments
 (0)