diff --git a/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js b/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js index dac1a0948e86..007e1bdf7b99 100644 --- a/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js +++ b/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js @@ -1,8 +1,14 @@ import fs from 'fs' +import path from 'path' + +import { Listr } from 'listr2' import { getConfigPath } from '@redwoodjs/project-config' +import { errorTelemetry } from '@redwoodjs/telemetry' -import { writeFile } from '../../lib' +import { getPaths, transformTSToJS, writeFile } from '../../lib' +import c from '../../lib/colors' +import { isTypeScriptProject } from '../../lib/project' import { command, @@ -11,44 +17,164 @@ import { } from './setupStreamingSsr' import { printTaskEpilogue } from './util' -export const handler = async ({ force }) => { +export const handler = async ({ force, verbose }) => { + const rwPaths = getPaths() const redwoodTomlPath = getConfigPath() const configContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + const ts = isTypeScriptProject() + const ext = path.extname(rwPaths.web.entryClient || '') - if (!configContent.includes('[experimental.streamingSsr]')) { - console.log('Adding config to redwood.toml...') + const tasks = new Listr( + [ + { + title: 'Check prerequisites', + task: () => { + if (!rwPaths.web.entryClient || !rwPaths.web.viteConfig) { + throw new Error( + 'Vite needs to be setup before you can enable Streaming SSR' + ) + } + }, + }, + { + title: 'Adding config to redwood.toml...', + task: (_ctx, task) => { + if (!configContent.includes('[experimental.streamingSsr]')) { + writeFile( + redwoodTomlPath, + configContent.concat( + `\n[experimental.streamingSsr]\n enabled = true\n` + ), + { + overwriteExisting: true, // redwood.toml always exists + } + ) + } else { + if (force) { + task.output = 'Overwriting config in redwood.toml' - // Use string replace to preserve comments and formatting - writeFile( - redwoodTomlPath, - configContent.concat(`\n[experimental.streamingSsr]\n enabled = true\n`), + writeFile( + redwoodTomlPath, + configContent.replace( + // Enable if it's currently disabled + `\n[experimental.streamingSsr]\n enabled = false\n`, + `\n[experimental.streamingSsr]\n enabled = true\n` + ), + { + overwriteExisting: true, // redwood.toml always exists + } + ) + } else { + task.skip( + `The [experimental.streamingSsr] config block already exists in your 'redwood.toml' file.` + ) + } + } + }, + options: { persistentOutput: true }, + }, { - overwriteExisting: true, // redwood.toml always exists - } - ) - } else { - if (force) { - console.log('Updating config in redwood.toml...') - writeFile( - redwoodTomlPath, - configContent.replace( - // Enable if it's currently disabled - `\n[experimental.streamingSsr]\n enabled = false\n`, - `\n[experimental.streamingSsr]\n enabled = true\n` - ), - { - overwriteExisting: true, // redwood.toml always exists - } - ) - } else { - console.log('Adding config to redwood.toml...') - console.log( - " The [experimental.studio] config block already exists in your 'redwood.toml' file." - ) - } - } + title: `Adding entry.client${ext}...`, + task: async (_ctx, task) => { + const entryClientTemplate = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'streamingSsr', + 'entry.client.tsx.template' + ), + 'utf-8' + ) + let entryClientPath = rwPaths.web.entryClient + const entryClientContent = ts + ? entryClientTemplate + : transformTSToJS(entryClientPath, entryClientTemplate) + + let overwriteExisting = force - console.log() + if (!force) { + overwriteExisting = await task.prompt({ + type: 'Confirm', + message: `Overwrite ${entryClientPath}?`, + }) - printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID) + if (!overwriteExisting) { + entryClientPath = entryClientPath.replace(ext, `.new${ext}`) + task.output = + `File will be written to ${entryClientPath}\n` + + `You'll manually need to merge it with your existing entry.client${ext} file.` + } + } + + writeFile(entryClientPath, entryClientContent, { overwriteExisting }) + }, + options: { persistentOutput: true }, + }, + { + title: `Adding entry.server${ext}...`, + task: async () => { + const entryServerTemplate = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'streamingSsr', + 'entry.server.tsx.template' + ), + 'utf-8' + ) + // Can't use rwPaths.web.entryServer because it might not be not created yet + const entryServerPath = path.join( + rwPaths.web.src, + `entry.server${ext}` + ) + const entryServerContent = ts + ? entryServerTemplate + : transformTSToJS(entryServerPath, entryServerTemplate) + + writeFile(entryServerPath, entryServerContent, { + overwriteExisting: force, + }) + }, + }, + { + title: `Adding Document${ext}...`, + task: async () => { + const documentTemplate = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'streamingSsr', + 'Document.tsx.template' + ), + 'utf-8' + ) + const documentPath = path.join(rwPaths.web.src, `Document${ext}`) + const documentContent = ts + ? documentTemplate + : transformTSToJS(documentPath, documentTemplate) + + writeFile(documentPath, documentContent, { + overwriteExisting: force, + }) + }, + }, + { + task: () => { + printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID) + }, + }, + ], + { + rendererOptions: { collapseSubtasks: false, persistentOutput: true }, + renderer: verbose ? 'verbose' : 'default', + } + ) + + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } } diff --git a/packages/cli/src/commands/experimental/templates/streamingSsr/Document.tsx.template b/packages/cli/src/commands/experimental/templates/streamingSsr/Document.tsx.template new file mode 100644 index 000000000000..838fcf86ac3e --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/streamingSsr/Document.tsx.template @@ -0,0 +1,26 @@ +import React from 'react' + +import { Css, Meta } from '@redwoodjs/web' +import type { TagDescriptor } from '@redwoodjs/web' + +interface DocumentProps { + children: React.ReactNode + css: string[] // array of css import strings + meta?: TagDescriptor[] +} + +export const Document: React.FC = ({ children, css, meta }) => { + return ( + + + + + + + + +
{children}
+ + + ) +} diff --git a/packages/cli/src/commands/experimental/templates/streamingSsr/entry.client.tsx.template b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.client.tsx.template new file mode 100644 index 000000000000..7d2b297c6034 --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.client.tsx.template @@ -0,0 +1,38 @@ +import { hydrateRoot, createRoot } from 'react-dom/client' + +// TODO (STREAMING) This was marked "temporary workaround" +// Need to figure out why it's a temporary workaround and what we +// should do instead. +import { ServerContextProvider } from '@redwoodjs/web/dist/serverContext' + +import App from './App' +import { Document } from './Document' + +/** + * When `#redwood-app` isn't empty then it's very likely that you're using + * prerendering. So React attaches event listeners to the existing markup + * rather than replacing it. + * https://reactjs.org/docs/react-dom-client.html#hydrateroot + */ +const redwoodAppElement = document.getElementById('redwood-app') + +if (redwoodAppElement.children?.length > 0) { + hydrateRoot( + document, + + + + + + ) +} else { + console.log('Rendering from scratch') + const root = createRoot(document) + root.render( + + + + + + ) +} diff --git a/packages/cli/src/commands/experimental/templates/streamingSsr/entry.server.tsx.template b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.server.tsx.template new file mode 100644 index 000000000000..dac1b959b928 --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.server.tsx.template @@ -0,0 +1,29 @@ +import { LocationProvider } from '@redwoodjs/router' +import { ServerContextProvider } from '@redwoodjs/web/dist/serverContext' + +import App from './App' +import { Document } from './Document' + +interface Props { + routeContext: any + url: string + css: string[] + meta?: any[] +} + +export const ServerEntry: React.FC = ({ + routeContext, + url, + css, + meta, +}) => { + return ( + + + + + + + + ) +}