diff --git a/packages/tuono/package.json b/packages/tuono/package.json index 55f06435..af92fcaf 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -101,6 +101,7 @@ "@babel/template": "^7.24.0", "@babel/traverse": "^7.24.1", "@babel/types": "^7.24.0", + "@rollup/plugin-inject": "^5.0.5", "@types/babel__core": "^7.20.5", "@types/node": "^22.0.0", "@vitejs/plugin-react-swc": "^3.7.0", @@ -109,7 +110,8 @@ "tuono-fs-router-vite-plugin": "workspace:*", "tuono-lazy-fn-vite-plugin": "workspace:*", "tuono-router": "workspace:*", - "vite": "^5.2.11" + "vite": "^5.2.11", + "web-streams-polyfill": "^4.0.0" }, "devDependencies": { "@types/babel-traverse": "^6.25.10", diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index 19ca09e0..fd6e13ac 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -1,6 +1,7 @@ -import type { InlineConfig } from 'vite' +import type { InlineConfig, Plugin } from 'vite' import { build, createServer, mergeConfig } from 'vite' import react from '@vitejs/plugin-react-swc' +import inject from '@rollup/plugin-inject' import ViteFsRouter from 'tuono-fs-router-vite-plugin' import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin' @@ -9,6 +10,14 @@ import type { TuonoConfig } from '../config' import { loadConfig, blockingAsync } from './utils' const VITE_PORT = 3001 +const VITE_SSR_PLUGINS: Array = [ + { + enforce: 'post', + ...inject({ + ReadableStream: ['web-streams-polyfill', 'ReadableStream'], + }), + }, +] /** * From a given {@link TuonoConfig} return a `vite` "mergeable" {@link InlineConfig} @@ -65,6 +74,7 @@ const developmentSSRBundle = (): void => { mergeConfig( createBaseViteConfigFromTuonoConfig(config), { + plugins: VITE_SSR_PLUGINS, build: { ssr: true, minify: false, @@ -143,6 +153,7 @@ const buildProd = (): void => { mergeConfig( createBaseViteConfigFromTuonoConfig(config), { + plugins: VITE_SSR_PLUGINS, build: { ssr: true, minify: true, diff --git a/packages/tuono/src/ssr/index.tsx b/packages/tuono/src/ssr/index.tsx index 2275d317..0c7e63ff 100644 --- a/packages/tuono/src/ssr/index.tsx +++ b/packages/tuono/src/ssr/index.tsx @@ -1,11 +1,15 @@ import 'fast-text-encoding' // Mandatory for React18 +import type { ReadableStream } from 'node:stream/web' + import * as React from 'react' -import { renderToString, renderToStaticMarkup } from 'react-dom/server' +import { renderToStaticMarkup, renderToReadableStream } from 'react-dom/server' import type { HelmetServerState } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async' import { RouterProvider, createRouter } from 'tuono-router' import type { createRoute } from 'tuono-router' +import { streamToString } from './utils' + type RouteTree = ReturnType type Mode = 'Dev' | 'Prod' @@ -37,7 +41,7 @@ function generateJsScripts(jsBundles: Array, mode: Mode): string { } export function serverSideRendering(routeTree: RouteTree) { - return function render(payload: string | undefined): string { + return async function render(payload: string | undefined): Promise { const serverProps = (payload ? JSON.parse(payload) : {}) as Record< string, unknown @@ -48,14 +52,21 @@ export function serverSideRendering(routeTree: RouteTree) { const cssBundles = serverProps.cssBundles as Array const router = createRouter({ routeTree }) // Render the app - const helmetContext = {} - const app = renderToString( + const helmetContext = {} as { helmet: HelmetServerState } + const stream = await renderToReadableStream( , ) - const { helmet } = helmetContext as { helmet: HelmetServerState } + await stream.allReady + + const { helmet } = helmetContext + + const app = await streamToString( + // ReadableStream should be implemented in node) + stream as unknown as ReadableStream, + ) return ` diff --git a/packages/tuono/src/ssr/utils.ts b/packages/tuono/src/ssr/utils.ts new file mode 100644 index 00000000..defe0d5b --- /dev/null +++ b/packages/tuono/src/ssr/utils.ts @@ -0,0 +1,38 @@ +// react ReadableStream type is an empty interface so we are using the one from +// node which match the runtime value +import type { ReadableStream } from 'node:stream/web' + +function concatArrayBuffers(chunks: Array): Uint8Array { + const result = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0)) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.length + } + return result +} + +async function streamToArrayBuffer( + stream: ReadableStream, +): Promise { + const chunks: Array = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + return concatArrayBuffers(chunks) +} + +/** + * This function awaits for the whole stream before returning the string. + * + * NOTE: we should improve the bond between the custom V8 runtime and the + * renderToReadableStream React function to return a stream directly to the client. + */ +export async function streamToString( + stream: ReadableStream, +): Promise { + const buffer = await streamToArrayBuffer(stream) + return new TextDecoder().decode(buffer) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71128ab8..b91d693f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: '@babel/types': specifier: ^7.24.0 version: 7.26.0 + '@rollup/plugin-inject': + specifier: ^5.0.5 + version: 5.0.5(rollup@4.25.0) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -327,6 +330,9 @@ importers: vite: specifier: ^5.2.11 version: 5.4.11(@types/node@22.10.0)(sugarss@4.0.1(postcss@8.4.49)) + web-streams-polyfill: + specifier: ^4.0.0 + version: 4.0.0 devDependencies: '@types/babel-traverse': specifier: ^6.25.10 @@ -799,6 +805,15 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@rollup/plugin-inject@5.0.5': + resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/pluginutils@5.1.3': resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} @@ -3423,6 +3438,10 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-streams-polyfill@4.0.0: + resolution: {integrity: sha512-0zJXHRAYEjM2tUfZ2DiSOHAa2aw1tisnnhU3ufD57R8iefL+DcdJyRBRyJpG+NUimDgbTI/lH+gAE1PAvV3Cgw==} + engines: {node: '>= 8'} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4031,6 +4050,14 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@rollup/plugin-inject@5.0.5(rollup@4.25.0)': + dependencies: + '@rollup/pluginutils': 5.1.3(rollup@4.25.0) + estree-walker: 2.0.2 + magic-string: 0.30.14 + optionalDependencies: + rollup: 4.25.0 + '@rollup/pluginutils@5.1.3(rollup@4.25.0)': dependencies: '@types/estree': 1.0.6 @@ -7276,6 +7303,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-streams-polyfill@4.0.0: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: