Skip to content

Commit d8ba36a

Browse files
committed
Merge remote-tracking branch 'upstream/main' into fix-dangeling-promise
2 parents b2c85f5 + 35cda9e commit d8ba36a

File tree

15 files changed

+973
-81
lines changed

15 files changed

+973
-81
lines changed

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,15 @@ jobs:
2929
- run: bun run lint
3030
- run: bun run build
3131
- run: bun run test
32+
33+
ci-windows:
34+
runs-on: windows-latest
35+
36+
steps:
37+
- uses: actions/checkout@v4
38+
- uses: actions/setup-node@v4
39+
with:
40+
node-version: 22.x
41+
- uses: oven-sh/setup-bun@v2
42+
- run: bun install
43+
- run: bun run test

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ serve({
125125
})
126126
```
127127

128+
### `autoCleanupIncoming`
129+
130+
The default value is `true`. The Node.js Adapter automatically cleans up (explicitly call `destroy()` method) if application is not finished to consume the incoming request. If you don't want to do that, set `false`.
131+
132+
If the application accepts connections from arbitrary clients, this cleanup must be done otherwise incomplete requests from clients may cause the application to stop responding. If your application only accepts connections from trusted clients, such as in a reverse proxy environment and there is no process that returns a response without reading the body of the POST request all the way through, you can improve performance by setting it to `false`.
133+
134+
```ts
135+
serve({
136+
fetch: app.fetch,
137+
autoCleanupIncoming: false,
138+
})
139+
```
140+
128141
## Middleware
129142

130143
Most built-in middleware also works with Node.js.
@@ -155,7 +168,7 @@ import { serveStatic } from '@hono/node-server/serve-static'
155168
app.use('/static/*', serveStatic({ root: './' }))
156169
```
157170

158-
Note that `root` must be _relative_ to the current working directory from which the app was started. Absolute paths are not supported.
171+
If using a relative path, `root` will be relative to the current working directory from which the app was started.
159172

