fix(studio/serve): pipe Response body via Readable.fromWeb

`for await (chunk of response.body)` works for simple responses but can
leave the web ReadableStream in a "disturbed/locked" state when the
TanStack Start handler peeks at the body internally, surfacing on the
next request as
  TypeError: Response body object should not be disturbed or locked

Switch to `Readable.fromWeb(response.body).pipe(res)` so the stream is
consumed via Node's stream machinery with backpressure and proper
cleanup. Resolves the Promise on `res.close`/`finish` and propagates
errors from either end.
This commit is contained in:
Alaister Young committed 2026-05-17 23:44:34 +08:00
1 parent 868958b16e
commit 0bd9f1bb2d
1 file changed
+24 -11
+24 -11
View File
@@ -15,13 +15,12 @@
// right MIME types and cache headers.
// - Forward everything else to the TanStack Start handler exported
// from `dist/server/server.js`.
import { createServer } from 'node:http'
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { createServer } from 'node:http'
import path from 'node:path'
import { Readable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const studioRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const clientDir = path.join(studioRoot, 'dist/client')
@@ -41,10 +40,7 @@ for (const file of envFiles) {
const m = dotenvLine.exec(raw)
if (!m) continue
let v = m[2]
if (
(v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'"))
) {
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1)
}
parsed[m[1]] = v
@@ -105,7 +101,10 @@ async function serveStatic(req, res) {
if (!st.isFile()) return false
res.statusCode = 200
res.setHeader('content-type', mimeByExt.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream')
res.setHeader(
'content-type',
mimeByExt.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream'
)
res.setHeader('content-length', String(st.size))
res.setHeader(
'cache-control',
@@ -140,10 +139,24 @@ function toWebRequest(req) {
async function pipeWebResponse(response, res) {
res.statusCode = response.status
for (const [k, v] of response.headers) res.setHeader(k, v)
if (response.body) {
for await (const chunk of response.body) res.write(chunk)
if (!response.body) {
res.end()
return
}
res.end()
// Pipe via Readable.fromWeb so the underlying stream gets proper backpressure
// and gets released cleanly. `for await (chunk of response.body)` works in
// simple cases but can leave the body in a "disturbed / locked" state when
// the handler internally peeks at it — surfacing as
// `TypeError: Response body object should not be disturbed or locked` on a
// subsequent request.
await new Promise((resolve, reject) => {
const readable = Readable.fromWeb(response.body)
readable.on('error', reject)
res.on('error', reject)
res.on('close', resolve)
res.on('finish', resolve)
readable.pipe(res)
})
}
const port = Number(process.env.PORT || 8082)