From 86d78d10e3367c6c321b6273a8feb2d0178a04f2 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 6 Aug 2021 12:41:40 -0400 Subject: [PATCH] refactor(server-renderer): adjust streaming API - add `pipeToNodeWritable` - add `pipeToWebWritable` --- packages/global.d.ts | 3 +- packages/server-renderer/README.md | 59 +++++++++++--- .../server-renderer/__tests__/render.spec.ts | 25 ++++-- .../__tests__/webStream.spec.ts | 47 +++++++++-- packages/server-renderer/src/index.ts | 7 +- .../server-renderer/src/renderToStream.ts | 79 ++++++++++++++----- 6 files changed, 173 insertions(+), 47 deletions(-) diff --git a/packages/global.d.ts b/packages/global.d.ts index 007f8ffdd19..2796f658d0a 100644 --- a/packages/global.d.ts +++ b/packages/global.d.ts @@ -36,5 +36,6 @@ declare module 'file-saver' { declare module 'stream/web' { const r: typeof ReadableStream - export { r as ReadableStream } + const t: typeof TransformStream + export { r as ReadableStream, t as TransformStream } } diff --git a/packages/server-renderer/README.md b/packages/server-renderer/README.md index 826e2514f4f..d56b75be269 100644 --- a/packages/server-renderer/README.md +++ b/packages/server-renderer/README.md @@ -50,10 +50,7 @@ Renders input as a [Node.js Readable stream](https://nodejs.org/api/stream.html# **Signature** ```ts -function renderToNodeStream( - input: App | VNode, - context?: SSRContext -): Readable +function renderToNodeStream(input: App | VNode, context?: SSRContext): Readable ``` **Usage** @@ -63,12 +60,27 @@ function renderToNodeStream( renderToNodeStream(app).pipe(res) ``` -In the ESM build of `@vue/server-renderer`, which is decoupled from Node.js environments, the `Readable` constructor must be explicitly passed in as the 3rd argument: +**Note:** This method is not supported in the ESM build of `@vue/server-renderer`, which is decoupled from Node.js environments. Use `pipeToNodeWritable` instead. -```js -import { Readable } from 'stream' +### `pipeToNodeWritable` + +Render and pipe to an existing [Node.js Writable stream](https://nodejs.org/api/stream.html#stream_writable_streams) instance. -renderToNodeStream(app, {}, Readable).pipe(res) +**Signature** + +```ts +function pipeToNodeWritable( + input: App | VNode, + context: SSRContext = {}, + writable: Writable +): void +``` + +**Usage** + +```js +// inside a Node.js http handler +pipeToNodeWritable(app, {}, res) ``` ### `renderToWebStream` @@ -88,19 +100,40 @@ function renderToWebStream( **Usage** ```js -// e.g. inside an environment with ReadableStream support +// inside an environment with ReadableStream support return new Response(renderToWebStream(app)) ``` -Note in environments that do not expose `ReadableStream` constructor in the global scope, the constructor must be explicitly passed in as the 3rd argument. For example in Node.js 16.5.0+ where web streams are also supported: +**Note:** in environments that do not expose `ReadableStream` constructor in the global scope, `pipeToWebWritable` should be used instead. + +### `pipeToWebWritable` + +Render and pipe to an existing [Web WritableStream](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) instance. + +**Signature** + +```ts +function pipeToWebWritable( + input: App | VNode, + context: SSRContext = {}, + writable: WritableStream +): void +``` + +**Usage** + +This is typically used in combination with [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream): ```js -import { ReadableStream } from 'stream/web' +// TransformStream is available in environments such as CloudFlare workers. +// in Node.js, TransformStream needs to be explicitly imported from 'stream/web' +const { readable, writable } = new TransformStream() +pipeToWebWritable(app, {}, writable) -const stream = renderToWebStream(app, {}, ReadableStream) +return new Response(readable) ``` -## `renderToSimpleStream` +### `renderToSimpleStream` Renders input in streaming mode using a simple readable interface. diff --git a/packages/server-renderer/__tests__/render.spec.ts b/packages/server-renderer/__tests__/render.spec.ts index c06af8d973c..1079a29a6c2 100644 --- a/packages/server-renderer/__tests__/render.spec.ts +++ b/packages/server-renderer/__tests__/render.spec.ts @@ -24,10 +24,10 @@ import { } from 'vue' import { escapeHtml } from '@vue/shared' import { renderToString } from '../src/renderToString' -import { renderToNodeStream } from '../src/renderToStream' +import { renderToNodeStream, pipeToNodeWritable } from '../src/renderToStream' import { ssrRenderSlot, SSRSlot } from '../src/helpers/ssrRenderSlot' import { ssrRenderComponent } from '../src/helpers/ssrRenderComponent' -import { Readable } from 'stream' +import { Readable, Transform } from 'stream' import { ssrRenderVNode } from '../src' const promisifyStream = (stream: Readable) => { @@ -45,12 +45,25 @@ const promisifyStream = (stream: Readable) => { }) } -const renderToStream = (app: any, context?: any) => - promisifyStream(renderToNodeStream(app, context)) +const renderToStream = (app: any, context?: any) => { + return promisifyStream(renderToNodeStream(app, context)) +} + +const pipeToWritable = (app: any, context?: any) => { + const stream = new Transform({ + transform(data, _encoding, cb) { + this.push(data) + cb() + } + }) + pipeToNodeWritable(app, context, stream) + return promisifyStream(stream) +} // we run the same tests twice, once for renderToString, once for renderToStream testRender(`renderToString`, renderToString) -testRender(`renderToStream`, renderToStream) +testRender(`renderToNodeStream`, renderToStream) +testRender(`pipeToNodeWritable`, pipeToWritable) function testRender(type: string, render: typeof renderToString) { describe(`ssr: ${type}`, () => { @@ -760,7 +773,7 @@ function testRender(type: string, render: typeof renderToString) { test('handle compiler errors', async () => { await render( // render different content since compilation is cached - createApp({ template: `<${type === 'renderToString' ? 'div' : 'p'}` }) + createApp({ template: `
${type} { +beforeEach(() => { + global.ReadableStream = ReadableStream +}) + +afterEach(() => { + // @ts-ignore + delete global.ReadableStream +}) + +test('renderToWebStream', async () => { const Async = defineAsyncComponent(() => Promise.resolve({ render: () => h('div', 'async') @@ -16,14 +25,42 @@ test('should work', async () => { render: () => [h('div', 'parent'), h(Async)] } - const stream = renderToWebStream(createApp(App), {}, ReadableStream) + const stream = renderToWebStream(createApp(App)) const reader = stream.getReader() + const decoder = new TextDecoder() + + let res = '' + await reader.read().then(function read({ done, value }): any { + if (!done) { + res += decoder.decode(value) + return reader.read().then(read) + } + }) + + expect(res).toBe(`
parent
async
`) +}) + +test('pipeToWebWritable', async () => { + const Async = defineAsyncComponent(() => + Promise.resolve({ + render: () => h('div', 'async') + }) + ) + const App = { + render: () => [h('div', 'parent'), h(Async)] + } + + const { readable, writable } = new TransformStream() + pipeToWebWritable(createApp(App), {}, writable) + + const reader = readable.getReader() + const decoder = new TextDecoder() let res = '' await reader.read().then(function read({ done, value }): any { if (!done) { - res += value + res += decoder.decode(value) return reader.read().then(read) } }) diff --git a/packages/server-renderer/src/index.ts b/packages/server-renderer/src/index.ts index c4b907127a1..e5a9f650e47 100644 --- a/packages/server-renderer/src/index.ts +++ b/packages/server-renderer/src/index.ts @@ -2,11 +2,14 @@ export { SSRContext } from './render' export { renderToString } from './renderToString' export { - renderToStream, renderToSimpleStream, renderToNodeStream, + pipeToNodeWritable, renderToWebStream, - SimpleReadable + pipeToWebWritable, + SimpleReadable, + // deprecated + renderToStream } from './renderToStream' // internal runtime helpers diff --git a/packages/server-renderer/src/renderToStream.ts b/packages/server-renderer/src/renderToStream.ts index ca69ce99a9e..437e0b6cdac 100644 --- a/packages/server-renderer/src/renderToStream.ts +++ b/packages/server-renderer/src/renderToStream.ts @@ -8,7 +8,7 @@ import { } from 'vue' import { isString, isPromise } from '@vue/shared' import { renderComponentVNode, SSRBuffer, SSRContext } from './render' -import { Readable } from 'stream' +import { Readable, Writable } from 'stream' const { isVNode } = ssrUtils @@ -99,51 +99,64 @@ export function renderToStream( export function renderToNodeStream( input: App | VNode, - context: SSRContext = {}, - UserReadable?: typeof Readable + context: SSRContext = {} ): Readable { - const stream: Readable = UserReadable - ? new UserReadable() - : __NODE_JS__ + const stream: Readable = __NODE_JS__ ? new (require('stream').Readable)() : null if (!stream) { throw new Error( - `ESM build of renderToStream() requires explicitly passing in the Node.js ` + - `Readable constructor the 3rd argument. Example:\n\n` + - ` import { Readable } from 'stream'\n` + - ` const stream = renderToStream(app, {}, Readable)` + `ESM build of renderToStream() does not support renderToNodeStream(). ` + + `Use pipeToNodeWritable() with an existing Node.js Writable stream ` + + `instance instead.` ) } return renderToSimpleStream(input, context, stream) } -const hasGlobalWebStream = typeof ReadableStream === 'function' +export function pipeToNodeWritable( + input: App | VNode, + context: SSRContext = {}, + writable: Writable +) { + renderToSimpleStream(input, context, { + push(content) { + if (content != null) { + writable.write(content) + } else { + writable.end() + } + }, + destroy(err) { + writable.destroy(err) + } + }) +} export function renderToWebStream( input: App | VNode, - context: SSRContext = {}, - Ctor?: { new (): ReadableStream } + context: SSRContext = {} ): ReadableStream { - if (!Ctor && !hasGlobalWebStream) { + if (typeof ReadableStream !== 'function') { throw new Error( - `ReadableStream constructor is not available in the global scope and ` + - `must be explicitly passed in as the 3rd argument:\n\n` + - ` import { ReadableStream } from 'stream/web'\n` + - ` const stream = renderToWebStream(app, {}, ReadableStream)` + `ReadableStream constructor is not available in the global scope. ` + + `If the target environment does support web streams, consider using ` + + `pipeToWebWritable() with an existing WritableStream instance instead.` ) } + const encoder = new TextEncoder() let cancelled = false - return new (Ctor || ReadableStream)({ + + return new ReadableStream({ start(controller) { renderToSimpleStream(input, context, { push(content) { if (cancelled) return if (content != null) { - controller.enqueue(content) + controller.enqueue(encoder.encode(content)) } else { controller.close() } @@ -158,3 +171,29 @@ export function renderToWebStream( } }) } + +export function pipeToWebWritable( + input: App | VNode, + context: SSRContext = {}, + writable: WritableStream +): void { + const writer = writable.getWriter() + const encoder = new TextEncoder() + + writer.ready.then(() => { + renderToSimpleStream(input, context, { + push(content) { + if (content != null) { + writer.write(encoder.encode(content)) + } else { + writer.close() + } + }, + destroy(err) { + // TODO better error handling? + console.log(err) + writer.close() + } + }) + }) +}