Azure / azure-functions-nodejs-library

The Node.js framework for Azure Functions
https://www.npmjs.com/package/@azure/functions
MIT License
58 stars 14 forks source link

How to support monorepo #260

Open ejizba opened 6 months ago

ejizba commented 6 months ago

Originally posted by @mildronize in https://github.com/Azure/azure-functions-nodejs-library/issues/201#issuecomment-2110788561

I've been stuck many kind of monorepo tools such as Nx, or pnpm workspace, or even npm/yarn workspace as well, bundling code with esbuild make the error when deploying into the Azure Functions on Azure.

Let's say for most cases of monorepo and bundling @ejizba, you just simple setup the basic monorepo calling the library across the project you will got the error without sending the issue, in the other hands, if it already support monorepo or bundling code, could you please give me some example how to make it possible?

In my experience, Azure Functions Node.js Runtime is hardest project to setup monorepo,

Without bundling in the monrepo

Deploying Azure Functions without bundling in the monrepo, you need some extra script to move the packages in the node_modules when you deploy from the subdirectory of the monrepo, for example, you need some extra script to copy from node_modules from the root project into azure-func-project-1 or azure-func-project-2, this structure come from Nx Integrated monorepo feature,

|  node_modules
|  apps
|    - azure-func-project-1
|    - azure-func-project-2
|  libs
|    - my-shared-libs

for most common monorepo pattern, they most use pnpm/npm/yarn workspaces, you need to prevent linked mode in the monorepo tool for workspace because modern monorepo tool mostly use the linked to safe space in the machine, I'm don't familiar with workspace configuration, i've tried many options but it's won't work. Another problem when azure-func-project-1 want to call my-shared-libs, you need to bundling the lib and copy into the node_modules, and when your monorepo go large, it quite complex task to maintain build process.

or if you want deploy it from the root of the project you'll also need multiple package.json file that has main field which located in the entrypoint of each project in the same monorepo, For examples, you will need to handle the complexity of node_modules above method and setup package.json separately in the root of the monorepo

|  node_modules
|  apps
|      - azure-func-project-1
|      - azure-func-project-2
|  libs
|      - my-shared-libs
|  - package.project-1.json
|  - package.project-2.json

However, I've never do following my thought, my guess is quite challenge to setup monorepo without custom magic script.

With Bundling

Another way using bundling like esbuild, I've already added the package suggested above @azure/functions-core, as the external dependency, however, deploying to Azure, it'still got the error, I don't have any error right now, or you can see in issue https://github.com/Azure/azure-functions-nodejs-library/issues/256#issue-2292563826

I've give up on monorepo or bunding Azure Function node.js runtime. so, if someone has error message please provide some error, it might be benefit.

I don't know the direction of the Azure Functions (Node.js Runtime), that's I've been stuck this issue so long, so, I've decided the moved out from the node.js runtime to custom handler instead, which is I've try to proof of concept in Nammatham v3

for now, in my design

I can build, bundling with monorepo and deploy successfully result below:

Github Actions E2E Results

However, if the Azure Functions Node.js runtime has decided the too fix this issue, I hope it most benefit for community. and that's I don't have any reason to continue develop Nammatham v3

Originally posted by @mildronize in https://github.com/Azure/azure-functions-nodejs-library/issues/201#issuecomment-2110788561

ejizba commented 6 months ago

Originally posted by @edgehero in https://github.com/Azure/azure-functions-nodejs-library/issues/201#issuecomment-2117810294

Do you know any workarounds? because i'm struggling too, not using this libary and using function.json brings in a whole batch of new problems ( no job functions founds etc ) it does not seem that microsoft takes this major bug seriously which makes using azure for serious projects not a option

ejizba commented 6 months ago

Another way using bundling like esbuild, I've already added the package suggested above @azure/functions-core, as the external dependency, however, deploying to Azure, it'still got the error, I don't have any error right now, or you can see in issue https://github.com/Azure/azure-functions-nodejs-library/issues/256#issue-2292563826

I've give up on monorepo or bunding Azure Function node.js runtime. so, if someone has error message please provide some error, it might be benefit.

@mildronize please expand on the exact error you're getting after you list the core package as external. For example, you could provide a sample app that hits this problem, or follow these instructions to share your app name and time of issue.

