Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove dependency on @hono/node-server #117

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/weak-mice-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hono/vite-dev-server": minor
---

Remove dependency on @hono/node-server, fixes Bun compatibility by using native Request and Response classes
4 changes: 2 additions & 2 deletions packages/dev-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
},
"homepage": "https://github.com/honojs/vite-plugins",
"devDependencies": {
"@hono/node-server": "^1.8.2",
"@playwright/test": "^1.37.1",
"glob": "^10.3.10",
"hono": "^4.0.1",
Expand All @@ -82,8 +83,7 @@
"node": ">=18.14.1"
},
"dependencies": {
"@hono/node-server": "^1.8.2",
"miniflare": "^3.20231218.2",
"minimatch": "^9.0.3"
}
}
}
2 changes: 1 addition & 1 deletion packages/dev-server/src/dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type http from 'http'
import { getRequestListener } from '@hono/node-server'
import { getRequestListener } from './listener'
import { minimatch } from 'minimatch'
import type { Plugin as VitePlugin, ViteDevServer, Connect } from 'vite'
import { getEnv as cloudflarePagesGetEnv } from './cloudflare-pages/index.js'
Expand Down
163 changes: 163 additions & 0 deletions packages/dev-server/src/listener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Adapted from @hono/node-server

import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node:http'
import { Http2ServerRequest, type Http2ServerResponse } from 'node:http2'
import type { CustomErrorHandler, FetchCallback, HttpBindings } from '@hono/node-server/dist/types'
import { newRequest } from './request'
import { getInternalBody } from './response'
import { buildOutgoingHttpHeaders, writeFromReadableStream } from './utils'

const X_ALREADY_SENT = 'x-hono-already-sent'
const regBuffer = /^no$/i
const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i

const handleFetchError = (e: unknown): Response =>
new Response(null, {
status:
e instanceof Error && (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError')
? 504 // timeout error emits 504 timeout
: 500,
})

const handleResponseError = (e: unknown, outgoing: ServerResponse | Http2ServerResponse) => {
const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & {
code: string
}
if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.info('The user aborted a request.')
} else {
console.error(e)
if (!outgoing.headersSent) {
outgoing.writeHead(500, { 'Content-Type': 'text/plain' })
}
outgoing.end(`Error: ${err.message}`)
outgoing.destroy(err)
}
}

const responseViaResponseObject = async (
res: Response | Promise<Response>,
outgoing: ServerResponse | Http2ServerResponse,
options: { errorHandler?: CustomErrorHandler } = {}
) => {
if (res instanceof Promise) {
if (options.errorHandler) {
try {
res = await res
} catch (err) {
const errRes = await options.errorHandler(err)
if (!errRes) {
return
}
res = errRes
}
} else {
res = await res.catch(handleFetchError)
}
}

const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers)

const internalBody = getInternalBody(res)
if (internalBody) {
if (internalBody.length) {
resHeaderRecord['content-length'] = internalBody.length
}
outgoing.writeHead(res.status, resHeaderRecord)
if (typeof internalBody.source === 'string' || internalBody.source instanceof Uint8Array) {
outgoing.end(internalBody.source)
} else if (internalBody.source instanceof Blob) {
outgoing.end(new Uint8Array(await internalBody.source.arrayBuffer()))
} else {
await writeFromReadableStream(internalBody.stream, outgoing)
}
} else if (res.body) {
/**
* If content-encoding is set, we assume that the response should be not decoded.
* Else if transfer-encoding is set, we assume that the response should be streamed.
* Else if content-length is set, we assume that the response content has been taken care of.
* Else if x-accel-buffering is set to no, we assume that the response should be streamed.
* Else if content-type is not application/json nor text/* but can be text/event-stream,
* we assume that the response should be streamed.
*/

const {
'transfer-encoding': transferEncoding,
'content-encoding': contentEncoding,
'content-length': contentLength,
'x-accel-buffering': accelBuffering,
'content-type': contentType,
} = resHeaderRecord

if (
transferEncoding ||
contentEncoding ||
contentLength ||
// nginx buffering variant
(accelBuffering && regBuffer.test(accelBuffering as string)) ||
!regContentType.test(contentType as string)
) {
outgoing.writeHead(res.status, resHeaderRecord)

await writeFromReadableStream(res.body, outgoing)
} else {
const buffer = await res.arrayBuffer()
resHeaderRecord['content-length'] = buffer.byteLength

outgoing.writeHead(res.status, resHeaderRecord)
outgoing.end(new Uint8Array(buffer))
}
} else if (resHeaderRecord[X_ALREADY_SENT]) {
// do nothing, the response has already been sent
} else {
outgoing.writeHead(res.status, resHeaderRecord)
outgoing.end()
}
}

