├── .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 |
--------------------------------------------------------------------------------