diff --git a/e2e/solid-start/basic-cloudflare/src/routes/index.tsx b/e2e/solid-start/basic-cloudflare/src/routes/index.tsx index 76e285a2baf..d3f3d7291b6 100644 --- a/e2e/solid-start/basic-cloudflare/src/routes/index.tsx +++ b/e2e/solid-start/basic-cloudflare/src/routes/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/solid-router' import { createServerFn } from '@tanstack/solid-start' +import { getRequestHeader } from '@tanstack/solid-start/server' import { env } from 'cloudflare:workers' export const Route = createFileRoute('/')({ @@ -11,6 +12,7 @@ const getData = createServerFn().handler(() => { return { message: `Running in ${navigator.userAgent}`, myVar: env.MY_VAR, + requestHeader: getRequestHeader('x-solid-start-context'), } }) @@ -22,6 +24,7 @@ function Home() {

Welcome Home!!!

{data().message}

{data().myVar}

+

{data().requestHeader}

) } diff --git a/e2e/solid-start/basic-cloudflare/tests/app.spec.ts b/e2e/solid-start/basic-cloudflare/tests/app.spec.ts index add28cb73ee..6160dabe11a 100644 --- a/e2e/solid-start/basic-cloudflare/tests/app.spec.ts +++ b/e2e/solid-start/basic-cloudflare/tests/app.spec.ts @@ -17,6 +17,16 @@ test('returns the correct value from a Cloudflare binding', async ({ await expect(page.getByTestId('myVar')).toHaveText('Hello from Cloudflare') }) +test('preserves request context with nodejs_als', async ({ page }) => { + await page.setExtraHTTPHeaders({ + 'x-solid-start-context': 'request-context-works', + }) + await page.goto('/') + await expect(page.getByTestId('requestHeader')).toHaveText( + 'request-context-works', + ) +}) + test('prerender with Cloudflare Workers runtime', async ({ page }) => { // Verify the static page was prerendered during build const distDir = join(process.cwd(), 'dist', 'client') diff --git a/e2e/solid-start/basic-cloudflare/worker-configuration.d.ts b/e2e/solid-start/basic-cloudflare/worker-configuration.d.ts index f7a8f163972..be4bba60c37 100644 --- a/e2e/solid-start/basic-cloudflare/worker-configuration.d.ts +++ b/e2e/solid-start/basic-cloudflare/worker-configuration.d.ts @@ -1,18 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b11df627d8b3c51b1bf3230a546b0f20) -// Runtime types generated with workerd@1.20260317.1 2025-09-24 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 24a3fe972e2c4aaa57bb30eaba75ee7a) +// Runtime types generated with workerd@1.20260317.1 2025-09-24 nodejs_als declare namespace Cloudflare { interface Env { MY_VAR: "Hello from Cloudflare"; } } interface Env extends Cloudflare.Env {} -type StringifyValues> = { - [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; -}; -declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} -} // Begin runtime types /*! ***************************************************************************** diff --git a/e2e/solid-start/basic-cloudflare/wrangler.jsonc b/e2e/solid-start/basic-cloudflare/wrangler.jsonc index 833d63d85a6..d218706c38b 100644 --- a/e2e/solid-start/basic-cloudflare/wrangler.jsonc +++ b/e2e/solid-start/basic-cloudflare/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "tanstack-start-app", "compatibility_date": "2025-09-24", - "compatibility_flags": ["nodejs_compat"], + "compatibility_flags": ["nodejs_als"], "main": "@tanstack/solid-start/server-entry", "vars": { "MY_VAR": "Hello from Cloudflare", diff --git a/examples/solid/start-basic-cloudflare/worker-configuration.d.ts b/examples/solid/start-basic-cloudflare/worker-configuration.d.ts index f7a8f163972..be4bba60c37 100644 --- a/examples/solid/start-basic-cloudflare/worker-configuration.d.ts +++ b/examples/solid/start-basic-cloudflare/worker-configuration.d.ts @@ -1,18 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b11df627d8b3c51b1bf3230a546b0f20) -// Runtime types generated with workerd@1.20260317.1 2025-09-24 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 24a3fe972e2c4aaa57bb30eaba75ee7a) +// Runtime types generated with workerd@1.20260317.1 2025-09-24 nodejs_als declare namespace Cloudflare { interface Env { MY_VAR: "Hello from Cloudflare"; } } interface Env extends Cloudflare.Env {} -type StringifyValues> = { - [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; -}; -declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} -} // Begin runtime types /*! ***************************************************************************** diff --git a/examples/solid/start-basic-cloudflare/wrangler.jsonc b/examples/solid/start-basic-cloudflare/wrangler.jsonc index 833d63d85a6..d218706c38b 100644 --- a/examples/solid/start-basic-cloudflare/wrangler.jsonc +++ b/examples/solid/start-basic-cloudflare/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "tanstack-start-app", "compatibility_date": "2025-09-24", - "compatibility_flags": ["nodejs_compat"], + "compatibility_flags": ["nodejs_als"], "main": "@tanstack/solid-start/server-entry", "vars": { "MY_VAR": "Hello from Cloudflare", diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index abfe36b86de..d46cdeb825b 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -3,11 +3,10 @@ import ReactDOMServer from 'react-dom/server' import { isbot } from 'isbot' import { createSsrStreamResponse, - transformPipeableStreamWithRouter, transformReadableStreamWithRouter, } from '@tanstack/router-core/ssr/server' +import { transformPipeableStreamWithRouter } from './transform-pipeable-stream-with-router' import type { AnyRouter } from '@tanstack/router-core' -import type { ReadableStream } from 'node:stream/web' import type { ReactNode } from 'react' const noop = () => {} diff --git a/packages/react-router/src/ssr/transform-pipeable-stream-with-router.ts b/packages/react-router/src/ssr/transform-pipeable-stream-with-router.ts new file mode 100644 index 00000000000..b54b5353a36 --- /dev/null +++ b/packages/react-router/src/ssr/transform-pipeable-stream-with-router.ts @@ -0,0 +1,16 @@ +import { Readable } from 'node:stream' +import { transformStreamWithRouter } from '@tanstack/router-core/ssr/server' +import type { TransformStreamWithRouterOptions } from '@tanstack/router-core/ssr/server' +import type { AnyRouter } from '@tanstack/router-core' +import type { ReadableStream as NodeReadableStream } from 'node:stream/web' + +export function transformPipeableStreamWithRouter( + router: AnyRouter, + routerStream: Readable, + opts?: TransformStreamWithRouterOptions, +) { + const appStream = Readable.toWeb(routerStream) as unknown as ReadableStream + const transformedStream = transformStreamWithRouter(router, appStream, opts) + + return Readable.fromWeb(transformedStream as unknown as NodeReadableStream) +} diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index f42456b7bec..13b7ef7568a 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -1,6 +1,8 @@ +import { PassThrough } from 'node:stream' import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' import { createMemoryHistory, createRootRoute, createRouter } from '../src' +import { transformPipeableStreamWithRouter } from '../src/ssr/transform-pipeable-stream-with-router' const reactDomServerMocks = vi.hoisted(() => ({ renderToReadableStream: undefined as undefined | (() => unknown), @@ -261,4 +263,35 @@ describe('renderRouterToStream - pipeable sync errors', () => { router.serverSsr?.cleanup() } }) + + test('destroying the pipeable response cancels the React adapter', async () => { + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const input = new PassThrough() + let aborts = 0 + const output = transformPipeableStreamWithRouter(router, input, { + onAbort: () => { + aborts++ + }, + }) + output.on('error', () => {}) + + try { + input.write('

x

') + await new Promise((resolve) => setImmediate(resolve)) + + output.destroy(new Error('client gone')) + await new Promise((resolve) => setImmediate(resolve)) + await Promise.resolve() + + expect(aborts).toEqual(1) + expect(input.destroyed).toEqual(true) + expect(cleanup).toHaveBeenCalledOnce() + } finally { + if (!input.destroyed) { + input.destroy() + } + router.serverSsr?.cleanup() + } + }) }) diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts index 93973999cbb..8a6fa73ed36 100644 --- a/packages/router-core/src/ssr/server.ts +++ b/packages/router-core/src/ssr/server.ts @@ -17,7 +17,6 @@ export type { SsrResponse, } from './handlerCallback' export { - transformPipeableStreamWithRouter, transformStreamWithRouter, transformReadableStreamWithRouter, } from './transformStreamWithRouter' diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 03c6ecf2e13..bbdac533caf 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -1,5 +1,3 @@ -import { ReadableStream } from 'node:stream/web' -import { Readable } from 'node:stream' import { TSR_SCRIPT_BARRIER_ID } from './constants' import type { AnyRouter } from '../router' @@ -28,16 +26,6 @@ export function transformReadableStreamWithRouter( return transformStreamWithRouter(router, routerStream, opts) } -export function transformPipeableStreamWithRouter( - router: AnyRouter, - routerStream: Readable, - opts?: TransformStreamWithRouterOptions, -) { - return Readable.fromWeb( - transformStreamWithRouter(router, Readable.toWeb(routerStream), opts), - ) -} - // Minimum length of a valid closing tag: = 4 characters const MIN_CLOSING_TAG_LENGTH = 4 diff --git a/packages/router-core/tests/transformStreamBackpressure.perf.test.ts b/packages/router-core/tests/transformStreamBackpressure.perf.test.ts index 267241f4164..f3e92c596f1 100644 --- a/packages/router-core/tests/transformStreamBackpressure.perf.test.ts +++ b/packages/router-core/tests/transformStreamBackpressure.perf.test.ts @@ -8,7 +8,6 @@ // transformStreamWithRouter calls controller.enqueue unconditionally, // allowing controller.[[queue]] to accumulate Uint8Array chunks (external // memory) when the consumer drains slower than the producer. -import { ReadableStream } from 'node:stream/web' import { describe, expect, it } from 'vitest' import { transformStreamWithRouter } from '../src/ssr/transformStreamWithRouter' diff --git a/packages/router-core/tests/transformStreamWithRouter.test.ts b/packages/router-core/tests/transformStreamWithRouter.test.ts index 903efeb0c95..fbc4f9493b1 100644 --- a/packages/router-core/tests/transformStreamWithRouter.test.ts +++ b/packages/router-core/tests/transformStreamWithRouter.test.ts @@ -4,8 +4,6 @@ // (TanStack/router#7402) without relying on GC, timing of real I/O, or // process.memoryUsage(). On-demand backpressure/external-memory // assertions live in transformStreamBackpressure.perf.test.ts. -import { ReadableStream } from 'node:stream/web' -import { PassThrough } from 'node:stream' import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' @@ -13,7 +11,6 @@ import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from '../src/ssr/constants' import { createSsrStreamResponse } from '../src/ssr/handlerCallback' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { - transformPipeableStreamWithRouter, transformReadableStreamWithRouter, transformStreamWithRouter, } from '../src/ssr/transformStreamWithRouter' @@ -1027,35 +1024,6 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { expect(aborts).toBe(0) }) - test('onAbort: fires when pipeable wrapper consumer destroys', async () => { - const { router, finishSerialization } = makeRouter() - finishSerialization() - - const pass = new PassThrough() - let aborts = 0 - const out = transformPipeableStreamWithRouter(router as any, pass, { - onAbort: () => aborts++, - }) - // Swallow expected error emission from destroy(). - out.on('error', () => {}) - - // Push something so the read loop is engaged. - pass.write('

x

') - // Wait a tick to let reader start. - await new Promise((r) => setImmediate(r)) - - // Destroy downstream — simulates Node response being closed by client. - out.destroy(new Error('client gone')) - // Allow microtasks + readable webstream cancel propagation. - await new Promise((r) => setImmediate(r)) - await Promise.resolve() - - expect(aborts).toBe(1) - - // Cleanup: destroy upstream so we don't leak. - if (!pass.destroyed) pass.destroy() - }) - test('onAbort: lifetime timeout triggers abort exactly once', async () => { vi.useFakeTimers() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 7432f195105..55d8747fd68 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -6,7 +6,6 @@ import { } from '@tanstack/router-core/ssr/server' import { makeSsrSerovalPlugin } from '@tanstack/router-core' import type { JSXElement } from 'solid-js' -import type { ReadableStream } from 'node:stream/web' import type { AnyRouter } from '@tanstack/router-core' const noop = () => {} diff --git a/packages/start-server-core/src/index.tsx b/packages/start-server-core/src/index.tsx index ee818e0cf1f..cc0a44dad56 100644 --- a/packages/start-server-core/src/index.tsx +++ b/packages/start-server-core/src/index.tsx @@ -18,7 +18,6 @@ export { createRequestHandler, defineHandlerCallback, transformReadableStreamWithRouter, - transformPipeableStreamWithRouter, } from '@tanstack/router-core/ssr/server' export type { HandlerCallback } from '@tanstack/router-core/ssr/server' diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index d352c524972..c7413a7c98b 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -1,4 +1,3 @@ -import { ReadableStream as NodeReadableStream } from 'node:stream/web' import * as Vue from 'vue' import { pipeToWebWritable, renderToString } from 'vue/server-renderer' import { isbot } from 'isbot' @@ -15,8 +14,8 @@ const isAbortError = (request: Request, error: unknown) => (error as any)?.code === 'ABORT_ERR' function prependDoctype( - readable: globalThis.ReadableStream, -): NodeReadableStream { + readable: ReadableStream, +): ReadableStream { const encoder = new TextEncoder() let sentDoctype = false let reader: ReadableStreamDefaultReader | undefined @@ -29,7 +28,7 @@ function prependDoctype( reader = undefined } - return new NodeReadableStream({ + return new ReadableStream({ start() { reader = readable.getReader() }, @@ -123,7 +122,7 @@ export const renderRouterToStream = async ({ } } - const { writable, readable } = new TransformStream() + const { writable, readable } = new TransformStream() const innerWriter = writable.getWriter() let writerDone = false const releaseWriter = () => { @@ -159,7 +158,7 @@ export const renderRouterToStream = async ({ throw err } - const vueWritable = new WritableStream({ + const vueWritable = new WritableStream({ write(chunk) { if (writerDone) { return