mildronize commented 6 months ago

@ejizba Thank you so much for reorganizing into the proper place. However, I am curious about monorepo & bundling, and it's quite challenging for me.

I tried to reproduce the error I encountered earlier (I think since the v4 alpha tag). It has been quite a while, so I don't remember exactly what was going on at that time.

Going back to my initial discussion about how to support monorepo, as mentioned in the issue description above, publishing a monorepo without bundling is quite challenging for me.

Today, I tested the esbuild bundling tool with a standalone setup (without monorepo yet), and I was surprised that everything worked without any errors on Azure at runtime 🎉. I think if I can bundle it, the monorepo should also be possible to work with.

I've attached my project setup used for the esbuild bundle: https://github.com/mildronize/az-func-nodejs-v4-monorepo/tree/89f03d95ae51cae1cad124dfb4baad3200868726/projects/standalone-bundle

However, I'll test on 2 kinds of monorepo: Nx-styled and workspace style (like turborepo/npm/yarn/pnpm). I'll let you know later. In the meantime, I'll try to find which use cases might cause runtime errors.

edgehero commented 6 months ago

@ejizba Thank you so much for reorganizing into the proper place. However, I am curious about monorepo & bundling, and it's quite challenging for me.

I tried to reproduce the error I encountered earlier (I think since the v4 alpha tag). It has been quite a while, so I don't remember exactly what was going on at that time.

Going back to my initial discussion about how to support monorepo, as mentioned in the issue description above, publishing a monorepo without bundling is quite challenging for me.

Today, I tested the esbuild bundling tool with a standalone setup (without monorepo yet), and I was surprised that everything worked without any errors on Azure at runtime 🎉. I think if I can bundle it, the monorepo should also be possible to work with.

I've attached my project setup used for the esbuild bundle: https://github.com/mildronize/az-func-nodejs-v4-monorepo/tree/89f03d95ae51cae1cad124dfb4baad3200868726/projects/standalone-bundle

However, I'll test on 2 kinds of monorepo: Nx-styled and workspace style (like turborepo/npm/yarn/pnpm). I'll let you know later. In the meantime, I'll try to find which use cases might cause runtime errors.

In your project you set "@azure/functions-core" as external, so you exclude this from the bundling process. when deploying this into blob storage and creating a azure function from this blob code. are the function http triggers stil runnable? for me they do not show up. meaning, "@azure/functions-core" can not be excluded from the bundling process

mildronize commented 6 months ago

@edgehero Nice test cases, i’ll check with blob storage trigger or input/output.

edgehero commented 6 months ago

@edgehero Nice test cases, i’ll check with blob storage trigger or input/output.

@mildronize wondering if that works for you. because for me it does not

mildronize commented 6 months ago

@edgehero (the code has been tested)) I've been test on http trigger with blob input/output that azure functions on azure storage blob with consumption plan (You can see the create infra script, deploy script in my repo), it's work me.

I've deploy into Azure Functions URL: https://msdocs-serverless-function-12342.azurewebsites.net I'll keep this in a couple day, then I'll destroy.

CleanShot 2567-05-23 at 14 38 37__

However, I'll test with monorepo for my real world use cases, I'll let you know later. @ejizba

P.S. I've capture the wrong code, here is the blob code run

import { app, input } from "@azure/functions";

const blobInput = input.storageBlob({
  connection: "AzureWebJobsStorage",
  path: "demo-input/xxx.txt",
});

const blobOutput = input.storageBlob({
  connection: "AzureWebJobsStorage",
  path: "demo-output/xxx-{rand-guid}.txt",
});

export const copyBlob = app.http("copyBlob", {
  methods: ["GET"],
  authLevel: "function",
  extraInputs: [blobInput],
  extraOutputs: [blobOutput],
  handler: async (request, context) => {
    context.log(`Http function processed request for url "${request.url}"`);
    const blobData = context.extraInputs.get(blobInput);
    context.log(`Blob data: ${blobData}`);
    context.extraOutputs.set(blobOutput, blobData);
    return { body: "Copy Blob Completed" };
  },
});
edgehero commented 6 months ago

@edgehero (the code has been tested)) I've been test on http trigger with blob input/output that azure functions on azure storage blob with consumption plan (You can see the create infra script, deploy script in my repo), it's work me.

