From 940f2684f4204f392ae5dbe74aaaa10423c1c75f Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Fri, 23 Sep 2022 16:41:24 -0400 Subject: [PATCH 01/14] allow wildcard routes --- README.md | 8 ++- index.js | 207 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 161 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 4a9e2ef..6084ea9 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.js b/index.js index 9ed2239..d90077c 100644 --- a/index.js +++ b/index.js @@ -8,21 +8,12 @@ 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'; /** @type {import('.').default} */ export default function ({ @@ -34,36 +25,31 @@ export default function ({ name: 'adapter-azure-swa', async adapt(builder) { - validateCustomConfig(customStaticWebAppConfig); + /** @type {import('./types/swa').StaticWebAppConfig} */ + const swaConfig = JSON.parse(JSON.stringify(customStaticWebAppConfig)); + swaConfig.navigationFallback = { + rewrite: ssrFunctionRoute, + ...swaConfig.navigationFallback + }; + swaConfig.platform = { + apiRuntime: 'node:16', + ...swaConfig.platform + }; + swaConfig.routes = []; - if (!customStaticWebAppConfig.routes) { - customStaticWebAppConfig.routes = []; + if (swaConfig.navigationFallback.rewrite === ssrTrigger) { + swaConfig.navigationFallback.rewrite = ssrFunctionRoute; } - /** @type {import('./types/swa').StaticWebAppConfig} */ - const swaConfig = { - ...customStaticWebAppConfig, - routes: [ - ...customStaticWebAppConfig.routes, - { - route: '*', - methods: ['POST', 'PUT', 'DELETE'], - rewrite: ssrFunctionRoute - }, - { - route: `/${builder.config.kit.appDir}/immutable/*`, - headers: { - 'cache-control': 'public, immutable, max-age=31536000' - } - } - ], - navigationFallback: { - rewrite: ssrFunctionRoute - }, - platform: { - apiRuntime: 'node:16' - } - }; + if (swaConfig.navigationFallback.rewrite !== ssrFunctionRoute) { + builder.log.warn( + `Setting navigationFallback.rewrite to a value other than '${ssrTrigger}' will prevent SSR!` + ); + } + + if (!swaConfig.platform.apiRuntime.startsWith('node:')) { + throw new Error('The platform.apiRuntime must be set to a node version, such as node:16'); + } if (!existsSync(join('api', 'package.json'))) { throw new Error( @@ -112,7 +98,7 @@ export default function ({ outfile: join(apiDir, 'index.js'), bundle: true, platform: 'node', - target: 'node16', + target: `${swaConfig.platform.apiRuntime.replace(':', '')}`, external: esbuildOptions.external }; @@ -122,21 +108,136 @@ export default function ({ builder.writeClient(staticDir); builder.writePrerendered(staticDir); + if (!customStaticWebAppConfig.routes) { + customStaticWebAppConfig.routes = []; + } + + /** @type {Record} */ + let handledRoutes = {}; + /** @type {import('./types/swa').Route} */ + let wildcardRoute = {}; + 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' + ); + } + handledRoutes[route.route] = true; + const methods = route.methods || []; + if (route.rewrite === ssrTrigger) { + route.rewrite = ssrFunctionRoute; + } + if (['/*', '*'].includes(route.route)) { + wildcardRoute = route; + } + if (methods.length === 0) { + 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: 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: 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: 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: ssrFunctionRoute, + ...route + }); + } else { + // This route is for some methods that must be SSR, but not all. We'll split it. + swaConfig.routes.push({ + 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: ssrFunctionRoute, + ...route + }); + } else { + // None of the methods in this route must be SSR, so accept it as-is. + swaConfig.routes.push({ ...route }); + } + } + } + + // Now that the specified rules have been processed, let's make sure the wildcard for SSR is there. + if (!handledRoutes['*'] && !handledRoutes['/*']) { + handledRoutes['*']; + swaConfig.routes.push({ + route: '*', + methods: ssrMethods, + rewrite: ssrFunctionRoute + }); + } + + handledRoutes[`/${builder.config.kit.appDir}/immutable/*`] = true; + swaConfig.routes.push({ + ...wildcardRoute, + route: `/${builder.config.kit.appDir}/immutable/*`, + headers: { + 'cache-control': 'public, immutable, max-age=31536000' + } + }); + if (!builder.prerendered.paths.includes('/')) { // Azure SWA requires an index.html to be present // 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 - } - ); + if (!handledRoutes['/index.html']) { + swaConfig.routes.push({ + rewrite: ssrFunctionRoute, + ...wildcardRoute, + route: '/index.html' + }); + } + if (!handledRoutes['/']) { + swaConfig.routes.push({ + rewrite: ssrFunctionRoute, + ...wildcardRoute, + route: '/' + }); + } } writeFileSync(`${publish}/staticwebapp.config.json`, JSON.stringify(swaConfig)); From 86f658ab403fc599b32385e3cf6141fb7022eef4 Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Fri, 23 Sep 2022 17:08:21 -0400 Subject: [PATCH 02/14] Ignore `methods` of wildcard when creating routes --- index.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index d90077c..688cd06 100644 --- a/index.js +++ b/index.js @@ -216,7 +216,8 @@ export default function ({ route: `/${builder.config.kit.appDir}/immutable/*`, headers: { 'cache-control': 'public, immutable, max-age=31536000' - } + }, + methods: undefined }); if (!builder.prerendered.paths.includes('/')) { @@ -228,14 +229,16 @@ export default function ({ swaConfig.routes.push({ rewrite: ssrFunctionRoute, ...wildcardRoute, - route: '/index.html' + route: '/index.html', + methods: undefined }); } if (!handledRoutes['/']) { swaConfig.routes.push({ rewrite: ssrFunctionRoute, ...wildcardRoute, - route: '/' + route: '/', + methods: undefined }); } } From fb497fc6eaf8e929e1de5d897648f3045a961b84 Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Fri, 23 Sep 2022 18:52:16 -0400 Subject: [PATCH 03/14] Track methods of routes to avoid conflicts --- index.js | 53 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/index.js b/index.js index 688cd06..f55e992 100644 --- a/index.js +++ b/index.js @@ -9,9 +9,9 @@ import esbuild from 'esbuild'; const ssrFunctionRoute = '/api/__render'; // These are the methods that don't have to be SSR if they were pre-rendered. -const staticMethods = ['GET','HEAD','OPTIONS']; +const staticMethods = ['GET', 'HEAD', 'OPTIONS']; // These are the methods that must always be SSR. -const ssrMethods = ['CONNECT','DELETE','PATCH','POST','PUT','TRACE']; +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'; @@ -112,8 +112,13 @@ export default function ({ customStaticWebAppConfig.routes = []; } - /** @type {Record} */ - let handledRoutes = {}; + /** @type {Record} */ + let handledRoutes = { + '*': [], + '/': [], + '/*': [], + '/index.html': [] + }; /** @type {import('./types/swa').Route} */ let wildcardRoute = {}; for (const route of customStaticWebAppConfig.routes) { @@ -122,15 +127,24 @@ export default function ({ 'A route pattern is required for each route. https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#routes' ); } - handledRoutes[route.route] = true; - const methods = route.methods || []; + const 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] || []), ...methods]; if (route.rewrite === ssrTrigger) { route.rewrite = ssrFunctionRoute; } if (['/*', '*'].includes(route.route)) { wildcardRoute = route; } - if (methods.length === 0) { + if (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('/') @@ -200,17 +214,24 @@ export default function ({ } } - // Now that the specified rules have been processed, let's make sure the wildcard for SSR is there. - if (!handledRoutes['*'] && !handledRoutes['/*']) { - handledRoutes['*']; + // Now that the specified rules have been processed, let's make sure the wildcard is there for each SSR method. + const wildcardMethods = handledRoutes['*'].concat( + handledRoutes['/*'].filter((i) => handledRoutes['*'].indexOf(i) < 0) + ); + const missingWildcardMethods = ssrMethods.filter((i) => !wildcardMethods.includes(i)); + if (missingWildcardMethods.length > 0) { + handledRoutes['*'] = missingWildcardMethods; swaConfig.routes.push({ - route: '*', - methods: ssrMethods, - rewrite: ssrFunctionRoute + rewrite: ssrFunctionRoute, + ...wildcardRoute, + methods: missingWildcardMethods }); } - handledRoutes[`/${builder.config.kit.appDir}/immutable/*`] = true; + handledRoutes[`/${builder.config.kit.appDir}/immutable/*`] = [ + ...staticMethods, + ...ssrMethods + ]; swaConfig.routes.push({ ...wildcardRoute, route: `/${builder.config.kit.appDir}/immutable/*`, @@ -225,7 +246,7 @@ 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`, ''); - if (!handledRoutes['/index.html']) { + if (!staticMethods.every((i) => handledRoutes['/index.html'].includes(i))) { swaConfig.routes.push({ rewrite: ssrFunctionRoute, ...wildcardRoute, @@ -233,7 +254,7 @@ export default function ({ methods: undefined }); } - if (!handledRoutes['/']) { + if (!staticMethods.every((i) => handledRoutes['/'].includes(i))) { swaConfig.routes.push({ rewrite: ssrFunctionRoute, ...wildcardRoute, From 5bc0d142ef320d8b86d8966123ff4c8f678b8274 Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Fri, 23 Sep 2022 19:00:57 -0400 Subject: [PATCH 04/14] Allow navigationFallback so exclude can be set --- types/swa.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/swa.d.ts b/types/swa.d.ts index a446ba7..b042b79 100644 --- a/types/swa.d.ts +++ b/types/swa.d.ts @@ -8,7 +8,7 @@ export interface StaticWebAppConfig { platform?: Platform; } -export type CustomStaticWebAppConfig = Omit; +export type CustomStaticWebAppConfig = StaticWebAppConfig; export interface Route { route: string; From 6634aa75b25c58f679b01419406c6c634139abcb Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Sat, 24 Sep 2022 09:57:40 -0400 Subject: [PATCH 05/14] Combine catch-all wildcards into one --- index.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index f55e992..e394584 100644 --- a/index.js +++ b/index.js @@ -114,13 +114,14 @@ export default function ({ /** @type {Record} */ let handledRoutes = { - '*': [], '/': [], '/*': [], '/index.html': [] }; /** @type {import('./types/swa').Route} */ - let wildcardRoute = {}; + let wildcardRoute = { + route: '/*' + }; for (const route of customStaticWebAppConfig.routes) { if (route.route === undefined || !route.route.length) { throw new Error( @@ -141,6 +142,7 @@ export default function ({ route.rewrite = ssrFunctionRoute; } if (['/*', '*'].includes(route.route)) { + route.route = '/*'; wildcardRoute = route; } if (methods.length === staticMethods.length + ssrMethods.length) { @@ -215,12 +217,11 @@ export default function ({ } // Now that the specified rules have been processed, let's make sure the wildcard is there for each SSR method. - const wildcardMethods = handledRoutes['*'].concat( - handledRoutes['/*'].filter((i) => handledRoutes['*'].indexOf(i) < 0) + const missingWildcardMethods = ssrMethods.filter( + (i) => !(wildcardRoute.methods || []).includes(i) ); - const missingWildcardMethods = ssrMethods.filter((i) => !wildcardMethods.includes(i)); if (missingWildcardMethods.length > 0) { - handledRoutes['*'] = missingWildcardMethods; + handledRoutes['/*'] = missingWildcardMethods; swaConfig.routes.push({ rewrite: ssrFunctionRoute, ...wildcardRoute, From e5ba666e01ce4075b54e4d840af45aaf53abdfc2 Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Sat, 24 Sep 2022 10:31:25 -0400 Subject: [PATCH 06/14] don't set rewrite when redirect is set --- index.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index e394584..c880f75 100644 --- a/index.js +++ b/index.js @@ -153,7 +153,7 @@ export default function ({ ) { // The root route must be fully SSR because it was not rendered. No need to split the route. swaConfig.routes.push({ - rewrite: ssrFunctionRoute, + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, ...route }); } else { @@ -163,7 +163,7 @@ export default function ({ methods: staticMethods }); swaConfig.routes.push({ - rewrite: ssrFunctionRoute, + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, ...route, methods: ssrMethods }); @@ -173,7 +173,7 @@ export default function ({ if (routeSSRMethods.length === methods.length) { // This route is only for SSR methods, so we'll rewrite the single rule. swaConfig.routes.push({ - rewrite: ssrFunctionRoute, + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, ...route }); } else { @@ -183,13 +183,13 @@ export default function ({ ) { // This special route must be SSR because it was not pre-rendered. swaConfig.routes.push({ - rewrite: ssrFunctionRoute, + 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: ssrFunctionRoute, + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, ...route, methods: routeSSRMethods }); @@ -206,7 +206,7 @@ export default function ({ ) { // This special route must be SSR because it was not pre-rendered. swaConfig.routes.push({ - rewrite: ssrFunctionRoute, + rewrite: route.redirect ? route.rewrite : ssrFunctionRoute, ...route }); } else { @@ -249,7 +249,7 @@ export default function ({ writeFileSync(`${staticDir}/index.html`, ''); if (!staticMethods.every((i) => handledRoutes['/index.html'].includes(i))) { swaConfig.routes.push({ - rewrite: ssrFunctionRoute, + rewrite: wildcardRoute.redirect ? wildcardRoute.rewrite : ssrFunctionRoute, ...wildcardRoute, route: '/index.html', methods: undefined @@ -257,7 +257,7 @@ export default function ({ } if (!staticMethods.every((i) => handledRoutes['/'].includes(i))) { swaConfig.routes.push({ - rewrite: ssrFunctionRoute, + rewrite: wildcardRoute.redirect ? wildcardRoute.rewrite : ssrFunctionRoute, ...wildcardRoute, route: '/', methods: undefined From db15751b4f57e80ea9af6050da9253fef16e1d2c Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Mon, 26 Sep 2022 10:03:12 -0400 Subject: [PATCH 07/14] write new tests --- test/index.test.js | 124 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 11 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index 9feb302..3726ad1 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -17,9 +17,9 @@ vi.mock('esbuild', () => ({ })); describe('generateConfig', () => { - test('no custom config', () => { - const result = generateConfig({}, 'appDir'); - expect(result).toStrictEqual({ + test('no custom config with static root', () => { + const result = generateConfig({}, 'appDir', false); + expect(result).toEqual({ navigationFallback: { rewrite: '/api/__render' }, @@ -28,7 +28,7 @@ describe('generateConfig', () => { }, routes: [ { - methods: ['POST', 'PUT', 'DELETE'], + methods: ['CONNECT', 'DELETE', 'PATCH', 'POST', 'PUT', 'TRACE'], rewrite: '/api/__render', route: '*' }, @@ -42,13 +42,37 @@ 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 result = generateConfig({}, 'appDir', true); + 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', () => { @@ -57,6 +81,84 @@ describe('generateConfig', () => { }); expect(result.globalHeaders).toStrictEqual({ 'X-Foo': 'bar' }); }); + + test('allowedRoles in custom wildcard route spreads to all routes', () => { + const result = generateConfig( + { + routes: [ + { + route: '*', + allowedRoles: ['authenticated'] + } + ] + }, + 'appDir', + true + ); + expect(result.routes.every((r) => r.allowedRoles[0] === 'authenticated')).toBeTruthy(); + }); + + test('rewrite ssr in wildcard route forces SSR rewriting', () => { + const result = generateConfig( + { + routes: [ + { + route: '*', + rewrite: 'ssr' + } + ] + }, + 'appDir', + true + ); + expect(result.routes.every((r) => r.rewrite === '/api/__render')).toBeTruthy(); + }); + + test('rewrite undefined in wildcard route disables SSR rewriting', () => { + const result = generateConfig( + { + routes: [ + { + route: '*', + rewrite: undefined + } + ] + }, + 'appDir', + true + ); + expect(result.routes.every((r) => r.rewrite === undefined)).toBeTruthy(); + expect(result.navigationFallback.rewrite).toBeUndefined(); + }); + + test('custom route does not accidentally override rewriting of SSR methods', () => { + const result = generateConfig( + { + routes: [ + { + route: '/api', + allowedRoles: ['authenticated'] + } + ] + }, + 'appDir', + true + ); + 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'] + } + ]); + }); }); describe('adapt', () => { From 9c3fd8e54c4de166c6d654354463f1c8e8dddaae Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Mon, 26 Sep 2022 10:32:21 -0400 Subject: [PATCH 08/14] test navigationFallback.exclude --- test/index.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/index.test.js b/test/index.test.js index 3726ad1..d49dd8a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -131,6 +131,22 @@ describe('generateConfig', () => { expect(result.navigationFallback.rewrite).toBeUndefined(); }); + test('exclude folder from SSR rewriting', () => { + const result = generateConfig( + { + navigationFallback: { + exclude: ['images/*.{png,jpg,gif}', '/css/*'] + } + }, + 'appDir', + true + ); + 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 result = generateConfig( { From 4209506d28b73c7f99c9dc8ec914476faaa5efbd Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Tue, 27 Sep 2022 11:48:16 -0400 Subject: [PATCH 09/14] Remove CustomStaticWebAppConfig --- index.d.ts | 4 ++-- index.js | 2 +- types/swa.d.ts | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index ab0bc77..df98f78 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 59ce936..d933ecd 100644 --- a/index.js +++ b/index.js @@ -104,7 +104,7 @@ export default function ({ } /** - * @param {import('./types/swa').CustomStaticWebAppConfig} customStaticWebAppConfig Custom configuration + * @param {import('./types/swa').StaticWebAppConfig} customStaticWebAppConfig Custom configuration * @param {string} appDir Path of App directory * @param {boolean} ssrRoot True if path '/' was not pre-rendered * @returns {import('./types/swa').StaticWebAppConfig} diff --git a/types/swa.d.ts b/types/swa.d.ts index b042b79..5c6c5c1 100644 --- a/types/swa.d.ts +++ b/types/swa.d.ts @@ -8,8 +8,6 @@ export interface StaticWebAppConfig { platform?: Platform; } -export type CustomStaticWebAppConfig = StaticWebAppConfig; - export interface Route { route: string; methods?: HttpMethod[]; From 73267d450e7b85a421dcd7ccea5b9bd87021e6ce Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Tue, 27 Sep 2022 11:50:22 -0400 Subject: [PATCH 10/14] Add type hint for adapter --- index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index d933ecd..0d68612 100644 --- a/index.js +++ b/index.js @@ -21,7 +21,8 @@ export default function ({ customStaticWebAppConfig = {}, esbuildOptions = {} } = {}) { - return { + /** @type {import('@sveltejs/kit').Adapter} */ + const adapter = { name: 'adapter-azure-swa', async adapt(builder) { @@ -101,6 +102,7 @@ export default function ({ writeFileSync(`${publish}/staticwebapp.config.json`, JSON.stringify(swaConfig)); } }; + return adapter; } /** From 442ea5e7d5d70268ff77e176aab31a9441efa7ee Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Tue, 27 Sep 2022 11:51:04 -0400 Subject: [PATCH 11/14] Change `generateConfig` signature and fix bug --- index.js | 37 +++++--------- test/index.test.js | 124 ++++++++++++++++++++++----------------------- 2 files changed, 75 insertions(+), 86 deletions(-) diff --git a/index.js b/index.js index 0d68612..0268126 100644 --- a/index.js +++ b/index.js @@ -83,21 +83,14 @@ export default function ({ builder.writeClient(staticDir); builder.writePrerendered(staticDir); - let ssrRoot = false; if (!builder.prerendered.paths.includes('/')) { // Azure SWA requires an index.html to be present // 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`, ''); - - ssrRoot = true; } - const swaConfig = generateConfig( - customStaticWebAppConfig, - builder.config.kit.appDir, - ssrRoot - ); + const swaConfig = generateConfig(builder, customStaticWebAppConfig); writeFileSync(`${publish}/staticwebapp.config.json`, JSON.stringify(swaConfig)); } @@ -106,15 +99,12 @@ export default function ({ } /** + * @param {import('@sveltejs/kit').Builder} builder A reference to the SvelteKit builder * @param {import('./types/swa').StaticWebAppConfig} customStaticWebAppConfig Custom configuration - * @param {string} appDir Path of App directory - * @param {boolean} ssrRoot True if path '/' was not pre-rendered * @returns {import('./types/swa').StaticWebAppConfig} */ -export function generateConfig(customStaticWebAppConfig, appDir, ssrRoot) { - - customStaticWebAppConfig = customStaticWebAppConfig || []; - +export function generateConfig(builder, customStaticWebAppConfig = {}) { + builder.log.minor(`Generating Configuration (staticwebapp.config.json)...`); /** @type {import('./types/swa').StaticWebAppConfig} */ const swaConfig = { ...customStaticWebAppConfig, @@ -133,11 +123,6 @@ export function generateConfig(customStaticWebAppConfig, appDir, ssrRoot) { swaConfig.navigationFallback.rewrite = ssrFunctionRoute; } - if (swaConfig.navigationFallback.rewrite !== ssrFunctionRoute) { - builder.log.warn( - `Setting navigationFallback.rewrite to a value other than '${ssrTrigger}' will prevent SSR!` - ); - } /** @type {Record} */ let handledRoutes = { '*': [], @@ -221,7 +206,7 @@ export function generateConfig(customStaticWebAppConfig, appDir, ssrRoot) { } } } else { - if (['/index.html', '/'].includes(route.route) && ssrRoot) { + 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, @@ -254,16 +239,16 @@ export function generateConfig(customStaticWebAppConfig, appDir, ssrRoot) { swaConfig.navigationFallback.rewrite = wildcardRoute.rewrite; } - handledRoutes[`/${appDir}/immutable/*`] = [...staticMethods, ...ssrMethods]; + handledRoutes[`/${builder.config.kit.appDir}/immutable/*`] = [...staticMethods, ...ssrMethods]; swaConfig.routes.push({ ...wildcardRoute, - route: `/${appDir}/immutable/*`, + route: `/${builder.config.kit.appDir}/immutable/*`, headers: { 'cache-control': 'public, immutable, max-age=31536000' }, methods: undefined }); - if (ssrRoot) { + if (!builder.prerendered.paths.includes('/')) { if (!staticMethods.every((i) => handledRoutes['/index.html'].includes(i))) { swaConfig.routes.push({ rewrite: wildcardRoute.redirect ? wildcardRoute.rewrite : ssrFunctionRoute, @@ -282,5 +267,11 @@ export function generateConfig(customStaticWebAppConfig, appDir, ssrRoot) { } } + 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 d49dd8a..cd737f8 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 { build } from 'esbuild'; expect.extend({ jsonMatching, toMatchJSON }); @@ -18,7 +19,8 @@ vi.mock('esbuild', () => ({ describe('generateConfig', () => { test('no custom config with static root', () => { - const result = generateConfig({}, 'appDir', false); + const builder = getMockBuilder(); + const result = generateConfig(builder, {}); expect(result).toEqual({ navigationFallback: { rewrite: '/api/__render' @@ -43,7 +45,9 @@ describe('generateConfig', () => { }); test('no custom config without static root', () => { - const result = generateConfig({}, 'appDir', true); + const builder = getMockBuilder(); + builder.prerendered.paths = []; + const result = generateConfig(builder, {}); expect(result).toEqual({ navigationFallback: { rewrite: '/api/__render' @@ -76,71 +80,61 @@ describe('generateConfig', () => { }); 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 result = generateConfig( - { - routes: [ - { - route: '*', - allowedRoles: ['authenticated'] - } - ] - }, - 'appDir', - true - ); + 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 result = generateConfig( - { - routes: [ - { - route: '*', - rewrite: 'ssr' - } - ] - }, - 'appDir', - true - ); + 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', () => { - const result = generateConfig( - { - routes: [ - { - route: '*', - rewrite: undefined - } - ] - }, - 'appDir', - true - ); + 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 result = generateConfig( - { - navigationFallback: { - exclude: ['images/*.{png,jpg,gif}', '/css/*'] - } - }, - 'appDir', - true - ); + 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/*'] @@ -148,18 +142,15 @@ describe('generateConfig', () => { }); test('custom route does not accidentally override rewriting of SSR methods', () => { - const result = generateConfig( - { - routes: [ - { - route: '/api', - allowedRoles: ['authenticated'] - } - ] - }, - 'appDir', - true - ); + const builder = getMockBuilder(); + const result = generateConfig(builder, { + routes: [ + { + route: '/api', + allowedRoles: ['authenticated'] + } + ] + }); const apiRoutes = result.routes.filter((r) => r.route === '/api'); expect(apiRoutes).toEqual([ { @@ -175,6 +166,12 @@ describe('generateConfig', () => { } ]); }); + + 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', () => { @@ -226,11 +223,12 @@ function getMockBuilder() { return { config: { kit: { - appDir: '/app' + appDir: 'appDir' } }, log: { - minor: vi.fn() + minor: vi.fn(), + warn: vi.fn() }, prerendered: { paths: ['/'] From 072b5dcd4ad471593f13cd3518addca9cd57a1d1 Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Tue, 27 Sep 2022 14:33:40 -0400 Subject: [PATCH 12/14] Allow customization of node version --- index.js | 16 +++++++++++++--- test/index.test.js | 45 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 0268126..ae6b503 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,8 @@ const staticMethods = ['GET', 'HEAD', 'OPTIONS']; 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 ({ @@ -67,13 +69,21 @@ export default function ({ builder.copy(join(files, 'api'), apiDir); + const apiRuntime = (customStaticWebAppConfig.platform || {}).apiRuntime || nodeVersion; + const apiRuntimeParts = apiRuntime.match(/^node:(\d\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}'.` + ); + } + /** @type {BuildOptions} */ const default_options = { entryPoints: [entry], outfile: join(apiDir, 'index.js'), bundle: true, platform: 'node', - target: 'node16', + target: `node${apiRuntimeParts[1]}`, external: esbuildOptions.external }; @@ -113,8 +123,8 @@ export function generateConfig(builder, customStaticWebAppConfig = {}) { ...customStaticWebAppConfig.navigationFallback }, platform: { - ...customStaticWebAppConfig.platform, - apiRuntime: 'node:16' + apiRuntime: nodeVersion, + ...customStaticWebAppConfig.platform }, routes: [] }; diff --git a/test/index.test.js b/test/index.test.js index cd737f8..30a24a0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -2,7 +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 { build } from 'esbuild'; +import esbuild from 'esbuild'; expect.extend({ jsonMatching, toMatchJSON }); @@ -191,6 +191,49 @@ 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('changes target for valid platform.apiRuntime', async () => { + vi.clearAllMocks(); + const adapter = azureAdapter({ + customStaticWebAppConfig: { + platform: { + apiRuntime: 'node:14' + } + } + }); + const builder = getMockBuilder(); + await adapter.adapt(builder); + + expect(esbuild.build).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'node14' + }) + ); + expect(writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('staticwebapp.config.json'), + expect.jsonMatching( + expect.objectContaining({ + platform: expect.objectContaining({ + apiRuntime: 'node:14' + }) + }) + ) + ); + }); + test('adds index.html when root not prerendered', async () => { const adapter = azureAdapter(); const builder = getMockBuilder(); From 93d69088e9b6c69d1da49f5dfa44f7f6c2bc54bb Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Wed, 5 Oct 2022 14:22:43 -0400 Subject: [PATCH 13/14] Update index.js Co-authored-by: Geoff Rich <4992896+geoffrich@users.noreply.github.com> --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index ae6b503..6ed5a81 100644 --- a/index.js +++ b/index.js @@ -114,7 +114,7 @@ export default function ({ * @returns {import('./types/swa').StaticWebAppConfig} */ export function generateConfig(builder, customStaticWebAppConfig = {}) { - builder.log.minor(`Generating Configuration (staticwebapp.config.json)...`); + builder.log.minor(`Generating static web app config...`); /** @type {import('./types/swa').StaticWebAppConfig} */ const swaConfig = { ...customStaticWebAppConfig, From 06ae5bf9ca8c8ea96de0a1e03548ad57a8ff6e54 Mon Sep 17 00:00:00 2001 From: Jon Musselwhite <35066367+FlippingBinary@users.noreply.github.com> Date: Tue, 11 Oct 2022 11:13:37 -0400 Subject: [PATCH 14/14] feat: throw error on node version less than 16 --- index.js | 8 ++++++-- test/index.test.js | 22 ++++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 17f417a..03dcb0e 100644 --- a/index.js +++ b/index.js @@ -70,11 +70,15 @@ export default function ({ builder.copy(join(files, 'api'), apiDir); const apiRuntime = (customStaticWebAppConfig.platform || {}).apiRuntime || nodeVersion; - const apiRuntimeParts = apiRuntime.match(/^node:(\d\d)$/); + 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} */ @@ -84,7 +88,7 @@ export default function ({ bundle: true, platform: 'node', target: `node${apiRuntimeParts[1]}`, - sourcemap: 'linked', + sourcemap: 'linked', external: esbuildOptions.external }; diff --git a/test/index.test.js b/test/index.test.js index 30a24a0..05b1194 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -205,12 +205,26 @@ describe('adapt', () => { ); }); - test('changes target for valid platform.apiRuntime', async () => { + 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:14' + apiRuntime: 'node:17' } } }); @@ -219,7 +233,7 @@ describe('adapt', () => { expect(esbuild.build).toHaveBeenCalledWith( expect.objectContaining({ - target: 'node14' + target: 'node17' }) ); expect(writeFileSync).toHaveBeenCalledWith( @@ -227,7 +241,7 @@ describe('adapt', () => { expect.jsonMatching( expect.objectContaining({ platform: expect.objectContaining({ - apiRuntime: 'node:14' + apiRuntime: 'node:17' }) }) )