diff --git a/.changeset/fix-2133-bun-deno-stream.md b/.changeset/fix-2133-bun-deno-stream.md new file mode 100644 index 000000000..82cbef61c --- /dev/null +++ b/.changeset/fix-2133-bun-deno-stream.md @@ -0,0 +1,6 @@ +--- +"@solidjs/start": patch +--- + +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 ea6575b08..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,14 +117,9 @@ 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 - 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; + }, + }); +}