I've deploy into Azure Functions URL: https://msdocs-serverless-function-12342.azurewebsites.net I'll keep this in a couple day, then I'll destroy.

CleanShot 2567-05-23 at 14 38 37__

However, I'll test with monorepo for my real world use cases, I'll let you know later. @ejizba

P.S. I've capture the wrong code, here is the blob code run

import { app, input } from "@azure/functions";

const blobInput = input.storageBlob({
  connection: "AzureWebJobsStorage",
  path: "demo-input/xxx.txt",
});

const blobOutput = input.storageBlob({
  connection: "AzureWebJobsStorage",
  path: "demo-output/xxx-{rand-guid}.txt",
});

export const copyBlob = app.http("copyBlob", {
  methods: ["GET"],
  authLevel: "function",
  extraInputs: [blobInput],
  extraOutputs: [blobOutput],
  handler: async (request, context) => {
    context.log(`Http function processed request for url "${request.url}"`);
    const blobData = context.extraInputs.get(blobInput);
    context.log(`Blob data: ${blobData}`);
    context.extraOutputs.set(blobOutput, blobData);
    return { body: "Copy Blob Completed" };
  },
});

thanks for documentation, really appreciate this :) , trying to set something simular up with pulumi, nx and esbuild :)

edgehero commented 6 months ago

tried every step correctly and still do not get any functions in azure. everything works locally but on azure itself no functions get shown

mildronize commented 6 months ago

@edgehero Could you please share the error log on App Insights (Log Analytic Workspace)

edgehero commented 6 months ago

can not find anything wierd there. image the files in the function app service editor are there and the main.js is readable and visible in the dist folder.

but still no functions are found by azure function

mildronize commented 6 months ago

@edgehero Can I see package.json, index.ts

edgehero commented 6 months ago

package.json was the cause :) for some reason you need to add "main": "dist/main.js", so the functions show up now. yet they are not runnable. image did you get past this part? @mildronize

mildronize commented 6 months ago

Yes, you need to setup the entrypoint "main": "dist/main.js" in package.json for Azure Function Host Runtime. The UI that you've captured cannot debug the function host run,

You need to setup host.json, logLevel: Trace for logging into Azure App Insights,

{
  "version": "2.0",
  "logging": {
    "applicationInsights": {
      "samplingSettings": {
        "isEnabled": true,
        "excludedTypes": "Request"
      }
    },
    "logLevel": {
      "default": "Trace"
    }
  }
}

In order to debug Azure Function Host Runtime use to open Azure Log Analytics Workspace from Azure App Insights and open the table trace, you will see azure functions host runtime log.

CleanShot 2567-05-24 at 22 14 01

edgehero commented 6 months ago

Getting no logs, can not test the function or even get the function url because that ends in a internalserver error

edgehero commented 6 months ago

@mildronize could you spot any errors in this infra configurations? i use the framework pulumi to create the infra, but it pretty much does the same as your script.

