From 08672222c611a61f6359543aa202f0841d199bcb Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 29 Jul 2021 13:12:50 -0400 Subject: [PATCH] feat(server-renderer): decouple esm build from Node + improve stream API - deprecate `renderToSTream` - added `renderToNodeStream` - added `renderToWebStream` - added `renderToSimpleStream` close #3467 close #3111 close #3460 --- packages/global.d.ts | 5 + packages/server-renderer/README.md | 127 +++++++++++++++++- .../server-renderer/__tests__/render.spec.ts | 4 +- .../__tests__/webStream.spec.ts | 32 +++++ .../server-renderer/src/helpers/ssrCompile.ts | 8 ++ packages/server-renderer/src/index.ts | 8 +- .../server-renderer/src/renderToStream.ts | 99 ++++++++++++-- 7 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 packages/server-renderer/__tests__/webStream.spec.ts diff --git a/packages/global.d.ts b/packages/global.d.ts index 9b7e3795e..007f8ffdd 100644 --- a/packages/global.d.ts +++ b/packages/global.d.ts @@ -33,3 +33,8 @@ declare module '*?raw' { declare module 'file-saver' { export function saveAs(blob: any, name: any): void } + +declare module 'stream/web' { + const r: typeof ReadableStream + export { r as ReadableStream } +} diff --git a/packages/server-renderer/README.md b/packages/server-renderer/README.md index 23831e51d..9d082009c 100644 --- a/packages/server-renderer/README.md +++ b/packages/server-renderer/README.md @@ -1,6 +1,21 @@ # @vue/server-renderer -``` js +## Basic API + +### `renderToString` + +**Signature** + +```ts +function renderToString( + input: App | VNode, + context?: SSRContext +): Promise +``` + +**Usage** + +```js const { createSSRApp } = require('vue') const { renderToString } = require('@vue/server-renderer') @@ -14,3 +29,113 @@ const app = createSSRApp({ console.log(html) })() ``` + +### Handling Teleports + +If the rendered app contains teleports, the teleported content will not be part of the rendered string. Instead, they are exposed under the `teleports` property of the ssr context object: + +```js +const ctx = {} +const html = await renderToString(app, ctx) + +console.log(ctx.teleports) // { '#teleported': 'teleported content' } +``` + +## Streaming API + +### `renderToNodeStream` + +Renders input as a [Node.js Readable stream](https://nodejs.org/api/stream.html#stream_class_stream_readable). + +**Signature** + +```ts +function renderToNodeStream(input: App | VNode, context?: SSRContext): Readable +``` + +**Usage** + +```js +// inside a Node.js http handler +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: + +```js +import { Readable } from 'stream' + +renderToNodeStream(app, {}, Readable).pipe(res) +``` + +### `renderToWebStream` + +Renders input as a [Web ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API). + +**Signature** + +```ts +function renderToWebStream( + input: App | VNode, + context?: SSRContext, + Ctor?: { new (): ReadableStream } +): ReadableStream +``` + +**Usage** + +```js +// e.g. inside a Cloudflare Worker +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: + +```js +import { ReadableStream } from 'stream/web' + +const stream = renderToWebStream(app, {}, ReadableStream) +``` + +## `renderToSimpleStream` + +Renders input in streaming mode using a simple readable interface. + +**Signature** + +```ts +function renderToSimpleStream( + input: App | VNode, + context: SSRContext, + options: SimpleReadable +): SimpleReadable + +interface SimpleReadable { + push(content: string | null): void + destroy(err: any): void +} +``` + +**Usage** + +```js +let res = '' + +renderToSimpleStream( + app, + {}, + { + push(chunk) { + if (chunk === null) { + // done + console(`render complete: ${res}`) + } else { + res += chunk + } + }, + destroy(err) { + // error encountered + } + } +) +``` diff --git a/packages/server-renderer/__tests__/render.spec.ts b/packages/server-renderer/__tests__/render.spec.ts index 64ce7411d..c06af8d97 100644 --- a/packages/server-renderer/__tests__/render.spec.ts +++ b/packages/server-renderer/__tests__/render.spec.ts @@ -24,7 +24,7 @@ import { } from 'vue' import { escapeHtml } from '@vue/shared' import { renderToString } from '../src/renderToString' -import { renderToStream as _renderToStream } from '../src/renderToStream' +import { renderToNodeStream } from '../src/renderToStream' import { ssrRenderSlot, SSRSlot } from '../src/helpers/ssrRenderSlot' import { ssrRenderComponent } from '../src/helpers/ssrRenderComponent' import { Readable } from 'stream' @@ -46,7 +46,7 @@ const promisifyStream = (stream: Readable) => { } const renderToStream = (app: any, context?: any) => - promisifyStream(_renderToStream(app, context)) + promisifyStream(renderToNodeStream(app, context)) // we run the same tests twice, once for renderToString, once for renderToStream testRender(`renderToString`, renderToString) diff --git a/packages/server-renderer/__tests__/webStream.spec.ts b/packages/server-renderer/__tests__/webStream.spec.ts new file mode 100644 index 000000000..f26c9491e --- /dev/null +++ b/packages/server-renderer/__tests__/webStream.spec.ts @@ -0,0 +1,32 @@ +/** + * @jest-environment node + */ + +import { createApp, h, defineAsyncComponent } from 'vue' +import { ReadableStream } from 'stream/web' +import { renderToWebStream } from '../src' + +test('should work', async () => { + const Async = defineAsyncComponent(() => + Promise.resolve({ + render: () => h('div', 'async') + }) + ) + const App = { + render: () => [h('div', 'parent'), h(Async)] + } + + const stream = renderToWebStream(createApp(App), {}, ReadableStream) + + const reader = stream.getReader() + + let res = '' + await reader.read().then(function read({ done, value }): any { + if (!done) { + res += value + return reader.read().then(read) + } + }) + + expect(res).toBe(`
parent
async
`) +}) diff --git a/packages/server-renderer/src/helpers/ssrCompile.ts b/packages/server-renderer/src/helpers/ssrCompile.ts index 19bf0489a..39fd6c09b 100644 --- a/packages/server-renderer/src/helpers/ssrCompile.ts +++ b/packages/server-renderer/src/helpers/ssrCompile.ts @@ -16,6 +16,14 @@ export function ssrCompile( template: string, instance: ComponentInternalInstance ): SSRRenderFunction { + if (!__NODE_JS__) { + throw new Error( + `On-the-fly template compilation is not supported in the ESM build of ` + + `@vue/server-renderer. All templates must be pre-compiled into ` + + `render functions.` + ) + } + const cached = compileCache[template] if (cached) { return cached diff --git a/packages/server-renderer/src/index.ts b/packages/server-renderer/src/index.ts index 9c5066e85..c4b907127 100644 --- a/packages/server-renderer/src/index.ts +++ b/packages/server-renderer/src/index.ts @@ -1,7 +1,13 @@ // public export { SSRContext } from './render' export { renderToString } from './renderToString' -export { renderToStream } from './renderToStream' +export { + renderToStream, + renderToSimpleStream, + renderToNodeStream, + renderToWebStream, + SimpleReadable +} from './renderToStream' // internal runtime helpers export { renderVNode as ssrRenderVNode } from './render' diff --git a/packages/server-renderer/src/renderToStream.ts b/packages/server-renderer/src/renderToStream.ts index 4952b51c2..516bff8ee 100644 --- a/packages/server-renderer/src/renderToStream.ts +++ b/packages/server-renderer/src/renderToStream.ts @@ -12,9 +12,14 @@ import { Readable } from 'stream' const { isVNode } = ssrUtils +export interface SimpleReadable { + push(chunk: string | null): void + destroy(err: any): void +} + async function unrollBuffer( buffer: SSRBuffer, - stream: Readable + stream: SimpleReadable ): Promise { if (buffer.hasAsync) { for (let i = 0; i < buffer.length; i++) { @@ -35,7 +40,7 @@ async function unrollBuffer( } } -function unrollBufferSync(buffer: SSRBuffer, stream: Readable) { +function unrollBufferSync(buffer: SSRBuffer, stream: SimpleReadable) { for (let i = 0; i < buffer.length; i++) { let item = buffer[i] if (isString(item)) { @@ -47,13 +52,18 @@ function unrollBufferSync(buffer: SSRBuffer, stream: Readable) { } } -export function renderToStream( +export function renderToSimpleStream( input: App | VNode, - context: SSRContext = {} -): Readable { + context: SSRContext, + stream: T +): T { if (isVNode(input)) { // raw vnode, wrap with app (for context) - return renderToStream(createApp({ render: () => input }), context) + return renderToSimpleStream( + createApp({ render: () => input }), + context, + stream + ) } // rendering an app @@ -62,8 +72,6 @@ export function renderToStream( // provide the ssr context to the tree input.provide(ssrContextKey, context) - const stream = new Readable() - Promise.resolve(renderComponentVNode(vnode)) .then(buffer => unrollBuffer(buffer, stream)) .then(() => { @@ -75,3 +83,78 @@ export function renderToStream( return stream } + +/** + * @deprecated + */ +export function renderToStream( + input: App | VNode, + context: SSRContext = {} +): Readable { + console.warn( + `[@vue/server-renderer] renderToStream is deprecated - use renderToNodeStream instead.` + ) + return renderToNodeStream(input, context) +} + +export function renderToNodeStream( + input: App | VNode, + context: SSRContext = {}, + UserReadable?: typeof Readable +): Readable { + const stream: Readable = UserReadable + ? new UserReadable() + : __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)` + ) + } + + return renderToSimpleStream(input, context, stream) +} + +const hasGlobalWebStream = typeof ReadableStream === 'function' + +export function renderToWebStream( + input: App | VNode, + context: SSRContext = {}, + Ctor?: { new (): ReadableStream } +): ReadableStream { + if (!Ctor && !hasGlobalWebStream) { + throw new Error( + `ReadableStream constructor is not avaialbe 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)` + ) + } + + let cancelled = false + return new (Ctor || ReadableStream)({ + start(controller) { + renderToSimpleStream(input, context, { + push(content) { + if (cancelled) return + if (content != null) { + controller.enqueue(content) + } else { + controller.close() + } + }, + destroy(err) { + controller.error(err) + } + }) + }, + cancel() { + cancelled = true + } + }) +}