export const getRequestListener = (
fetchCallback: FetchCallback,
options: { errorHandler?: CustomErrorHandler } = {}
) => {
const abortController = new AbortController()

return async (
incoming: IncomingMessage | Http2ServerRequest,
outgoing: ServerResponse | Http2ServerResponse
) => {
let res
const req = newRequest(incoming, abortController)

// Detect if request was aborted.
outgoing.on('close', () => {
if (incoming.destroyed) {
abortController.abort()
}
})

try {
res = fetchCallback(req, { incoming, outgoing } as HttpBindings) as
| Response
| Promise<Response>
} catch (e: unknown) {
if (!res) {
if (options.errorHandler) {
res = await options.errorHandler(e)
if (!res) {
return
}
} else {
res = handleFetchError(e)
}
} else {
return handleResponseError(e, outgoing)
}
}

try {
return responseViaResponseObject(res, outgoing, options)
} catch (e) {
return handleResponseError(e, outgoing)
}
}
}
58 changes: 58 additions & 0 deletions packages/dev-server/src/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

// Adapted from @hono/node-server
//
// Unlike upstream, doesn't override Request in order to maintain compatibility
// with Bun and potentially other JS runtimes.

import type { IncomingMessage } from 'node:http'
import { Http2ServerRequest } from 'node:http2'
import { Readable } from 'node:stream'
import type { TLSSocket } from 'node:tls'

const newRequestFromIncoming = (
method: string,
url: string,
incoming: IncomingMessage | Http2ServerRequest,
abortController: AbortController
): Request => {
const headerRecord: [string, string][] = []
const rawHeaders = incoming.rawHeaders
for (let i = 0; i < rawHeaders.length; i += 2) {
const { [i]: key, [i + 1]: value } = rawHeaders
if (key.charCodeAt(0) !== /*:*/ 0x3a) {
headerRecord.push([key, value])
}
}

const init = {
method: method,
headers: headerRecord,
signal: abortController.signal,
} as RequestInit

if (!(method === 'GET' || method === 'HEAD')) {
// lazy-consume request body
init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array>
}

return new Request(url, init)
}

export const newRequest = (
incoming: IncomingMessage | Http2ServerRequest,
abortController: AbortController
) => {
const url = new URL(
`${
incoming instanceof Http2ServerRequest ||
(incoming.socket && (incoming.socket as TLSSocket).encrypted)
? 'https'
: 'http'
}://${incoming instanceof Http2ServerRequest ? incoming.authority : incoming.headers.host}${
incoming.url
}`
).href

return newRequestFromIncoming(incoming.method || 'GET', url, incoming, abortController)
}
28 changes: 28 additions & 0 deletions packages/dev-server/src/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Adapted from @hono/node-server
//
// Unlike upstream, doesn't override Response in order to maintain compatibility
// with Bun and potentially other JS runtimes.

interface InternalBody {
source: string | Uint8Array | FormData | Blob | null
stream: ReadableStream
length: number | null
}

const stateKey = Reflect.ownKeys(new Response()).find(
(k) => typeof k === 'symbol' && k.toString() === 'Symbol(state)'
) as symbol | undefined
if (!stateKey) {
console.warn('Failed to find Response internal state key')
}

export function getInternalBody(response: Response): InternalBody | undefined {
if (!stateKey) {
return
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state = (response as any)[stateKey] as { body?: InternalBody } | undefined

return (state && state.body) || undefined
}
63 changes: 63 additions & 0 deletions packages/dev-server/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Adapted from @hono/node-server

import type { OutgoingHttpHeaders } from 'node:http'
import type { Writable } from 'node:stream'

export function writeFromReadableStream(stream: ReadableStream<Uint8Array>, writable: Writable) {
if (stream.locked) {
throw new TypeError('ReadableStream is locked.')
} else if (writable.destroyed) {
stream.cancel()
return
}
const reader = stream.getReader()
writable.on('close', cancel)
writable.on('error', cancel)
reader.read().then(flow, cancel)
return reader.closed.finally(() => {
writable.off('close', cancel)
writable.off('error', cancel)
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function cancel(error?: any) {
reader.cancel(error).catch(() => {})
if (error) {
writable.destroy(error)
}
}
function onDrain() {
reader.read().then(flow, cancel)
}
function flow({ done, value }: ReadableStreamReadResult<Uint8Array>): void | Promise<void> {
try {
if (done) {
writable.end()
} else if (!writable.write(value)) {
writable.once('drain', onDrain)
} else {
return reader.read().then(flow, cancel)
}
} catch (e) {
cancel(e)
}
}
}

export const buildOutgoingHttpHeaders = (headers: Headers): OutgoingHttpHeaders => {
const res: OutgoingHttpHeaders = {}

const cookies = []
for (const [k, v] of headers) {
if (k === 'set-cookie') {
cookies.push(v)
} else {
res[k] = v
}
}
if (cookies.length > 0) {
res['set-cookie'] = cookies
}
res['content-type'] ??= 'text/plain; charset=UTF-8'

return res
}