`import as pulumi from "@pulumi/pulumi"; import as azure from "@pulumi/azure-native";

// Import the program's configuration settings. const config = new pulumi.Config(); const appPath = config.get("appPath") || "./apps/mqtt-broker";

// Create a resource group for the website. const resourceGroup = new azure.resources.ResourceGroup("rob-group", { location: "WestEurope", });

// Create a blob storage account. const account = new azure.storage.StorageAccount("robaccount", { resourceGroupName: resourceGroup.name, kind: azure.storage.Kind.StorageV2, sku: { name: azure.storage.SkuName.Standard_LRS, }, }, { dependsOn: [resourceGroup] });

// Create a storage container for the serverless app. const appContainer = new azure.storage.BlobContainer("rob-app-container", { accountName: account.name, resourceGroupName: resourceGroup.name, publicAccess: azure.storage.PublicAccess.None, }, { dependsOn: [account] });

// Upload the serverless app to the storage container. const appBlob = new azure.storage.Blob("rob-app-blob", { accountName: account.name, resourceGroupName: resourceGroup.name, containerName: appContainer.name, source: new pulumi.asset.FileArchive(appPath), }, { dependsOn: [appContainer] });

// Create a shared access signature to give the Function App access to the code. const signature = azure.storage.listStorageAccountServiceSASOutput({ resourceGroupName: resourceGroup.name, accountName: account.name, protocols: azure.storage.HttpProtocol.Https, sharedAccessStartTime: "2022-01-01", sharedAccessExpiryTime: "2030-01-01", resource: azure.storage.SignedResource.C, permissions: azure.storage.Permissions.R, contentType: "application/json", cacheControl: "max-age=5", contentDisposition: "inline", contentEncoding: "deflate", canonicalizedResource: pulumi.interpolate/blob/${account.name}/${appContainer.name}, });

// Create an App Service plan for the Function App. const plan = new azure.web.AppServicePlan("plan", { resourceGroupName: resourceGroup.name, sku: { name: "Y1", tier: "Dynamic", }, }, { dependsOn: [appContainer] });

const dummyVariable = new Date().toISOString(); // Create the Function App. const functionApp = new azure.web.WebApp("rob-function-app", { resourceGroupName: resourceGroup.name, serverFarmId: plan.id, kind: "FunctionApp", siteConfig: { appSettings: [ { name: "FUNCTIONS_WORKER_RUNTIME", value: "node", }, { name: "WEBSITE_NODE_DEFAULT_VERSION", value: "~20", }, { name: "FUNCTIONS_EXTENSION_VERSION", value: "~4", }, { name: "WEBSITE_RUN_FROM_PACKAGE", value: pulumi.all([account.name, appContainer.name, appBlob.name, signature]) .apply(([accountName, containerName, blobName, signature]) => https://${accountName}.blob.core.windows.net/${containerName}/${blobName}?${signature.serviceSasToken}), }, { name: "UPDATE_HASH", // This is a dummy variable to trigger an update value: dummyVariable, }, ], cors: { allowedOrigins: [ "*" ], }, }, }, { dependsOn: [appBlob, plan] });

export const apiURL = pulumi.interpolatehttps://${functionApp.defaultHostName}/api;`

mildronize commented 6 months ago

@edgehero, I believe we need to break down the testing to identify the root cause.

The problem might arise from:

  1. Pulumi configuration
  2. Bundling Azure Functions with esbuild

However, focusing specifically on the issue of using a monorepo with a bundling method, I suggest testing by creating Azure Functions on the Azure Portal and then publishing the dist code via the official deployment CLI like func azure functionapp publish <your-func-name>.

From my investigation, when you use func azure functionapp publish ..., it automatically syncs the triggers to Azure Functions on the Azure Cloud infrastructure. This command performs a couple of steps:

  1. Uploads the dist code to a blob and sets up authentication (e.g., SAS token).
  2. Syncs triggers to the Azure Cloud.

In your Pulumi code, I've not observed the second step (Sync triggers to Azure Cloud). I don't know how to manually perform this without using the func CLI (Azure Function Host CLI). This might not be related to the issue with Azure Functions Node.js v4.

Let's break down how Azure Functions sync their triggers to the Azure Cloud (based on my understanding, without official references, so confirmation from Azure would be helpful):

In Azure Functions v3, the process relied on a function.json file at the project root. When you deploy an Azure Function to the Azure Cloud, it reads metadata from these function.json files and registers it into global triggers. These global triggers handle all bindings and triggers for all Azure Functions on the platform, forwarding requests to your Azure Functions server to trigger the registered event.

However, for v4 (the current version in this issue), the handling of function.json files has changed. From my reading, it seems that when you publish code via the func CLI, it runs your v4 code once to send a request directly to the Azure Cloud infrastructure instead of reading metadata from function.json.

Note: Most of the above explanation is based on my understanding from using Azure Functions and is not officially referenced. Please consider this information carefully.

Note to Azure Team @ejizba: If there are any inaccuracies in my understanding, please feel free to correct me. Your input is highly appreciated.

edgehero commented 6 months ago

