Skip to content

Commit c0203ab

Browse files
mrstorkserhalp
authored andcommitted
feat: add React Router 7 plugin
1 parent 34f4061 commit c0203ab

Some content is hidden

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

46 files changed

+1632
-249
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,6 @@ build/
142142

143143
# Generated by `deno types`
144144
/packages/remix-edge-adapter/deno.d.ts
145+
146+
# React Router
147+
.react-router/

.release-please-manifest.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1-
{"packages/remix-edge-adapter":"3.4.2","packages/remix-runtime":"2.3.1","packages/remix-adapter":"2.5.1"}
1+
{
2+
"packages/remix-edge-adapter": "3.4.2",
3+
"packages/remix-runtime": "2.3.1",
4+
"packages/remix-adapter": "2.5.1",
5+
"packages/vite-plugin-react-router": "0.0.0"
6+
}

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"p-limit": "^5.0.0",
5353
"prettier": "^3.0.0",
5454
"typescript": "^5.0.0",
55-
"vitest": "^1.0.0"
55+
"vitest": "^2.1.8"
5656
},
5757
"dependencies": {
5858
"@netlify/edge-functions": "^2.10.0",

packages/remix-adapter/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
"react": "^18.2.0",
7171
"react-dom": "^18.2.0",
7272
"tsup": "^8.0.2",
73-
"vite": "^5.1.3"
73+
"vite": "^5.4.11"
7474
},
7575
"peerDependencies": {
7676
"vite": "^5.0.0"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Changelog
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
MIT License
2+
3+
Copyright (c) Netlify Inc. 2024
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8+
persons to whom the Software is furnished to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or
11+
substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# React Router Adapter for Netlify
2+
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/).
5+
6+
To deploy a React Router 7+ site to Netlify, install this package:
7+
8+
## How to use
9+
10+
```sh
11+
npm install @netlify/vite-plugin-react-router
12+
```
13+
14+
and include the Netlify plugin in your `vite.config.ts`:
15+
16+
```typescript
17+
import { reactRouter } from '@react-router/dev/vite'
18+
import { defineConfig } from 'vite'
19+
import tsconfigPaths from 'vite-tsconfig-paths'
20+
import netlifyPlugin from '@netlify/vite-plugin-react-router' // <- add this
21+
22+
export default defineConfig({
23+
plugins: [
24+
reactRouter(),
25+
tsconfigPaths(),
26+
netlifyPlugin(), // <- add this
27+
],
28+
})
29+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"name": "@netlify/vite-plugin-react-router",
3+
"version": "0.0.0",
4+
"description": "React Router 7+ Vite plugin for Netlify",
5+
"type": "commonjs",
6+
"main": "./dist/index.js",
7+
"module": "./dist/index.mjs",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"require": {
12+
"types": "./dist/index.d.ts",
13+
"default": "./dist/index.js"
14+
},
15+
"import": {
16+
"types": "./dist/index.d.mts",
17+
"default": "./dist/index.mjs"
18+
}
19+
}
20+
},
21+
"files": [
22+
"dist/**/*",
23+
"LICENSE",
24+
"README.md",
25+
"CHANGELOG.md"
26+
],
27+
"scripts": {
28+
"prepack": "pnpm run build",
29+
"build": "tsup-node src/index.ts --format esm,cjs --dts --target node18 --clean",
30+
"build:watch": "pnpm run build --watch"
31+
},
32+
"repository": {
33+
"type": "git",
34+
"url": "https://github.com/netlify/remix-compute",
35+
"directory": "packages/vite-plugin-react-router"
36+
},
37+
"keywords": [
38+
"react-router",
39+
"vite-plugin",
40+
"netlify"
41+
],
42+
"license": "MIT",
43+
"bugs": {
44+
"url": "https://github.com/netlify/remix-compute/issues"
45+
},
46+
"homepage": "https://github.com/netlify/remix-compute#readme",
47+
"dependencies": {
48+
"@react-router/node": "^7.0.1",
49+
"isbot": "^5.0.0",
50+
"react-router": "^7.0.1"
51+
},
52+
"devDependencies": {
53+
"@netlify/functions": "^2.8.1",
54+
"@types/react": "^18.0.27",
55+
"@types/react-dom": "^18.0.10",
56+
"react": "^18.2.0",
57+
"react-dom": "^18.2.0",
58+
"tsup": "^8.0.2",
59+
"vite": "^5.4.11"
60+
},
61+
"peerDependencies": {
62+
"vite": ">=5.0.0"
63+
},
64+
"engines": {
65+
"node": ">=18"
66+
},
67+
"publishConfig": {
68+
"access": "public"
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type { GetLoadContextFunction, RequestHandler } from './server'
2+
export { createRequestHandler } from './server'
3+
4+
export { netlifyPlugin as default } from './plugin'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { Plugin, ResolvedConfig } from 'vite'
2+
import { mkdir, writeFile } from 'node:fs/promises'
3+
import { join, relative, sep } from 'node:path'
4+
import { sep as posixSep } from 'node:path/posix'
5+
import { version, name } from '../package.json'
6+
7+
const NETLIFY_FUNCTIONS_DIR = '.netlify/functions-internal'
8+
9+
const FUNCTION_FILENAME = 'react-router-server.mjs'
10+
/**
11+
* The chunk filename without an extension, i.e. in the Rollup config `input` format
12+
*/
13+
const FUNCTION_HANDLER_CHUNK = 'server'
14+
15+
const FUNCTION_HANDLER_MODULE_ID = 'virtual:netlify-server'
16+
const RESOLVED_FUNCTION_HANDLER_MODULE_ID = `\0${FUNCTION_HANDLER_MODULE_ID}`
17+
18+
const toPosixPath = (path: string) => path.split(sep).join(posixSep)
19+
20+
// The virtual module that is the compiled Vite SSR entrypoint (a Netlify Function handler)
21+
const FUNCTION_HANDLER = /* js */ `
22+
import { createRequestHandler } from "@netlify/vite-plugin-react-router";
23+
import * as build from "virtual:react-router/server-build";
24+
export default createRequestHandler({
25+
build,
26+
getLoadContext: async (_req, ctx) => ctx,
27+
});
28+
`
29+
30+
// This is written to the functions directory. It just re-exports
31+
// the compiled entrypoint, along with Netlify function config.
32+
function generateNetlifyFunction(handlerPath: string) {
33+
return /* js */ `
34+
export { default } from "${handlerPath}";
35+
36+
export const config = {
37+
name: "React Router server handler",
38+
generator: "${name}@${version}",
39+
path: "/*",
40+
preferStatic: true,
41+
};
42+
`
43+
}
44+
45+
export function netlifyPlugin(): Plugin {
46+
let resolvedConfig: ResolvedConfig
47+
let currentCommand: string
48+
let isSsr: boolean | undefined
49+
return {
50+
name: 'vite-plugin-react-router-netlify-functions',
51+
config(config, { command, isSsrBuild }) {
52+
currentCommand = command
53+
isSsr = isSsrBuild
54+
if (command === 'build') {
55+
if (isSsrBuild) {
56+
// We need to add an extra SSR entrypoint, as we need to compile
57+
// the server entrypoint too. This is because it uses virtual
58+
// modules.
59+
// NOTE: the below is making various assumptions about the React Router Vite plugin's
60+
// implementation details:
61+
// https://github.com/remix-run/remix/blob/cc65962b1a96d1e134336aa9620ef1dad7c5efb1/packages/remix-dev/vite/plugin.ts#L1149-L1168
62+
// TODO(serhalp) Stop making these assumptions or assert them explictly.
63+
// TODO(serhalp) Unless I'm misunderstanding something, we should only need to *replace*
64+
// the default React Router Vite SSR entrypoint, not add an additional one.
65+
if (typeof config.build?.rollupOptions?.input === 'string') {
66+
config.build.rollupOptions.input = {
67+
[FUNCTION_HANDLER_CHUNK]: FUNCTION_HANDLER_MODULE_ID,
68+
index: config.build.rollupOptions.input,
69+
}
70+
if (config.build.rollupOptions.output && !Array.isArray(config.build.rollupOptions.output)) {
71+
config.build.rollupOptions.output.entryFileNames = '[name].js'
72+
}
73+
}
74+
}
75+
}
76+
},
77+
async resolveId(source) {
78+
if (source === FUNCTION_HANDLER_MODULE_ID) {
79+
return RESOLVED_FUNCTION_HANDLER_MODULE_ID
80+
}
81+
},
82+
// See https://vitejs.dev/guide/api-plugin#virtual-modules-convention.
83+
load(id) {
84+
if (id === RESOLVED_FUNCTION_HANDLER_MODULE_ID) {
85+
return FUNCTION_HANDLER
86+
}
87+
},
88+
async configResolved(config) {
89+
resolvedConfig = config
90+
},
91+
// See https://rollupjs.org/plugin-development/#writebundle.
92+
async writeBundle() {
93+
// Write the server entrypoint to the Netlify functions directory
94+
if (currentCommand === 'build' && isSsr) {
95+
const functionsDirectory = join(resolvedConfig.root, NETLIFY_FUNCTIONS_DIR)
96+
97+
await mkdir(functionsDirectory, { recursive: true })
98+
99+
const handlerPath = join(resolvedConfig.build.outDir, `${FUNCTION_HANDLER_CHUNK}.js`)
100+
const relativeHandlerPath = toPosixPath(relative(functionsDirectory, handlerPath))
101+
102+
await writeFile(join(functionsDirectory, FUNCTION_FILENAME), generateNetlifyFunction(relativeHandlerPath))
103+
}
104+
},
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { AppLoadContext, ServerBuild } from 'react-router'
2+
import { createRequestHandler as createReactRouterRequestHandler } from 'react-router'
3+
import type { Context as NetlifyContext } from '@netlify/functions'
4+
5+
type LoadContext = AppLoadContext & NetlifyContext
6+
7+
/**
8+
* A function that returns the value to use as `context` in route `loader` and
9+
* `action` functions.
10+
*
11+
* You can think of this as an escape hatch that allows you to pass
12+
* environment/platform-specific values through to your loader/action.
13+
*/
14+
export type GetLoadContextFunction = (request: Request, context: NetlifyContext) => Promise<LoadContext> | LoadContext
15+
16+
export type RequestHandler = (request: Request, context: LoadContext) => Promise<Response | void>
17+
18+
/**
19+
* Given a build and a callback to get the base loader context, this returns
20+
* a Netlify Function handler (https://docs.netlify.com/functions/overview/) which renders the
21+
* requested path. The loader context in this lifecycle will contain the Netlify Functions context
22+
* fields merged in.
23+
*/
24+
export function createRequestHandler({
25+
build,
26+
mode,
27+
getLoadContext,
28+
}: {
29+
build: ServerBuild
30+
mode?: string
31+
getLoadContext?: GetLoadContextFunction
32+
}): RequestHandler {
33+
const reactRouterHandler = createReactRouterRequestHandler(build, mode)
34+
35+
return async (request: Request, netlifyContext: NetlifyContext): Promise<Response | void> => {
36+
const start = Date.now()
37+
console.log(`[${request.method}] ${request.url}`)
38+
try {
39+
const mergedLoadContext = (await getLoadContext?.(request, netlifyContext)) || netlifyContext
40+
41+
const response = await reactRouterHandler(request, mergedLoadContext)
42+
43+
// A useful header for debugging
44+
response.headers.set('x-nf-runtime', 'Node')
45+
console.log(`[${response.status}] ${request.url} (${Date.now() - start}ms)`)
46+
return response
47+
} catch (error) {
48+
console.error(error)
49+
50+
return new Response('Internal Error', { status: 500 })
51+
}
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "src",
5+
"jsx": "react-jsx",
6+
"outDir": "./build"
7+
},
8+
"include": ["./src"]
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference types="vitest" />
2+
/// <reference types="vite/client" />
3+
4+
import { defineProject } from 'vitest/config'
5+
6+
export default defineProject({
7+
plugins: [],
8+
test: {
9+
include: ['./__tests__/*.{js,jsx,tsx,ts}'],
10+
globals: true,
11+
},
12+
})

0 commit comments

Comments
 (0)