diff --git a/src/WorkerChannel.ts b/src/WorkerChannel.ts index 7225490c..80cee7d2 100644 --- a/src/WorkerChannel.ts +++ b/src/WorkerChannel.ts @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { HookCallback, HookContext } from '@azure/functions-core'; +import { HookCallback, HookContext, HookData } from '@azure/functions-core'; import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; import { Disposable } from './Disposable'; import { IFunctionLoader } from './FunctionLoader'; @@ -15,8 +15,21 @@ export class WorkerChannel { eventStream: IEventStream; functionLoader: IFunctionLoader; packageJson: PackageJson; + /** + * This will only be set after worker init request is received + */ + hostVersion?: string; + /** + * this hook data will be passed to (and set by) all hooks in all scopes + */ + appHookData: HookData = {}; + /** + * this hook data is limited to the app-level scope and persisted only for app-level hooks + */ + appLevelOnlyHookData: HookData = {}; #preInvocationHooks: HookCallback[] = []; #postInvocationHooks: HookCallback[] = []; + #appStartHooks: HookCallback[] = []; constructor(eventStream: IEventStream, functionLoader: IFunctionLoader) { this.eventStream = eventStream; @@ -80,6 +93,8 @@ export class WorkerChannel { return this.#preInvocationHooks; case 'postInvocation': return this.#postInvocationHooks; + case 'appStart': + return this.#appStartHooks; default: throw new RangeError(`Unrecognized hook "${hookName}"`); } diff --git a/src/eventHandlers/FunctionEnvironmentReloadHandler.ts b/src/eventHandlers/FunctionEnvironmentReloadHandler.ts index ccf14b2a..d28eab80 100644 --- a/src/eventHandlers/FunctionEnvironmentReloadHandler.ts +++ b/src/eventHandlers/FunctionEnvironmentReloadHandler.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; +import { startApp } from '../startApp'; import { WorkerChannel } from '../WorkerChannel'; import { EventHandler } from './EventHandler'; import LogCategory = rpc.RpcLog.RpcLogCategory; @@ -35,6 +36,7 @@ export class FunctionEnvironmentReloadHandler extends EventHandler< }); process.env = Object.assign({}, msg.environmentVariables); + // Change current working directory if (msg.functionAppDirectory) { channel.log({ @@ -43,7 +45,7 @@ export class FunctionEnvironmentReloadHandler extends EventHandler< logCategory: LogCategory.System, }); process.chdir(msg.functionAppDirectory); - await channel.updatePackageJson(msg.functionAppDirectory); + await startApp(msg.functionAppDirectory, channel); } return response; diff --git a/src/eventHandlers/InvocationHandler.ts b/src/eventHandlers/InvocationHandler.ts index 8049ea7d..9a55dd97 100644 --- a/src/eventHandlers/InvocationHandler.ts +++ b/src/eventHandlers/InvocationHandler.ts @@ -90,16 +90,18 @@ export class InvocationHandler extends EventHandler<'invocationRequest', 'invoca }); }); - const hookData: HookData = {}; let userFunction = channel.functionLoader.getFunc(functionId); + + const invocationHookData: HookData = {}; + const preInvocContext: PreInvocationContext = { - hookData, + hookData: invocationHookData, + appHookData: channel.appHookData, invocationContext: context, functionCallback: userFunction, inputs, }; - - await channel.executeHooks('preInvocation', preInvocContext, msg.invocationId, msgCategory); + await channel.executeHooks('preInvocation', preInvocContext, invocationId, msgCategory); inputs = preInvocContext.inputs; userFunction = preInvocContext.functionCallback; @@ -117,7 +119,8 @@ export class InvocationHandler extends EventHandler<'invocationRequest', 'invoca } const postInvocContext: PostInvocationContext = { - hookData, + hookData: invocationHookData, + appHookData: channel.appHookData, invocationContext: context, inputs, result: null, diff --git a/src/eventHandlers/WorkerInitHandler.ts b/src/eventHandlers/WorkerInitHandler.ts index 45ba230c..7e83862f 100644 --- a/src/eventHandlers/WorkerInitHandler.ts +++ b/src/eventHandlers/WorkerInitHandler.ts @@ -4,7 +4,9 @@ import { access, constants } from 'fs'; import * as path from 'path'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; +import { startApp } from '../startApp'; import { isError } from '../utils/ensureErrorType'; +import { nonNullProp } from '../utils/nonNull'; import { WorkerChannel } from '../WorkerChannel'; import { EventHandler } from './EventHandler'; import LogCategory = rpc.RpcLog.RpcLogCategory; @@ -30,9 +32,11 @@ export class WorkerInitHandler extends EventHandler<'workerInitRequest', 'worker }); logColdStartWarning(channel); - const functionAppDirectory = msg.functionAppDirectory; - if (functionAppDirectory) { - await channel.updatePackageJson(functionAppDirectory); + + channel.hostVersion = nonNullProp(msg, 'hostVersion'); + + if (msg.functionAppDirectory) { + await startApp(msg.functionAppDirectory, channel); } response.capabilities = { diff --git a/src/startApp.ts b/src/startApp.ts new file mode 100644 index 00000000..3e03c25c --- /dev/null +++ b/src/startApp.ts @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { AppStartContext } from '@azure/functions-core'; +import { pathExists } from 'fs-extra'; +import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; +import { loadScriptFile } from './loadScriptFile'; +import { ensureErrorType } from './utils/ensureErrorType'; +import { nonNullProp } from './utils/nonNull'; +import { WorkerChannel } from './WorkerChannel'; +import path = require('path'); +import LogLevel = rpc.RpcLog.Level; +import LogCategory = rpc.RpcLog.RpcLogCategory; + +/** + * Starting an app can happen in two places, depending on if the worker was specialized or not + * 1. The worker can start in "normal" mode, meaning `workerInitRequest` will reference the user's app + * 2. The worker can start in "placeholder" mode, meaning `workerInitRequest` will reference a dummy app to "warm up" the worker and `functionEnvironmentReloadRequest` will be sent with the user's actual app. + * This process is called worker specialization and it helps with cold start times. + * The dummy app should never have actual startup code, so it should be safe to call `startApp` twice in this case + * Worker specialization happens only once, so we don't need to worry about cleaning up resources from previous `functionEnvironmentReloadRequest`s. + */ +export async function startApp(functionAppDirectory: string, channel: WorkerChannel): Promise { + await channel.updatePackageJson(functionAppDirectory); + await loadEntryPointFile(functionAppDirectory, channel); + const appStartContext: AppStartContext = { + hookData: channel.appLevelOnlyHookData, + appHookData: channel.appHookData, + functionAppDirectory, + hostVersion: nonNullProp(channel, 'hostVersion'), + }; + await channel.executeHooks('appStart', appStartContext); +} + +async function loadEntryPointFile(functionAppDirectory: string, channel: WorkerChannel): Promise { + const entryPointFile = channel.packageJson.main; + if (entryPointFile) { + channel.log({ + message: `Loading entry point "${entryPointFile}"`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }); + try { + const entryPointFullPath = path.join(functionAppDirectory, entryPointFile); + if (!(await pathExists(entryPointFullPath))) { + throw new Error(`file does not exist`); + } + + await loadScriptFile(entryPointFullPath, channel.packageJson); + channel.log({ + message: `Loaded entry point "${entryPointFile}"`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }); + } catch (err) { + const error = ensureErrorType(err); + error.isAzureFunctionsInternalException = true; + error.message = `Worker was unable to load entry point "${entryPointFile}": ${error.message}`; + throw error; + } + } +} diff --git a/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts b/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts index 01563d8e..4630521f 100644 --- a/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts +++ b/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts @@ -8,11 +8,12 @@ import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language import { WorkerChannel } from '../../src/WorkerChannel'; import { beforeEventHandlerSuite } from './beforeEventHandlerSuite'; import { TestEventStream } from './TestEventStream'; +import { Msg as WorkerInitMsg } from './WorkerInitHandler.test'; import path = require('path'); import LogCategory = rpc.RpcLog.RpcLogCategory; import LogLevel = rpc.RpcLog.Level; -namespace Msg { +export namespace Msg { export function reloadEnvVarsLog(numVars: number): rpc.IStreamingMessage { return { rpcLog: { @@ -69,11 +70,14 @@ describe('FunctionEnvironmentReloadHandler', () => { let stream: TestEventStream; let channel: WorkerChannel; - // Reset `process.env` after this test suite so it doesn't affect other tests + // Reset `process.env` and process.cwd() after this test suite so it doesn't affect other tests let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; before(() => { + originalCwd = process.cwd(); originalEnv = process.env; ({ stream, channel } = beforeEventHandlerSuite()); + channel.hostVersion = '2.7.0'; }); after(() => { @@ -82,6 +86,7 @@ describe('FunctionEnvironmentReloadHandler', () => { afterEach(async () => { mock.restore(); + process.chdir(originalCwd); await stream.afterEachEventHandlerTest(); }); @@ -229,6 +234,83 @@ describe('FunctionEnvironmentReloadHandler', () => { }); await stream.assertCalledWith(Msg.reloadEnvVarsLog(0), Msg.changingCwdLog(newDirAbsolute), Msg.reloadSuccess); expect(channel.packageJson).to.deep.equal(newPackageJson); - process.chdir(cwd); }); + + it('correctly loads package.json in specialization scenario', async () => { + const cwd = process.cwd(); + const tempDir = 'temp'; + const appDir = 'app'; + const packageJson = { + type: 'module', + hello: 'world', + }; + + mock({ + [tempDir]: {}, + [appDir]: { + 'package.json': JSON.stringify(packageJson), + }, + }); + + stream.addTestMessage(WorkerInitMsg.init(path.join(cwd, tempDir))); + await stream.assertCalledWith( + WorkerInitMsg.receivedInitLog, + WorkerInitMsg.warning(`Worker failed to load package.json: file does not exist`), + WorkerInitMsg.response + ); + expect(channel.packageJson).to.be.empty; + + stream.addTestMessage({ + requestId: 'id', + functionEnvironmentReloadRequest: { + functionAppDirectory: path.join(cwd, appDir), + }, + }); + await stream.assertCalledWith( + Msg.reloadEnvVarsLog(0), + Msg.changingCwdLog(path.join(cwd, appDir)), + Msg.reloadSuccess + ); + expect(channel.packageJson).to.deep.equal(packageJson); + }); + + for (const extension of ['.js', '.mjs', '.cjs']) { + it(`Loads entry point (${extension}) in specialization scenario`, async () => { + const cwd = process.cwd(); + const tempDir = 'temp'; + const fileName = `entryPointFiles/doNothing${extension}`; + const expectedPackageJson = { + main: fileName, + }; + mock({ + [tempDir]: {}, + [__dirname]: { + 'package.json': JSON.stringify(expectedPackageJson), + // 'require' and 'mockFs' don't play well together so we need these files in both the mock and real file systems + entryPointFiles: mock.load(path.join(__dirname, 'entryPointFiles')), + }, + }); + + stream.addTestMessage(WorkerInitMsg.init(path.join(cwd, tempDir))); + await stream.assertCalledWith( + WorkerInitMsg.receivedInitLog, + WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'), + WorkerInitMsg.response + ); + + stream.addTestMessage({ + requestId: 'id', + functionEnvironmentReloadRequest: { + functionAppDirectory: __dirname, + }, + }); + await stream.assertCalledWith( + Msg.reloadEnvVarsLog(0), + Msg.changingCwdLog(__dirname), + WorkerInitMsg.loadingEntryPoint(fileName), + WorkerInitMsg.loadedEntryPoint(fileName), + Msg.reloadSuccess + ); + }); + } }); diff --git a/test/eventHandlers/InvocationHandler.test.ts b/test/eventHandlers/InvocationHandler.test.ts index 309932c8..70970025 100644 --- a/test/eventHandlers/InvocationHandler.test.ts +++ b/test/eventHandlers/InvocationHandler.test.ts @@ -11,8 +11,11 @@ import * as sinon from 'sinon'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; import { FunctionInfo } from '../../src/FunctionInfo'; import { FunctionLoader } from '../../src/FunctionLoader'; +import { WorkerChannel } from '../../src/WorkerChannel'; +import { Msg as AppStartMsg } from '../startApp.test'; import { beforeEventHandlerSuite } from './beforeEventHandlerSuite'; import { TestEventStream } from './TestEventStream'; +import { Msg as WorkerInitMsg } from './WorkerInitHandler.test'; import LogCategory = rpc.RpcLog.RpcLogCategory; import LogLevel = rpc.RpcLog.Level; @@ -322,16 +325,19 @@ namespace InputData { describe('InvocationHandler', () => { let stream: TestEventStream; let loader: sinon.SinonStubbedInstance; + let channel: WorkerChannel; let coreApi: typeof coreTypes; let testDisposables: coreTypes.Disposable[] = []; before(async () => { - ({ stream, loader } = beforeEventHandlerSuite()); + ({ stream, loader, channel } = beforeEventHandlerSuite()); coreApi = await import('@azure/functions-core'); }); beforeEach(async () => { hookData = ''; + channel.appHookData = {}; + channel.appLevelOnlyHookData = {}; }); afterEach(async () => { @@ -759,6 +765,226 @@ describe('InvocationHandler', () => { expect(hookData).to.equal('prepost'); }); + it('appHookData changes from appStart hooks are persisted in invocation hook contexts', async () => { + const functionAppDirectory = __dirname; + const expectedAppHookData = { + hello: 'world', + test: { + test2: 3, + }, + }; + const startFunc = sinon.spy((context: coreTypes.AppStartContext) => { + Object.assign(context.appHookData, expectedAppHookData); + hookData += 'appStart'; + }); + testDisposables.push(coreApi.registerHook('appStart', startFunc)); + + stream.addTestMessage(WorkerInitMsg.init(functionAppDirectory)); + + await stream.assertCalledWith( + WorkerInitMsg.receivedInitLog, + WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'), + AppStartMsg.executingHooksLog(1, 'appStart'), + AppStartMsg.executedHooksLog('appStart'), + WorkerInitMsg.response + ); + expect(startFunc.callCount).to.be.equal(1); + + loader.getFunc.returns(async () => {}); + loader.getInfo.returns(new FunctionInfo(Binding.queue)); + + testDisposables.push( + coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { + expect(context.appHookData).to.deep.equal(expectedAppHookData); + hookData += 'preInvoc'; + }) + ); + + testDisposables.push( + coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { + expect(context.appHookData).to.deep.equal(expectedAppHookData); + hookData += 'postInvoc'; + }) + ); + + sendInvokeMessage([InputData.http]); + await stream.assertCalledWith( + Msg.receivedInvocLog(), + Msg.executingHooksLog(1, 'preInvocation'), + Msg.executedHooksLog('preInvocation'), + Msg.executingHooksLog(1, 'postInvocation'), + Msg.executedHooksLog('postInvocation'), + Msg.invocResponse([]) + ); + expect(hookData).to.equal('appStartpreInvocpostInvoc'); + }); + + it('hookData changes from appStart hooks are not persisted in invocation hook contexts', async () => { + const functionAppDirectory = __dirname; + const startFunc = sinon.spy((context: coreTypes.AppStartContext) => { + Object.assign(context.hookData, { + hello: 'world', + test: { + test2: 3, + }, + }); + hookData += 'appStart'; + }); + testDisposables.push(coreApi.registerHook('appStart', startFunc)); + + stream.addTestMessage(WorkerInitMsg.init(functionAppDirectory)); + + await stream.assertCalledWith( + WorkerInitMsg.receivedInitLog, + WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'), + AppStartMsg.executingHooksLog(1, 'appStart'), + AppStartMsg.executedHooksLog('appStart'), + WorkerInitMsg.response + ); + expect(startFunc.callCount).to.be.equal(1); + + loader.getFunc.returns(async () => {}); + loader.getInfo.returns(new FunctionInfo(Binding.queue)); + + testDisposables.push( + coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { + expect(context.hookData).to.be.empty; + expect(context.appHookData).to.be.empty; + hookData += 'preInvoc'; + }) + ); + + testDisposables.push( + coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { + expect(context.hookData).to.be.empty; + expect(context.appHookData).to.be.empty; + hookData += 'postInvoc'; + }) + ); + + sendInvokeMessage([InputData.http]); + await stream.assertCalledWith( + Msg.receivedInvocLog(), + Msg.executingHooksLog(1, 'preInvocation'), + Msg.executedHooksLog('preInvocation'), + Msg.executingHooksLog(1, 'postInvocation'), + Msg.executedHooksLog('postInvocation'), + Msg.invocResponse([]) + ); + + expect(hookData).to.equal('appStartpreInvocpostInvoc'); + }); + + it('appHookData changes are persisted between invocation-level hooks', async () => { + const expectedAppHookData = { + hello: 'world', + test: { + test2: 3, + }, + }; + + loader.getFunc.returns(async () => {}); + loader.getInfo.returns(new FunctionInfo(Binding.queue)); + + testDisposables.push( + coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { + Object.assign(context.appHookData, expectedAppHookData); + hookData += 'pre'; + }) + ); + + testDisposables.push( + coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { + expect(context.appHookData).to.deep.equal(expectedAppHookData); + hookData += 'post'; + }) + ); + + sendInvokeMessage([InputData.http]); + await stream.assertCalledWith( + Msg.receivedInvocLog(), + Msg.executingHooksLog(1, 'preInvocation'), + Msg.executedHooksLog('preInvocation'), + Msg.executingHooksLog(1, 'postInvocation'), + Msg.executedHooksLog('postInvocation'), + Msg.invocResponse([]) + ); + + expect(hookData).to.equal('prepost'); + }); + + it('appHookData changes are persisted across different invocations while hookData changes are not', async () => { + const expectedAppHookData = { + hello: 'world', + test: { + test2: 3, + }, + }; + const expectedInvocationHookData = { + hello2: 'world2', + test2: { + test4: 5, + }, + }; + + loader.getFunc.returns(async () => {}); + loader.getInfo.returns(new FunctionInfo(Binding.queue)); + + const pre1 = coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { + Object.assign(context.appHookData, expectedAppHookData); + Object.assign(context.hookData, expectedInvocationHookData); + hookData += 'pre1'; + }); + testDisposables.push(pre1); + + const post1 = coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { + expect(context.appHookData).to.deep.equal(expectedAppHookData); + expect(context.hookData).to.deep.equal(expectedInvocationHookData); + hookData += 'post1'; + }); + testDisposables.push(post1); + + sendInvokeMessage([InputData.http]); + await stream.assertCalledWith( + Msg.receivedInvocLog(), + Msg.executingHooksLog(1, 'preInvocation'), + Msg.executedHooksLog('preInvocation'), + Msg.executingHooksLog(1, 'postInvocation'), + Msg.executedHooksLog('postInvocation'), + Msg.invocResponse([]) + ); + expect(hookData).to.equal('pre1post1'); + + pre1.dispose(); + post1.dispose(); + + const pre2 = coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { + expect(context.appHookData).to.deep.equal(expectedAppHookData); + expect(context.hookData).to.be.empty; + hookData += 'pre2'; + }); + testDisposables.push(pre2); + + const post2 = coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { + expect(context.appHookData).to.deep.equal(expectedAppHookData); + expect(context.hookData).to.be.empty; + hookData += 'post2'; + }); + testDisposables.push(post2); + + sendInvokeMessage([InputData.http]); + await stream.assertCalledWith( + Msg.receivedInvocLog(), + Msg.executingHooksLog(1, 'preInvocation'), + Msg.executedHooksLog('preInvocation'), + Msg.executingHooksLog(1, 'postInvocation'), + Msg.executedHooksLog('postInvocation'), + Msg.invocResponse([]) + ); + + expect(hookData).to.equal('pre1post1pre2post2'); + }); + it('dispose hooks', async () => { loader.getFunc.returns(async () => {}); loader.getInfo.returns(new FunctionInfo(Binding.queue)); diff --git a/test/eventHandlers/WorkerInitHandler.test.ts b/test/eventHandlers/WorkerInitHandler.test.ts index 0470bc99..a1b53350 100644 --- a/test/eventHandlers/WorkerInitHandler.test.ts +++ b/test/eventHandlers/WorkerInitHandler.test.ts @@ -15,13 +15,14 @@ import path = require('path'); import LogCategory = rpc.RpcLog.RpcLogCategory; import LogLevel = rpc.RpcLog.Level; -namespace Msg { - export function init(functionAppDirectory: string = __dirname): rpc.IStreamingMessage { +export namespace Msg { + export function init(functionAppDirectory: string = __dirname, hostVersion = '2.7.0'): rpc.IStreamingMessage { return { requestId: 'id', workerInitRequest: { capabilities: {}, functionAppDirectory, + hostVersion, }, }; } @@ -214,10 +215,7 @@ describe('WorkerInitHandler', () => { }); for (const extension of ['.js', '.mjs', '.cjs']) { - it(`Loads entry point (${extension})`, async function (this: ITestCallbackContext) { - // Should be re-enabled after https://github.com/Azure/azure-functions-nodejs-worker/pull/577 - this.skip(); - + it(`Loads entry point (${extension}) in non-specialization scenario`, async () => { const fileName = `entryPointFiles/doNothing${extension}`; const expectedPackageJson = { main: fileName, diff --git a/test/startApp.test.ts b/test/startApp.test.ts new file mode 100644 index 00000000..808347d3 --- /dev/null +++ b/test/startApp.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as coreTypes from '@azure/functions-core'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; +import { WorkerChannel } from '../src/WorkerChannel'; +import { beforeEventHandlerSuite } from './eventHandlers/beforeEventHandlerSuite'; +import { Msg as EnvReloadMsg } from './eventHandlers/FunctionEnvironmentReloadHandler.test'; +import { TestEventStream } from './eventHandlers/TestEventStream'; +import { Msg as WorkerInitMsg } from './eventHandlers/WorkerInitHandler.test'; +import LogCategory = rpc.RpcLog.RpcLogCategory; +import LogLevel = rpc.RpcLog.Level; + +export namespace Msg { + export function executingHooksLog(count: number, hookName: string): rpc.IStreamingMessage { + return { + rpcLog: { + category: undefined, + invocationId: undefined, + message: `Executing ${count} "${hookName}" hooks`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }, + }; + } + export function executedHooksLog(hookName: string): rpc.IStreamingMessage { + return { + rpcLog: { + category: undefined, + invocationId: undefined, + message: `Executed "${hookName}" hooks`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }, + }; + } +} + +describe('startApp', () => { + let channel: WorkerChannel; + let stream: TestEventStream; + let coreApi: typeof coreTypes; + let testDisposables: coreTypes.Disposable[] = []; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + + before(async () => { + originalCwd = process.cwd(); + originalEnv = process.env; + ({ stream, channel } = beforeEventHandlerSuite()); + coreApi = await import('@azure/functions-core'); + }); + + after(() => { + process.env = originalEnv; + }); + + afterEach(async () => { + await stream.afterEachEventHandlerTest(); + coreApi.Disposable.from(...testDisposables).dispose(); + testDisposables = []; + process.chdir(originalCwd); + channel.appHookData = {}; + channel.appLevelOnlyHookData = {}; + }); + + it('runs app start hooks in non-specialization scenario', async () => { + const hostVersion = '2.7.0'; + const functionAppDirectory = __dirname; + const expectedStartContext: coreTypes.AppStartContext = { + functionAppDirectory, + hostVersion, + hookData: {}, + appHookData: {}, + }; + + const startFunc = sinon.spy(); + testDisposables.push(coreApi.registerHook('appStart', startFunc)); + + stream.addTestMessage(WorkerInitMsg.init(functionAppDirectory, hostVersion)); + + await stream.assertCalledWith( + WorkerInitMsg.receivedInitLog, + WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'), + Msg.executingHooksLog(1, 'appStart'), + Msg.executedHooksLog('appStart'), + WorkerInitMsg.response + ); + + expect(startFunc.callCount).to.be.equal(1); + expect(startFunc.args[0][0]).to.deep.equal(expectedStartContext); + }); + + it('runs app start hooks only once in specialiation scenario', async () => { + const hostVersion = '2.7.0'; + const functionAppDirectory = __dirname; + const expectedStartContext: coreTypes.AppStartContext = { + functionAppDirectory, + hostVersion, + hookData: {}, + appHookData: {}, + }; + const startFunc = sinon.spy(); + + stream.addTestMessage(WorkerInitMsg.init(functionAppDirectory, hostVersion)); + await stream.assertCalledWith( + WorkerInitMsg.receivedInitLog, + WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'), + WorkerInitMsg.response + ); + + testDisposables.push(coreApi.registerHook('appStart', startFunc)); + + stream.addTestMessage({ + requestId: 'id', + functionEnvironmentReloadRequest: { + functionAppDirectory, + }, + }); + await stream.assertCalledWith( + EnvReloadMsg.reloadEnvVarsLog(0), + EnvReloadMsg.changingCwdLog(functionAppDirectory), + WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'), + Msg.executingHooksLog(1, 'appStart'), + Msg.executedHooksLog('appStart'), + EnvReloadMsg.reloadSuccess + ); + + expect(startFunc.callCount).to.be.equal(1); + expect(startFunc.args[0][0]).to.deep.equal(expectedStartContext); + }); + + it('allows different appStart hooks to share data', async () => { + const functionAppDirectory = __dirname; + let hookData = ''; + testDisposables.push( + coreApi.registerHook('appStart', (context) => { + context.hookData.hello = 'world'; + hookData += 'start1'; + }) + ); + testDisposables.push( + coreApi.registerHook('appStart', (context) => { + expect(context.hookData.hello).to.equal('world'); + hookData += 'start2'; + }) + ); + + stream.addTestMessage(WorkerInitMsg.init(functionAppDirectory)); + + await stream.assertCalledWith( + WorkerInitMsg.receivedInitLog, + WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'), + Msg.executingHooksLog(2, 'appStart'), + Msg.executedHooksLog('appStart'), + WorkerInitMsg.response + ); + + expect(hookData).to.equal('start1start2'); + }); +}); diff --git a/types-core/index.d.ts b/types-core/index.d.ts index 736b8386..70750d61 100644 --- a/types-core/index.d.ts +++ b/types-core/index.d.ts @@ -18,11 +18,13 @@ declare module '@azure/functions-core' { */ export function registerHook(hookName: 'preInvocation', callback: PreInvocationCallback): Disposable; export function registerHook(hookName: 'postInvocation', callback: PostInvocationCallback): Disposable; + export function registerHook(hookName: 'appStart', callback: AppStartCallback): Disposable; export function registerHook(hookName: string, callback: HookCallback): Disposable; export type HookCallback = (context: HookContext) => void | Promise; export type PreInvocationCallback = (context: PreInvocationContext) => void | Promise; export type PostInvocationCallback = (context: PostInvocationContext) => void | Promise; + export type AppStartCallback = (context: AppStartContext) => void | Promise; export type HookData = { [key: string]: any }; @@ -31,9 +33,13 @@ declare module '@azure/functions-core' { */ export interface HookContext { /** - * The recommended place to share data between hooks + * The recommended place to share data between hooks in the same scope (app-level vs invocation-level) */ hookData: HookData; + /** + * The recommended place to share data across scopes for all hooks + */ + appHookData: HookData; } /** @@ -83,6 +89,21 @@ declare module '@azure/functions-core' { error: any; } + /** + * Context on a function app that is about to be started + * This object will be passed to all app start hooks + */ + export interface AppStartContext extends HookContext { + /** + * Absolute directory of the function app + */ + functionAppDirectory: string; + /** + * The version of the host running the function app + */ + hostVersion: string; + } + /** * Represents a type which can release resources, such as event listening or a timer. */