├── .github └── workflows │ └── e2e-tests.yaml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── denofunc.ts ├── deps.ts ├── e2e_test.ts ├── mod.ts ├── types.ts └── worker_deps.ts /.github/workflows/e2e-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run end-to-end tests 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | denoVersion: 7 | description: 'Deno Version' 8 | required: true 9 | default: '1.5.4' 10 | denoWorkerVersion: 11 | description: 'Deno Worker Version' 12 | required: true 13 | default: 'preview' 14 | denoTemplateVersion: 15 | description: 'Deno Template Version' 16 | required: true 17 | default: 'preview' 18 | 19 | jobs: 20 | e2e: 21 | strategy: 22 | matrix: 23 | os: [windows-latest, ubuntu-latest, macos-latest] 24 | include: 25 | - os: windows-latest 26 | winFuncAppName: deno-worker-tests-win-win 27 | linuxFuncAppName: deno-worker-tests-win-linux 28 | - os: ubuntu-latest 29 | winFuncAppName: deno-worker-tests-linux-win 30 | linuxFuncAppName: deno-worker-tests-linux-linux 31 | - os: macos-latest 32 | winFuncAppName: deno-worker-tests-mac-win 33 | linuxFuncAppName: deno-worker-tests-mac-linux 34 | 35 | name: ${{ matrix.os }} end-to-end 36 | runs-on: ${{ matrix.os }} 37 | 38 | steps: 39 | 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | - name: Login to Azure 44 | uses: azure/login@v1.1 45 | with: 46 | creds: ${{secrets.AZURE_CREDENTIALS}} 47 | 48 | - uses: denolib/setup-deno@v2 49 | with: 50 | deno-version: ${{ github.event.inputs.denoVersion }} 51 | 52 | - run: | 53 | if [ $MATRIX_OS == "windows-latest" ]; then 54 | choco install azure-functions-core-tools-3 --params "'/x64'" 55 | elif [ $MATRIX_OS == "macos-latest" ]; then 56 | brew tap azure/functions 57 | brew install azure-functions-core-tools@3 58 | else 59 | wget -q https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb 60 | sudo dpkg -i packages-microsoft-prod.deb 61 | sudo apt-get update 62 | sudo apt-get install azure-functions-core-tools-3 63 | fi 64 | name: Install Azure Functions Core Tools 65 | shell: bash 66 | env: 67 | MATRIX_OS: ${{ matrix.os }} 68 | 69 | - run: | 70 | if [ $MATRIX_OS == "windows-latest" ]; then 71 | export DENOFUNC_COMMAND=denofunc.cmd 72 | else 73 | export DENOFUNC_COMMAND=$HOME/.deno/bin/denofunc 74 | fi 75 | 76 | export PATH=$HOME/.deno/bin:$PATH 77 | deno install --allow-run --allow-read --allow-write --allow-net --unstable --no-check --force --name=denofunc https://raw.githubusercontent.com/anthonychu/azure-functions-deno-worker/${{ github.event.inputs.denoWorkerVersion }}/denofunc.ts 78 | $DENOFUNC_COMMAND help 79 | mkdir myapp && cd myapp 80 | echo "Running $DENOFUNC_COMMAND init..." 81 | $DENOFUNC_COMMAND init ${{ github.event.inputs.denoTemplateVersion }} 82 | echo "Running $DENOFUNC_COMMAND start..." 83 | $DENOFUNC_COMMAND start & 84 | cd .. 85 | sleep 60 86 | deno test -A 87 | name: Test denofunc init and start 88 | shell: bash 89 | env: 90 | MATRIX_OS: ${{ matrix.os }} 91 | 92 | - run: | 93 | if [ $MATRIX_OS == "windows-latest" ]; then 94 | export DENOFUNC_COMMAND=denofunc.cmd 95 | else 96 | export DENOFUNC_COMMAND=denofunc 97 | fi 98 | 99 | export PATH=$HOME/.deno/bin:$PATH 100 | cd myapp 101 | 102 | $DENOFUNC_COMMAND publish $MATRIX_LINUX_FUNC_APP_NAME 103 | $DENOFUNC_COMMAND publish $MATRIX_WIN_FUNC_APP_NAME 104 | 105 | cd .. 106 | export FUNCTION_APP_BASE_URL=https://$MATRIX_LINUX_FUNC_APP_NAME.azurewebsites.net 107 | deno test -A 108 | 109 | export FUNCTION_APP_BASE_URL=https://$MATRIX_WIN_FUNC_APP_NAME.azurewebsites.net 110 | deno test -A 111 | name: Test denofunc publish 112 | shell: bash 113 | env: 114 | MATRIX_OS: ${{ matrix.os }} 115 | MATRIX_WIN_FUNC_APP_NAME: ${{ matrix.winFuncAppName }} 116 | MATRIX_LINUX_FUNC_APP_NAME: ${{ matrix.linuxFuncAppName }} -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anthony Chu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deno for Azure Functions 2 | 3 | ``` 4 | @@@@@@@@@@@, 5 | @@@@@@@@@@@@@@@@@@@ %%%%%% 6 | @@@@@@ @@@@@@@@@@ %%%%%% 7 | @@@@@ @ @ *@@@@@ @ %%%%%% @ 8 | @@@ @@@@@ @@ %%%%%% @@ 9 | @@@@@ @@@@@ @@@ %%%%%%%%%%% @@@ 10 | @@@@@@@@@@@@@@@ @@@@ @@ %%%%%%%%%% @@ 11 | @@@@@@@@@@@@@@ @@@@ @@ %%%% @@ 12 | @@@@@@@@@@@@@@ @@@ @@ %%% @@ 13 | @@@@@@@@@@@@@ @ @@ %% @@ 14 | @@@@@@@@@@@ %% 15 | @@@@@@@ % 16 | ``` 17 | 18 | ## Overview 19 | 20 |

21 | 22 | 23 | 24 |

