diff --git a/.changeset/client-disconnect-499.md b/.changeset/client-disconnect-499.md new file mode 100644 index 00000000000..6e652f45b79 --- /dev/null +++ b/.changeset/client-disconnect-499.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-server-core': patch +--- + +Return `499 Client Closed Request` instead of an unhandled 500 when the client disconnects mid-request. Applies to SSR, server routes, and server functions. diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index c7b427bfb1f..6759c2fa4c2 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -779,6 +779,15 @@ export function createStartHandler( request.signal.throwIfAborted() responseOwnsCleanup = response.serverSsrCleanup === 'stream' return response.response + } catch (err) { + // classify a client disconnect as 499 instead of an unhandled 500 + if (request.signal.aborted && err === request.signal.reason) { + return new Response(null, { + status: 499, + statusText: 'Client Closed Request', + }) + } + throw err } finally { if (router?.serverSsr && !responseOwnsCleanup) { // Clean up router SSR state if it was set up but won't be cleaned up by the callback diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index dbcc169cb59..51a7a704409 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -406,7 +406,7 @@ describe('createStartHandler request cancellation', () => { const cancellation = new Error('request disconnected') requestController.abort(cancellation) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(routeSignal?.aborted).toBe(true) expect(routeSignal?.reason).toBe(cancellation) expect(render).not.toHaveBeenCalled() @@ -463,7 +463,7 @@ describe('createStartHandler request cancellation', () => { await renderStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(cleanupCalls).toBe(1) expect(router.serverSsr).toBeUndefined() @@ -503,7 +503,7 @@ describe('createStartHandler request cancellation', () => { const cancellation = new Error('request disconnected') requestController.abort(cancellation) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) resolveRender(new Response(new ReadableStream({ cancel }))) await vi.waitFor(() => { expect(cancel).toHaveBeenCalledTimes(1) @@ -542,7 +542,7 @@ describe('createStartHandler request cancellation', () => { const cancellation = new Error('request disconnected') requestController.abort(cancellation) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) resolveMiddleware(new Response(new ReadableStream({ cancel }))) await vi.waitFor(() => { expect(cancel).toHaveBeenCalledTimes(1) @@ -594,7 +594,7 @@ describe('createStartHandler request cancellation', () => { await renderStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) resolveRender({ response: new Response('stream'), @@ -657,7 +657,7 @@ describe('createStartHandler request cancellation', () => { await middlewareStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) await vi.waitFor(() => { expect(consoleError).toHaveBeenCalledWith(cleanupError) }) @@ -731,7 +731,7 @@ describe('createStartHandler request cancellation', () => { await middlewareStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(render).not.toHaveBeenCalled() releaseMiddleware({ @@ -779,7 +779,7 @@ describe('createStartHandler request cancellation', () => { await innerStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(outerFinally).toHaveBeenCalledOnce() expect(render).not.toHaveBeenCalled() }) @@ -812,7 +812,7 @@ describe('createStartHandler request cancellation', () => { await innerStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(render).not.toHaveBeenCalled() }) @@ -838,7 +838,7 @@ describe('createStartHandler request cancellation', () => { {}, ) - expect(response.status).toBe(500) + expect(response.status).toBe(499) expect(render).not.toHaveBeenCalled() }) @@ -882,7 +882,7 @@ describe('createStartHandler request cancellation', () => { {}, ) - expect(response.status).toBe(500) + expect(response.status).toBe(499) await vi.waitFor(() => expect(observedErrors).toEqual([reason])) await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() @@ -920,7 +920,7 @@ describe('createStartHandler request cancellation', () => { {}, ) - expect(response.status).toBe(500) + expect(response.status).toBe(499) await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() expect(dispose).toHaveBeenCalledWith(reason) @@ -985,7 +985,7 @@ describe('createStartHandler request cancellation', () => { const dispose = vi.spyOn(ssrResponse as any, 'dispose') requestController.abort(reason) - expect((await result).status).toBe(500) + expect((await result).status).toBe(499) releaseMiddleware() await lateResultDelivered await vi.waitFor(() => { @@ -996,6 +996,101 @@ describe('createStartHandler request cancellation', () => { }) expect(router.serverSsr).toBeUndefined() }) + + it('returns 499 when the client disconnects during SSR rendering', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyRenderStarted!: () => void + const renderStarted = new Promise((resolve) => { + notifyRenderStarted = resolve + }) + const handler = createStartHandler(() => { + notifyRenderStarted() + return new Promise(() => {}) + }) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await renderStarted + requestController.abort(new Error('request disconnected')) + + const result = await response + expect(result.status).toBe(499) + expect(result.statusText).toBe('Client Closed Request') + }) + + it('returns 499 when the client disconnects during a server route handler', async () => { + const rootRoute = new BaseRootRoute({}) + let notifyHandlerStarted!: () => void + const handlerStarted = new Promise((resolve) => { + notifyHandlerStarted = resolve + }) + const apiRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/api/slow', + server: { + handlers: { + GET: () => { + notifyHandlerStarted() + return new Promise(() => {}) + }, + }, + }, + }) + const router = new RouterCore( + { + history: createMemoryHistory({ initialEntries: ['/api/slow'] }), + routeTree: rootRoute.addChildren([apiRoute]), + }, + getStoreConfig, + ) + router.isServer = true + startMocks.router = router + const requestController = new AbortController() + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + const response = handler( + new Request('http://localhost/api/slow', { + signal: requestController.signal, + }), + {}, + ) + + await handlerStarted + requestController.abort(new Error('request disconnected')) + + const result = await response + expect(result.status).toBe(499) + expect(result.statusText).toBe('Client Closed Request') + expect(render).not.toHaveBeenCalled() + }) + + it('returns 499 when the client disconnects during a server function call', async () => { + const router = makeRouter() + startMocks.router = router + startMocks.serverFnResult = new Promise(() => {}) + const requestController = new AbortController() + const handler = createStartHandler(() => new Response('unused')) + const response = handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + {}, + ) + + await Promise.resolve() + requestController.abort(new Error('request disconnected')) + + const result = await response + expect(result.status).toBe(499) + expect(result.statusText).toBe('Client Closed Request') + }) }) describe('createStartHandler inlineCss option', () => {