Skip to content

Commit 5b9a00c

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

Some content is hidden

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

64 files changed

+1229
-78
lines changed

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+
}

packages/vite-plugin-react-router/src/server.ts renamed to packages/vite-plugin-react-router/src/function-handler.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import {
44
RouterContextProvider,
55
createRequestHandler as createReactRouterRequestHandler,
66
} from 'react-router'
7-
import type { Context as NetlifyContext } from '@netlify/functions'
7+
import type { Context as NetlifyFunctionContext } from '@netlify/functions'
88

99
// Augment the user's `AppLoadContext` to include Netlify context fields
1010
// This is the recommended approach: https://reactrouter.com/upgrading/remix#9-update-types-for-apploadcontext.
1111
declare module 'react-router' {
12-
interface AppLoadContext extends NetlifyContext {}
12+
interface AppLoadContext extends NetlifyFunctionContext {}
1313
}
1414

1515
/**
@@ -26,14 +26,14 @@ declare module 'react-router' {
2626
export type GetLoadContextFunction = GetLoadContextFunction_V7 | GetLoadContextFunction_V8
2727
export type GetLoadContextFunction_V7 = (
2828
request: Request,
29-
context: NetlifyContext,
29+
context: NetlifyFunctionContext,
3030
) => Promise<AppLoadContext> | AppLoadContext
3131
export type GetLoadContextFunction_V8 = (
3232
request: Request,
33-
context: NetlifyContext,
33+
context: NetlifyFunctionContext,
3434
) => Promise<RouterContextProvider> | RouterContextProvider
3535

36-
export type RequestHandler = (request: Request, context: NetlifyContext) => Promise<Response>
36+
export type RequestHandler = (request: Request, context: NetlifyFunctionContext) => Promise<Response>
3737

3838
/**
3939
* An instance of `ReactContextProvider` providing access to
@@ -45,7 +45,7 @@ export const netlifyRouterContext =
4545
// We must use a singleton because Remix contexts rely on referential equality.
4646
// We can't hook into the request lifecycle in dev mode, so we use a Proxy to always read from the
4747
// current `Netlify.context` value, which is always contextual to the in-flight request.
48-
createContext<Partial<NetlifyContext>>(
48+
createContext<Partial<NetlifyFunctionContext>>(
4949
new Proxy(
5050
// Can't reference `Netlify.context` here because it isn't set outside of a request lifecycle
5151
{},
@@ -89,7 +89,7 @@ export function createRequestHandler({
8989
}): RequestHandler {
9090
const reactRouterHandler = createReactRouterRequestHandler(build, mode)
9191

92-
return async (request: Request, netlifyContext: NetlifyContext): Promise<Response> => {
92+
return async (request: Request, netlifyContext: NetlifyFunctionContext): Promise<Response> => {
9393
const start = Date.now()
9494
console.log(`[${request.method}] ${request.url}`)
9595
try {
@@ -110,8 +110,6 @@ export function createRequestHandler({
110110
// presence of the `future.v8_middleware` flag in the user's config, which we don't have access to.
111111
const response = await reactRouterHandler(request, reactRouterContext)
112112

113-
// A useful header for debugging
114-
response.headers.set('x-nf-runtime', 'Node')
115113
console.log(`[${response.status}] ${request.url} (${Date.now() - start}ms)`)
116114
return response
117115
} catch (error) {
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'

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

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import type { Plugin, ResolvedConfig } from 'vite'
2-
import { mkdir, writeFile } from 'node:fs/promises'
2+
import { mkdir, writeFile, readdir } from 'node:fs/promises'
33
import { join, relative, sep } from 'node:path'
44
import { sep as posixSep } from 'node:path/posix'
55
import { version, name } from '../package.json'
66

7+
export interface NetlifyPluginOptions {
8+
/**
9+
* Deploy to Netlify Edge Functions instead of Netlify Functions.
10+
* @default false
11+
*/
12+
edge?: boolean
13+
}
14+
715
// https://docs.netlify.com/frameworks-api/#netlify-v1-functions
816
const NETLIFY_FUNCTIONS_DIR = '.netlify/v1/functions'
17+
// https://docs.netlify.com/frameworks-api/#netlify-v1-edge-functions
18+
const NETLIFY_EDGE_FUNCTIONS_DIR = '.netlify/v1/edge-functions'
919

1020
const FUNCTION_FILENAME = 'react-router-server.mjs'
1121
/**
@@ -20,7 +30,16 @@ const toPosixPath = (path: string) => path.split(sep).join(posixSep)
2030

2131
// The virtual module that is the compiled Vite SSR entrypoint (a Netlify Function handler)
2232
const FUNCTION_HANDLER = /* js */ `
23-
import { createRequestHandler } from "@netlify/vite-plugin-react-router";
33+
import { createRequestHandler } from "@netlify/vite-plugin-react-router/function-handler";
34+
import * as build from "virtual:react-router/server-build";
35+
export default createRequestHandler({
36+
build,
37+
});
38+
`
39+
40+
// The virtual module for Edge Functions
41+
const EDGE_FUNCTION_HANDLER = /* js */ `
42+
import { createRequestHandler } from "@netlify/vite-plugin-react-router/edge-function-handler";
2443
import * as build from "virtual:react-router/server-build";
2544
export default createRequestHandler({
2645
build,
@@ -42,7 +61,24 @@ function generateNetlifyFunction(handlerPath: string) {
4261
`
4362
}
4463

