Skip to content

Commit 827ed3f

Browse files
committed
feat: add react-router-hono package
1 parent 13aee9f commit 827ed3f

File tree

17 files changed

+1448
-340
lines changed

17 files changed

+1448
-340
lines changed

.changeset/lovely-doors-greet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pizzajsdev/react-router-hono': minor
3+
---
4+
5+
initial release

packages/app-router-fs/README.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,3 @@ const routes = createRouterConfig(collectedRoutes)
4242

4343
export default routes
4444
```
45-
46-
## Example Routes
47-
48-
![image](https://github.com/user-attachments/assets/c763ce13-4774-432f-849c-171cd3745545)
49-
50-
![image](https://github.com/user-attachments/assets/0424f543-22d8-4739-8fe5-aac171157a5b)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# react-router-hono
2+
3+
React Router v7+ adapter for Hono, compatible with Node and Vercel servers.
4+
5+
## Setup
6+
7+
```bash
8+
echo "@pizzajsdev:registry=https://npm.pkg.github.com" >> .npmrc
9+
pnpm add @pizzajsdev/react-router-hono
10+
```
11+
12+
## Usage
13+
14+
### Vercel
15+
16+
`react-router.config.ts`:
17+
18+
```ts
19+
import type { Config } from '@react-router/dev/config'
20+
import { createAutomaticPreset } from '@pizzajsdev/react-router-hono/presets'
21+
22+
export default {
23+
presets: [createAutomaticPreset()],
24+
} satisfies Config
25+
```
26+
27+
`app/entry.server.tsx`:
28+
29+
```ts
30+
import { handleRequest } from '@pizzajsdev/react-router-hono/server-entry'
31+
32+
export default handleRequest
33+
```
34+
35+
`app/context.server.ts`:
36+
37+
```ts
38+
import type { HttpBindings } from '@hono/node-server'
39+
import type { Context } from 'hono'
40+
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'
41+
42+
export const getLoadContext = async (ctx: Context<{ Bindings: HttpBindings }>) => {
43+
const req = ctx.req.raw
44+
const url = new URL(req.url)
45+
const cookie = req.headers.get('Cookie') ?? ''
46+
const userAgent = req.headers.get('User-Agent')
47+
48+
return {
49+
url,
50+
userAgent,
51+
cookie,
52+
// other data, e.g.:
53+
// lang,
54+
// session
55+
}
56+
}
57+
58+
export interface LoadContext extends Awaited<ReturnType<typeof getLoadContext>> {}
59+
export type LoaderFunctionArgsWithContext = LoaderFunctionArgs<LoadContext>
60+
export type ActionFunctionArgsWithContext = ActionFunctionArgs<LoadContext>
61+
export type ServerFunctionArgsWithContext = LoaderFunctionArgsWithContext | ActionFunctionArgsWithContext
62+
63+
declare module 'react-router' {
64+
interface AppLoadContext extends LoadContext {}
65+
}
66+
```
67+
68+
`app/server.vercel.ts`:
69+
70+
```ts
71+
import { createHonoVercelServer } from '@pizzajsdev/react-router-hono/presets/vercel/server'
72+
import { getLoadContext } from './context.server'
73+
74+
export default await createHonoVercelServer({
75+
getLoadContext: getLoadContext,
76+
})
77+
```
78+
79+
`app/server.node.ts`:
80+
81+
```ts
82+
import { createHonoNodeServer } from '@pizzajsdev/react-router-hono/presets/node/server'
83+
import { getLoadContext } from './context.server'
84+
85+
export default await createHonoNodeServer({
86+
getLoadContext: getLoadContext,
87+
})
88+
```
89+
90+
## Credits
91+
92+
Partially ported from the following projects:
93+
94+
- https://github.com/huijiewei/resolid-react-router-hono
95+
- https://github.com/huijiewei/react-router-hono-vercel-template
96+
97+
Other references:
98+
99+
- https://vercel.com/docs/frameworks/react-router
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"name": "@pizzajsdev/react-router-hono",
3+
"version": "0.0.1",
4+
"description": "React Router v7+ adapter for Hono, compatible with Node and Vercel servers.",
5+
"homepage": "https://github.com/pizzajsdev/pizzajs#readme",
6+
"bugs": {
7+
"url": "https://github.com//pizzajsdev/pizzajs/issues"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/pizzajsdev/pizzajs.git"
12+
},
13+
"license": "MIT",
14+
"sideEffects": false,
15+
"type": "module",
16+
"exports": {
17+
"./*": {
18+
"import": {
19+
"types": "./dist/*.d.ts",
20+
"default": "./dist/*.js"
21+
}
22+
}
23+
},
24+
"files": [
25+
"dist"
26+
],
27+
"scripts": {
28+
"build": "rm -rf dist && tsup --clean",
29+
"postbuild": "publint",
30+
"dev": "tsup --watch",
31+
"typecheck": "tsc --noEmit"
32+
},
33+
"dependencies": {
34+
"@hono/node-server": "^1.14.0",
35+
"@vercel/nft": "^0.29.2",
36+
"esbuild": "^0.25.2",
37+
"hono": "^4.7.5",
38+
"minimatch": "^10.0.1",
39+
"vite": "^6.2.4"
40+
},
41+
"devDependencies": {
42+
"@react-router/dev": "^7.4.1",
43+
"@react-router/node": "^7.4.1",
44+
"@types/node": "^22.13.14",
45+
"@types/react": "^19.0.12",
46+
"@types/react-dom": "^19.0.4",
47+
"isbot": "^5.1.25",
48+
"publint": "^0.3.9",
49+
"react": "^19.0.0",
50+
"react-dom": "^19.0.0",
51+
"react-router": "^7.4.1",
52+
"tsup": "^8.4.0",
53+
"typescript": "^5.8.2"
54+
},
55+
"peerDependencies": {
56+
"@react-router/node": "^7.4.1",
57+
"isbot": "^5.1.25",
58+
"react": "^19.0.0",
59+
"react-dom": "^19.0.0",
60+
"react-router": "^7.4.1"
61+
},
62+
"publishConfig": {
63+
"access": "public",
64+
"registry": "https://npm.pkg.github.com"
65+
}
66+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import esbuild from 'esbuild'
2+
import { readFile, writeFile } from 'node:fs/promises'
3+
import { join } from 'node:path'
4+
import { exit } from 'node:process'
5+
import type { ResolvedConfig } from 'vite'
6+
import type { NodeVersion } from './types'
7+
8+
type PackageJson = {
9+
name: string
10+
type: string
11+
scripts?: Record<string, string>
12+
dependencies?: Record<string, string>
13+
}
14+
15+
type SsrExternal = ResolvedConfig['ssr']['external']
16+
17+
const getPackageDependencies = (dependencies: Record<string, string | undefined>, ssrExternal: SsrExternal) => {
18+
const ssrExternalFiltered = Array.isArray(ssrExternal)
19+
? ssrExternal.filter(
20+
(id) =>
21+
![
22+
'react-router',
23+
'react-router-dom',
24+
'@react-router/architect',
25+
'@react-router/cloudflare',
26+
'@react-router/dev',
27+
'@react-router/express',
28+
'@react-router/node',
29+
'@react-router/serve',
30+
].includes(id),
31+
)
32+
: ssrExternal
33+
34+
return Object.keys(dependencies)
35+
.filter((key) => {
36+
if (ssrExternalFiltered === undefined || ssrExternalFiltered === true) {
37+
return false
38+
}
39+
40+
return ssrExternalFiltered.includes(key)
41+
})
42+
.reduce((obj: Record<string, string>, key) => {
43+
obj[key] = dependencies[key] ?? ''
44+
45+
return obj
46+
}, {})
47+
}
48+
49+
const writePackageJson = async (
50+
pkg: PackageJson,
51+
outputFile: string,
52+
dependencies: unknown,
53+
nodeVersion: NodeVersion,
54+
) => {
55+
const distPkg = {
56+
name: pkg.name,
57+
type: pkg.type,
58+
scripts: {
59+
postinstall: pkg.scripts?.['postinstall'] ?? '',
60+
},
61+
dependencies: dependencies,
62+
engines: {
63+
node: `${nodeVersion}.x`,
64+
},
65+
}
66+
67+
await writeFile(outputFile, JSON.stringify(distPkg, null, 2), 'utf8')
68+
}
69+
70+
export const buildEntry = async (
71+
appPath: string,
72+
entryFile: string,
73+
buildPath: string,
74+
buildFile: string,
75+
buildDir: string,
76+
assetsDir: string,
77+
serverBundleId: string,
78+
packageFile: string,
79+
ssrExternal: string[] | true | undefined,
80+
nodeVersion: NodeVersion,
81+
): Promise<string> => {
82+
console.log(`Bundle Server file for ${serverBundleId}...`)
83+
84+
const pkg = JSON.parse(await readFile(packageFile, 'utf8')) as PackageJson
85+
const packageDependencies = getPackageDependencies({ ...pkg.dependencies }, ssrExternal)
86+
87+
await writePackageJson(pkg, join(buildPath, 'package.json'), packageDependencies, nodeVersion)
88+
89+
const bundleFile = join(buildPath, 'server.mjs')
90+
91+
await esbuild
92+
.build({
93+
outfile: bundleFile,
94+
entryPoints: [join(appPath, entryFile)],
95+
alias: {
96+
'virtual:react-router/server-build': buildFile,
97+
},
98+
define: {
99+
'process.env.NODE_ENV': "'production'",
100+
'import.meta.env.PIZZAJS_BUILD_DIR': `'${buildDir}'`,
101+
'import.meta.env.PIZZAJS_ASSETS_DIR': `'${assetsDir}'`,
102+
},
103+
banner: { js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);" },
104+
platform: 'node',
105+
target: `node${nodeVersion}`,
106+
format: 'esm',
107+
external: ['vite', ...Object.keys(packageDependencies)],
108+
bundle: true,
109+
charset: 'utf8',
110+
legalComments: 'none',
111+
minify: false,
112+
})
113+
.catch((error: unknown) => {
114+
console.error(error)
115+
exit(1)
116+
})
117+
118+
return bundleFile
119+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { type Context, type Env, Hono } from 'hono'
2+
import type { HonoOptions } from 'hono/hono-base'
3+
import type { BlankEnv } from 'hono/types'
4+
import { type AppLoadContext, createRequestHandler, type ServerBuild } from 'react-router'
5+
6+
export type HonoServerOptions<E extends Env = BlankEnv> = {
7+
configure?: <E extends Env = BlankEnv>(app: Hono<E>) => Promise<void> | void
8+
getLoadContext?: (
9+
c: Context,
10+
options: {
11+
build: ServerBuild
12+
mode?: string
13+
},
14+
) => Promise<AppLoadContext> | AppLoadContext
15+
honoOptions?: HonoOptions<E>
16+
}
17+
18+
export const createHonoServer = async <E extends Env = BlankEnv>(
19+
mode: string | undefined,
20+
options: HonoServerOptions<E> = {},
21+
) => {
22+
const server = new Hono<E>(options.honoOptions)
23+
24+
if (options.configure) {
25+
await options.configure(server)
26+
}
27+
28+
server.use('*', async (c) => {
29+
const build: ServerBuild = (await import(
30+
// Virtual module provided by React Router at build time
31+
// @ts-ignore
32+
'virtual:react-router/server-build'
33+
)) as ServerBuild
34+
35+
return (async (c) => {
36+
const requestHandler = createRequestHandler(build, mode)
37+
const loadContext = options.getLoadContext?.(c, { build, mode })
38+
return requestHandler(c.req.raw, loadContext instanceof Promise ? await loadContext : loadContext)
39+
})(c)
40+
})
41+
42+
return server
43+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { nodePreset } from './node/preset'
2+
import type { NodeVersion } from './types'
3+
import { vercelPreset } from './vercel/preset'
4+
5+
/**
6+
* Automatically creates a React Router preset based on the environment. If it detects that it is running in Vercel,
7+
* it will use the Vercel preset, otherwise it will use the Node preset.
8+
*
9+
* @param vercelRegions - The regions to deploy the Vercel functions to. Deploying Serverless Functions to more
10+
* than 3 regions is restricted to the Enterprise plan. Default is ['fra1'] (Frankfurt).
11+
* @param nodeVersion - The Node.js version to use. Default is 22.
12+
* @param entryFiles - The entry files for the Node and Vercel servers, relative to the app directory.
13+
* Default is `{ node: 'server.node.ts', vercel: 'server.vercel.ts' }`.
14+
* @returns A React Router preset.
15+
*
16+
* @example
17+
* ```ts
18+
* // react-router.config.ts
19+
* import type { Config } from '@react-router/dev/config'
20+
* import { createAutomaticPreset } from '@pizzajsdev/react-router-hono/presets'
21+
*
22+
* export default {
23+
* presets: [createAutomaticPreset()],
24+
* } satisfies Config
25+
* ```
26+
*/
27+
export function createAutomaticPreset(
28+
vercelRegions: string[] = ['fra1'],
29+
nodeVersion: NodeVersion = 22,
30+
entryFiles: {
31+
node: string
32+
vercel: string
33+
} = {
34+
node: 'server.node.ts',
35+
vercel: 'server.vercel.ts',
36+
},
37+
) {
38+
return [
39+
process.env['VERCEL'] == '1'
40+
? vercelPreset({
41+
regions: vercelRegions,
42+
entryFile: entryFiles.vercel,
43+
nodeVersion,
44+
})
45+
: nodePreset({
46+
entryFile: entryFiles.node,
47+
nodeVersion,
48+
}),
49+
]
50+
}

0 commit comments

Comments
 (0)