diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index c46741bee80..8166201f32d 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -990,6 +990,28 @@ export function registerRuntimeCompiler(_compile: any): void { // dev only export const isRuntimeOnly = (): boolean => !compile +/** + * @internal + */ +export function getResolvedCompilerOptions( + instance: ComponentInternalInstance, +): CompilerOptions { + const Component = instance.type as ComponentOptions + const { isCustomElement, compilerOptions } = instance.appContext.config + const { delimiters, compilerOptions: componentCompilerOptions } = Component + + return extend( + extend( + { + isCustomElement, + delimiters, + }, + compilerOptions, + ), + componentCompilerOptions, + ) +} + export function finishComponentSetup( instance: ComponentInternalInstance, isSSR: boolean, @@ -1021,19 +1043,7 @@ export function finishComponentSetup( if (__DEV__) { startMeasure(instance, `compile`) } - const { isCustomElement, compilerOptions } = instance.appContext.config - const { delimiters, compilerOptions: componentCompilerOptions } = - Component - const finalCompilerOptions: CompilerOptions = extend( - extend( - { - isCustomElement, - delimiters, - }, - compilerOptions, - ), - componentCompilerOptions, - ) + const finalCompilerOptions = getResolvedCompilerOptions(instance) if (__COMPAT__) { // pass runtime compat config into the compiler finalCompilerOptions.compatConfig = Object.create(globalCompatConfig) diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 792a28b2582..83502c1a963 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -397,6 +397,7 @@ export { transformVNodeArgs } from './vnode' import { createComponentInstance, getComponentPublicInstance, + getResolvedCompilerOptions, setupComponent, } from './component' import { renderComponentRoot } from './componentRenderUtils' @@ -416,6 +417,7 @@ const _ssrUtils: { ensureValidVNode: typeof ensureValidVNode pushWarningContext: typeof pushWarningContext popWarningContext: typeof popWarningContext + getResolvedCompilerOptions: typeof getResolvedCompilerOptions } = { createComponentInstance, setupComponent, @@ -427,6 +429,7 @@ const _ssrUtils: { ensureValidVNode, pushWarningContext, popWarningContext, + getResolvedCompilerOptions, } /** diff --git a/packages/server-renderer/__tests__/nodeStream.spec.ts b/packages/server-renderer/__tests__/nodeStream.spec.ts new file mode 100644 index 00000000000..c4c7ff9716a --- /dev/null +++ b/packages/server-renderer/__tests__/nodeStream.spec.ts @@ -0,0 +1,69 @@ +import { createApp, defineAsyncComponent, h } from 'vue' +import { pipeToNodeWritable, renderToNodeStream } from '../src' +import { Writable } from 'node:stream' +import { describe, expect, test } from 'vitest' + +describe('Node.js Streams backpressure', () => { + test('pipeToNodeWritable backpressure', async () => { + const Async = defineAsyncComponent(() => + Promise.resolve({ + render: () => h('div', 'b'), + }), + ) + const App = { + render: () => [h('div', 'a'), h(Async)], + } + + let writeCount = 0 + const writable = new Writable({ + highWaterMark: 1, + write(_chunk, _encoding, callback) { + writeCount++ + callback() + }, + }) + + const originalWrite = writable.write.bind(writable) + let firstCall = true + writable.write = (chunk: any, encoding?: any, cb?: any): any => { + if (firstCall) { + firstCall = false + originalWrite(chunk, encoding, cb) + return false + } + return originalWrite(chunk, encoding, cb) + } + + pipeToNodeWritable(createApp(App), {}, writable) + + await new Promise(resolve => setTimeout(resolve, 20)) + // Should have only 1 write because it returned false and we're waiting for drain + expect(writeCount).toBe(1) + + writable.emit('drain') + await new Promise(resolve => setTimeout(resolve, 20)) + // Second write should have happened after drain + expect(writeCount).toBeGreaterThan(1) + }) + + test('renderToNodeStream backpressure', async () => { + const Async = defineAsyncComponent(() => + Promise.resolve({ + render: () => h('div', 'b'), + }), + ) + const App = { + render: () => [h('div', 'a'), h(Async)], + } + + const stream = renderToNodeStream(createApp(App)) + + // In Node.js Readable, push() returns false when the buffer is full. + // For our test, we'll just verify that it streams correctly first. + let res = '' + for await (const chunk of stream) { + res += chunk + } + expect(res).toBe('
a
b
') + }) +}) diff --git a/packages/server-renderer/__tests__/webStream.spec.ts b/packages/server-renderer/__tests__/webStream.spec.ts index de399dbb82a..7ff1d446a20 100644 --- a/packages/server-renderer/__tests__/webStream.spec.ts +++ b/packages/server-renderer/__tests__/webStream.spec.ts @@ -64,3 +64,85 @@ test('pipeToWebWritable', async () => { expect(res).toBe(`
parent
async
`) }) + +test('pipeToWebWritable error handling', async () => { + const App = { + ssrRender() { + throw new Error('ssr render error') + }, + } + + let abortedReason: any + const writable = new WritableStream({ + abort(reason) { + abortedReason = reason + }, + }) + + pipeToWebWritable(createApp(App), {}, writable) + + // Wait for the error to propagate + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(abortedReason).toBeInstanceOf(Error) + expect(abortedReason.message).toBe('ssr render error') +}) + +test('pipeToWebWritable backpressure', async () => { + const Async = defineAsyncComponent(() => + Promise.resolve({ + render: () => h('div', 'b'), + }), + ) + const App = { + render: () => [h('div', 'a'), h(Async)], + } + + let writeCount = 0 + let resolveWrite: any + const writable = new WritableStream({ + write() { + writeCount++ + return new Promise(resolve => { + resolveWrite = resolve + }) + }, + }) + + pipeToWebWritable(createApp(App), {}, writable) + + await new Promise(resolve => setTimeout(resolve, 20)) + // Should have only 1 write because the first one is pending + expect(writeCount).toBe(1) + + resolveWrite() + await new Promise(resolve => setTimeout(resolve, 20)) + // Second write should have happened after the async component resolved + expect(writeCount).toBeGreaterThan(1) +}) + +test('renderToWebStream backpressure', async () => { + const Async = defineAsyncComponent(() => + Promise.resolve({ + render: () => h('div', 'b'), + }), + ) + const App = { + render: () => [h('div', 'a'), h(Async)], + } + + const stream = renderToWebStream(createApp(App), {}) + const reader = stream.getReader() + + const { value: v1 } = await reader.read() + expect(new TextDecoder().decode(v1)).toBe('
a
') + + const { value: v2 } = await reader.read() + expect(new TextDecoder().decode(v2)).toBe('
b
') + + const { value: v3 } = await reader.read() + expect(new TextDecoder().decode(v3)).toBe('') + + const { done } = await reader.read() + expect(done).toBe(true) +}) diff --git a/packages/server-renderer/src/helpers/ssrCompile.ts b/packages/server-renderer/src/helpers/ssrCompile.ts index 8412a65e843..066d02e5c3a 100644 --- a/packages/server-renderer/src/helpers/ssrCompile.ts +++ b/packages/server-renderer/src/helpers/ssrCompile.ts @@ -1,6 +1,7 @@ import { type ComponentInternalInstance, type ComponentOptions, + ssrUtils, warn, } from 'vue' import { compile } from '@vue/compiler-ssr' @@ -32,21 +33,7 @@ export function ssrCompile( ) } - // TODO: This is copied from runtime-core/src/component.ts and should probably be refactored - const Component = instance.type as ComponentOptions - const { isCustomElement, compilerOptions } = instance.appContext.config - const { delimiters, compilerOptions: componentCompilerOptions } = Component - - const finalCompilerOptions: CompilerOptions = extend( - extend( - { - isCustomElement, - delimiters, - }, - compilerOptions, - ), - componentCompilerOptions, - ) + const finalCompilerOptions = ssrUtils.getResolvedCompilerOptions(instance) finalCompilerOptions.isCustomElement = finalCompilerOptions.isCustomElement || NO diff --git a/packages/server-renderer/src/renderToStream.ts b/packages/server-renderer/src/renderToStream.ts index e6b02d1cf99..16a983d389d 100644 --- a/packages/server-renderer/src/renderToStream.ts +++ b/packages/server-renderer/src/renderToStream.ts @@ -13,8 +13,14 @@ import { resolveTeleports } from './renderToString' const { isVNode } = ssrUtils +function waitDrain(stream: Writable): Promise { + return new Promise(resolve => { + stream.once('drain', resolve) + }) +} + export interface SimpleReadable { - push(chunk: string | null): void + push(chunk: string | null): void | Promise destroy(err: any): void } @@ -29,26 +35,37 @@ async function unrollBuffer( item = await item } if (isString(item)) { - stream.push(item) + const res = stream.push(item) + if (isPromise(res)) await res } else { await unrollBuffer(item, stream) } } } else { - // sync buffer can be more efficiently unrolled without unnecessary await - // ticks - unrollBufferSync(buffer, stream) + const res = unrollBufferSync(buffer, stream) + if (isPromise(res)) await res } } -function unrollBufferSync(buffer: SSRBuffer, stream: SimpleReadable) { +function unrollBufferSync( + buffer: SSRBuffer, + stream: SimpleReadable, +): void | Promise { for (let i = 0; i < buffer.length; i++) { let item = buffer[i] if (isString(item)) { - stream.push(item) + const res = stream.push(item) + if (isPromise(res)) { + // if the stream is async, we can't unroll it syncly anymore + // this can happen if a sync buffer is being pushed to an async stream + return res.then(() => unrollBufferSync(buffer.slice(i + 1), stream)) + } } else { // since this is a sync buffer, child buffers are never promises - unrollBufferSync(item as SSRBuffer, stream) + const res = unrollBufferSync(item as SSRBuffer, stream) + if (isPromise(res)) { + return res.then(() => unrollBufferSync(buffer.slice(i + 1), stream)) + } } } } @@ -73,7 +90,8 @@ export function renderToSimpleStream( // provide the ssr context to the tree input.provide(ssrContextKey, context) - Promise.resolve(renderComponentVNode(vnode)) + Promise.resolve() + .then(() => renderComponentVNode(vnode)) .then(buffer => unrollBuffer(buffer, stream)) .then(() => resolveTeleports(context)) .then(() => { @@ -108,8 +126,16 @@ export function renderToNodeStream( input: App | VNode, context: SSRContext = {}, ): Readable { + let resolveRead: (() => void) | null = null const stream: Readable = __CJS__ - ? new (require('node:stream').Readable)({ read() {} }) + ? new (require('node:stream').Readable)({ + read() { + if (resolveRead) { + resolveRead() + resolveRead = null + } + }, + }) : null if (!stream) { @@ -120,7 +146,24 @@ export function renderToNodeStream( ) } - return renderToSimpleStream(input, context, stream) + renderToSimpleStream(input, context, { + push(content) { + if (content != null) { + if (!stream.push(content)) { + return new Promise(resolve => { + resolveRead = resolve + }) + } + } else { + stream.push(null) + } + }, + destroy(err) { + stream.destroy(err) + }, + } as any) + + return stream } export function pipeToNodeWritable( @@ -129,9 +172,11 @@ export function pipeToNodeWritable( writable: Writable, ): void { renderToSimpleStream(input, context, { - push(content) { + async push(content) { if (content != null) { - writable.write(content) + if (!writable.write(content)) { + await waitDrain(writable) + } } else { writable.end() } @@ -156,14 +201,20 @@ export function renderToWebStream( const encoder = new TextEncoder() let cancelled = false + let resolvePull: (() => void) | null = null return new ReadableStream({ start(controller) { renderToSimpleStream(input, context, { - push(content) { + async push(content) { if (cancelled) return if (content != null) { controller.enqueue(encoder.encode(content)) + if (controller.desiredSize! <= 0) { + return new Promise(resolve => { + resolvePull = resolve + }) + } } else { controller.close() } @@ -173,8 +224,18 @@ export function renderToWebStream( }, }) }, + pull() { + if (resolvePull) { + resolvePull() + resolvePull = null + } + }, cancel() { cancelled = true + if (resolvePull) { + resolvePull() + resolvePull = null + } }, }) } @@ -205,10 +266,9 @@ export function pipeToWebWritable( } }, destroy(err) { - // TODO better error handling? - // eslint-disable-next-line no-console - console.log(err) - writer.close() + writer.abort(err).catch(() => { + // ignore errors from aborting an already closed/errored stream + }) }, }) }