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
+ })
},
})
}