Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/fix-2133-bun-deno-stream.md
Original file line number Diff line number Diff line change
@@ -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]`.
12 changes: 4 additions & 8 deletions packages/start/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}),
});

Expand Down
73 changes: 73 additions & 0 deletions packages/start/src/server/web-stream.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>(resolve => {
resolveComplete = resolve;
});
const stream = renderToStream(
() => {
const [data] = createResource(
() =>
new Promise<string>(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();
});
});
29 changes: 29 additions & 0 deletions packages/start/src/server/web-stream.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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;
},
});
}
Loading