Skip to content

Commit b552eb6

Browse files
committed
feat(vite-plugin-react-router): add edge support
1 parent b24ced1 commit b552eb6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1316
-73
lines changed

packages/vite-plugin-react-router/README.md

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
# React Router Adapter for Netlify
22

3-
The React Router Adapter for Netlify allows you to deploy your [React Router](https://reactrouter.com) app to
4-
[Netlify Functions](https://docs.netlify.com/functions/overview/).
3+
The React Router Adapter for Netlify allows you to deploy your [React Router](https://reactrouter.com) app to Netlify.
54

65
## How to use
76

87
To deploy a React Router 7+ site to Netlify, install this package:
98

109
```sh
11-
npm install --save-dev @netlify/vite-plugin-react-router
10+
npm install @netlify/vite-plugin-react-router
1211
```
1312

1413
It's also recommended (but not required) to use the
@@ -38,6 +37,97 @@ export default defineConfig({
3837
})
3938
```
4039

40+
Your app is ready to [deploy to Netlify](https://docs.netlify.com/deploy/create-deploys/).
41+
42+
### Deploying to Edge Functions
43+
44+
By default, this plugin deploys your React Router app to
45+
[Netlify Functions](https://docs.netlify.com/functions/overview/) (Node.js runtime). You can optionally deploy to
46+
[Netlify Edge Functions](https://docs.netlify.com/edge-functions/overview/) (Deno runtime) instead.
47+
48+
First, toggle the `edge` option:
49+
50+
```typescript
51+
export default defineConfig({
52+
plugins: [
53+
reactRouter(),
54+
tsconfigPaths(),
55+
netlifyReactRouter({ edge: true }), // <- deploy to Edge Functions
56+
netlify(),
57+
],
58+
})
59+
```
60+
61+
Second, you **must** provide an `app/entry.server.tsx` (or `.jsx`) file that uses web-standard APIs compatible with the
62+
Deno runtime. Create a file with the following content:
63+
64+
```tsx
65+
import type { AppLoadContext, EntryContext } from 'react-router'
66+
import { ServerRouter } from 'react-router'
67+
import { isbot } from 'isbot'
68+
import { renderToReadableStream } from 'react-dom/server'
69+
70+
export default async function handleRequest(
71+
request: Request,
72+
responseStatusCode: number,
73+
responseHeaders: Headers,
74+
routerContext: EntryContext,
75+
_loadContext: AppLoadContext,
76+
) {
77+
let shellRendered = false
78+
const userAgent = request.headers.get('user-agent')
79+
80+
const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
81+
onError(error: unknown) {
82+
responseStatusCode = 500
83+
// Log streaming rendering errors from inside the shell. Don't log
84+
// errors encountered during initial shell rendering since they'll
85+
// reject and get logged in handleDocumentRequest.
86+
if (shellRendered) {
87+
console.error(error)
88+
}
89+
},
90+
})
91+
shellRendered = true
92+
93+
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
94+
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
95+
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
96+
await body.allReady
97+
}
98+
99+
responseHeaders.set('Content-Type', 'text/html')
100+
return new Response(body, {
101+
headers: responseHeaders,
102+
status: responseStatusCode,
103+
})
104+
}
105+
```
106+
107+
You may need to `npm install isbot` if you do not have this dependency.
108+
109+
> [!IMPORTANT]
110+
>
111+
> This file uses `renderToReadableStream` (Web Streams API) instead of `renderToPipeableStream` (Node.js API), which is
112+
> required for the Deno runtime. You may customize your server entry file, but see below for
113+
114+
#### Moving back from Edge Functions to Functions
115+
116+
To switch from Edge Functions back to Functions, you must:
117+
118+
1. Remove the `edge: true` option from your `vite.config.ts`
119+
2. **Delete the `app/entry.server.tsx` file** (React Router will use its default Node.js-compatible entry)
120+
121+
#### Edge runtime
122+
123+
Before deploying to Edge Functions, review the Netlify Edge Functions documentation for important details:
124+
125+
- [Runtime environment](https://docs.netlify.com/build/edge-functions/api/#runtime-environment) - Understand the Deno
126+
runtime
127+
- [Supported Web APIs](https://docs.netlify.com/build/edge-functions/api/#supported-web-apis) - Check which APIs are
128+
available
129+
- [Limitations](https://docs.netlify.com/build/edge-functions/limits/) - Be aware of resource limits and constraints
130+
41131
### Load context
42132

43133
This plugin automatically includes all

packages/vite-plugin-react-router/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
"types": "./dist/index.d.mts",
1717
"default": "./dist/index.mjs"
1818
}
19+
},
20+
"./function-handler": {
21+
"types": "./dist/function-handler.d.mts",
22+
"default": "./dist/function-handler.mjs"
23+
},
24+
"./edge-function-handler": {
25+
"types": "./dist/edge-function-handler.d.mts",
26+
"default": "./dist/edge-function-handler.mjs"
1927
}
2028
},
2129
"files": [
@@ -26,7 +34,7 @@
2634
],
2735
"scripts": {
2836
"prepack": "pnpm run build",
29-
"build": "tsup-node src/index.ts --format esm,cjs --dts --target node18 --clean",
37+
"build": "tsup-node",
3038
"build:watch": "pnpm run build --watch"
3139
},
3240
"repository": {
@@ -48,6 +56,7 @@
4856
"isbot": "^5.0.0"
4957
},
5058
"devDependencies": {
59+
"@netlify/edge-functions": "^2.11.0",
5160
"@netlify/functions": "^3.1.9",
5261
"@types/react": "^18.0.27",
5362
"@types/react-dom": "^18.0.10",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { AppLoadContext, ServerBuild } from 'react-router'
2+
import {
3+
createContext,
4+
RouterContextProvider,
5+
createRequestHandler as createReactRouterRequestHandler,
6+
} from 'react-router'
7+
import type { Context as NetlifyEdgeContext } from '@netlify/edge-functions'
8+
9+
// Augment the user's `AppLoadContext` to include Netlify context fields
10+
// This is the recommended approach: https://reactrouter.com/upgrading/remix#9-update-types-for-apploadcontext.
11+
declare module 'react-router' {
12+
interface AppLoadContext extends NetlifyEdgeContext {}
13+
}
14+
15+
/**
16+
* A function that returns the value to use as `context` in route `loader` and `action` functions.
17+
*
18+
* You can think of this as an escape hatch that allows you to pass environment/platform-specific
19+
* values through to your loader/action.
20+
*
21+
* NOTE: v7.9.0 introduced a breaking change when the user opts in to `future.v8_middleware`. This
22+
* requires returning an instance of `RouterContextProvider` instead of a plain object. We have a
23+
* peer dependency on >=7.9.0 so we can safely *import* these, but we cannot assume the user has
24+
* opted in to the flag.
25+
*/
26+
export type GetLoadContextFunction = GetLoadContextFunction_V7 | GetLoadContextFunction_V8
27+
export type GetLoadContextFunction_V7 = (
28+
request: Request,
29+
context: NetlifyEdgeContext,
30+
) => Promise<AppLoadContext> | AppLoadContext
31+
export type GetLoadContextFunction_V8 = (
32+
request: Request,
33+
context: NetlifyEdgeContext,
34+
) => Promise<RouterContextProvider> | RouterContextProvider
35+
36+
export type RequestHandler = (request: Request, context: NetlifyEdgeContext) => Promise<Response>
37+
38+
/**
39+
* An instance of `ReactContextProvider` providing access to
40+
* [Netlify request context]{@link https://docs.netlify.com/build/functions/api/#netlify-specific-context-object}
41+
*
42+
* @example context.get(netlifyRouterContext).geo?.country?.name
43+
*/
44+
export const netlifyRouterContext =
45+
// We must use a singleton because Remix contexts rely on referential equality.
46+
// We can't hook into the request lifecycle in dev mode, so we use a Proxy to always read from the
47+
// current `Netlify.context` value, which is always contextual to the in-flight request.
48+
createContext<Partial<NetlifyEdgeContext>>(
49+
new Proxy(
50+
// Can't reference `Netlify.context` here because it isn't set outside of a request lifecycle
51+
{},
52+
{
53+
get(_target, prop, receiver) {
54+
return Reflect.get(Netlify.context ?? {}, prop, receiver)
55+
},
56+
set(_target, prop, value, receiver) {
57+
return Reflect.set(Netlify.context ?? {}, prop, value, receiver)
58+
},
59+
has(_target, prop) {
60+
return Reflect.has(Netlify.context ?? {}, prop)
61+
},
62+
deleteProperty(_target, prop) {
63+
return Reflect.deleteProperty(Netlify.context ?? {}, prop)
64+
},
65+
ownKeys(_target) {
66+
return Reflect.ownKeys(Netlify.context ?? {})
67+
},
68+
getOwnPropertyDescriptor(_target, prop) {
69+
return Reflect.getOwnPropertyDescriptor(Netlify.context ?? {}, prop)
70+
},
71+
},
72+
),
73+
)
74+
75+
/**
76+
* Given a build and a callback to get the base loader context, this returns
77+
* a Netlify Edge Function handler (https://docs.netlify.com/edge-functions/overview/) which renders the
78+
* requested path. The loader context in this lifecycle will contain the Netlify Edge Functions context
79+
* fields merged in.
80+
*/
81+
export function createRequestHandler({
82+
build,
83+
mode,
84+
getLoadContext,
85+
}: {
86+
build: ServerBuild
87+
mode?: string
88+
getLoadContext?: GetLoadContextFunction
89+
}): RequestHandler {
90+
const reactRouterHandler = createReactRouterRequestHandler(build, mode)
91+
92+
return async (request: Request, netlifyContext: NetlifyEdgeContext): Promise<Response> => {
93+
const start = Date.now()
94+
console.log(`[${request.method}] ${request.url}`)
95+
try {
96+
const getDefaultReactRouterContext = () => {
97+
const ctx = new RouterContextProvider()
98+
ctx.set(netlifyRouterContext, netlifyContext)
99+
100+
// Provide backwards compatibility with previous plain object context
101+
// See https://reactrouter.com/how-to/middleware#migration-from-apploadcontext.
102+
Object.assign(ctx, netlifyContext)
103+
104+
return ctx
105+
}
106+
const reactRouterContext = (await getLoadContext?.(request, netlifyContext)) ?? getDefaultReactRouterContext()
107+
108+
// @ts-expect-error -- I don't think there's any way to type this properly. We're passing a
109+
// union of the two types here, but this function accepts a conditional type based on the
110+
// presence of the `future.v8_middleware` flag in the user's config, which we don't have access to.
111+
const response = await reactRouterHandler(request, reactRouterContext)
112+
113+
// We can return any React Router response as-is (whether it's a default 404, custom 404,
114+
// or any other response) because our edge function's excludedPath config is exhaustive -
115+
// static assets are excluded from the edge function entirely, so we never need to fall
116+
// through to the CDN.
117+
console.log(`[${response.status}] ${request.url} (${Date.now() - start}ms)`)
118+
return response
119+
} catch (error) {
120+
console.error(error)
121+
122+
return new Response('Internal Error', { status: 500 })
123+
}
124+
}
125+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { GetLoadContextFunction, RequestHandler } from './server'
2-
export { createRequestHandler, netlifyRouterContext } from './server'
1+
export type { GetLoadContextFunction, RequestHandler } from './function-handler'
2+
export { createRequestHandler, netlifyRouterContext } from './function-handler'
33

44
export { netlifyPlugin as default } from './plugin'

0 commit comments

Comments
 (0)