Skip to content

Commit

Permalink
feat: support redirect, headers, and cors route rules (#538)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored Oct 11, 2022
1 parent 4e0ee44 commit 1026edb
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 20 deletions.
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
73 changes: 61 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,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)
}
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,
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) || {}
// 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

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?: 301 | 302 | 307 | 308 }
headers?: Record<string, string>
cors?: boolean
}

export interface NitroRoutesOptions {
Expand Down
61 changes: 61 additions & 0 deletions test/presets/netlify.test.ts
Original file line number Diff line number Diff line change
@@ -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<APIGatewayEvent> = {
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 */
})
})
88 changes: 87 additions & 1 deletion test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 () => {
Expand Down
Loading

0 comments on commit 1026edb

Please sign in to comment.