Skip to content

Commit 39d9cd4

Browse files
committed
add more use cases for manual QA: SSE, HTML streaming, cookies, etc., add RSC check and allow custom app instance
1 parent 5ff3a41 commit 39d9cd4

15 files changed

Lines changed: 1406 additions & 119 deletions

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ Fastify adapter for React Router server builds.
44

55
It wraps a React Router server build with a Fastify app, optionally serves the built client files,
66
and returns a runner function that works with
7-
[`node-cluster-serve`](https://github.com/itsjavi/node-cluster-serve) or direct startup code.
7+
[`node-cluster-serve`](https://github.com/dendrell/node-cluster-serve) or direct startup code.
88

99
This package is for production-style server builds. It is not a Fastify development server.
1010

1111
This library also assumes that you handle HTTP response body compression in an upper layer (e.g.
1212
Nginx), so it does not handle that for you.
1313

14+
> **Note**: RSC Server Builds are not supported yet, but any PRs for that are welcome as soon as the
15+
> feature becomes stable in React Router.
16+
1417
## Install
1518

1619
```bash
@@ -138,6 +141,11 @@ declare function createServerRunner(
138141
the public origin.
139142
- If omitted, the adapter falls back to the resolved `host` and `port`.
140143

144+
`options.app`
145+
146+
- Fastify app instance to use for the server.
147+
- If omitted, a new Fastify app is created.
148+
141149
## Behavior
142150

143151
- React Router requests are passed through as standard Web `Request` objects.

demo/app/lib/sse-log.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { appendFile, mkdir, readFile } from 'node:fs/promises'
2+
import path from 'node:path'
3+
4+
export type DemoNotification = {
5+
id: string
6+
message: string
7+
sentAt: string
8+
}
9+
10+
const notificationLogFile = path.resolve(process.cwd(), '.local', 'demo', 'sse-events.ndjson')
11+
12+
async function ensureNotificationLog() {
13+
await mkdir(path.dirname(notificationLogFile), { recursive: true })
14+
await appendFile(notificationLogFile, '', 'utf8')
15+
}
16+
17+
function parseNotificationLines(contents: string): DemoNotification[] {
18+
return contents
19+
.split('\n')
20+
.map((line) => line.trim())
21+
.filter(Boolean)
22+
.flatMap((line) => {
23+
try {
24+
return [JSON.parse(line) as DemoNotification]
25+
} catch {
26+
return []
27+
}
28+
})
29+
}
30+
31+
export async function getNotificationCount(): Promise<number> {
32+
await ensureNotificationLog()
33+
let contents = await readFile(notificationLogFile, 'utf8')
34+
35+
return contents
36+
.split('\n')
37+
.map((line) => line.trim())
38+
.filter(Boolean).length
39+
}
40+
41+
export async function readNotificationsAfter(count: number): Promise<{
42+
notifications: DemoNotification[]
43+
nextCount: number
44+
}> {
45+
await ensureNotificationLog()
46+
let contents = await readFile(notificationLogFile, 'utf8')
47+
let notifications = parseNotificationLines(contents)
48+
49+
return {
50+
notifications: notifications.slice(count),
51+
nextCount: notifications.length,
52+
}
53+
}
54+
55+
export async function publishNotification(message: string): Promise<DemoNotification> {
56+
let normalizedMessage = message.trim() || 'Hello from the demo SSE publisher.'
57+
let notification: DemoNotification = {
58+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
59+
message: normalizedMessage,
60+
sentAt: new Date().toISOString(),
61+
}
62+
63+
await ensureNotificationLog()
64+
await appendFile(notificationLogFile, `${JSON.stringify(notification)}\n`, 'utf8')
65+
66+
return notification
67+
}

demo/app/routes.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1-
import { type RouteConfig, index } from '@react-router/dev/routes'
1+
import { type RouteConfig, index, route } from '@react-router/dev/routes'
22

3-
export default [index('routes/home.tsx')] satisfies RouteConfig
3+
export default [
4+
index('routes/home.tsx'),
5+
route('cookies-demo', 'routes/cookies-demo.ts'),
6+
route('download', 'routes/download.ts'),
7+
route('method-probe', 'routes/method-probe.ts'),
8+
route('redirect-absolute', 'routes/redirect-absolute.ts'),
9+
route('sse', 'routes/sse.ts'),
10+
route('sse/publish', 'routes/sse-publish.ts'),
11+
route('streaming-html', 'routes/streaming-html.ts'),
12+
] satisfies RouteConfig

demo/app/routes/cookies-demo.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
const demoCookieName = 'rrh_demo_cookie'
2+
const extraCookieNames = ['rrh_demo_cookie_a', 'rrh_demo_cookie_b']
3+
4+
type CookieDemoPayload = {
5+
action: 'read' | 'set' | 'set-many' | 'clear'
6+
cookieValue: string | null
7+
cookieHeader: string | null
8+
cookies: Record<string, string>
9+
receivedAt: string
10+
}
11+
12+
function parseCookies(header: string | null): Record<string, string> {
13+
if (!header) return {}
14+
15+
return Object.fromEntries(
16+
header
17+
.split(';')
18+
.map((part) => part.trim())
19+
.filter(Boolean)
20+
.map((part) => {
21+
let separatorIndex = part.indexOf('=')
22+
if (separatorIndex === -1) {
23+
return [part, '']
24+
}
25+
26+
let key = part.slice(0, separatorIndex).trim()
27+
let value = part.slice(separatorIndex + 1).trim()
28+
29+
try {
30+
return [key, decodeURIComponent(value)]
31+
} catch {
32+
return [key, value]
33+
}
34+
}),
35+
)
36+
}
37+
38+
function serializeCookie(value: string | null) {
39+
let base = `${demoCookieName}=${value ? encodeURIComponent(value) : ''}; Path=/; HttpOnly; SameSite=Lax`
40+
return value ? `${base}; Max-Age=${60 * 60 * 24 * 7}` : `${base}; Max-Age=0`
41+
}
42+
43+
function createPayload(request: Request, action: CookieDemoPayload['action']): CookieDemoPayload {
44+
let cookieHeader = request.headers.get('cookie')
45+
let cookies = parseCookies(cookieHeader)
46+
47+
return {
48+
action,
49+
cookieValue: cookies[demoCookieName] ?? null,
50+
cookieHeader,
51+
cookies,
52+
receivedAt: new Date().toISOString(),
53+
}
54+
}
55+
56+
export async function loader({ request }: { request: Request }) {
57+
return Response.json(createPayload(request, 'read'))
58+
}
59+
60+
export async function action({ request }: { request: Request }) {
61+
let formData = await request.formData()
62+
let intent = String(formData.get('intent') ?? 'read') as CookieDemoPayload['action']
63+
64+
if (intent === 'set') {
65+
let value = String(formData.get('value') ?? '').trim() || 'hello-from-cookie-demo'
66+
67+
return Response.json(
68+
{
69+
...createPayload(request, 'set'),
70+
cookieValue: value,
71+
} satisfies CookieDemoPayload,
72+
{
73+
headers: {
74+
'Set-Cookie': serializeCookie(value),
75+
},
76+
},
77+
)
78+
}
79+
80+
if (intent === 'set-many') {
81+
let value = String(formData.get('value') ?? '').trim() || 'hello-from-cookie-demo'
82+
let headers = new Headers()
83+
84+
headers.append('Set-Cookie', serializeCookie(value))
85+
headers.append(
86+
'Set-Cookie',
87+
serializeCookie(`${value}-a`).replace(demoCookieName, extraCookieNames[0]),
88+
)
89+
headers.append(
90+
'Set-Cookie',
91+
serializeCookie(`${value}-b`).replace(demoCookieName, extraCookieNames[1]),
92+
)
93+
94+
return Response.json(
95+
{
96+
...createPayload(request, 'set-many'),
97+
cookieValue: value,
98+
} satisfies CookieDemoPayload,
99+
{
100+
headers,
101+
},
102+
)
103+
}
104+
105+
if (intent === 'clear') {
106+
let headers = new Headers()
107+
headers.append('Set-Cookie', serializeCookie(null))
108+
headers.append('Set-Cookie', serializeCookie(null).replace(demoCookieName, extraCookieNames[0]))
109+
headers.append('Set-Cookie', serializeCookie(null).replace(demoCookieName, extraCookieNames[1]))
110+
111+
return Response.json(createPayload(request, 'clear'), {
112+
headers,
113+
})
114+
}
115+
116+
return Response.json(createPayload(request, 'read'))
117+
}

demo/app/routes/download.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
function sanitizeFilename(input: string): string {
2+
let trimmed = input.trim() || 'react-router-hono-demo.txt'
3+
return trimmed.replace(/[\r\n"]/g, '').replace(/[\\/]/g, '-')
4+
}
5+
6+
function createAsciiFilename(input: string): string {
7+
return input.replace(/[^\x20-\x7E]/g, '_')
8+
}
9+
10+
function createContentDisposition(filename: string): string {
11+
let safeFilename = sanitizeFilename(filename)
12+
let asciiFilename = createAsciiFilename(safeFilename)
13+
let encodedFilename = encodeURIComponent(safeFilename)
14+
15+
return `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`
16+
}
17+
18+
export async function loader({ request }: { request: Request }) {
19+
let url = new URL(request.url)
20+
let filename = url.searchParams.get('filename') ?? 'react-router-hono-demo.txt'
21+
let content = url.searchParams.get('content') ?? 'Hello from react-router-hono.'
22+
23+
let body = new TextEncoder().encode(content)
24+
25+
return new Response(body, {
26+
headers: {
27+
'Content-Disposition': createContentDisposition(filename),
28+
'Content-Length': String(body.byteLength),
29+
'Content-Type': 'text/plain; charset=utf-8',
30+
'X-Demo-Response': 'attachment',
31+
},
32+
})
33+
}

0 commit comments

Comments
 (0)