fix that i found is to deploy it on linux `import as pulumi from "@pulumi/pulumi"; import as azure from "@pulumi/azure-native";

// Import the program's configuration settings. const config = new pulumi.Config(); const appPath = config.get("appPath") || "../";

// Create a resource group for the website. const resourceGroup = new azure.resources.ResourceGroup("rob-group", { location: "WestEurope", });

// Create a blob storage account. const account = new azure.storage.StorageAccount("robaccount", { resourceGroupName: resourceGroup.name, kind: azure.storage.Kind.StorageV2, sku: { name: azure.storage.SkuName.Standard_LRS, }, minimumTlsVersion: "TLS1_2", }, { dependsOn: [resourceGroup] });

// Create a storage container for the serverless app. const appContainer = new azure.storage.BlobContainer("rob-app-container", { accountName: account.name, resourceGroupName: resourceGroup.name, publicAccess: azure.storage.PublicAccess.None, }, { dependsOn: [account] });

// Upload the serverless app to the storage container. const appBlob = new azure.storage.Blob("rob-app-blob", { accountName: account.name, resourceGroupName: resourceGroup.name, containerName: appContainer.name, source: new pulumi.asset.FileArchive(appPath), }, { dependsOn: [appContainer] });

// Create a shared access signature to give the Function App access to the code. const signature = azure.storage.listStorageAccountServiceSASOutput({ resourceGroupName: resourceGroup.name, accountName: account.name, protocols: azure.storage.HttpProtocol.Https, sharedAccessStartTime: "2022-01-01", sharedAccessExpiryTime: "2030-01-01", resource: azure.storage.SignedResource.C, permissions: azure.storage.Permissions.R, canonicalizedResource: pulumi.interpolate/blob/${account.name}/${appContainer.name}, contentType: "application/json", cacheControl: "max-age=5", contentDisposition: "inline", contentEncoding: "deflate", });

// Create an App Service plan for the Function App. const plan = new azure.web.AppServicePlan("plan", { resourceGroupName: resourceGroup.name, kind: "linux", reserved: true, sku: { name: "Y1", tier: "Dynamic", }, }, { dependsOn: [appContainer] });

function getConnectionString( resourceGroupName: pulumi.Input, accountName: pulumi.Input ): pulumi.Output { // Retrieve the primary storage account key. const storageAccountKeys = azure.storage.listStorageAccountKeysOutput({ resourceGroupName, accountName, }); const primaryStorageKey = storageAccountKeys.keys[0].value;

// Build the connection string to the storage account. return pulumi.interpolateDefaultEndpointsProtocol=https;AccountName=${accountName};AccountKey=${primaryStorageKey}; }

const dummyVariable = new Date().toISOString(); // Create the Function App. const functionApp = new azure.web.WebApp("rob-function-app", { resourceGroupName: resourceGroup.name, serverFarmId: plan.id, kind: "functionapp,linux", siteConfig: { appSettings: [ { name: "FUNCTIONS_WORKER_RUNTIME", value: "node", }, { name: "WEBSITE_NODE_DEFAULT_VERSION", value: "~20", }, { name: "FUNCTIONS_EXTENSION_VERSION", value: "~4", }, { name: "WEBSITE_RUN_FROM_PACKAGE", value: pulumi.all([account.name, appContainer.name, appBlob.name, signature]) .apply(([accountName, containerName, blobName, signature]) => https://${accountName}.blob.core.windows.net/${containerName}/${blobName}?${signature.serviceSasToken}), }, { name: "AzureWebJobsStorage", value: pulumi.all([resourceGroup.name, account.name]) .apply(([resourceGroupName, accountName]) => getConnectionString(resourceGroupName, accountName)), }, { name: "UPDATE_HASH", // This is a dummy variable to trigger an update value: dummyVariable, }, ], cors: { allowedOrigins: [ "*" ], }, linuxFxVersion: "Node|20", nodeVersion: "~20", }, }, { dependsOn: [appBlob, plan] });

export const apiURL = pulumi.interpolatehttps://${functionApp.defaultHostName}/api; `

hibohiboo commented 4 months ago

I also encountered the same phenomenon. I was able to deploy by copying essential files such as package.json to a different folder.

#!/bin/bash

BUILD_DIR=build
rimraf $BUILD_DIR
mkdir $BUILD_DIR
cp -r dist $BUILD_DIR
cp host.json $BUILD_DIR
cp local.settings.json $BUILD_DIR
jq 'del(.devDependencies)' package.json > temp.json && mv temp.json $BUILD_DIR/package.json

cd $BUILD_DIR && npm install --omit=dev \
   && func azure functionapp publish $APP_NAME

deploy success code