Skip to content

Commit

Permalink
feat: introduce Node.js handler (#26)
Browse files Browse the repository at this point in the history
* [WIP] feat: introduce Node.js handler

* add example for node

* add `node` to `format` command

* add load-context for the node example

* add e2e test

* update readme
  • Loading branch information
yusukebe authored Nov 22, 2024
1 parent 1e50149 commit 4eec025
Show file tree
Hide file tree
Showing 22 changed files with 1,157 additions and 202 deletions.
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# hono-remix-adapter

`hono-remix-adapter` is a set of tools for adapting between Hono and Remix. It is composed of a Vite plugin and handlers that enable it to support platforms like Cloudflare Workers. You can create an Hono app, and it will be applied to your Remix app.
`hono-remix-adapter` is a set of tools for adapting between Hono and Remix. It is composed of a Vite plugin and handlers that enable it to support platforms like Cloudflare Workers and Node.js. You just create Hono app, and it will be applied to your Remix app.

```ts
// server/index.ts
Expand Down Expand Up @@ -120,6 +120,42 @@ import server from '../server'
export const onRequest = handle(build, server)
```

## Node.js

If you want to run your app on Node.js, you can use `hono-remix-adapter/node`. Write `main.ts`:

```ts
// main.ts
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import handle from 'hono-remix-adapter/node'
import * as build from './build/server'
import { getLoadContext } from './load-context'
import server from './server'

server.use(
serveStatic({
root: './build/client',
})
)

const handler = handle(build, server, { getLoadContext })

serve({ fetch: handler.fetch, port: 3010 })
```

Run `main.ts` with [`tsx`](https://github.com/privatenumber/tsx):

```bash
tsx main.ts
```

Or you can compile to a pure JavaScript file with `esbuild` with the command below:

```bash
esbuild main.ts --bundle --outfile=main.mjs --platform=node --target=node16.8 --format=esm --banner:js='import { createRequire as topLevelCreateRequire } from "module"; const require = topLevelCreateRequire(import.meta.url);'
```

## `getLoadContext`

If you want to add extra context values when you use Remix routes, like in the following use case:
Expand Down
10 changes: 10 additions & 0 deletions examples/node/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules

test-results

/.cache
/build
.env
.dev.vars

.wrangler
18 changes: 18 additions & 0 deletions examples/node/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.client
*/

import { RemixBrowser } from '@remix-run/react'
import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
)
})
122 changes: 122 additions & 0 deletions examples/node/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/

import { PassThrough } from 'node:stream'

import type { AppLoadContext, EntryContext } from '@remix-run/node'
import { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'

const ABORT_DELAY = 5_000

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
return isbot(request.headers.get('user-agent') || '')
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext)
}

function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
onAllReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)

responseHeaders.set('Content-Type', 'text/html')

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
)

pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
},
}
)

setTimeout(abort, ABORT_DELAY)
})
}

function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
onShellReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)

responseHeaders.set('Content-Type', 'text/html')

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
)

pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
},
}
)

setTimeout(abort, ABORT_DELAY)
})
}
20 changes: 20 additions & 0 deletions examples/node/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Outlet, Scripts } from '@remix-run/react'

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<head>
<meta charSet='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}

export default function App() {
return <Outlet />
}
19 changes: 19 additions & 0 deletions examples/node/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'

export const loader = (args: LoaderFunctionArgs) => {
const extra = args.context.extra
const url = args.context.url
return { extra, url }
}

export default function Index() {
const { extra, url } = useLoaderData<typeof loader>()
return (
<div>
<h1>Remix and Hono</h1>
<h2>URL is {url}</h2>
<h3>Extra is {extra}</h3>
</div>
)
}
24 changes: 24 additions & 0 deletions examples/node/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test'

test('Should return 200 response - /', async ({ page }) => {
const response = await page.goto('/')
expect(response?.status()).toBe(200)

const headers = response?.headers() ?? {}
expect(headers['x-powered-by']).toBe('Remix and Hono')

const contentH1 = await page.textContent('h1')
expect(contentH1).toBe('Remix and Hono')

const contentH2 = await page.textContent('h2')
expect(contentH2).toMatch(/URL is http:\/\/localhost:\d+/)

const contentH3 = await page.textContent('h3')
expect(contentH3).toBe('Extra is stuff')
})

test('Should return 200 response - /api', async ({ page }) => {
const response = await page.goto('/api')
expect(response?.status()).toBe(200)
expect(await response?.json()).toEqual({ message: 'Hello' })
})
17 changes: 17 additions & 0 deletions examples/node/load-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
type GetLoadContextArgs = {
request: Request
}

declare module '@remix-run/node' {
interface AppLoadContext extends ReturnType<typeof getLoadContext> {
url: string
extra: string
}
}

export function getLoadContext(args: GetLoadContextArgs) {
return {
url: args.request.url,
extra: 'stuff',
}
}
17 changes: 17 additions & 0 deletions examples/node/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// main.ts
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import handle from 'hono-remix-adapter/node'
import * as build from './build/server'
import { getLoadContext } from './load-context'
import server from './server'

server.use(
serveStatic({
root: './build/client',
})
)

const handler = handle(build, server, { getLoadContext })

serve({ fetch: handler.fetch, port: 3010 })
40 changes: 40 additions & 0 deletions examples/node/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "example-node",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"start": "remix-serve ./build/server/index.js",
"start-with-adapter": "tsx main.ts",
"test:e2e:vite": "playwright test -c playwright-vite.config.ts e2e.test.ts",
"test:e2e:node": "npm run build && playwright test -c playwright-node.config.ts e2e.test.ts",
"typecheck": "tsc"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0",
"hono": "^4.6.11",
"isbot": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@playwright/test": "^1.48.2",
"@remix-run/dev": "^2.14.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"autoprefixer": "^10.4.19",
"playwright": "^1.47.0",
"tsx": "^4.19.2",
"typescript": "^5.1.6",
"vite": "^5.1.0",
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {
"node": ">=20.0.0"
}
}
26 changes: 26 additions & 0 deletions examples/node/playwright-node.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test'

const port = 3010

export default defineConfig({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: `http://localhost:${port.toString()}`,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 5000,
retries: 2,
},
],
webServer: {
command: 'npm exec tsx ./main.ts',
port,
reuseExistingServer: !process.env.CI,
},
})
Loading

0 comments on commit 4eec025

Please sign in to comment.