Repository: geoffrich/svelte-adapter-azure-swa
Pull request #92 (2022-10-29 22:51:35)

feat: auto-create required function files
This PR simplifies the setup needed to use the adapter.
- if you do not have any custom Azure functions, you only need to configure your build with the proper API location and static file location. Previously, you had to create a `/api/render` folder with some custom files in it.
- if you have custom Azure functions, you can set the `apiDir` option to instruct the adapter to output the SvelteKit render function into a directory with the rest of your functions

This is a **breaking change**, but migration should be straightforward for most. See below for a migration guide. For a full working setup, see the `demo` folder in this repo.

Fix for #70. Also implements #42.

## Migration guide

If you _do not_ have additional custom Azure functions in the `/api` directory (the majority of users):
- delete the `/api` directory.
- (optional) remove the `/api/render` entry from your `.gitignore`
- update your build configuration (likely in `/.github/workflows/azure-static-web-apps-*.yml`) to use `build/server` for the `api_location`. The adapter now outputs the built function into the `build` directory.

```diff
          app_location: '/demo' # App source code path
 -        api_location: 'api' # Api source code path - optional
 +        api_location: 'build/server' # Api source code path - optional
          output_location: 'build/static' # Built app content directory - optional
```

If you _do_ have additional custom Azure functions in the `/api` directory:
- update your `.gitignore` to ignore the new SvelteKit function directory, `api/sk_render`. Previously, this was `api/render`
- pass the [apiDir](https://github.com/geoffrich/svelte-adapter-azure-swa#apidir) option to the adapter and set it to the location of the api directory. The adapter will output a folder named `sk_render` to this directory that contains the Azure function for rendering.

```js
// svelte.config.js
import adapter from 'svelte-adapter-azure-swa';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	kit: {
		adapter: adapter({ apiDir: 'api' })
	}
};

export default config;
```

If you have questions about the migration, please open an issue on this repo.

github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net
github-actions[bot]: Azure Static Web Apps: Your stage site is ready! Visit it here: https://polite-desert-00b80111e-92.westus2.2.azurestaticapps.net

Patch:
diff --git a/.github/workflows/azure-static-web-apps-polite-desert-00b80111e.yml b/.github/workflows/azure-static-web-apps-polite-desert-00b80111e.yml
index 8f8bdb1..64113a6 100644
--- a/.github/workflows/azure-static-web-apps-polite-desert-00b80111e.yml
+++ b/.github/workflows/azure-static-web-apps-polite-desert-00b80111e.yml
@@ -41,7 +41,7 @@ jobs:
           ###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
           # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
           app_location: '/demo' # App source code path
-          api_location: 'demo/api' # Api source code path - optional
+          api_location: 'demo/build/server' # Api source code path - optional
           output_location: 'build/static' # Built app content directory - optional
           ###### End of Repository/Build Configurations ######
     outputs:
diff --git a/README.md b/README.md
index 9489050..35223b3 100644
--- a/README.md
+++ b/README.md
@@ -23,27 +23,7 @@ export default {
 };
 ```
 
-You will need to create an `api/` folder in your project root containing a [`host.json`](https://docs.microsoft.com/en-us/azure/azure-functions/functions-host-json) and a `package.json` (see samples below). The adapter will output the `render` Azure function for SSR in that folder. The `api` folder needs to be in your repo so that Azure can recognize the API at build time. However, you can add `api/render` to your .gitignore so that the generated function is not in source control.
-
-### Sample `host.json`
-
-```json
-{
-	"version": "2.0",
-	"extensionBundle": {
-		"id": "Microsoft.Azure.Functions.ExtensionBundle",
-		"version": "[2.*, 3.0.0)"
-	}
-}
-```
-
-### Sample `package.json`
-
-It's okay for this to be empty. Not including it causes the Azure Function build to fail.
-
-```json
-{}
-```
+:warning: **IMPORTANT**: you also need to configure your build so that your SvelteKit site deploys properly. Failing to do so will prevent the project from building and deploying. See the next section for instructions.
 
 ## Azure configuration
 
@@ -52,9 +32,11 @@ When deploying to Azure, you will need to properly [configure your build](https:
 | property          | value          |
 | ----------------- | -------------- |
 | `app_location`    | `./`           |
-| `api_location`    | `api`          |
+| `api_location`    | `build/server` |
 | `output_location` | `build/static` |
 
+If you use a custom API directory (see [below](#apiDir)), your `api_location` will be the same as the value you pass to `apiDir`.
+
 ## Running locally with the Azure SWA CLI
 
 You can debug using the [Azure Static Web Apps CLI](https://github.com/Azure/static-web-apps-cli). Note that the CLI is currently in preview and you may encounter issues.
@@ -68,7 +50,7 @@ To run the CLI, install `@azure/static-web-apps-cli` and the [Azure Functions Co
 	"configurations": {
 		"app": {
 			"outputLocation": "./build/static",
-			"apiLocation": "./api"
+			"apiLocation": "./build/server"
 		}
 	}
 }
@@ -76,6 +58,52 @@ To run the CLI, install `@azure/static-web-apps-cli` and the [Azure Functions Co
 
 ## Options
 
+### apiDir
+
+The directory where the `sk_render` Azure function for SSR will be placed. Most of the time, you shouldn't need to set this.
+
+By default, the adapter will output the `sk_render` Azure function for SSR in the `build/server` folder. If you want to output it to a different directory instead (e.g. if you have additional Azure functions to deploy), you can set this option.
+
+```js
+import azure from 'svelte-adapter-azure-swa';
+
+export default {
+	kit: {
+		...
+		adapter: azure({
+			apiDir: 'custom/api'
+		})
+	}
+};
+```
+
+If you set this option, you will also need to create a `host.json` and `package.json` in your API directory. The adapter normally generates these files by default, but skips them when a custom API directory is provided to prevent overwriting any existing files. You can see the default files the adapter generates in [this directory](https://github.com/geoffrich/svelte-adapter-azure-swa/tree/main/files/api).
+
+For instance, by default the adapter outputs these files...
+
+```
+build/
+└── server/
+    ├── sk_render/
+    │   ├── function.json
+    │   └── index.js
+    ├── host.json
+    ├── local.settings.json
+    └── package.json
+```
+
+... but only outputs these files when a custom API directory is provided:
+
+```
+custom/
+└── api/
+    └── sk_render/
+        ├── function.json
+        └── index.js
+```
+
+Also note that the adapter reserves the folder prefix `sk_render` and API route prefix `__render` for Azure functions generated by the adapter. So, if you use a custom API directory, you cannot have any other folder starting with `sk_render` or functions available at the `__render` route, since these will conflict with the adapter's Azure functions.
+
 ### customStaticWebAppConfig
 
 An object containing additional Azure SWA [configuration options](https://docs.microsoft.com/en-us/azure/static-web-apps/configuration). This will be merged with the `staticwebapp.config.json` generated by the adapter.
diff --git a/demo/.gitignore b/demo/.gitignore
index 4200df2..64007bd 100644
--- a/demo/.gitignore
+++ b/demo/.gitignore
@@ -8,4 +8,3 @@ node_modules
 !.env.example
 .vercel
 .output
-/api/render
diff --git a/files/api/function.json b/files/api/function.json
deleted file mode 100644
index 3faf389..0000000
--- a/files/api/function.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-	"bindings": [
-		{
-			"authLevel": "anonymous",
-			"type": "httpTrigger",
-			"direction": "in",
-			"name": "req",
-			"route": "__render"
-		},
-		{
-			"type": "http",
-			"direction": "out",
-			"name": "res"
-		}
-	]
-}
diff --git a/demo/api/host.json b/files/api/host.json
similarity index 100%
rename from demo/api/host.json
rename to files/api/host.json
diff --git a/files/api/local.settings.json b/files/api/local.settings.json
new file mode 100644
index 0000000..471e04c
--- /dev/null
+++ b/files/api/local.settings.json
@@ -0,0 +1,6 @@
+{
+	"IsEncrypted": false,
+	"Values": {
+		"FUNCTIONS_WORKER_RUNTIME": "node"
+	}
+}
diff --git a/demo/api/package.json b/files/api/package.json
similarity index 100%
rename from demo/api/package.json
rename to files/api/package.json
diff --git a/index.d.ts b/index.d.ts
index ab0bc77..022a116 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -6,6 +6,7 @@ type Options = {
 	debug?: boolean;
 	customStaticWebAppConfig?: CustomStaticWebAppConfig;
 	esbuildOptions?: Pick<esbuild.BuildOptions, 'external'>;
+	apiDir?: string;
 };
 
 export default function plugin(options?: Options): Adapter;
diff --git a/index.js b/index.js
index 04a9177..51f7a76 100644
--- a/index.js
+++ b/index.js
@@ -9,6 +9,25 @@ import esbuild from 'esbuild';
 
 const ssrFunctionRoute = '/api/__render';
 
+const functionJson = `
+{
+	"bindings": [
+		{
+			"authLevel": "anonymous",
+			"type": "httpTrigger",
+			"direction": "in",
+			"name": "req",
+			"route": "__render"
+		},
+		{
+			"type": "http",
+			"direction": "out",
+			"name": "res"
+		}
+	]
+}
+`;
+
 /**
  * Validate the static web app configuration does not override the minimum config for the adapter to work correctly.
  * @param config {import('./types/swa').CustomStaticWebAppConfig}
@@ -28,30 +47,32 @@ function validateCustomConfig(config) {
 export default function ({
 	debug = false,
 	customStaticWebAppConfig = {},
-	esbuildOptions = {}
+	esbuildOptions = {},
+	apiDir: customApiDir = undefined
 } = {}) {
 	return {
 		name: 'adapter-azure-swa',
 
 		async adapt(builder) {
-			if (!existsSync(join('api', 'package.json'))) {
-				throw new Error(
-					'You need to create a package.json in your `api` directory. See the adapter README for details.'
+			// TODO: remove for 1.0
+			if (!customApiDir && existsSync(join('api', 'render'))) {
+				builder.log.warn(
+					`Warning: you have an api/render folder but this adapter now uses the build/server folder for API functions. You may need to update your build configuration. Failing to do so could break your deployed site.
+Please see the PR for migration instructions: https://github.com/geoffrich/svelte-adapter-azure-swa/pull/92`
 				);
 			}
