Skip to content

Commit

Permalink
Add Zod and router state validation (#46962)
Browse files Browse the repository at this point in the history
This PR adds Zod to the precompiled libraries, and use it to create schemas for the router state tree for validation. In other planned features/changes, Zod will also be used to do run-time data validation.

Fixes NEXT-135.
  • Loading branch information
shuding authored Mar 10, 2023
1 parent 05f6de1 commit 10f2268
Show file tree
Hide file tree
Showing 12 changed files with 147 additions and 16 deletions.
3 changes: 2 additions & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,8 @@
"webpack": "5.74.0",
"webpack-sources1": "npm:[email protected]",
"webpack-sources3": "npm:[email protected]",
"ws": "8.2.3"
"ws": "8.2.3",
"zod": "3.21.4"
},
"resolutions": {
"browserslist": "4.20.2",
Expand Down
21 changes: 21 additions & 0 deletions packages/next/src/compiled/zod/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2020 Colin McDonnell

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1 change: 1 addition & 0 deletions packages/next/src/compiled/zod/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/next/src/compiled/zod/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"zod","main":"index.js","author":"Colin McDonnell <[email protected]>","license":"MIT"}
58 changes: 43 additions & 15 deletions packages/next/src/server/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-erro
import { patchFetch } from './lib/patch-fetch'
import { AppRenderSpan } from './lib/trace/constants'
import { getTracer } from './lib/trace/tracer'
import zod from 'next/dist/compiled/zod'

const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'

Expand Down Expand Up @@ -442,10 +443,14 @@ function createServerComponentRenderer(
}

type DynamicParamTypes = 'catchall' | 'optional-catchall' | 'dynamic'
// c = catchall
// oc = optional catchall
// d = dynamic
export type DynamicParamTypesShort = 'c' | 'oc' | 'd'

const dynamicParamTypesSchema = zod.enum(['c', 'oc', 'd'])
/**
* c = catchall
* oc = optional catchall
* d = dynamic
*/
export type DynamicParamTypesShort = zod.infer<typeof dynamicParamTypesSchema>

/**
* Shorten the dynamic param in order to make it smaller when transmitted to the browser.
Expand All @@ -465,13 +470,36 @@ function getShortDynamicParamType(
}
}

const segmentSchema = zod.union([
zod.string(),
zod.tuple([zod.string(), zod.string(), dynamicParamTypesSchema]),
])
/**
* Segment in the router state.
*/
export type Segment =
| string
| [param: string, value: string, type: DynamicParamTypesShort]

export type Segment = zod.infer<typeof segmentSchema>

const flightRouterStateSchema: zod.ZodType<FlightRouterState> = zod.lazy(() => {
const parallelRoutesSchema = zod.record(flightRouterStateSchema)
const urlSchema = zod.string().nullable().optional()
const refreshSchema = zod.literal('refetch').nullable().optional()
const isRootLayoutSchema = zod.boolean().optional()

// Due to the lack of optional tuple types in Zod, we need to use union here.
// https://github.com/colinhacks/zod/issues/1465
return zod.union([
zod.tuple([
segmentSchema,
parallelRoutesSchema,
urlSchema,
refreshSchema,
isRootLayoutSchema,
]),
zod.tuple([segmentSchema, parallelRoutesSchema, urlSchema, refreshSchema]),
zod.tuple([segmentSchema, parallelRoutesSchema, urlSchema]),
zod.tuple([segmentSchema, parallelRoutesSchema]),
])
})
/**
* Router state
*/
Expand Down Expand Up @@ -740,7 +768,9 @@ async function renderToString(element: React.ReactElement) {
})
}

function parseFlightRouterState(stateHeader: string | string[] | undefined) {
function parseAndValidateFlightRouterState(
stateHeader: string | string[] | undefined
): FlightRouterState | undefined {
if (typeof stateHeader === 'undefined') {
return undefined
}
Expand All @@ -750,8 +780,8 @@ function parseFlightRouterState(stateHeader: string | string[] | undefined) {
)
}
try {
return JSON.parse(stateHeader)
} catch (err) {
return flightRouterStateSchema.parse(JSON.parse(stateHeader))
} catch {
throw new Error('The router state header was sent but could not be parsed.')
}
}
Expand Down Expand Up @@ -857,13 +887,12 @@ export async function renderToHTMLOrFlight(
const isPrefetch =
req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()] !== undefined