25 | 26 | This is a worker that lets you run Deno on [Azure Functions](https://docs.microsoft.com/azure/azure-functions/functions-overview). It is implemented as an [Azure Functions Custom Handler](https://docs.microsoft.com/azure/azure-functions/functions-custom-handlers) and runs on the Azure Functions Consumption (serverless) plan. 27 | 28 | The project includes a CLI `denofunc` to make it easy to create, run, and deploy your Deno Azure Functions apps. 29 | 30 | ### 3 commands to get started 31 | 32 | ```bash 33 | # initialize function app 34 | denofunc init 35 | 36 | # run function app locally 37 | denofunc start 38 | 39 | # deploy the app 40 | denofunc publish $functionAppName [--slot $slotName] [--allow-run] [--allow-write] 41 | ``` 42 | 43 | For more information, try the [quickstart](#getting-started) below. 44 | 45 | ### Programming model 46 | 47 | All Azure Functions [triggers and bindings](https://docs.microsoft.com/azure/azure-functions/functions-triggers-bindings) (including custom bindings) are supported. 48 | 49 | In this simplified programming model, each function is a single file. Here are a couple of examples: 50 | * [HTTP trigger](https://github.com/anthonychu/azure-functions-deno-template/blob/main/functions/hello_world.ts) 51 | * [Queue trigger](https://github.com/anthonychu/azure-functions-deno-template/blob/main/functions/queue_trigger.ts) 52 | 53 | Check out the [new project template](https://github.com/anthonychu/azure-functions-deno-template) for the entire app structure. 54 | 55 | ## Getting started - building a Deno function app 56 | 57 | ### Requirements 58 | 59 | * Linux, macOS, Windows 60 | * [Deno](https://deno.land/x/install/) 61 | - Tested on: 62 | - `1.16.2` 63 | * [Azure Functions Core Tools V3](https://github.com/Azure/azure-functions-core-tools#azure-functions-core-tools) - needed for running the app locally and deploying it 64 | * [Azure CLI](https://docs.microsoft.com/cli/azure/install-azure-cli?view=azure-cli-latest#install) - needed to deploy the app 65 | * `denofunc` CLI - see [below](#install-the-denofunc-cli) 66 | 67 | > #### Codespaces 68 | > 69 | > You can also get a preconfigured, cloud-based dev environment from Codespaces: 70 | > 71 | > * **Visual Studio Codespaces** - [click to create](https://online.visualstudio.com/environments/new?name=Deno%20Azure%20Functions&repo=anthonychu/azure-functions-deno-template) 72 | > * **GitHub Codespaces** ([private preview](https://github.com/features/codespaces)) - [go to the template repo](https://github.com/anthonychu/azure-functions-deno-template) and create a Codespace 73 | 74 | #### Install the denofunc CLI 75 | 76 | To help create, run, and deploy a Deno for Azure Functions app, you need to install the `denofunc` CLI. `denofunc` wraps the Azure Functions Core Tools (`func`) and is used for generating artifacts required to run/deploy the app. 77 | 78 | To install the CLI, run the following Deno command. 79 | 80 | ```bash 81 | deno install --allow-run --allow-read --allow-write --allow-net --unstable --force \ 82 | --name=denofunc https://raw.githubusercontent.com/anthonychu/azure-functions-deno-worker/v0.9.0/denofunc.ts 83 | ``` 84 | 85 | Confirm it is installed correctly: 86 | 87 | ```bash 88 | denofunc --help 89 | ``` 90 | 91 | ### Create and run an app locally 92 | 93 | 1. Create and change into an empty folder. 94 | 95 | 1. Initialize the project: 96 | 97 | ```bash 98 | denofunc init 99 | ``` 100 | 101 | A few of the files that are important to know about: 102 | - [`functions/hello_world.ts`](https://github.com/anthonychu/azure-functions-deno-template/blob/main/functions/hello_world.ts) - a basic HTTP triggered function 103 | - [`worker.ts`](https://github.com/anthonychu/azure-functions-deno-template/blob/main/worker.ts) - the Deno worker used by Azure Functions 104 | - [`host.json`](https://github.com/anthonychu/azure-functions-deno-template/blob/main/host.json) - configuration of the function host 105 | 106 | 1. Run the app locally: 107 | 108 | ```bash 109 | denofunc start 110 | ``` 111 | 112 | The Azure Functions Core Tools (`func`) is then called to run the function app. 113 | 114 | > Note: A folder is automatically generated for the `hello_world` function containing a file named `function.json` that is used by the Azure Functions runtime to load the function (they are ignored in `.gitnore`). 115 | 116 | 1. Open the URL displayed on the screen (http://localhost:7071/api/hello_world) to run the function. 117 | 118 | 1. `Ctrl-C` to stop the app. 119 | 120 | ### Deploy the app to Azure 121 | 122 | Now that you've run the function app locally, it's time to deploy it to Azure! 123 | 124 | 1. Configure some variables (examples are in bash): 125 | 126 | ```bash 127 | region=centralus # any region where Linux Azure Functions are available 128 | resourceGroupName= 129 | functionAppName= 130 | storageName= # must be between 3 and 24 characters in length and may contain numbers and lowercase letters only. 131 | ``` 132 | 133 | 1. If you are not authenticated with the Azure CLI, log in. 134 | 135 | ```bash 136 | # Log in to the Azure CLI 137 | az login 138 | ``` 139 | 140 | This might not work in some environments (e.g. Codespaces). Try `az login --use-device-code` instead. 141 | 142 | 1. Run these Azure CLI commands to create and configure the function app: 143 | 144 | ```bash 145 | # Create resource group 146 | az group create -l $region -n $resourceGroupName 147 | 148 | # Create storage account needed by function app 149 | az storage account create -n $storageName -l $region -g $resourceGroupName --sku Standard_LRS 150 | 151 | # Create function app (also works on Windows) 152 | az functionapp create -n $functionAppName --storage-account $storageName \ 153 | --consumption-plan-location $region -g $resourceGroupName \ 154 | --functions-version 3 --runtime dotnet --os-type Linux 155 | ``` 156 | 157 | 1. Deploy the app: 158 | 159 | ```bash 160 | denofunc publish $functionAppName 161 | ``` 162 | 163 | Prior to deployment, `denofunc` tool will download the Deno Linux binary matching your locally installed version of deno that is included with the deployment package. 164 | 165 | 1. The deployment output will print out the URL of the deployed function. Open to the URL to run your function. 166 | 167 | ### 🎉 Congratulations! 168 | 169 | You've deployed your first Azure Functions app in Deno! 🦕 170 | 171 | --- 172 | 173 | *Disclaimer: This is a community open source project. No official support is provided by Microsoft.* 174 | -------------------------------------------------------------------------------- /denofunc.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parse, 3 | readZip, 4 | ensureDir, 5 | move, 6 | walk, 7 | semver, 8 | } from "./deps.ts"; 9 | 10 | const baseExecutableFileName = "worker"; 11 | const bundleFileName = "worker.bundle.js"; 12 | const commonDenoOptions = ["--allow-env", "--allow-net", "--allow-read"]; 13 | const additionalDenoOptions:string[] = []; 14 | const parsedArgs = parse(Deno.args); 15 | 16 | const bundleStyles = ["executable", "jsbundle", "none"]; 17 | const STYLE_EXECUTABLE = 0; 18 | const STYLE_JSBUNDLE = 1; 19 | const STYLE_NONE = 2; 20 | 21 | if (parsedArgs._[0] === "help") { 22 | printHelp(); 23 | Deno.exit(); 24 | } 25 | 26 | if (parsedArgs._.length >= 1 && parsedArgs._[0] === "init") { 27 | const templateDownloadBranch: string | undefined = parsedArgs?._[1]?.toString(); 28 | await initializeFromTemplate(templateDownloadBranch); 29 | } else if ( 30 | parsedArgs._.length === 1 && parsedArgs._[0] === "start" || 31 | parsedArgs._.length === 2 && parsedArgs._.join(' ') === "host start" 32 | ) { 33 | await generateFunctions(); 34 | await createJSBundle(); 35 | await runFunc("start"); 36 | } else if (parsedArgs._[0] === "publish" && parsedArgs._.length === 2) { 37 | const bundleStyle = parsedArgs["bundle-style"] // use specified bundle style 38 | || (semver.satisfies(Deno.version.deno, ">=1.6.0") // default style depends on Deno runtime version 39 | ? bundleStyles[STYLE_EXECUTABLE] // for v1.6.0 or later 40 | : bundleStyles[STYLE_JSBUNDLE] // for others 41 | ); 42 | if (!bundleStyles.includes(bundleStyle)) { 43 | console.error(`The value \`${parsedArgs["bundle-style"]}\` of \`--bundle-style\` option is not acceptable.`) 44 | Deno.exit(1); 45 | } else if (semver.satisfies(Deno.version.deno, "<1.6.0") && bundleStyle === bundleStyles[STYLE_EXECUTABLE]) { 46 | console.error(`Deno version v${Deno.version.deno} doesn't support \`${bundleStyles[STYLE_EXECUTABLE]}\` for bundle style.`); 47 | Deno.exit(1); 48 | } 49 | // adding options which names start with `--allow-` and are not included in `commonDenoOptions`. 50 | additionalDenoOptions.splice(0, 0, 51 | ...Object.keys(parsedArgs).map(p => `--${p}`) 52 | .filter(key => key.startsWith('--allow-') && !commonDenoOptions.includes(key)) 53 | ); 54 | const appName = parsedArgs._[1].toString(); 55 | const slotName = parsedArgs["slot"]?.toString(); 56 | const platform = await getAppPlatform(appName, slotName); 57 | if (!["windows", "linux"].includes(platform)) { 58 | console.error(`The value \`${platform}\` for the function app \`${appName + (slotName ? `/${slotName}` : "")}\` is not valid.`); 59 | Deno.exit(1); 60 | } 61 | await updateHostJson(platform, bundleStyle); 62 | await generateFunctions(); 63 | 64 | if (bundleStyle === bundleStyles[STYLE_EXECUTABLE]) { 65 | await generateExecutable(platform); 66 | } else { 67 | await downloadBinary(platform); 68 | if (bundleStyle === bundleStyles[STYLE_JSBUNDLE]) await createJSBundle(); 69 | } 70 | await publishApp(appName, slotName); 71 | } else { 72 | printHelp(); 73 | } 74 | 75 | async function fileExists(path: string) { 76 | try { 77 | const f = await Deno.lstat(path); 78 | return f.isFile; 79 | } catch { 80 | return false; 81 | } 82 | } 83 | 84 | async function directoryExists(path: string) { 85 | try { 86 | const f = await Deno.lstat(path); 87 | return f.isDirectory; 88 | } catch { 89 | return false; 90 | } 91 | } 92 | 93 | async function listFiles(dir: string) { 94 | const files: string[] = []; 95 | for await (const dirEntry of Deno.readDir(dir)) { 96 | files.push(`${dir}/${dirEntry.name}`); 97 | if (dirEntry.isDirectory) { 98 | (await listFiles(`${dir}/${dirEntry.name}`)).forEach((s) => { 99 | files.push(s); 100 | }); 101 | } 102 | } 103 | return files; 104 | } 105 | 106 | async function generateExecutable(platformArg?: string) { 107 | try { 108 | await Deno.remove('./bin', { recursive: true }); 109 | await Deno.remove(`./${bundleFileName}`); 110 | } catch { } 111 | 112 | const platform = platformArg || Deno.build.os; 113 | await Deno.mkdir(`./bin/${platform}`, { recursive: true }); 114 | 115 | const cmd = [ 116 | "deno", 117 | "compile", 118 | "--unstable", 119 | ...(semver.satisfies(Deno.version.deno, ">=1.7.1 <1.10.0") ? ["--lite"] : []), // `--lite` option is implemented only between v1.7.1 and v1.9.x 120 | ...commonDenoOptions.concat(additionalDenoOptions), 121 | "--output", 122 | `./bin/${platform}/${baseExecutableFileName}`, 123 | ...(['windows', 'linux'].includes(platform) 124 | ? ['--target', platform === 'windows' ? 'x86_64-pc-windows-msvc' : 'x86_64-unknown-linux-gnu'] 125 | : [] 126 | ), 127 | "worker.ts" 128 | ]; 129 | console.info(`Running command: ${cmd.join(" ")}`); 130 | const generateProcess = Deno.run({ cmd }); 131 | await generateProcess.status(); 132 | } 133 | 134 | async function createJSBundle() { 135 | const cmd = ["deno", "bundle", "--unstable", "worker.ts", bundleFileName]; 136 | console.info(`Running command: ${cmd.join(" ")}`); 137 | const generateProcess = Deno.run({ cmd }); 138 | await generateProcess.status(); 139 | } 140 | 141 | async function getAppPlatform(appName: string, slotName?: string): Promise { 142 | console.info(`Checking platform type of : ${appName + (slotName ? `/${slotName}` : "")} ...`); 143 | const azResourceCmd = [ 144 | "az", 145 | "resource", 146 | "list", 147 | "--resource-type", 148 | `Microsoft.web/sites${slotName ? "/slots" : ""}`, 149 | "-o", 150 | "json", 151 | ]; 152 | const azResourceProcess = await runWithRetry( 153 | { cmd: azResourceCmd, stdout: "piped" }, 154 | "az.cmd", 155 | ); 156 | const azResourceOutput = await azResourceProcess.output(); 157 | const resources = JSON.parse( 158 | new TextDecoder().decode(azResourceOutput), 159 | ); 160 | azResourceProcess.close(); 161 | 162 | try { 163 | const resource = resources.find((resource: any) => 164 | resource.name === (appName + (slotName ? `/${slotName}` : "")) 165 | ); 166 | 167 | const azFunctionAppSettingsCmd = [ 168 | "az", 169 | "functionapp", 170 | "config", 171 | "appsettings", 172 | "set", 173 | "--ids", 174 | resource.id, 175 | ...(slotName 176 | ? ["--slot", slotName] 177 | : [] 178 | ), 179 | "--settings", 180 | "FUNCTIONS_WORKER_RUNTIME=custom", 181 | "-o", 182 | "json", 183 | ]; 184 | const azFunctionAppSettingsProcess = await runWithRetry( 185 | { cmd: azFunctionAppSettingsCmd, stdout: "null" }, 186 | "az.cmd", 187 | ); 188 | await azFunctionAppSettingsProcess.status(); 189 | azFunctionAppSettingsProcess.close(); 190 | 191 | return (resource.kind as string).includes("linux") ? "linux" : "windows"; 192 | } catch { 193 | throw new Error(`Not found: ${appName + (slotName ? `/${slotName}` : "")}`); 194 | } 195 | } 196 | 197 | async function updateHostJson(platform: string, bundleStyle: string) { 198 | // update `defaultExecutablePath` and `arguments` in host.json 199 | const hostJsonPath = "./host.json"; 200 | if (!(await fileExists(hostJsonPath))) { 201 | throw new Error(`\`${hostJsonPath}\` not found`); 202 | } 203 | 204 | const hostJSON: any = await readJson(hostJsonPath); 205 | if (!hostJSON.customHandler) hostJSON.customHandler = {}; 206 | hostJSON.customHandler.description = { 207 | defaultExecutablePath: `bin/${platform}/${bundleStyle === bundleStyles[STYLE_EXECUTABLE] ? baseExecutableFileName : "deno"}${platform === "windows" ? ".exe" : ""}`, 208 | arguments: bundleStyle === bundleStyles[STYLE_EXECUTABLE] 209 | ? [] 210 | : [ 211 | "run", 212 | ...commonDenoOptions.concat(additionalDenoOptions), 213 | bundleStyle === bundleStyles[STYLE_JSBUNDLE] ? bundleFileName : "worker.ts" 214 | ] 215 | }; 216 | 217 | await writeJson(hostJsonPath, hostJSON); // returns a promise 218 | } 219 | 220 | function writeJson(path: string, data: object): void { 221 | Deno.writeTextFileSync(path, JSON.stringify(data, null, 2)); 222 | } 223 | 224 | function readJson(path: string): string { 225 | const decoder = new TextDecoder("utf-8"); 226 | return JSON.parse(decoder.decode(Deno.readFileSync(path))); 227 | } 228 | 229 | async function downloadBinary(platform: string) { 230 | const binDir = `./bin/${platform}`; 231 | const binPath = `${binDir}/deno${platform === "windows" ? ".exe" : ""}`; 232 | const archive: any = { 233 | "windows": "pc-windows-msvc", 234 | "linux": "unknown-linux-gnu", 235 | }; 236 | 237 | // remove unnecessary files/dirs in "./bin" 238 | if (await directoryExists("./bin")) { 239 | const entries = (await listFiles("./bin")) 240 | .filter((entry) => !binPath.startsWith(entry)) 241 | .sort((str1, str2) => str1.length < str2.length ? 1 : -1); 242 | for (const entry of entries) { 243 | await Deno.remove(entry); 244 | } 245 | } 246 | try { 247 | await Deno.remove(`./${bundleFileName}`); 248 | } catch { } 249 | 250 | const binZipPath = `${binDir}/deno.zip`; 251 | if (!(await fileExists(binPath))) { 252 | const downloadUrl = 253 | `https://github.com/denoland/deno/releases/download/v${Deno.version.deno}/deno-x86_64-${archive[platform] 254 | }.zip`; 255 | console.info(`Downloading deno binary from: ${downloadUrl} ...`); 256 | // download deno binary (that gets deployed to Azure) 257 | const response = await fetch(downloadUrl); 258 | await ensureDir(binDir); 259 | const zipFile = await Deno.create(binZipPath); 260 | const download = new Deno.Buffer(await response.arrayBuffer()); 261 | await Deno.copy(download, zipFile); 262 | Deno.close(zipFile.rid); 263 | 264 | const zip = await readZip(binZipPath); 265 | 266 | await zip.unzip(binDir); 267 | 268 | if (Deno.build.os !== "windows") { 269 | await Deno.chmod(binPath, 0o755); 270 | } 271 | 272 | await Deno.remove(binZipPath); 273 | console.info(`Downloaded deno binary at: ${await Deno.realPath(binPath)}`); 274 | } 275 | } 276 | 277 | async function initializeFromTemplate(downloadBranch: string = "main") { 278 | const templateZipPath = `./template.zip`; 279 | const templateDownloadPath = `https://github.com/anthonychu/azure-functions-deno-template/archive/${downloadBranch}.zip`; 280 | let isEmpty = true; 281 | for await (const dirEntry of Deno.readDir(".")) { 282 | isEmpty = false; 283 | } 284 | 285 | if (isEmpty) { 286 | console.info("Initializing project..."); 287 | console.info(`Downloading from ${templateDownloadPath}...`); 288 | // download deno binary (that gets deployed to Azure) 289 | const response = await fetch(templateDownloadPath); 290 | const zipFile = await Deno.create(templateZipPath); 291 | const download = new Deno.Buffer(await response.arrayBuffer()); 292 | await Deno.copy(download, zipFile); 293 | Deno.close(zipFile.rid); 294 | 295 | const zip = await readZip(templateZipPath); 296 | 297 | const subDirPath = `azure-functions-deno-template-${downloadBranch}`; 298 | 299 | await zip.unzip("."); 300 | await Deno.remove(templateZipPath); 301 | 302 | for await (const entry of walk(".")) { 303 | if (entry.path.startsWith(subDirPath) && entry.path !== subDirPath) { 304 | const dest = entry.path.replace(subDirPath, "."); 305 | console.info(dest); 306 | if (entry.isDirectory) { 307 | await Deno.mkdir(dest, { recursive: true }); 308 | } else { 309 | await move(entry.path, dest); 310 | } 311 | } 312 | } 313 | await Deno.remove(subDirPath, { recursive: true }); 314 | } else { 315 | console.error("Cannot initialize. Folder is not empty."); 316 | } 317 | } 318 | 319 | async function generateFunctions() { 320 | console.info("Generating functions..."); 321 | const generateProcess = Deno.run({ 322 | cmd: [ 323 | "deno", 324 | "run", 325 | ...commonDenoOptions, 326 | "--allow-write", 327 | "--unstable", 328 | "--no-check", 329 | "worker.ts", 330 | ], 331 | env: { "DENOFUNC_GENERATE": "1" }, 332 | }); 333 | const status = await generateProcess.status(); 334 | if (status.code || !status.success) Deno.exit(status.code); 335 | } 336 | 337 | async function runFunc(...args: string[]) { 338 | let cmd = ["func", ...args]; 339 | const env = { 340 | "logging__logLevel__Microsoft": "warning", 341 | "logging__logLevel__Worker": "warning", 342 | }; 343 | 344 | const proc = await runWithRetry({ cmd, env }, "func.cmd"); 345 | await proc.status(); 346 | proc.close(); 347 | } 348 | 349 | async function runWithRetry( 350 | runOptions: Deno.RunOptions, 351 | backupCommand: string, 352 | ) { 353 | try { 354 | console.info(`Running command: ${runOptions.cmd.join(" ")}`); 355 | return Deno.run(runOptions); 356 | } catch (ex) { 357 | if (Deno.build.os === "windows") { 358 | console.info( 359 | `Could not start ${runOptions.cmd[0] 360 | } from path, searching for executable...`, 361 | ); 362 | const whereCmd = ["where.exe", backupCommand]; 363 | const proc = Deno.run({ 364 | cmd: whereCmd, 365 | stdout: "piped", 366 | }); 367 | await proc.status(); 368 | const rawOutput = await proc.output(); 369 | const newPath = new TextDecoder().decode(rawOutput).split(/\r?\n/).find( 370 | (p) => p.endsWith(backupCommand), 371 | ); 372 | if (newPath) { 373 | const newCmd = [...runOptions.cmd].map(e => e.toString()); 374 | newCmd[0] = newPath; 375 | const newOptions = { ...runOptions }; 376 | newOptions.cmd = newCmd; 377 | console.info(`Running command: ${newOptions.cmd.join(" ")}`); 378 | return Deno.run(newOptions); 379 | } else { 380 | throw `Could not locate ${backupCommand}. Please ensure it is installed and in the path.`; 381 | } 382 | } else { 383 | throw ex; 384 | } 385 | } 386 | } 387 | 388 | async function publishApp(appName: string, slotName?: string) { 389 | const runFuncArgs = [ 390 | "azure", 391 | "functionapp", 392 | "publish", 393 | appName 394 | ]; 395 | await runFunc(...(slotName ? runFuncArgs.concat(["--slot", slotName]) : runFuncArgs)); 396 | } 397 | 398 | function printLogo() { 399 | const logo = ` 400 | @@@@@@@@@@@, 401 | @@@@@@@@@@@@@@@@@@@ %%%%%% 402 | @@@@@@ @@@@@@@@@@ %%%%%% 403 | @@@@@ @ @ *@@@@@ @ %%%%%% @ 404 | @@@ @@@@@ @@ %%%%%% @@ 405 | @@@@@ @@@@@ @@@ %%%%%%%%%%% @@@ 406 | @@@@@@@@@@@@@@@ @@@@ @@ %%%%%%%%%% @@ 407 | @@@@@@@@@@@@@@ @@@@ @@ %%%% @@ 408 | @@@@@@@@@@@@@@ @@@ @@ %%% @@ 409 | @@@@@@@@@@@@@ @ @@ %% @@ 410 | @@@@@@@@@@@ %% 411 | @@@@@@@ % 412 | `; 413 | console.info(logo); 414 | } 415 | 416 | function printHelp() { 417 | printLogo(); 418 | console.info("Deno for Azure Functions - CLI"); 419 | console.info(` 420 | Commands: 421 | 422 | denofunc --help 423 | This screen 424 | 425 | denofunc init 426 | Initialize project in an empty folder 427 | 428 | denofunc start 429 | Generate functions artifacts and start Azure Functions Core Tools 430 | 431 | denofunc publish [options] 432 | Publish to Azure 433 | options: 434 | --slot Specify name of the deployment slot 435 | --bundle-style executable|jsbundle|none Select bundle style on deployment 436 | 437 | executable: Bundle as one executable(default option for Deno v1.6.0 or later). 438 | jsbundle: Bundle as one javascript worker & Deno runtime 439 | none: No bundle 440 | --allow-run Same as Deno's permission option 441 | --allow-write Same as Deno's permission option 442 | `); 443 | } 444 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { parse } from "https://deno.land/std@0.79.0/flags/mod.ts"; 2 | export { readZip } from "https://raw.githubusercontent.com/anthonychu/deno-zip/std-0.66.0/mod.ts"; 3 | export { 4 | ensureDir, 5 | move, 6 | walk, 7 | } from "https://deno.land/std@0.79.0/fs/mod.ts"; 8 | export * as semver from "https://deno.land/x/semver@v1.4.0/mod.ts"; 9 | -------------------------------------------------------------------------------- /e2e_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assertStringContains 4 | } from "https://deno.land/std@0.67.0/testing/asserts.ts"; 5 | 6 | Deno.test("hello_world HTTP trigger works", async () => { 7 | const baseUrl = Deno.env.get("FUNCTION_APP_BASE_URL") ?? "http://localhost:7071" 8 | console.log(`Testing function app at: ${baseUrl}`) 9 | const resp = await fetch(`${baseUrl}/api/hello_world`); 10 | const data = await resp.text(); 11 | assertStringContains(data, "Azure Functions"); 12 | }); 13 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { Application, Router, OakContext, Body } from "./worker_deps.ts"; 2 | import type { 3 | AzureFunction, 4 | HttpMethod, 5 | } from "./types.ts"; 6 | import type { 7 | Context, 8 | Logger, 9 | HttpRequest, 10 | } from "./types.ts"; 11 | 12 | export interface FunctionRegistration { 13 | name: string; 14 | handler: AzureFunction; 15 | metadata?: any; 16 | } 17 | 18 | function createLogger(isHttpPassthrough: boolean): Logger { 19 | const logs: string[] = []; 20 | const logger: Logger = function (message: string) { 21 | if (isHttpPassthrough) { 22 | console.log(message); 23 | } else { 24 | logs.push(message); 25 | } 26 | }; 27 | logger.logs = logs; 28 | return logger; 29 | } 30 | 31 | class FunctionContext implements Context { 32 | bindings: { [key: string]: any } = {}; 33 | bindingData: { [key: string]: any } = {}; 34 | log: Logger; 35 | req?: HttpRequest | undefined; 36 | res?: { [key: string]: any } | undefined = { 37 | status: 200, 38 | }; 39 | 40 | constructor(isHttpPassthrough = false) { 41 | this.log = createLogger(isHttpPassthrough); 42 | } 43 | } 44 | 45 | class FunctionHttpRequest implements HttpRequest { 46 | headers: { [key: string]: string } = {}; 47 | query: { [key: string]: string } = {}; 48 | params: { [key: string]: string } = {}; 49 | url = ""; 50 | body?: any; 51 | rawBody?: any; 52 | 53 | constructor(public method: HttpMethod | null) { 54 | } 55 | } 56 | 57 | async function parseBody(body: { type: string; value: any }) { 58 | let value = await body.value; 59 | if (body.type === "text") { 60 | try { 61 | value = JSON.parse(value); 62 | } catch { } 63 | } 64 | return value; 65 | } 66 | 67 | function tryJsonParse(input: any) { 68 | try { 69 | input = JSON.parse(input); 70 | } catch { } 71 | return input; 72 | } 73 | 74 | function toCamelCase(input: string) { 75 | const restOfString: string = input.length > 1 ? input.substring(1) : ""; 76 | return input.substring(0, 1).toLowerCase() + restOfString; 77 | } 78 | 79 | function toCamelCaseKeys(input: any) { 80 | if (typeof (input) === "object") { 81 | for (const [key, value] of Object.entries(input)) { 82 | const firstChar = key.substring(0, 1); 83 | if (firstChar !== firstChar.toLowerCase()) { 84 | const restOfString = key.length > 1 ? key.substring(1) : ""; 85 | input[firstChar.toLowerCase() + restOfString] = value; 86 | delete input[key]; 87 | } 88 | } 89 | } 90 | } 91 | 92 | export class AzureFunctionsWorker { 93 | #app: Application; 94 | #functionRegistrations: FunctionRegistration[]; 95 | 96 | constructor(functionRegistrations: FunctionRegistration[]) { 97 | this.#functionRegistrations = functionRegistrations; 98 | 99 | // check if a function name is already used in another function 100 | const funcNames:string[] = []; 101 | this.#functionRegistrations.forEach((funcReg) => { 102 | if (funcNames.includes(funcReg.name)) 103 | throw new Error( 104 | `A function name \`${funcReg.name}\` is already used in another function. Make sure each function name.` 105 | ); 106 | funcNames.push(funcReg.name); 107 | }); 108 | 109 | const router = new Router(); 110 | 111 | for (const registration of functionRegistrations) { 112 | if (!registration.metadata) { 113 | registration.metadata = getDefaultFunctionMetadata(); 114 | } 115 | router.all(`/${registration.name}`, async (ctx: OakContext) => { 116 | try { 117 | let body: Body; 118 | 119 | try { 120 | body = await ctx.request.body(); 121 | } catch { 122 | body = { 123 | type: "undefined", 124 | value: undefined, 125 | }; 126 | } 127 | 128 | let parsedBody: any = await parseBody(body); 129 | 130 | const isHttpPassthrough: boolean = parsedBody === undefined || 131 | !(parsedBody.Data && parsedBody.Metadata); 132 | const context = new FunctionContext(); 133 | 134 | // lots of stuff need camelcasing 135 | // TODO: refactor 136 | parsedBody.Metadata.sys = tryJsonParse(parsedBody.Metadata.sys); 137 | toCamelCaseKeys(parsedBody.Metadata.sys); 138 | toCamelCaseKeys(parsedBody.Data.req); 139 | 140 | context.req = parsedBody.Data.req; 141 | for (const [key, value] of Object.entries(parsedBody.Data)) { 142 | context.bindings[toCamelCase(key)] = tryJsonParse(value); 143 | } 144 | for (const [key, value] of Object.entries(parsedBody.Metadata)) { 145 | context.bindingData[toCamelCase(key)] = tryJsonParse(value); 146 | } 147 | 148 | const result = await Promise.resolve(registration.handler(context)); 149 | 150 | // Merge `context.res` into `context.bindings` 151 | // `context.res` is the special property for HTTP response 152 | // https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=v2#response-object 153 | if (!context.bindings.res) { 154 | if (context.res?.status) { 155 | context.res.statusCode = context.res.status; 156 | delete context.res.status; 157 | } 158 | context.bindings.res = context.res; 159 | } 160 | registration.metadata?.bindings?.forEach((bindingDef:any) => { 161 | if (bindingDef.type !== "http" || bindingDef.direction !== "out") return; 162 | if (bindingDef.name === "$return") { 163 | result.statusCode = result.status; 164 | delete result.status; 165 | return; 166 | } 167 | if (!context.bindings[bindingDef.name].status) return 168 | const httpOutBinding = context.bindings[bindingDef.name] 169 | httpOutBinding.statusCode = httpOutBinding.status; 170 | delete httpOutBinding.status; 171 | }); 172 | 173 | ctx.response.body = { 174 | Logs: context.log.logs, 175 | Outputs: context.bindings, 176 | ReturnValue: result, 177 | }; 178 | ctx.response.headers.set("content-type", "application/json"); 179 | } catch (ex) { 180 | console.error(ex); 181 | ctx.response.status = 500; 182 | } 183 | }); 184 | } 185 | 186 | const app = new Application(); 187 | 188 | app.use(router.routes()); 189 | app.use(router.allowedMethods()); 190 | 191 | this.#app = app; 192 | } 193 | 194 | async run() { 195 | if (Deno.env.get("DENOFUNC_GENERATE")) { 196 | await this.regenerateFunctions(); 197 | } else { 198 | const port = Deno.env.get("FUNCTIONS_HTTPWORKER_PORT") || 8000; 199 | console.log("listening to port " + port); 200 | return await this.#app.listen({ port: +port }); 201 | } 202 | } 203 | 204 | private async regenerateFunctions() { 205 | console.info("Cleaning function folders..."); 206 | for await (const dirEntry of Deno.readDir(".")) { 207 | if (dirEntry.isDirectory) { 208 | let hasFunctionJson, hasOtherThings = false; 209 | for await (const subdirEntry of Deno.readDir(dirEntry.name)) { 210 | if (subdirEntry.isFile && subdirEntry.name === "function.json") { 211 | hasFunctionJson = true; 212 | } else { 213 | hasOtherThings = true; 214 | } 215 | } 216 | 217 | if (hasFunctionJson && hasOtherThings) { 218 | console.warn( 219 | `Folder ${dirEntry.name} contains functions.json but also has other files. Delete skipped.`, 220 | ); 221 | } else if (hasFunctionJson) { 222 | console.info(`Deleting folder ${dirEntry.name}.`); 223 | await Deno.remove(dirEntry.name, { recursive: true }); 224 | } 225 | } 226 | } 227 | 228 | console.info("Generating function folders..."); 229 | for (const func of this.#functionRegistrations) { 230 | try { 231 | await Deno.mkdir(func.name); 232 | } catch { } 233 | const encoder = new TextEncoder(); 234 | const data = encoder.encode(JSON.stringify(func.metadata, null, 2)); 235 | console.info(`Generating file ${func.name}/function.json.`); 236 | await Deno.writeFile(`${func.name}/function.json`, data); 237 | } 238 | } 239 | } 240 | 241 | function getDefaultFunctionMetadata() { 242 | return { 243 | "bindings": [ 244 | { 245 | "type": "httpTrigger", 246 | "authLevel": "anonymous", 247 | "direction": "in", 248 | "methods": [ 249 | "GET", 250 | "POST", 251 | ], 252 | "name": "req", 253 | }, 254 | { 255 | "type": "http", 256 | "direction": "out", 257 | "name": "res", 258 | }, 259 | ], 260 | }; 261 | } 262 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface for your Azure Function code. This function must be exported (via module.exports or exports) 3 | * and will execute when triggered. It is recommended that you declare this function as async, which 4 | * implicitly returns a Promise. 5 | * @param context Context object passed to your function from the Azure Functions runtime. 6 | * @param {any[]} args Optional array of input and trigger binding data. These binding data are passed to the 7 | * function in the same order that they are defined in function.json. Valid input types are string, HttpRequest, 8 | * and Buffer. 9 | * @returns Output bindings (optional). If you are returning a result from a Promise (or an async function), this 10 | * result will be passed to JSON.stringify unless it is a string, Buffer, ArrayBufferView, or number. 11 | */ 12 | export declare type AzureFunction = (( 13 | context: Context, 14 | ...args: any[] 15 | ) => Promise | void | any); 16 | /** 17 | * The context object can be used for writing logs, reading data from bindings, setting outputs and using 18 | * the context.done callback when your exported function is synchronous. A context object is passed 19 | * to your function from the Azure Functions runtime on function invocation. 20 | */ 21 | export interface Context { 22 | // /** 23 | // * A unique GUID per function invocation. 24 | // */ 25 | // invocationId: string; 26 | // /** 27 | // * Function execution metadata. 28 | // */ 29 | // executionContext: ExecutionContext; 30 | /** 31 | * Input and trigger binding data, as defined in function.json. Properties on this object are dynamically 32 | * generated and named based off of the "name" property in function.json. 33 | */ 34 | bindings: { 35 | [key: string]: any; 36 | }; 37 | /** 38 | * Trigger metadata and function invocation data. 39 | */ 40 | bindingData: { 41 | [key: string]: any; 42 | }; 43 | // /** 44 | // * TraceContext information to enable distributed tracing scenarios. 45 | // */ 46 | // traceContext: TraceContext; 47 | // /** 48 | // * Bindings your function uses, as defined in function.json. 49 | // */ 50 | // bindingDefinitions: BindingDefinition[]; 51 | /** 52 | * Allows you to write streaming function logs. Calling directly allows you to write streaming function logs 53 | * at the default trace level. 54 | */ 55 | log: Logger; 56 | /** 57 | * HTTP request object. Provided to your function when using HTTP Bindings. 58 | */ 59 | req?: HttpRequest; 60 | /** 61 | * HTTP response object. Provided to your function when using HTTP Bindings. 62 | */ 63 | res?: { 64 | [key: string]: any; 65 | }; 66 | } 67 | /** 68 | * HTTP request object. Provided to your function when using HTTP Bindings. 69 | */ 70 | export interface HttpRequest { 71 | /** 72 | * HTTP request method used to invoke this function. 73 | */ 74 | method: HttpMethod | null; 75 | /** 76 | * Request URL. 77 | */ 78 | url: string; 79 | /** 80 | * HTTP request headers. 81 | */ 82 | headers: { 83 | [key: string]: string; 84 | }; 85 | /** 86 | * Query string parameter keys and values from the URL. 87 | */ 88 | query: { 89 | [key: string]: string; 90 | }; 91 | /** 92 | * Route parameter keys and values. 93 | */ 94 | params: { 95 | [key: string]: string; 96 | }; 97 | /** 98 | * The HTTP request body. 99 | */ 100 | body?: any; 101 | /** 102 | * The HTTP request body as a UTF-8 string. 103 | */ 104 | rawBody?: any; 105 | } 106 | /** 107 | * Possible values for an HTTP request method. 108 | */ 109 | export declare type HttpMethod = 110 | | "GET" 111 | | "POST" 112 | | "DELETE" 113 | | "HEAD" 114 | | "PATCH" 115 | | "PUT" 116 | | "OPTIONS" 117 | | "TRACE" 118 | | "CONNECT"; 119 | /** 120 | * Http response cookie object to "Set-Cookie" 121 | */ 122 | export interface Cookie { 123 | /** Cookie name */ 124 | name: string; 125 | /** Cookie value */ 126 | value: string; 127 | /** Specifies allowed hosts to receive the cookie */ 128 | domain?: string; 129 | /** Specifies URL path that must exist in the requested URL */ 130 | path?: string; 131 | /** 132 | * NOTE: It is generally recommended that you use maxAge over expires. 133 | * Sets the cookie to expire at a specific date instead of when the client closes. 134 | * This can be a Javascript Date or Unix time in milliseconds. 135 | */ 136 | expires?: Date | number; 137 | /** Sets the cookie to only be sent with an encrypted request */ 138 | secure?: boolean; 139 | /** Sets the cookie to be inaccessible to JavaScript's Document.cookie API */ 140 | httpOnly?: boolean; 141 | /** Can restrict the cookie to not be sent with cross-site requests */ 142 | sameSite?: "Strict" | "Lax" | undefined; 143 | /** Number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. */ 144 | maxAge?: number; 145 | } 146 | export interface ExecutionContext { 147 | /** 148 | * A unique GUID per function invocation. 149 | */ 150 | invocationId: string; 151 | /** 152 | * The name of the function that is being invoked. The name of your function is always the same as the 153 | * name of the corresponding function.json's parent directory. 154 | */ 155 | functionName: string; 156 | /** 157 | * The directory your function is in (this is the parent directory of this function's function.json). 158 | */ 159 | functionDirectory: string; 160 | } 161 | /** 162 | * TraceContext information to enable distributed tracing scenarios. 163 | */ 164 | export interface TraceContext { 165 | /** Describes the position of the incoming request in its trace graph in a portable, fixed-length format. */ 166 | traceparent: string | null | undefined; 167 | /** Extends traceparent with vendor-specific data. */ 168 | tracestate: string | null | undefined; 169 | /** Holds additional properties being sent as part of request telemetry. */ 170 | attributes: 171 | | { 172 | [k: string]: string; 173 | } 174 | | null 175 | | undefined; 176 | } 177 | export interface BindingDefinition { 178 | /** 179 | * The name of your binding, as defined in function.json. 180 | */ 181 | name: string; 182 | /** 183 | * The type of your binding, as defined in function.json. 184 | */ 185 | type: string; 186 | /** 187 | * The direction of your binding, as defined in function.json. 188 | */ 189 | direction: "in" | "out" | "inout" | undefined; 190 | } 191 | /** 192 | * Allows you to write streaming function logs. 193 | */ 194 | export interface Logger { 195 | /** 196 | * Writes streaming function logs at the default trace level. 197 | */ 198 | (message: string): void; 199 | // /** 200 | // * Writes to error level logging or lower. 201 | // */ 202 | // error(...args: any[]): void; 203 | // /** 204 | // * Writes to warning level logging or lower. 205 | // */ 206 | // warn(...args: any[]): void; 207 | // /** 208 | // * Writes to info level logging or lower. 209 | // */ 210 | // info(...args: any[]): void; 211 | // /** 212 | // * Writes to verbose level logging. 213 | // */ 214 | // verbose(...args: any[]): void; 215 | logs: string[]; 216 | } 217 | -------------------------------------------------------------------------------- /worker_deps.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Body, 3 | } from "https://deno.land/x/oak@v6.4.2/mod.ts"; 4 | export { 5 | Application, 6 | Router, 7 | Context as OakContext, 8 | } from "https://deno.land/x/oak@v6.4.2/mod.ts"; 9 | --------------------------------------------------------------------------------