-
 			const swaConfig = generateConfig(customStaticWebAppConfig, builder.config.kit.appDir);
 
 			const tmp = builder.getBuildDirectory('azure-tmp');
 			const publish = 'build';
 			const staticDir = join(publish, 'static');
-			const apiDir = join('api', 'render');
+			const apiDir = customApiDir || join(publish, 'server');
+			const functionDir = join(apiDir, 'sk_render');
 			const entry = `${tmp}/entry.js`;
 			builder.log.minor(`Publishing to "${publish}"`);
 
 			builder.rimraf(tmp);
 			builder.rimraf(publish);
-			builder.rimraf(apiDir);
 
 			const files = fileURLToPath(new URL('./files', import.meta.url));
 
@@ -68,7 +89,12 @@ export default function ({
 				}
 			});
 
-			builder.copy(join(files, 'api'), apiDir);
+			if (customApiDir) {
+				checkForMissingFiles();
+			} else {
+				// if the user specified a custom API directory, assume they are creating the required function files themselves
+				builder.copy(join(files, 'api'), apiDir);
+			}
 
 			writeFileSync(
 				`${tmp}/manifest.js`,
@@ -80,7 +106,7 @@ export default function ({
 			/** @type {BuildOptions} */
 			const default_options = {
 				entryPoints: [entry],
-				outfile: join(apiDir, 'index.js'),
+				outfile: join(functionDir, 'index.js'),
 				bundle: true,
 				platform: 'node',
 				target: 'node16',
@@ -89,6 +115,7 @@ export default function ({
 			};
 
 			await esbuild.build(default_options);
+			writeFileSync(join(functionDir, 'function.json'), functionJson);
 
 			builder.log.minor('Copying assets...');
 			builder.writeClient(staticDir);
@@ -111,7 +138,21 @@ export default function ({
 				);
 			}
 
-			writeFileSync(`${publish}/staticwebapp.config.json`, JSON.stringify(swaConfig));
+			writeFileSync(`${publish}/staticwebapp.config.json`, JSON.stringify(swaConfig, null, 2));
+
+			/**
+			 * Check for missing files when a custom API directory is provided.
+			 */
+			function checkForMissingFiles() {
+				const requiredFiles = ['host.json', 'package.json'];
+				for (const file of requiredFiles) {
+					if (!existsSync(join(customApiDir, file))) {
+						builder.log.warn(
+							`Warning: apiDir set but ${file} does not exist. You will need to create this file yourself. See the docs for more information: https://github.com/geoffrich/svelte-adapter-azure-swa#apidir`
+						);
+					}
+				}
+			}
 		}
 	};
 }


Test patch:
diff --git a/demo/tests/test.js b/demo/tests/test.js
index 9397fae..f19a1eb 100644
--- a/demo/tests/test.js
+++ b/demo/tests/test.js
@@ -9,9 +9,11 @@ test('submits sverdle guess', async ({ page }) => {
 	await page.goto('/sverdle');
 	const input = page.locator('input[name=guess]').first();
 	await expect(input).not.toBeDisabled();
+	await input.focus();
 
 	await page.keyboard.type('AZURE');
 	await page.keyboard.press('Enter');
 
+	await expect(input).toHaveValue('a');
 	await expect(input).toBeDisabled();
 });
diff --git a/test/index.test.js b/test/index.test.js
index 9feb302..c305af2 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -66,14 +66,27 @@ describe('adapt', () => {
 		await adapter.adapt(builder);
 		expect(builder.writePrerendered).toBeCalled();
 		expect(builder.writeClient).toBeCalled();
+		expect(builder.copy).toBeCalledWith(expect.stringContaining('api'), 'build/server');
 	});
 
-	test('throws error when no package.json', async () => {
-		existsSync.mockImplementationOnce(() => false);
+	test('writes to custom api directory', async () => {
+		const adapter = azureAdapter({ apiDir: 'custom/api' });
+		const builder = getMockBuilder();
+		await adapter.adapt(builder);
+		expect(writeFileSync).toBeCalledWith(
+			'custom/api/sk_render/function.json',
+			expect.stringContaining('__render')
+		);
+		// we don't copy the required function files to a custom API directory
+		expect(builder.copy).not.toBeCalledWith(expect.stringContaining('api'), 'custom/api');
+	});
 
-		const adapter = azureAdapter();
+	test('logs warning when custom api directory set and required file does not exist', async () => {
+		vi.mocked(existsSync).mockImplementationOnce(() => false);
+		const adapter = azureAdapter({ apiDir: 'custom/api' });
 		const builder = getMockBuilder();
-		await expect(adapter.adapt(builder)).rejects.toThrowError('You need to create a package.json');
+		await adapter.adapt(builder);
+		expect(builder.log.warn).toBeCalled();
 	});
 
 	test('adds index.html when root not prerendered', async () => {
@@ -112,7 +125,8 @@ function getMockBuilder() {
 			}
 		},
 		log: {
-			minor: vi.fn()
+			minor: vi.fn(),
+			warn: vi.fn()
 		},
 		prerendered: {
 			paths: ['/']