diff --git a/README.md b/README.md index 4f228486..4e01af2a 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,13 @@ To run the CLI, install `@azure/static-web-apps-cli` and the [Azure Functions Co 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. -Attempting to override the default catch-all route (`route: '*'`) or the `navigationFallback` options will throw an error, since they are critical for server-side rendering. +It can be useful to understand what's happening 'under the hood'. All requests for dynamic responses must be rewritten for the server-side rendering (SSR) function to render the page. If a request is not rewritten properly, bad things can happen. If the method requires a dynamic response (such as a `POST` request), SWA will respond with a `405` error. If the request method is `GET` but the file was not pre-rendered, SWA will use the `navigationFallback` rule to rewrite the request, which will fail if not set correctly. + +This adapter will ensure several routes are defined, along with `navigationFallback`, so dynamic rendering will occur correctly. If you define a catch-all wildcard route (`route: '/*'` or `route: '*'`), those settings will be used by every route this adapter creates. This will allow you to set a default `allowedRoles`, among other things. If you override the `rewrite` key of that route, you may force or prevent SSR from taking place. + +If you want to force a route to deliver only dynamically-generated responses, set `rewrite: 'ssr'` inside the route. Files that are not rendered by SvelteKit, such as static assets, may be inaccessible if you do this. Conversely, set `rewrite: undefined` to disable SSR in a route. + +If you want to prevent dynamic responses for requests in a route that has no dynamic content (such as an `/images` folder), you can set `exclude` in the `navigationFallback` rule to an array of routes that should not be dynamically handled. Read [Microsoft's documentation](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#fallback-routes) for a description of how `exclude` works. **Note:** customizing this config (especially `routes`) has the potential to break how SvelteKit handles the request. Make sure to test any modifications thoroughly. diff --git a/index.d.ts b/index.d.ts index ab0bc77e..df98f784 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,10 +1,10 @@ import { Adapter } from '@sveltejs/kit'; -import { CustomStaticWebAppConfig } from './types/swa'; +import { StaticWebAppConfig } from './types/swa'; import esbuild from 'esbuild'; type Options = { debug?: boolean; - customStaticWebAppConfig?: CustomStaticWebAppConfig; + customStaticWebAppConfig?: StaticWebAppConfig; esbuildOptions?: Pick; }; diff --git a/index.js b/index.js index 7c64eb19..03dcb0ef 100644 --- a/index.js +++ b/index.js @@ -8,21 +8,14 @@ import esbuild from 'esbuild'; */ const ssrFunctionRoute = '/api/__render'; - -/** - * Validate the static web app configuration does not override the minimum config for the adapter to work correctly. - * @param config {import('./types/swa').CustomStaticWebAppConfig} - * */ -function validateCustomConfig(config) { - if (config) { - if ('navigationFallback' in config) { - throw new Error('customStaticWebAppConfig cannot override navigationFallback.'); - } - if (config.routes && config.routes.find((route) => route.route === '*')) { - throw new Error(`customStaticWebAppConfig cannot override '*' route.`); - } - } -} +// These are the methods that don't have to be SSR if they were pre-rendered. +const staticMethods = ['GET', 'HEAD', 'OPTIONS']; +// These are the methods that must always be SSR. +const ssrMethods = ['CONNECT', 'DELETE', 'PATCH', 'POST', 'PUT', 'TRACE']; +// This is the phrase that will be replaced with ssrFunctionRoute in custom configurations that use it. +const ssrTrigger = 'ssr'; +// Default version of node to target +const nodeVersion = 'node:16'; /** @type {import('.').default} */ export default function ({ @@ -30,7 +23,8 @@ export default function ({ customStaticWebAppConfig = {}, esbuildOptions = {} } = {}) { - return { + /** @type {import('@sveltejs/kit').Adapter} */ + const adapter = { name: 'adapter-azure-swa', async adapt(builder) { @@ -40,8 +34,6 @@ export default function ({ ); } - const swaConfig = generateConfig(customStaticWebAppConfig, builder.config.kit.appDir); - const tmp = builder.getBuildDirectory('azure-tmp'); const publish = 'build'; const staticDir = join(publish, 'static'); @@ -77,13 +69,25 @@ export default function ({ builder.copy(join(files, 'api'), apiDir); + const apiRuntime = (customStaticWebAppConfig.platform || {}).apiRuntime || nodeVersion; + const apiRuntimeParts = apiRuntime.match(/^node:(\d+)$/); + if (apiRuntimeParts === null) { + throw new Error( + `The configuration key platform.apiRuntime, if included, must be a supported Node version such as 'node:16'. It is currently '${apiRuntime}'.` + ); + } else if (parseInt(apiRuntimeParts[1]) < 16) { + throw new Error( + `The minimum node version supported by SvelteKit is 16, please change configuration key platform.runTime from '${apiRuntime}' to a supported version like 'node:16' or remove it entirely.` + ); + } + /** @type {BuildOptions} */ const default_options = { entryPoints: [entry], outfile: join(apiDir, 'index.js'), bundle: true, platform: 'node', - target: 'node16', + target: `node${apiRuntimeParts[1]}`, sourcemap: 'linked', external: esbuildOptions.external }; @@ -99,59 +103,190 @@ export default function ({ // If the root was not pre-rendered, add a placeholder index.html // Route all requests for the index to the SSR function writeFileSync(`${staticDir}/index.html`, ''); - swaConfig.routes.push( - { - route: '/index.html', - rewrite: ssrFunctionRoute - }, - { - route: '/', - rewrite: ssrFunctionRoute - } - ); } + const swaConfig = generateConfig(builder, customStaticWebAppConfig); + writeFileSync(`${publish}/staticwebapp.config.json`, JSON.stringify(swaConfig)); } }; + return adapter; } /** - * @param {import('./types/swa').CustomStaticWebAppConfig} customStaticWebAppConfig - * @param {string} appDir + * @param {import('@sveltejs/kit').Builder} builder A reference to the SvelteKit builder + * @param {import('./types/swa').StaticWebAppConfig} customStaticWebAppConfig Custom configuration * @returns {import('./types/swa').StaticWebAppConfig} */ -export function generateConfig(customStaticWebAppConfig, appDir) { - validateCustomConfig(customStaticWebAppConfig); - - if (!customStaticWebAppConfig.routes) { - customStaticWebAppConfig.routes = []; - } - +export function generateConfig(builder, customStaticWebAppConfig = {}) { + builder.log.minor(`Generating static web app config...`); /** @type {import('./types/swa').StaticWebAppConfig} */ const swaConfig = { ...customStaticWebAppConfig, - routes: [ - ...customStaticWebAppConfig.routes, - { - route: '*', - methods: ['POST', 'PUT', 'DELETE'], - rewrite: ssrFunctionRoute - }, - { - route: `/${appDir}/immutable/*`, - headers: { - 'cache-control': 'public, immutable, max-age=31536000' - } - } - ], navigationFallback: { - rewrite: ssrFunctionRoute + rewrite: ssrFunctionRoute, + ...customStaticWebAppConfig.navigationFallback }, platform: { - apiRuntime: 'node:16' - } + apiRuntime: nodeVersion, + ...customStaticWebAppConfig.platform + }, + routes: [] + }; + + if (swaConfig.navigationFallback.rewrite === ssrTrigger) { + swaConfig.navigationFallback.rewrite = ssrFunctionRoute; + } + + /** @type {Record} */ + let handledRoutes = { + '*': [], + '/': [], + '/index.html': [] + }; + /** @type {import('./types/swa').Route} */ + let wildcardRoute = { + route: '*' }; + for (const route of customStaticWebAppConfig.routes || []) { + if (route.route === undefined || !route.route.length) { + throw new Error( + 'A route pattern is required for each route. https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#routes' + ); + } + route.methods = route.methods || [...staticMethods, ...ssrMethods]; + if (handledRoutes[route.route] && handledRoutes[route.route].some((i) => methods.includes(i))) { + throw new Error( + 'There is a route that conflicts with another route. https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#routes' + ); + } + handledRoutes[route.route] = [...(handledRoutes[route.route] || []), ...route.methods]; + if (route.rewrite === ssrTrigger) { + route.rewrite = ssrFunctionRoute; + } + if (['/*', '*'].includes(route.route)) { + route.route = '*'; + wildcardRoute = route; + } + if (route.methods.length === staticMethods.length + ssrMethods.length) { + // Either method isn't specified in this route, or all methods are. + if (['/index.html', '/'].includes(route.route) && !builder.prerendered.paths.includes('/')) { + // The root route must be fully SSR because it was not rendered. No need to split the route. + swaConfig.routes.push({ + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, + ...route + }); + } else { + // This route catches all methods, but we don't want to force SSR for all methods, so will split the rule. + swaConfig.routes.push({ + ...route, + methods: staticMethods + }); + swaConfig.routes.push({ + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, + ...route, + methods: ssrMethods + }); + } + } else if (route.methods.some((r) => ssrMethods.includes(r))) { + const routeSSRMethods = methods.filter((m) => ssrMethods.includes(m)); + if (routeSSRMethods.length === methods.length) { + // This route is only for SSR methods, so we'll rewrite the single rule. + swaConfig.routes.push({ + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, + ...route + }); + } else { + if ( + ['/index.html', '/'].includes(route.route) && + !builder.prerendered.paths.includes('/') + ) { + // This special route must be SSR because it was not pre-rendered. + swaConfig.routes.push({ + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, + ...route + }); + } else { + // This route is for some methods that must be SSR, but not all. We'll split it. + swaConfig.routes.push({ + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, + ...route, + methods: routeSSRMethods + }); + swaConfig.routes.push({ + ...route, + methods: methods.filter((m) => staticMethods.includes(m)) + }); + } + } + } else { + if (['/index.html', '/'].includes(route.route) && !builder.prerendered.paths.includes('/')) { + // This special route must be SSR because it was not pre-rendered. + swaConfig.routes.push({ + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, + ...route + }); + } else { + // None of the methods in this route must be SSR, so accept it as-is. + swaConfig.routes.push({ ...route }); + } + } + } + + // Make sure the wildcard is there for each SSR method. + const missingWildcardMethods = ssrMethods.filter( + (i) => !(wildcardRoute.methods || []).includes(i) + ); + if (missingWildcardMethods.length > 0) { + handledRoutes['*'] = missingWildcardMethods; + swaConfig.routes.push({ + rewrite: ssrFunctionRoute, + ...wildcardRoute, + methods: missingWildcardMethods + }); + } + + // Make sure the fallback rewrite matches the custom config or wildcard route, if present. + if ((customStaticWebAppConfig.navigationFallback || []).hasOwnProperty('rewrite')) { + swaConfig.navigationFallback.rewrite = customStaticWebAppConfig.navigationFallback.rewrite; + } else if (wildcardRoute.hasOwnProperty('rewrite')) { + swaConfig.navigationFallback.rewrite = wildcardRoute.rewrite; + } + + handledRoutes[`/${builder.config.kit.appDir}/immutable/*`] = [...staticMethods, ...ssrMethods]; + swaConfig.routes.push({ + ...wildcardRoute, + route: `/${builder.config.kit.appDir}/immutable/*`, + headers: { + 'cache-control': 'public, immutable, max-age=31536000' + }, + methods: undefined + }); + if (!builder.prerendered.paths.includes('/')) { + if (!staticMethods.every((i) => handledRoutes['/index.html'].includes(i))) { + swaConfig.routes.push({ + rewrite: wildcardRoute.redirect ? wildcardRoute.rewrite : ssrFunctionRoute, + ...wildcardRoute, + route: '/index.html', + methods: undefined + }); + } + if (!staticMethods.every((i) => handledRoutes['/'].includes(i))) { + swaConfig.routes.push({ + rewrite: wildcardRoute.redirect ? wildcardRoute.rewrite : ssrFunctionRoute, + ...wildcardRoute, + route: '/', + methods: undefined + }); + } + } + + if (swaConfig.navigationFallback.rewrite !== ssrFunctionRoute) { + builder.log.warn( + `Custom configuration has navigationFallback.rewrite set to a value other than '${ssrTrigger}'. SSR will fail unless a route specifies it.` + ); + } + return swaConfig; } diff --git a/test/index.test.js b/test/index.test.js index 9feb3023..05b1194a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -2,6 +2,7 @@ import { expect, describe, test, vi } from 'vitest'; import azureAdapter, { generateConfig } from '../index'; import { writeFileSync, existsSync } from 'fs'; import { jsonMatching, toMatchJSON } from './json'; +import esbuild from 'esbuild'; expect.extend({ jsonMatching, toMatchJSON }); @@ -17,9 +18,10 @@ vi.mock('esbuild', () => ({ })); describe('generateConfig', () => { - test('no custom config', () => { - const result = generateConfig({}, 'appDir'); - expect(result).toStrictEqual({ + test('no custom config with static root', () => { + const builder = getMockBuilder(); + const result = generateConfig(builder, {}); + expect(result).toEqual({ navigationFallback: { rewrite: '/api/__render' }, @@ -28,7 +30,7 @@ describe('generateConfig', () => { }, routes: [ { - methods: ['POST', 'PUT', 'DELETE'], + methods: ['CONNECT', 'DELETE', 'PATCH', 'POST', 'PUT', 'TRACE'], rewrite: '/api/__render', route: '*' }, @@ -42,21 +44,134 @@ describe('generateConfig', () => { }); }); - test('throws errors for invalid custom config', () => { - expect(() => generateConfig({ navigationFallback: {} })).toThrowError( - 'cannot override navigationFallback' - ); - expect(() => generateConfig({ routes: [{ route: '*' }] })).toThrowError( - "cannot override '*' route" - ); + test('no custom config without static root', () => { + const builder = getMockBuilder(); + builder.prerendered.paths = []; + const result = generateConfig(builder, {}); + expect(result).toEqual({ + navigationFallback: { + rewrite: '/api/__render' + }, + platform: { + apiRuntime: 'node:16' + }, + routes: [ + { + methods: ['CONNECT', 'DELETE', 'PATCH', 'POST', 'PUT', 'TRACE'], + rewrite: '/api/__render', + route: '*' + }, + { + headers: { + 'cache-control': 'public, immutable, max-age=31536000' + }, + route: '/appDir/immutable/*' + }, + { + rewrite: '/api/__render', + route: '/index.html' + }, + { + rewrite: '/api/__render', + route: '/' + } + ] + }); }); test('accepts custom config', () => { - const result = generateConfig({ + const builder = getMockBuilder(); + const result = generateConfig(builder, { globalHeaders: { 'X-Foo': 'bar' } }); expect(result.globalHeaders).toStrictEqual({ 'X-Foo': 'bar' }); }); + + test('allowedRoles in custom wildcard route spreads to all routes', () => { + const builder = getMockBuilder(); + const result = generateConfig(builder, { + routes: [ + { + route: '*', + allowedRoles: ['authenticated'] + } + ] + }); + expect(result.routes.every((r) => r.allowedRoles[0] === 'authenticated')).toBeTruthy(); + }); + + test('rewrite ssr in wildcard route forces SSR rewriting', () => { + const builder = getMockBuilder(); + const result = generateConfig(builder, { + routes: [ + { + route: '*', + rewrite: 'ssr' + } + ] + }); + expect(result.routes.every((r) => r.rewrite === '/api/__render')).toBeTruthy(); + }); + + test('rewrite undefined in wildcard route disables SSR rewriting and warns about it', () => { + const builder = getMockBuilder(); + const result = generateConfig(builder, { + routes: [ + { + route: '*', + rewrite: undefined + } + ] + }); + expect(result.routes.every((r) => r.rewrite === undefined)).toBeTruthy(); + expect(result.navigationFallback.rewrite).toBeUndefined(); + expect(builder.log.warn).toHaveBeenCalledOnce(); + }); + + test('exclude folder from SSR rewriting', () => { + const builder = getMockBuilder(); + const result = generateConfig(builder, { + navigationFallback: { + exclude: ['images/*.{png,jpg,gif}', '/css/*'] + } + }); + expect(result.navigationFallback).toEqual({ + rewrite: '/api/__render', + exclude: ['images/*.{png,jpg,gif}', '/css/*'] + }); + }); + + test('custom route does not accidentally override rewriting of SSR methods', () => { + const builder = getMockBuilder(); + const result = generateConfig(builder, { + routes: [ + { + route: '/api', + allowedRoles: ['authenticated'] + } + ] + }); + const apiRoutes = result.routes.filter((r) => r.route === '/api'); + expect(apiRoutes).toEqual([ + { + route: '/api', + allowedRoles: ['authenticated'], + methods: ['GET', 'HEAD', 'OPTIONS'] + }, + { + rewrite: '/api/__render', + route: '/api', + allowedRoles: ['authenticated'], + methods: ['CONNECT', 'DELETE', 'PATCH', 'POST', 'PUT', 'TRACE'] + } + ]); + }); + + test('setting navigationFallback.rewrite reports a warning', () => { + const builder = getMockBuilder(); + const result = generateConfig(builder, { navigationFallback: { rewrite: 'index.html' } }); + expect(builder.log.warn).toHaveBeenCalledOnce(); + }); }); describe('adapt', () => { @@ -76,6 +191,63 @@ describe('adapt', () => { await expect(adapter.adapt(builder)).rejects.toThrowError('You need to create a package.json'); }); + test('throws error for invalid platform.apiRuntime', async () => { + const adapter = azureAdapter({ + customStaticWebAppConfig: { + platform: { + apiRuntime: 'dotnet:3.1' + } + } + }); + const builder = getMockBuilder(); + await expect(adapter.adapt(builder)).rejects.toThrowError( + `The configuration key platform.apiRuntime, if included, must be a supported Node version such as 'node:16'. It is currently 'dotnet:3.1'.` + ); + }); + + test('throws error for invalid node version', async () => { + const adapter = azureAdapter({ + customStaticWebAppConfig: { + platform: { + apiRuntime: 'node:15' + } + } + }); + const builder = getMockBuilder(); + await expect(adapter.adapt(builder)).rejects.toThrowError( + `The minimum node version supported by SvelteKit is 16, please change configuration key platform.runTime from 'node:15' to a supported version like 'node:16' or remove it entirely.` + ); + }); + + test('changes target for valid node version', async () => { + vi.clearAllMocks(); + const adapter = azureAdapter({ + customStaticWebAppConfig: { + platform: { + apiRuntime: 'node:17' + } + } + }); + const builder = getMockBuilder(); + await adapter.adapt(builder); + + expect(esbuild.build).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'node17' + }) + ); + expect(writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('staticwebapp.config.json'), + expect.jsonMatching( + expect.objectContaining({ + platform: expect.objectContaining({ + apiRuntime: 'node:17' + }) + }) + ) + ); + }); + test('adds index.html when root not prerendered', async () => { const adapter = azureAdapter(); const builder = getMockBuilder(); @@ -108,11 +280,12 @@ function getMockBuilder() { return { config: { kit: { - appDir: '/app' + appDir: 'appDir' } }, log: { - minor: vi.fn() + minor: vi.fn(), + warn: vi.fn() }, prerendered: { paths: ['/'] diff --git a/types/swa.d.ts b/types/swa.d.ts index a446ba7a..5c6c5c10 100644 --- a/types/swa.d.ts +++ b/types/swa.d.ts @@ -8,8 +8,6 @@ export interface StaticWebAppConfig { platform?: Platform; } -export type CustomStaticWebAppConfig = Omit; - export interface Route { route: string; methods?: HttpMethod[];