// TODO-APP: verify the tree is valid
// TODO-APP: verify tree can't grow out of control
/**
* Router state provided from the client-side router. Used to handle rendering from the common layout down.
*/
let providedFlightRouterState: FlightRouterState = isFlight
? parseFlightRouterState(
let providedFlightRouterState = isFlight
? parseAndValidateFlightRouterState(
req.headers[NEXT_ROUTER_STATE_TREE.toLowerCase()]
)
: undefined
Expand Down Expand Up @@ -1518,7 +1547,6 @@ export async function renderToHTMLOrFlight(
}
// Handle Flight render request. This is only used when client-side navigating. E.g. when you `router.push('/dashboard')` or `router.reload()`.
const generateFlight = async (): Promise<RenderResult> => {
// TODO-APP: throw on invalid flightRouterState
/**
* Use router state to decide at what common layout to render the page.
* This can either be the common layout between two pages or a specific place to start rendering from using the "refetch" marker in the tree.
Expand Down
10 changes: 10 additions & 0 deletions packages/next/taskfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,15 @@ export async function compile_config_schema(task, opts) {
await fs.rmdir(join(__dirname, 'dist/next-config-validate'))
}

// eslint-disable-next-line camelcase
externals['zod'] = 'next/dist/compiled/zod'
export async function ncc_zod(task, opts) {
await task
.source(relative(__dirname, require.resolve('zod')))
.ncc({ packageName: 'zod', externals })
.target('src/compiled/zod')
}

// eslint-disable-next-line camelcase
externals['acorn'] = 'next/dist/compiled/acorn'
export async function ncc_acorn(task, opts) {
Expand Down Expand Up @@ -2083,6 +2092,7 @@ export async function ncc(task, opts) {
'ncc_node_shell_quote',
'ncc_undici',
'ncc_acorn',
'ncc_zod',
'ncc_amphtml_validator',
'ncc_arg',
'ncc_async_retry',
Expand Down
5 changes: 5 additions & 0 deletions packages/next/types/misc.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,8 @@ declare module 'next/dist/compiled/@opentelemetry/api' {
import * as m from '@opentelemetry/api'
export = m
}

declare module 'next/dist/compiled/zod' {
import m from 'zod'
export = m
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions test/e2e/app-dir/app-validation/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export default function Root({ children }) {
return (
<html>
<head>
<title>Hello World</title>
<script
dangerouslySetInnerHTML={{
__html: `if (location.search.includes('bot')) {
Object.defineProperty(navigator, 'userAgent', {
value: new URLSearchParams(location.search).get("useragent"),
});
}`,
}}
/>
</head>
<body>{children}</body>
</html>
)
}
6 changes: 6 additions & 0 deletions test/e2e/app-dir/app-validation/app/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default function HomePage() {
return <h1>Home</h1>
}

// Ensures that the flight requests are always handled by the server.
export const dynamic = 'force-dynamic'
5 changes: 5 additions & 0 deletions test/e2e/app-dir/app-validation/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
experimental: {
appDir: true,
},
}
28 changes: 28 additions & 0 deletions test/e2e/app-dir/app-validation/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createNextDescribe } from 'e2e-utils'

createNextDescribe(
'app dir validation',
{
files: __dirname,
skipDeployment: true,
},
({ next }) => {
it('should error when passing invalid router state tree', async () => {
const res = await next.fetch('/', {
headers: {
RSC: '1',
'Next-Router-State-Tree': JSON.stringify(['', '']),
},
})
expect(res.status).toBe(500)

const res2 = await next.fetch('/', {
headers: {
RSC: '1',
'Next-Router-State-Tree': JSON.stringify(['', {}]),
},
})
expect(res2.status).toBe(200)
})
}
)

0 comments on commit 10f2268

Please sign in to comment.