From 795c43d3d20c14a8134ac571f97a49eef80664f1 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Thu, 16 Jul 2026 20:34:10 -0500 Subject: [PATCH 1/2] fix: hand h3 a standard ReadableStream in dev The dev path returned the raw Solid stream object ({ pipe, pipeTo }) from renderToStream. That only renders on Node by accident: srvx's NodeResponse duck-types `.pipe` on the body. Under Bun and Deno, srvx's FastResponse is the native Response, which coerces the object to the literal string "[object Object]" - a blank page. Always pipe through a TransformStream like the production path so h3 receives a standard web ReadableStream on every runtime. The special case guarded against a dev-server crash when a stream was cancelled mid-flight; that no longer reproduces (solid-js's pipeTo catches every write/close since 1.9), verified by aborting requests mid-stream under both node and bun with a slow Suspense boundary. Co-Authored-By: Claude Fable 5 --- .changeset/fix-2133-bun-deno-stream.md | 5 +++++ packages/start/src/server/handler.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-2133-bun-deno-stream.md diff --git a/.changeset/fix-2133-bun-deno-stream.md b/.changeset/fix-2133-bun-deno-stream.md new file mode 100644 index 000000000..daeb30401 --- /dev/null +++ b/.changeset/fix-2133-bun-deno-stream.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +fix: always pipe SSR output through a `TransformStream` in dev so h3 receives a standard `ReadableStream`. Returning the raw Solid stream only rendered on Node by accident; under Bun and Deno the response body was coerced to the literal string `[object Object]` (#2133) diff --git a/packages/start/src/server/handler.ts b/packages/start/src/server/handler.ts index ea6575b08..98dbbc4ad 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -116,11 +116,12 @@ export function createBaseHandler( delete (stream as any).then; - // using TransformStream in dev can cause solid-start-dev-server to crash - // when stream is cancelled - if (globalThis.USING_SOLID_START_DEV_SERVER) return stream; - - // returning stream directly breaks cloudflare workers + // Always pipe through a TransformStream so h3 receives a standard web + // ReadableStream. Returning the raw Solid stream only renders on Node + // by accident (srvx's NodeResponse duck-types `.pipe`); on Bun and + // Deno srvx's FastResponse is the native Response, which coerces the + // object to the string "[object Object]" (#2133). Returning the raw + // stream also breaks Cloudflare Workers. const { writable, readable } = new TransformStream(); stream.pipeTo(writable); return readable; From 2f116edbf30c5780e1526a3250081f80cd978e30 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Sat, 18 Jul 2026 22:05:51 +0200 Subject: [PATCH 2/2] add web stream adapter --- .changeset/fix-2133-bun-deno-stream.md | 3 +- packages/start/src/server/handler.ts | 13 ++-- packages/start/src/server/web-stream.spec.ts | 73 ++++++++++++++++++++ packages/start/src/server/web-stream.ts | 29 ++++++++ 4 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 packages/start/src/server/web-stream.spec.ts create mode 100644 packages/start/src/server/web-stream.ts diff --git a/.changeset/fix-2133-bun-deno-stream.md b/.changeset/fix-2133-bun-deno-stream.md index daeb30401..82cbef61c 100644 --- a/.changeset/fix-2133-bun-deno-stream.md +++ b/.changeset/fix-2133-bun-deno-stream.md @@ -2,4 +2,5 @@ "@solidjs/start": patch --- -fix: always pipe SSR output through a `TransformStream` in dev so h3 receives a standard `ReadableStream`. Returning the raw Solid stream only rendered on Node by accident; under Bun and Deno the response body was coerced to the literal string `[object Object]` (#2133) +Return a cancellation-safe web `ReadableStream` for streaming SSR in development. Returning Solid's +raw stream only rendered on Node; Bun and Deno coerced it to `[object Object]`. diff --git a/packages/start/src/server/handler.ts b/packages/start/src/server/handler.ts index 98dbbc4ad..bbf703ebc 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -12,6 +12,7 @@ import { matchAPIRoute } from "./routes.ts"; import { handleServerFunction } from "../fns/handler.ts"; import type { APIEvent, FetchEvent, HandlerOptions, PageEvent } from "./types.ts"; import { getExpectedRedirectStatus } from "./util.ts"; +import { toWebReadableStream } from "./web-stream.ts"; const SERVER_FN_BASE = "/_server"; @@ -116,15 +117,9 @@ export function createBaseHandler( delete (stream as any).then; - // Always pipe through a TransformStream so h3 receives a standard web - // ReadableStream. Returning the raw Solid stream only renders on Node - // by accident (srvx's NodeResponse duck-types `.pipe`); on Bun and - // Deno srvx's FastResponse is the native Response, which coerces the - // object to the string "[object Object]" (#2133). Returning the raw - // stream also breaks Cloudflare Workers. - const { writable, readable } = new TransformStream(); - stream.pipeTo(writable); - return readable; + // h3 expects a standard web ReadableStream across runtimes. The adapter + // also tolerates cancellation while Solid finishes outstanding work. + return toWebReadableStream(stream); }), }); diff --git a/packages/start/src/server/web-stream.spec.ts b/packages/start/src/server/web-stream.spec.ts new file mode 100644 index 000000000..945272816 --- /dev/null +++ b/packages/start/src/server/web-stream.spec.ts @@ -0,0 +1,73 @@ +import { createComponent, createResource, Suspense } from "solid-js"; +import { renderToStream } from "solid-js/web"; +import { describe, expect, it } from "vitest"; + +import { toWebReadableStream } from "./web-stream.ts"; + +describe("toWebReadableStream", () => { + it("returns encoded output as a standard ReadableStream", async () => { + const readable = toWebReadableStream({ + pipe(writable) { + writable.write("Hello, "); + writable.write("world!"); + writable.end(); + }, + }); + + expect(readable).toBeInstanceOf(ReadableStream); + await expect(new Response(readable).text()).resolves.toBe("Hello, world!"); + }); + + it("ignores writes after the consumer cancels", async () => { + let writable!: { write(payload: string): void; end(): void }; + const readable = toWebReadableStream({ + pipe(value) { + writable = value; + writable.write("shell"); + }, + }); + const reader = readable.getReader(); + + const shell = await reader.read(); + expect(new TextDecoder().decode(shell.value)).toBe("shell"); + + await reader.cancel("client disconnected"); + + expect(() => writable.write("late Suspense content")).not.toThrow(); + expect(() => writable.end()).not.toThrow(); + }); + + it("allows Solid to finish Suspense work after cancellation", async () => { + let resolveData!: (value: string) => void; + let resolveComplete!: () => void; + const complete = new Promise(resolve => { + resolveComplete = resolve; + }); + const stream = renderToStream( + () => { + const [data] = createResource( + () => + new Promise(resolve => { + resolveData = resolve; + }), + ); + return createComponent(Suspense, { + fallback: "loading", + get children() { + return data(); + }, + }); + }, + { onCompleteAll: () => resolveComplete() }, + ); + const reader = toWebReadableStream(stream).getReader(); + + const shell = await reader.read(); + expect(new TextDecoder().decode(shell.value)).toContain("loading"); + + await reader.cancel("client disconnected"); + resolveData("late Suspense content"); + + await expect(complete).resolves.toBeUndefined(); + }); +}); diff --git a/packages/start/src/server/web-stream.ts b/packages/start/src/server/web-stream.ts new file mode 100644 index 000000000..900e43039 --- /dev/null +++ b/packages/start/src/server/web-stream.ts @@ -0,0 +1,29 @@ +type PipeableStream = { + pipe(writable: { write(payload: string): void; end(): void }): void; +}; + +/** Convert Solid's streaming SSR result into a cancellation-safe web stream. */ +export function toWebReadableStream(stream: PipeableStream): ReadableStream { + const encoder = new TextEncoder(); + let active = true; + + return new ReadableStream({ + start(controller) { + stream.pipe({ + write(payload) { + if (active) controller.enqueue(encoder.encode(payload)); + }, + end() { + if (!active) return; + active = false; + controller.close(); + }, + }); + }, + cancel() { + // Solid may still resolve Suspense resources after the response is + // cancelled. Ignore those writes and let Solid finish its cleanup. + active = false; + }, + }); +}