From 1026edb79dd2d468040e8f1a661daed718bae0f2 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 11 Oct 2022 12:46:51 +0100 Subject: [PATCH] feat: support `redirect`, `headers`, and `cors` route rules (#538) --- docs/content/3.config/index.md | 5 +- src/presets/netlify.ts | 73 +++++++++++++++++++++++----- src/presets/vercel.ts | 28 +++++++++++ src/runtime/app.ts | 22 ++++++++- src/types/nitro.ts | 4 +- test/presets/netlify.test.ts | 61 +++++++++++++++++++++++ test/presets/vercel.test.ts | 88 +++++++++++++++++++++++++++++++++- test/tests.ts | 55 +++++++++++++++++++-- 8 files changed, 316 insertions(+), 20 deletions(-) create mode 100644 test/presets/netlify.test.ts diff --git a/docs/content/3.config/index.md b/docs/content/3.config/index.md index 16492bebaf..2ee22acd1f 100644 --- a/docs/content/3.config/index.md +++ b/docs/content/3.config/index.md @@ -173,7 +173,10 @@ Example: ```js { routes: { - '/blog/**': { swr: true } + '/blog/**': { swr: true }, + '/assets/**': { headers: { 'cache-control': 's-maxage=0' } }, + '/api/v1/**': { cors: true, headers: { 'access-control-allowed-methods': 'GET' } }, + '/old-page': { redirect: '/new-page' } } } ``` diff --git a/src/presets/netlify.ts b/src/presets/netlify.ts index a2d61431fe..fcfe2cdef3 100644 --- a/src/presets/netlify.ts +++ b/src/presets/netlify.ts @@ -17,18 +17,8 @@ export const netlify = defineNitroPreset({ }, hooks: { async 'compiled' (nitro: Nitro) { - const redirectsPath = join(nitro.options.output.publicDir, '_redirects') - let contents = '/* /.netlify/functions/server 200' - if (existsSync(redirectsPath)) { - const currentRedirects = await fsp.readFile(redirectsPath, 'utf-8') - if (currentRedirects.match(/^\/\* /m)) { - nitro.logger.info('Not adding Nitro fallback to `_redirects` (as an existing fallback was found).') - return - } - nitro.logger.info('Adding Nitro fallback to `_redirects` to handle all unmatched routes.') - contents = currentRedirects + '\n' + contents - } - await fsp.writeFile(redirectsPath, contents) + await writeHeaders(nitro) + await writeRedirects(nitro) const serverCJSPath = join(nitro.options.output.serverDir, 'server.js') const serverJSCode = ` @@ -85,3 +75,62 @@ export const netlifyEdge = defineNitroPreset({ } } }) + +async function writeRedirects (nitro: Nitro) { + const redirectsPath = join(nitro.options.output.publicDir, '_redirects') + let contents = '/* /.netlify/functions/server 200' + + for (const [key, value] of Object.entries(nitro.options.routes).filter(([_, value]) => value.redirect)) { + const redirect = typeof value.redirect === 'string' ? { to: value.redirect } : value.redirect + // TODO: update to 307 when netlify support 307/308 + contents = `${key.replace('/**', '/*')}\t${redirect.to}\t${redirect.statusCode || 301}\n` + contents + } + + if (existsSync(redirectsPath)) { + const currentRedirects = await fsp.readFile(redirectsPath, 'utf-8') + if (currentRedirects.match(/^\/\* /m)) { + nitro.logger.info('Not adding Nitro fallback to `_redirects` (as an existing fallback was found).') + return + } + nitro.logger.info('Adding Nitro fallback to `_redirects` to handle all unmatched routes.') + contents = currentRedirects + '\n' + contents + } + + await fsp.writeFile(redirectsPath, contents) +} + +async function writeHeaders (nitro: Nitro) { + const headersPath = join(nitro.options.output.publicDir, '_headers') + let contents = '' + + for (const [key, value] of Object.entries(nitro.options.routes).filter(([_, value]) => value.cors || value.headers)) { + const headers = [ + key.replace('/**', '/*'), + ...Object.entries({ + ...value.cors + ? { + 'access-control-allow-origin': '*', + 'access-control-allowed-methods': '*', + 'access-control-allow-headers': '*', + 'access-control-max-age': '0' + } + : {}, + ...value.headers || {} + }).map(([header, value]) => ` ${header}: ${value}`) + ].join('\n') + + contents += headers + '\n' + } + + if (existsSync(headersPath)) { + const currentHeaders = await fsp.readFile(headersPath, 'utf-8') + if (currentHeaders.match(/^\/\* /m)) { + nitro.logger.info('Not adding Nitro fallback to `_headers` (as an existing fallback was found).') + return + } + nitro.logger.info('Adding Nitro fallback to `_headers` to handle all unmatched routes.') + contents = currentHeaders + '\n' + contents + } + + await fsp.writeFile(headersPath, contents) +} diff --git a/src/presets/vercel.ts b/src/presets/vercel.ts index 11da92ea0c..6d58bca796 100644 --- a/src/presets/vercel.ts +++ b/src/presets/vercel.ts @@ -82,6 +82,34 @@ function generateBuildConfig (nitro: Nitro) { ) ), routes: [ + ...Object.entries(nitro.options.routes).filter(([_, value]) => value.redirect || value.headers || value.cors).map(([key, value]) => { + let route = { + src: key.replace('/**', '/.*') + } + if (value.redirect) { + const redirect = typeof value.redirect === 'string' ? { to: value.redirect } : value.redirect + route = defu(route, { + status: redirect.statusCode || 307, + headers: { Location: redirect.to } + }) + } + if (value.cors) { + route = defu(route, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allowed-methods': '*', + 'access-control-allow-headers': '*', + 'access-control-max-age': '0' + } + }) + } + if (value.headers) { + route = defu(route, { + headers: value.headers + }) + } + return route + }), ...nitro.options.publicAssets .filter(asset => !asset.fallthrough) .map(asset => asset.baseURL) diff --git a/src/runtime/app.ts b/src/runtime/app.ts index 1840c9a87b..8a53bec2fc 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -1,4 +1,4 @@ -import { App as H3App, createApp, createRouter, lazyEventHandler, Router } from 'h3' +import { App as H3App, createApp, createRouter, eventHandler, lazyEventHandler, Router, sendRedirect, setHeaders } from 'h3' import { createFetch, Headers } from 'ohmyfetch' import destr from 'destr' import { createRouter as createMatcher } from 'radix3' @@ -36,6 +36,26 @@ function createNitroApp (): NitroApp { const routerOptions = createMatcher({ routes: config.nitro.routes }) + h3App.use(eventHandler((event) => { + const routeOptions = routerOptions.lookup(event.req.url) || {} + // Share applicable route rules across handlers + event.context.routeOptions = routeOptions + if (routeOptions.cors) { + setHeaders(event, { + 'access-control-allow-origin': '*', + 'access-control-allowed-methods': '*', + 'access-control-allow-headers': '*', + 'access-control-max-age': '0' + }) + } + if (routeOptions.headers) { + setHeaders(event, routeOptions.headers) + } + if (routeOptions.redirect) { + return sendRedirect(event, routeOptions.redirect.to || routeOptions.redirect, routeOptions.redirect.statusCode || 307) + } + })) + for (const h of handlers) { let handler = h.lazy ? lazyEventHandler(h.handler) : h.handler diff --git a/src/types/nitro.ts b/src/types/nitro.ts index 3dceab9d9b..b28e9d473d 100644 --- a/src/types/nitro.ts +++ b/src/types/nitro.ts @@ -68,7 +68,9 @@ export interface NitroConfig extends DeepPartial { export interface NitroRouteOption { swr?: boolean | number - redirect?: string + redirect?: string | { to: string, statusCode?: 301 | 302 | 307 | 308 } + headers?: Record + cors?: boolean } export interface NitroRoutesOptions { diff --git a/test/presets/netlify.test.ts b/test/presets/netlify.test.ts new file mode 100644 index 0000000000..3470d1f95f --- /dev/null +++ b/test/presets/netlify.test.ts @@ -0,0 +1,61 @@ +import { promises as fsp } from 'fs' +import { resolve } from 'pathe' +import destr from 'destr' +import { describe, it, expect } from 'vitest' +import { Handler, APIGatewayEvent } from 'aws-lambda' +import { setupTest, testNitro } from '../tests' + +describe('nitro:preset:netlify', async () => { + const ctx = await setupTest('netlify') + testNitro(ctx, async () => { + const { handler } = await import(resolve(ctx.outDir, 'server/server.js')) as { handler: Handler } + return async ({ url: rawRelativeUrl, headers, method, body }) => { + // creating new URL object to parse query easier + const url = new URL(`https://example.com${rawRelativeUrl}`) + const queryStringParameters = Object.fromEntries(url.searchParams.entries()) + const event: Partial = { + resource: '/my/path', + path: url.pathname, + headers: headers || {}, + httpMethod: method || 'GET', + queryStringParameters, + body: body || '' + } + const res = await handler(event, {} as any, () => {}) + return { + data: destr(res.body), + status: res.statusCode, + headers: res.headers + } + } + }) + it('should add route rules - redirects', async () => { + const redirects = await fsp.readFile(resolve(ctx.rootDir, 'dist/_redirects'), 'utf-8') + /* eslint-disable no-tabs */ + expect(redirects).toMatchInlineSnapshot(` + "/rules/nested/override /other 301 + /rules/nested/* /base 301 + /rules/redirect/obj https://nitro.unjs.io/ 308 + /rules/redirect /base 301 + /* /.netlify/functions/server 200" + `) + /* eslint-enable no-tabs */ + }) + it('should add route rules - headers', async () => { + const headers = await fsp.readFile(resolve(ctx.rootDir, 'dist/_headers'), 'utf-8') + /* eslint-disable no-tabs */ + expect(headers).toMatchInlineSnapshot(` + "/rules/headers + cache-control: s-maxage=60 + /rules/cors + access-control-allow-origin: * + access-control-allowed-methods: GET + access-control-allow-headers: * + access-control-max-age: 0 + /rules/nested/* + x-test: test + " + `) + /* eslint-enable no-tabs */ + }) +}) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 3bafc10af1..bf88bc7e77 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -1,6 +1,6 @@ import { promises as fsp } from 'fs' import { resolve } from 'pathe' -import { describe } from 'vitest' +import { describe, it, expect } from 'vitest' import { EdgeRuntime } from 'edge-runtime' import { setupTest, startServer, testNitro } from '../tests' @@ -15,6 +15,92 @@ describe('nitro:preset:vercel', async () => { return res } }) + it('should add route rules to config', async () => { + const config = await fsp.readFile(resolve(ctx.outDir, 'config.json'), 'utf-8') + .then(r => JSON.parse(r)) + expect(config).toMatchInlineSnapshot(` + { + "overrides": { + "api/param/foo.json/index.html": { + "path": "api/param/foo.json", + }, + "api/param/prerender1/index.html": { + "path": "api/param/prerender1", + }, + "api/param/prerender2/index.html": { + "path": "api/param/prerender2", + }, + "api/param/prerender3/index.html": { + "path": "api/param/prerender3", + }, + "prerender/index.html": { + "path": "prerender", + }, + }, + "routes": [ + { + "headers": { + "cache-control": "s-maxage=60", + }, + "src": "/rules/headers", + }, + { + "headers": { + "access-control-allow-headers": "*", + "access-control-allow-origin": "*", + "access-control-allowed-methods": "*", + "access-control-max-age": "0", + }, + "src": "/rules/cors", + }, + { + "headers": { + "Location": "/base", + }, + "src": "/rules/redirect", + "status": 307, + }, + { + "headers": { + "Location": "https://nitro.unjs.io/", + }, + "src": "/rules/redirect/obj", + "status": 308, + }, + { + "headers": { + "Location": "/base", + "x-test": "test", + }, + "src": "/rules/nested/.*", + "status": 307, + }, + { + "headers": { + "Location": "/other", + }, + "src": "/rules/nested/override", + "status": 307, + }, + { + "continue": true, + "headers": { + "cache-control": "public,max-age=31536000,immutable", + }, + "src": "/build(.*)", + }, + { + "handle": "filesystem", + }, + { + "dest": "/__nitro", + "src": "/(.*)", + }, + ], + "version": 3, + } + `) + }) }) describe.skip('nitro:preset:vercel-edge', async () => { diff --git a/test/tests.ts b/test/tests.ts index ecd0172d65..6a2ad3b07c 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -15,7 +15,7 @@ interface Context { nitro?: Nitro, rootDir: string outDir: string - fetch: (url:string) => Promise + fetch: (url: string) => Promise server?: Listener } @@ -26,14 +26,24 @@ export async function setupTest (preset) { preset, rootDir: fixtureDir, outDir: resolve(fixtureDir, '.output', preset), - fetch: url => fetch(joinURL(ctx.server!.url, url.slice(1))) + fetch: url => fetch(joinURL(ctx.server!.url, url.slice(1)), { redirect: 'manual' }) } const nitro = ctx.nitro = await createNitro({ preset: ctx.preset, rootDir: ctx.rootDir, serveStatic: preset !== 'cloudflare' && preset !== 'vercel-edge', - output: { dir: ctx.outDir } + output: { dir: ctx.outDir }, + routes: { + '/rules/headers': { headers: { 'cache-control': 's-maxage=60' } }, + '/rules/cors': { cors: true, headers: { 'access-control-allowed-methods': 'GET' } }, + '/rules/redirect': { redirect: '/base' }, + '/rules/redirect/obj': { + redirect: { to: 'https://nitro.unjs.io/', statusCode: 308 } + }, + '/rules/nested/**': { redirect: '/base', headers: { 'x-test': 'test' } }, + '/rules/nested/override': { redirect: { to: '/other' } } + } }) await prepare(nitro) await copyPublicAssets(nitro) @@ -57,7 +67,7 @@ export async function startServer (ctx, handle) { console.log('>', ctx.server!.url) } -type TestHandlerResult = { data: any, status: number, headers: Record} +type TestHandlerResult = { data: any, status: number, headers: Record } type TestHandler = (options: any) => Promise export function testNitro (ctx: Context, getHandler: () => TestHandler | Promise) { @@ -96,6 +106,43 @@ export function testNitro (ctx: Context, getHandler: () => TestHandler | Promise expect(paramsData2).toBe('foo/bar/baz') }) + it('handles route rules - redirects', async () => { + const base = await callHandler({ url: '/rules/redirect' }) + expect(base.status).toBe(307) + expect(base.headers.location).toBe('/base') + + const obj = await callHandler({ url: '/rules/redirect/obj' }) + expect(obj.status).toBe(308) + expect(obj.headers.location).toBe('https://nitro.unjs.io/') + }) + + it('handles route rules - headers', async () => { + const { headers } = await callHandler({ url: '/rules/headers' }) + expect(headers['cache-control']).toBe('s-maxage=60') + }) + + it('handles route rules - cors', async () => { + const expectedHeaders = { + 'access-control-allow-origin': '*', + 'access-control-allowed-methods': 'GET', + 'access-control-allow-headers': '*', + 'access-control-max-age': '0' + } + const { headers } = await callHandler({ url: '/rules/cors' }) + expect(headers).toMatchObject(expectedHeaders) + }) + + it('handles route rules - allowing overriding', async () => { + const override = await callHandler({ url: '/rules/nested/override' }) + expect(override.headers.location).toBe('/other') + // TODO: allow merging? + // expect(override.headers['x-test']).toBe('test') + + const base = await callHandler({ url: '/rules/nested/base' }) + expect(base.headers.location).toBe('/base') + expect(base.headers['x-test']).toBe('test') + }) + it('handles errors', async () => { const { status } = await callHandler({ url: '/api/error',