160173
This can cause confusion when running your application locally.
161174

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module.exports = {
22
testMatch: ['**/test/**/*.+(ts)', '**/src/**/(*.)+(test).+(ts)'],
3-
modulePathIgnorePatterns: ["test/setup.ts"],
3+
modulePathIgnorePatterns: ["test/setup.ts", "test/app.ts"],
44
transform: {
55
'^.+\\.(ts)$': 'ts-jest',
66
},

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hono/node-server",
3-
"version": "1.15.0",
3+
"version": "1.17.0",
44
"description": "Node.js Adapter for Hono",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
@@ -54,7 +54,7 @@
5454
}
5555
},
5656
"scripts": {
57-
"test": "node --expose-gc ./node_modules/.bin/jest",
57+
"test": "node --expose-gc node_modules/jest/bin/jest.js",
5858
"build": "tsup --external hono",
5959
"watch": "tsup --watch",
6060
"postbuild": "publint",

src/listener.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node:http'
2-
import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2'
2+
import { Http2ServerRequest } from 'node:http2'
3+
import type { Http2ServerResponse } from 'node:http2'
4+
import type { IncomingMessageWithWrapBodyStream } from './request'
35
import {
46
abortControllerKey,
57
newRequest,
68
Request as LightweightRequest,
9+
wrapBodyStream,
710
toRequestError,
811
} from './request'
912
import { cacheKey, Response as LightweightResponse } from './response'
@@ -13,6 +16,11 @@ import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils'
1316
import { X_ALREADY_SENT } from './utils/response/constants'
1417
import './globals'
1518

19+
const outgoingEnded = Symbol('outgoingEnded')
20+
type OutgoingHasOutgoingEnded = Http2ServerResponse & {
21+
[outgoingEnded]?: () => void
22+
}
23+
1624
const regBuffer = /^no$/i
1725
const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i
1826

@@ -78,10 +86,12 @@ const responseViaCache = async (
7886
outgoing.end(new Uint8Array(await body.arrayBuffer()))
7987
} else {
8088
flushHeaders(outgoing)
81-
return writeFromReadableStream(body, outgoing)?.catch(
89+
await writeFromReadableStream(body, outgoing)?.catch(
8290
(e) => handleResponseError(e, outgoing) as undefined
8391
)
8492
}
93+
94+
;(outgoing as OutgoingHasOutgoingEnded)[outgoingEnded]?.()
8595
}
8696

8797
const responseViaResponseObject = async (
@@ -154,6 +164,8 @@ const responseViaResponseObject = async (
154164
outgoing.writeHead(res.status, resHeaderRecord)
155165
outgoing.end()
156166
}
167+
168+
;(outgoing as OutgoingHasOutgoingEnded)[outgoingEnded]?.()
157169
}
158170

159171
export const getRequestListener = (
@@ -162,8 +174,10 @@ export const getRequestListener = (
162174
hostname?: string
163175
errorHandler?: CustomErrorHandler
164176
overrideGlobalObjects?: boolean
177+
autoCleanupIncoming?: boolean
165178
} = {}
166179
) => {
180+
const autoCleanupIncoming = options.autoCleanupIncoming ?? true
167181
if (options.overrideGlobalObjects !== false && global.Request !== LightweightRequest) {
168182
Object.defineProperty(global, 'Request', {
169183
value: LightweightRequest,
@@ -185,17 +199,59 @@ export const getRequestListener = (
185199
// so generate a pseudo Request object with only the minimum required information.
186200
req = newRequest(incoming, options.hostname)
187201

202+
let incomingEnded =
203+
!autoCleanupIncoming || incoming.method === 'GET' || incoming.method === 'HEAD'
204+
if (!incomingEnded) {
205+
;(incoming as IncomingMessageWithWrapBodyStream)[wrapBodyStream] = true
206+
incoming.on('end', () => {
207+
incomingEnded = true
208+
})
209+
210+
if (incoming instanceof Http2ServerRequest) {
211+
// a Http2ServerResponse instance requires additional processing on exit
212+
// since outgoing.on('close') is not called even after outgoing.end() is called
213+
// when the state is incomplete
214+
;(outgoing as OutgoingHasOutgoingEnded)[outgoingEnded] = () => {
215+
// incoming is not consumed to the end
216+
if (!incomingEnded) {
217+
setTimeout(() => {
218+
// in the case of a simple POST request, the cleanup process may be done automatically
219+
// and end is called at this point. At that point, nothing is done.
220+
if (!incomingEnded) {
221+
setTimeout(() => {
222+
incoming.destroy()
223+
// a Http2ServerResponse instance will not terminate without also calling outgoing.destroy()
224+
outgoing.destroy()
225+
})
226+
}
227+
})
228+
}
229+
}
230+
}
231+
}
232+
188233
// Detect if request was aborted.
189234
outgoing.on('close', () => {
190235
const abortController = req[abortControllerKey] as AbortController | undefined
191-
if (!abortController) {
192-
return
236+
if (abortController) {
237+
if (incoming.errored) {
238+
req[abortControllerKey].abort(incoming.errored.toString())
239+
} else if (!outgoing.writableFinished) {
240+
req[abortControllerKey].abort('Client connection prematurely closed.')
241+
}
193242
}
194243

195-
if (incoming.errored) {
196-
req[abortControllerKey].abort(incoming.errored.toString())
197-
} else if (!outgoing.writableFinished) {
198-
req[abortControllerKey].abort('Client connection prematurely closed.')
244+
// incoming is not consumed to the end
245+
if (!incomingEnded) {
246+
setTimeout(() => {
247+
// in the case of a simple POST request, the cleanup process may be done automatically
248+
// and end is called at this point. At that point, nothing is done.
249+
if (!incomingEnded) {
250+
setTimeout(() => {
251+
incoming.destroy()
252+
})
253+
}
254+
})
199255
}
200256
})
201257

src/request.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import type { IncomingMessage } from 'node:http'
55
import { Http2ServerRequest } from 'node:http2'
66
import { Readable } from 'node:stream'
7+
import type { ReadableStreamDefaultReader } from 'node:stream/web'
78
import type { TLSSocket } from 'node:tls'
89

910
export class RequestError extends Error {
@@ -41,6 +42,8 @@ export class Request extends GlobalRequest {
4142
}
4243
}
4344

45+
export type IncomingMessageWithWrapBodyStream = IncomingMessage & { [wrapBodyStream]: boolean }
46+
export const wrapBodyStream = Symbol('wrapBodyStream')
4447
const newRequestFromIncoming = (
4548
method: string,
4649
url: string,
@@ -83,6 +86,23 @@ const newRequestFromIncoming = (
8386
controller.close()
8487
},
8588
})
89+
} else if ((incoming as IncomingMessageWithWrapBodyStream)[wrapBodyStream]) {
90+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
91+
init.body = new ReadableStream({
92+
async pull(controller) {
93+
try {
94+
reader ||= Readable.toWeb(incoming).getReader()
95+
const { done, value } = await reader.read()
96+
if (done) {
97+
controller.close()
98+
} else {
99+
controller.enqueue(value)
100+
}
101+
} catch (error) {
102+
controller.error(error)
103+
}
104+
},
105+
})
86106
} else {
87107
// lazy-consume request body
88108
init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array>

src/serve-static.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Context, Env, MiddlewareHandler } from 'hono'
2-
import { getFilePath, getFilePathWithoutDefaultDocument } from 'hono/utils/filepath'
32
import { getMimeType } from 'hono/utils/mime'
4-
import { createReadStream, lstatSync } from 'node:fs'
53
import type { ReadStream, Stats } from 'node:fs'
4+
import { createReadStream, lstatSync } from 'node:fs'
5+
import { join, resolve } from 'node:path'
66

77
export type ServeStaticOptions<E extends Env = Env> = {
88
/**
@@ -44,10 +44,6 @@ const createStreamBody = (stream: ReadStream) => {
4444
return body
4545
}
4646

47-
const addCurrentDirPrefix = (path: string) => {
48-
return `./${path}`
49-
}
50-
5147
const getStats = (path: string) => {
5248
let stats: Stats | undefined
5349
try {
@@ -60,6 +56,9 @@ const getStats = (path: string) => {
6056
export const serveStatic = <E extends Env = any>(
6157
options: ServeStaticOptions<E> = { root: '' }
6258
): MiddlewareHandler<E> => {
59+
const root = resolve(options.root || '.')
60+
const optionPath = options.path
61+
6362
return async (c, next) => {
6463
// Do nothing if Response is already set
6564
if (c.finalized) {
@@ -69,35 +68,40 @@ export const serveStatic = <E extends Env = any>(
6968
let filename: string
7069

7170
try {
72-
filename = options.path ?? decodeURIComponent(c.req.path)
71+
const rawPath = optionPath ?? c.req.path
72+
// Prevent encoded path traversal attacks
73+
if (!optionPath) {
74+
const decodedPath = decodeURIComponent(rawPath)
75+
if (decodedPath.includes('..')) {
76+
await options.onNotFound?.(rawPath, c)
77+
return next()
78+
}
79+
}
80+
filename = optionPath ?? decodeURIComponent(c.req.path)
7381
} catch {
7482
await options.onNotFound?.(c.req.path, c)
7583
return next()
7684
}
7785

78-
let path = getFilePathWithoutDefaultDocument({
79-
filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename,
80-
root: options.root,
81-
})
86+
const requestPath = options.rewriteRequestPath
87+
? options.rewriteRequestPath(filename, c)
88+
: filename
8289

83-
if (path) {
84-
path = addCurrentDirPrefix(path)
85-
} else {
86-
return next()
87-
}
90+
let path = optionPath
91+
? options.root
92+
? resolve(join(root, optionPath))
93+
: optionPath
94+
: resolve(join(root, requestPath))
8895

8996
let stats = getStats(path)
9097

9198
if (stats && stats.isDirectory()) {
92-
path = getFilePath({
93-
filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename,
94-
root: options.root,
95-
defaultDocument: options.index ?? 'index.html',
96-
})
99+
const indexFile = options.index ?? 'index.html'
100+
path = resolve(join(path, indexFile))
97101

98-
if (path) {
99-
path = addCurrentDirPrefix(path)
100-
} else {
102+
// Security check: prevent path traversal attacks
103+
if (!optionPath && !path.startsWith(root)) {
104+
await options.onNotFound?.(path, c)
101105
return next()
102106
}
103107

src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const createAdaptorServer = (options: Options): ServerType => {
88
const requestListener = getRequestListener(fetchCallback, {
99
hostname: options.hostname,
1010
overrideGlobalObjects: options.overrideGlobalObjects,
11+
autoCleanupIncoming: options.autoCleanupIncoming,
1112
})
1213
// ts will complain about createServerHTTP and createServerHTTP2 not being callable, which works just fine
1314
// eslint-disable-next-line @typescript-eslint/no-explicit-any

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export type ServerOptions =
7070
export type Options = {
7171
fetch: FetchCallback
7272
overrideGlobalObjects?: boolean
73+
autoCleanupIncoming?: boolean
7374
port?: number
7475
hostname?: string
7576
} & ServerOptions

src/utils.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,43 @@ export function writeFromReadableStream(stream: ReadableStream<Uint8Array>, writ
77
} else if (writable.destroyed) {
88
return stream.cancel()
99
}
10+
1011
const reader = stream.getReader()
11-
writable.on('close', cancel)
12-
writable.on('error', cancel)
13-
reader.read().then(flow, cancel)
12+
13+
const handleError = () => {
14+
// ignore the error
15+
}
16+
17+
writable.on('error', handleError)
18+
19+
reader.read().then(flow, handleStreamError)
20+
1421
return reader.closed.finally(() => {
15-
writable.off('close', cancel)
16-
writable.off('error', cancel)
22+
writable.off('error', handleError)
1723
})
24+
1825
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19-
function cancel(error?: any) {
20-
reader.cancel(error).catch(() => {})
26+
function handleStreamError(error: any) {
2127
if (error) {
2228
writable.destroy(error)
2329
}
2430
}
31+
2532
function onDrain() {
26-
reader.read().then(flow, cancel)
33+
reader.read().then(flow, handleStreamError)
2734
}
35+
2836
function flow({ done, value }: ReadableStreamReadResult<Uint8Array>): void | Promise<void> {
2937
try {
3038
if (done) {
3139
writable.end()
3240
} else if (!writable.write(value)) {
3341
writable.once('drain', onDrain)
3442
} else {
35-
return reader.read().then(flow, cancel)
43+
return reader.read().then(flow, handleStreamError)
3644
}
3745
} catch (e) {
38-
cancel(e)
46+
handleStreamError(e)
3947
}
4048
}
4149
}

0 commit comments

Comments
 (0)