Skip to content

Commit dfb27c1

Browse files
authored
fix(@netlify/vite-plugin-react-router): fix local dev with edge: true (#572)
* fix(deps): bump isbot because why not * fix(deps): move incorrect dev deps to prod deps These are now needed at runtime when importing `netlifyRouterContext`. * fix: fix local dev in edge mode There's some more sophisticated solution involving the Vite Environment API down the line, but for now I just ported something very similar to what we do for Remix: have users write a one-liner server entry to uses our Vite virtual module that conditionally resolves depending on build (edge entry) vs. dev (node.js entry). As a bonus, the file for users to copy is a single line instead of 40.
1 parent 4852a7d commit dfb27c1

File tree

8 files changed

+105
-138
lines changed

8 files changed

+105
-138
lines changed

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

Lines changed: 13 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -58,59 +58,23 @@ export default defineConfig({
5858
})
5959
```
6060

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-
> [!IMPORTANT]
65-
>
66-
> This file uses `renderToReadableStream` (Web Streams API) instead of `renderToPipeableStream` (Node.js API), which is
67-
> required for the Deno runtime. You may customize your server entry file, but see below for important edge runtime
68-
> constraints.
61+
Second, you **must** provide an `app/entry.server.tsx` (or `.jsx`) file. Create a file with the following content:
6962

7063
```tsx
71-
import type { AppLoadContext, EntryContext } from 'react-router'
72-
import { ServerRouter } from 'react-router'
73-
import { isbot } from 'isbot'
74-
import { renderToReadableStream } from 'react-dom/server'
75-
76-
export default async function handleRequest(
77-
request: Request,
78-
responseStatusCode: number,
79-
responseHeaders: Headers,
80-
routerContext: EntryContext,
81-
_loadContext: AppLoadContext,
82-
) {
83-
let shellRendered = false
84-
const userAgent = request.headers.get('user-agent')
85-
86-
const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
87-
onError(error: unknown) {
88-
responseStatusCode = 500
89-
// Log streaming rendering errors from inside the shell. Don't log
90-
// errors encountered during initial shell rendering since they'll
91-
// reject and get logged in handleDocumentRequest.
92-
if (shellRendered) {
93-
console.error(error)
94-
}
95-
},
96-
})
97-
shellRendered = true
98-
99-
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
100-
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
101-
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
102-
await body.allReady
103-
}
104-
105-
responseHeaders.set('Content-Type', 'text/html')
106-
return new Response(body, {
107-
headers: responseHeaders,
108-
status: responseStatusCode,
109-
})
110-
}
64+
export { default } from 'virtual:netlify-server-entry'
11165
```
11266

113-
You may need to `npm install isbot` if you do not have this dependency.
67+
> [!TIP]
68+
>
69+
> If you prefer to avoid a `@ts-ignore` here, add this to `vite-env.d.ts` in your project root (or anywhere you prefer):
70+
>
71+
> ```typescript
72+
> declare module 'virtual:netlify-server-entry' {
73+
> import type { ServerEntryModule } from 'react-router'
74+
> const entry: ServerEntryModule
75+
> export default entry
76+
> }
77+
> ```
11478
11579
Finally, if you have your own Netlify Functions (typically in `netlify/functions`) for which you've configured a `path`,
11680
you must exclude those paths to avoid conflicts with the generated React Router SSR handler:

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"./edge": {
2525
"types": "./dist/edge.d.mts",
2626
"default": "./dist/edge.mjs"
27+
},
28+
"./entry.server.edge": {
29+
"types": "./dist/entry.server.edge.d.mts",
30+
"default": "./dist/entry.server.edge.mjs"
2731
}
2832
},
2933
"files": [
@@ -54,11 +58,11 @@
5458
},
5559
"homepage": "https://github.com/netlify/remix-compute#readme",
5660
"dependencies": {
57-
"isbot": "^5.0.0"
58-
},
59-
"devDependencies": {
6061
"@netlify/edge-functions": "^3.0.2",
6162
"@netlify/functions": "^5.1.0",
63+
"isbot": "^5.1.25"
64+
},
65+
"devDependencies": {
6266
"@types/react": "^18.0.27",
6367
"@types/react-dom": "^18.0.10",
6468
"react": "^18.2.0",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { AppLoadContext, EntryContext } from 'react-router'
2+
import { ServerRouter } from 'react-router'
3+
import { isbot } from 'isbot'
4+
import { renderToReadableStream } from 'react-dom/server'
5+
6+
/**
7+
* Edge-compatible server entry using Web Streams instead of Node.js Streams.
8+
* @see {@link https://reactrouter.com/api/framework-conventions/entry.server.tsx}
9+
*
10+
* This file was copied as-is from the React Router repository.
11+
* @see {@link
12+
* https://github.com/remix-run/react-router/blob/cb9a090316003988ff367bb2f2d1ef5bd03bd3af/integration/helpers/vite-plugin-cloudflare-template/app/entry.server.tsx}
13+
*
14+
*
15+
* @example Export this from your `app/entry.server.tsx` when using `edge: true`:
16+
*
17+
* ```tsx
18+
* export { default } from 'virtual:netlify-server-entry'
19+
* ```
20+
*/
21+
export default async function handleRequest(
22+
request: Request,
23+
responseStatusCode: number,
24+
responseHeaders: Headers,
25+
routerContext: EntryContext,
26+
_loadContext: AppLoadContext,
27+
) {
28+
let shellRendered = false
29+
const userAgent = request.headers.get('user-agent')
30+
31+
const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
32+
onError(error: unknown) {
33+
responseStatusCode = 500
34+
// Log streaming rendering errors from inside the shell. Don't log
35+
// errors encountered during initial shell rendering since they'll
36+
// reject and get logged in handleDocumentRequest.
37+
if (shellRendered) {
38+
console.error(error)
39+
}
40+
},
41+
})
42+
shellRendered = true
43+
44+
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
45+
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
46+
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
47+
await body.allReady
48+
}
49+
50+
responseHeaders.set('Content-Type', 'text/html')
51+
return new Response(body, {
52+
headers: responseHeaders,
53+
status: responseStatusCode,
54+
})
55+
}

packages/vite-plugin-react-router/src/plugin.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { mkdir, writeFile, readdir } from 'node:fs/promises'
2-
import { join, relative, sep } from 'node:path'
2+
import { dirname, join, relative, resolve, sep } from 'node:path'
33
import { sep as posixSep } from 'node:path/posix'
44

55
import type { Plugin, ResolvedConfig } from 'vite'
@@ -38,6 +38,8 @@ const FUNCTION_HANDLER_CHUNK = 'server'
3838
const FUNCTION_HANDLER_MODULE_ID = 'virtual:netlify-server'
3939
const RESOLVED_FUNCTION_HANDLER_MODULE_ID = `\0${FUNCTION_HANDLER_MODULE_ID}`
4040

41+
const SERVER_ENTRY_MODULE_ID = 'virtual:netlify-server-entry'
42+
4143
const toPosixPath = (path: string) => path.split(sep).join(posixSep)
4244

4345
// The virtual module that is the compiled Vite SSR entrypoint (a Netlify Function handler)
@@ -95,9 +97,11 @@ export function netlifyPlugin(options: NetlifyPluginOptions = {}): Plugin {
9597
const additionalExcludedPaths = options.excludedPaths ?? []
9698
let resolvedConfig: ResolvedConfig
9799
let isProductionSsrBuild = false
100+
let currentCommand: 'build' | 'serve' | undefined
98101
return {
99102
name: 'vite-plugin-netlify-react-router',
100103
config(config, { command, isSsrBuild }) {
104+
currentCommand = command
101105
isProductionSsrBuild = isSsrBuild === true && command === 'build'
102106
if (isProductionSsrBuild) {
103107
// Replace the default SSR entrypoint with our own entrypoint (which is imported by our
@@ -132,10 +136,26 @@ export function netlifyPlugin(options: NetlifyPluginOptions = {}): Plugin {
132136
}
133137
}
134138
},
135-
async resolveId(source) {
139+
async resolveId(source, importer, options) {
136140
if (source === FUNCTION_HANDLER_MODULE_ID) {
137141
return RESOLVED_FUNCTION_HANDLER_MODULE_ID
138142
}
143+
144+
// Conditionally resolve the server entry based on the command and runtime.
145+
// Users will export from 'virtual:netlify-server-entry' in their `app/entry.server.tsx`
146+
//
147+
if (source === SERVER_ENTRY_MODULE_ID && edge) {
148+
if (currentCommand === 'serve') {
149+
// Dev mode with edge runtime: use the default Node.js-compatible entry
150+
const reactRouterDev = await this.resolve('@react-router/dev/config', importer, options)
151+
if (!reactRouterDev) {
152+
throw new Error('The @react-router/dev package is required for local development. Please install it.')
153+
}
154+
return resolve(dirname(reactRouterDev.id), 'config/defaults/entry.server.node.tsx')
155+
}
156+
// Production build with edge runtime: use our edge-compatible entry
157+
return this.resolve('@netlify/vite-plugin-react-router/entry.server.edge', importer, options)
158+
}
139159
},
140160
// See https://vitejs.dev/guide/api-plugin#virtual-modules-convention.
141161
load(id) {

packages/vite-plugin-react-router/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default defineConfig([
66
index: 'src/index.ts',
77
serverless: 'src/runtimes/netlify-functions.ts',
88
edge: 'src/runtimes/netlify-edge-functions.ts',
9+
'entry.server.edge': 'src/entry.server.edge.tsx',
910
},
1011
format: ['esm'],
1112
dts: true,

pnpm-lock.yaml

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1 @@
1-
import type { AppLoadContext, EntryContext } from 'react-router'
2-
import { ServerRouter } from 'react-router'
3-
import { isbot } from 'isbot'
4-
import { renderToReadableStream } from 'react-dom/server'
5-
6-
export default async function handleRequest(
7-
request: Request,
8-
responseStatusCode: number,
9-
responseHeaders: Headers,
10-
routerContext: EntryContext,
11-
_loadContext: AppLoadContext,
12-
) {
13-
let shellRendered = false
14-
const userAgent = request.headers.get('user-agent')
15-
16-
const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
17-
onError(error: unknown) {
18-
responseStatusCode = 500
19-
// Log streaming rendering errors from inside the shell. Don't log
20-
// errors encountered during initial shell rendering since they'll
21-
// reject and get logged in handleDocumentRequest.
22-
if (shellRendered) {
23-
console.error(error)
24-
}
25-
},
26-
})
27-
shellRendered = true
28-
29-
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
30-
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
31-
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
32-
await body.allReady
33-
}
34-
35-
responseHeaders.set('Content-Type', 'text/html')
36-
return new Response(body, {
37-
headers: responseHeaders,
38-
status: responseStatusCode,
39-
})
40-
}
1+
export { default } from 'virtual:netlify-server-entry'
Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1 @@
1-
import type { AppLoadContext, EntryContext } from 'react-router'
2-
import { ServerRouter } from 'react-router'
3-
import { isbot } from 'isbot'
4-
import { renderToReadableStream } from 'react-dom/server'
5-
6-
export default async function handleRequest(
7-
request: Request,
8-
responseStatusCode: number,
9-
responseHeaders: Headers,
10-
routerContext: EntryContext,
11-
_loadContext: AppLoadContext,
12-
) {
13-
let shellRendered = false
14-
const userAgent = request.headers.get('user-agent')
15-
16-
const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
17-
onError(error: unknown) {
18-
responseStatusCode = 500
19-
// Log streaming rendering errors from inside the shell. Don't log
20-
// errors encountered during initial shell rendering since they'll
21-
// reject and get logged in handleDocumentRequest.
22-
if (shellRendered) {
23-
console.error(error)
24-
}
25-
},
26-
})
27-
shellRendered = true
28-
29-
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
30-
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
31-
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
32-
await body.allReady
33-
}
34-
35-
responseHeaders.set('Content-Type', 'text/html')
36-
return new Response(body, {
37-
headers: responseHeaders,
38-
status: responseStatusCode,
39-
})
40-
}
1+
export { default } from 'virtual:netlify-server-entry'

0 commit comments

Comments
 (0)