Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support redirect, headers, and cors route rules #538

Merged
merged 14 commits into from
Oct 11, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/content/3.config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
}
```
Expand Down
72 changes: 60 additions & 12 deletions src/presets/netlify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -85,3 +75,61 @@ 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
contents += `${key.replace('/**', '/*')}\t${redirect.to}\t${redirect.statusCode || 307}\n`
}

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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(related to cors refactor)

}
: {},
...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)
}
28 changes: 28 additions & 0 deletions src/presets/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaults to be moved into nitro

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)
Expand Down
22 changes: 21 additions & 1 deletion src/runtime/app.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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) || {}
pi0 marked this conversation as resolved.
Show resolved Hide resolved
// Share applicable route rules across handlers
event.context.routeOptions = routeOptions
pi0 marked this conversation as resolved.
Show resolved Hide resolved
if (routeOptions.cors) {
setHeaders(event, {
'access-control-allow-origin': '*',
'access-control-allowed-methods': '*',
'access-control-allow-headers': '*',
'access-control-max-age': '0'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might refactor this to h3 later and support cors options (rather than a boolean). Alternatively this could be compiled into headers for better cross platform support

})
}
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

Expand Down
4 changes: 3 additions & 1 deletion src/types/nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export interface NitroConfig extends DeepPartial<NitroOptions> {

export interface NitroRouteOption {
swr?: boolean | number
redirect?: string
redirect?: string | { to: string, statusCode?: 307 | 308 }
headers?: Record<string, string>
cors?: boolean
}

export interface NitroRoutesOptions {
Expand Down
42 changes: 38 additions & 4 deletions test/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface Context {
nitro?: Nitro,
rootDir: string
outDir: string
fetch: (url:string) => Promise<any>
fetch: (url: string) => Promise<any>
server?: Listener
}

Expand All @@ -26,14 +26,22 @@ 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 }
}
}
})
await prepare(nitro)
await copyPublicAssets(nitro)
Expand All @@ -57,7 +65,7 @@ export async function startServer (ctx, handle) {
console.log('>', ctx.server!.url)
}

type TestHandlerResult = { data: any, status: number, headers: Record<string, string>}
type TestHandlerResult = { data: any, status: number, headers: Record<string, string> }
type TestHandler = (options: any) => Promise<TestHandlerResult | Response>

export function testNitro (ctx: Context, getHandler: () => TestHandler | Promise<TestHandler>) {
Expand Down Expand Up @@ -96,6 +104,32 @@ 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 errors', async () => {
const { status } = await callHandler({
url: '/api/error',
Expand Down