45-
export function netlifyPlugin(): Plugin {
64+
// This is written to the edge functions directory. It just re-exports
65+
// the compiled entrypoint, along with Netlify edge function config.
66+
function generateEdgeFunction(handlerPath: string, excludePath: Array<string> = []) {
67+
return /* js */ `
68+
export { default } from "${handlerPath}";
69+
70+
export const config = {
71+
name: "React Router server handler",
72+
generator: "${name}@${version}",
73+
cache: "manual",
74+
path: "/*",
75+
excludedPath: ${JSON.stringify(excludePath)},
76+
};
77+
`
78+
}
79+
80+
export function netlifyPlugin(options: NetlifyPluginOptions = {}): Plugin {
81+
const { edge = false } = options
4682
let resolvedConfig: ResolvedConfig
4783
let isProductionSsrBuild = false
4884
return {
@@ -65,6 +101,24 @@ export function netlifyPlugin(): Plugin {
65101
config.build.rollupOptions.output = {}
66102
}
67103
config.build.rollupOptions.output.entryFileNames = '[name].js'
104+
105+
// Configure for Edge Functions if enabled
106+
if (edge) {
107+
config.ssr = {
108+
...config.ssr,
109+
target: 'webworker',
110+
// Only externalize Node builtins
111+
noExternal: /^(?!node:).*$/,
112+
resolve: {
113+
conditions: ['worker', 'deno', 'browser'],
114+
externalConditions: ['worker', 'deno'],
115+
},
116+
}
117+
config.resolve = {
118+
...config.resolve,
119+
conditions: ['worker', 'deno', ...(config.resolve?.conditions || [])],
120+
}
121+
}
68122
}
69123
},
70124
async resolveId(source) {
@@ -75,24 +129,44 @@ export function netlifyPlugin(): Plugin {
75129
// See https://vitejs.dev/guide/api-plugin#virtual-modules-convention.
76130
load(id) {
77131
if (id === RESOLVED_FUNCTION_HANDLER_MODULE_ID) {
78-
return FUNCTION_HANDLER
132+
return edge ? EDGE_FUNCTION_HANDLER : FUNCTION_HANDLER
79133
}
80134
},
81135
async configResolved(config) {
82136
resolvedConfig = config
83137
},
84138
// See https://rollupjs.org/plugin-development/#writebundle.
85139
async writeBundle() {
86-
// Write the server entrypoint to the Netlify functions directory
87140
if (isProductionSsrBuild) {
88-
const functionsDirectory = join(resolvedConfig.root, NETLIFY_FUNCTIONS_DIR)
89-
90-
await mkdir(functionsDirectory, { recursive: true })
91-
92141
const handlerPath = join(resolvedConfig.build.outDir, `${FUNCTION_HANDLER_CHUNK}.js`)
93-
const relativeHandlerPath = toPosixPath(relative(functionsDirectory, handlerPath))
94142

95-
await writeFile(join(functionsDirectory, FUNCTION_FILENAME), generateNetlifyFunction(relativeHandlerPath))
143+
if (edge) {
144+
// Edge Functions do not have a `preferStatic` option, so we must exhaustively exclude
145+
// static files to serve them from the CDN without compute.
146+
// RR7's build out dir contains /server and /client subdirectories. This is documented and
147+
// not configurable, so the client out dir is always at ../client from the server out dir.
148+
const clientDir = join(resolvedConfig.build.outDir, '..', 'client')
149+
const entries = await readdir(clientDir, { withFileTypes: true })
150+
const excludePath = [
151+
'/.netlify/*',
152+
...entries.map((entry) => (entry.isDirectory() ? `/${entry.name}/*` : `/${entry.name}`)),
153+
]
154+
155+
// Write the server entry point to the Netlify Edge Functions directory
156+
const edgeFunctionsDir = join(resolvedConfig.root, NETLIFY_EDGE_FUNCTIONS_DIR)
157+
await mkdir(edgeFunctionsDir, { recursive: true })
158+
const relativeHandlerPath = toPosixPath(relative(edgeFunctionsDir, handlerPath))
159+
await writeFile(
160+
join(edgeFunctionsDir, FUNCTION_FILENAME),
161+
generateEdgeFunction(relativeHandlerPath, excludePath),
162+
)
163+
} else {
164+
// Write the server entry point to the Netlify Functions directory
165+
const functionsDir = join(resolvedConfig.root, NETLIFY_FUNCTIONS_DIR)
166+
await mkdir(functionsDir, { recursive: true })
167+
const relativeHandlerPath = toPosixPath(relative(functionsDir, handlerPath))
168+
await writeFile(join(functionsDir, FUNCTION_FILENAME), generateNetlifyFunction(relativeHandlerPath))
169+
}
96170
}
97171
},
98172
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { defineConfig } from 'tsup'
2+
3+
export default defineConfig([
4+
{
5+
entry: ['src/index.ts'],
6+
format: ['esm', 'cjs'],
7+
dts: true,
8+
target: 'node18',
9+
clean: true,
10+
},
11+
{
12+
entry: ['src/function-handler.ts'],
13+
format: ['esm'],
14+
dts: true,
15+
target: 'node18',
16+
},
17+
{
18+
entry: ['src/edge-function-handler.ts'],
19+
format: ['esm'],
20+
dts: true,
21+
target: 'node18',
22+
},
23+
])

0 commit comments

Comments
 (0)