Skip to content

Commit 6da5aae

Browse files
authored
Add files needed for React Streaming SSR (#8810)
1 parent fd14fd5 commit 6da5aae

4 files changed

Lines changed: 253 additions & 34 deletions

File tree

Lines changed: 160 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import fs from 'fs'
2+
import path from 'path'
3+
4+
import { Listr } from 'listr2'
25

36
import { getConfigPath } from '@redwoodjs/project-config'
7+
import { errorTelemetry } from '@redwoodjs/telemetry'
48

5-
import { writeFile } from '../../lib'
9+
import { getPaths, transformTSToJS, writeFile } from '../../lib'
10+
import c from '../../lib/colors'
11+
import { isTypeScriptProject } from '../../lib/project'
612

713
import {
814
command,
@@ -11,44 +17,164 @@ import {
1117
} from './setupStreamingSsr'
1218
import { printTaskEpilogue } from './util'
1319

14-
export const handler = async ({ force }) => {
20+
export const handler = async ({ force, verbose }) => {
21+
const rwPaths = getPaths()
1522
const redwoodTomlPath = getConfigPath()
1623
const configContent = fs.readFileSync(redwoodTomlPath, 'utf-8')
24+
const ts = isTypeScriptProject()
25+
const ext = path.extname(rwPaths.web.entryClient || '')
1726

18-
if (!configContent.includes('[experimental.streamingSsr]')) {
19-
console.log('Adding config to redwood.toml...')
27+
const tasks = new Listr(
28+
[
29+
{
30+
title: 'Check prerequisites',
31+
task: () => {
32+
if (!rwPaths.web.entryClient || !rwPaths.web.viteConfig) {
33+
throw new Error(
34+
'Vite needs to be setup before you can enable Streaming SSR'
35+
)
36+
}
37+
},
38+
},
39+
{
40+
title: 'Adding config to redwood.toml...',
41+
task: (_ctx, task) => {
42+
if (!configContent.includes('[experimental.streamingSsr]')) {
43+
writeFile(
44+
redwoodTomlPath,
45+
configContent.concat(
46+
`\n[experimental.streamingSsr]\n enabled = true\n`
47+
),
48+
{
49+
overwriteExisting: true, // redwood.toml always exists
50+
}
51+
)
52+
} else {
53+
if (force) {
54+
task.output = 'Overwriting config in redwood.toml'
2055

21-
// Use string replace to preserve comments and formatting
22-
writeFile(
23-
redwoodTomlPath,
24-
configContent.concat(`\n[experimental.streamingSsr]\n enabled = true\n`),
56+
writeFile(
57+
redwoodTomlPath,
58+
configContent.replace(
59+
// Enable if it's currently disabled
60+
`\n[experimental.streamingSsr]\n enabled = false\n`,
61+
`\n[experimental.streamingSsr]\n enabled = true\n`
62+
),
63+
{
64+
overwriteExisting: true, // redwood.toml always exists
65+
}
66+
)
67+
} else {
68+
task.skip(
69+
`The [experimental.streamingSsr] config block already exists in your 'redwood.toml' file.`
70+
)
71+
}
72+
}
73+
},
74+
options: { persistentOutput: true },
75+
},
2576
{
26-
overwriteExisting: true, // redwood.toml always exists
27-
}
28-
)
29-
} else {
30-
if (force) {
31-
console.log('Updating config in redwood.toml...')
32-
writeFile(
33-
redwoodTomlPath,
34-
configContent.replace(
35-
// Enable if it's currently disabled
36-
`\n[experimental.streamingSsr]\n enabled = false\n`,
37-
`\n[experimental.streamingSsr]\n enabled = true\n`
38-
),
39-
{
40-
overwriteExisting: true, // redwood.toml always exists
41-
}
42-
)
43-
} else {
44-
console.log('Adding config to redwood.toml...')
45-
console.log(
46-
" The [experimental.studio] config block already exists in your 'redwood.toml' file."
47-
)
48-
}
49-
}
77+
title: `Adding entry.client${ext}...`,
78+
task: async (_ctx, task) => {
79+
const entryClientTemplate = fs.readFileSync(
80+
path.resolve(
81+
__dirname,
82+
'templates',
83+
'streamingSsr',
84+
'entry.client.tsx.template'
85+
),
86+
'utf-8'
87+
)
88+
let entryClientPath = rwPaths.web.entryClient
89+
const entryClientContent = ts
90+
? entryClientTemplate
91+
: transformTSToJS(entryClientPath, entryClientTemplate)
92+
93+
let overwriteExisting = force
5094

51-
console.log()
95+
if (!force) {
96+
overwriteExisting = await task.prompt({
97+
type: 'Confirm',
98+
message: `Overwrite ${entryClientPath}?`,
99+
})
52100

53-
printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID)
101+
if (!overwriteExisting) {
102+
entryClientPath = entryClientPath.replace(ext, `.new${ext}`)
103+
task.output =
104+
`File will be written to ${entryClientPath}\n` +
105+
`You'll manually need to merge it with your existing entry.client${ext} file.`
106+
}
107+
}
108+
109+
writeFile(entryClientPath, entryClientContent, { overwriteExisting })
110+
},
111+
options: { persistentOutput: true },
112+
},
113+
{
114+
title: `Adding entry.server${ext}...`,
115+
task: async () => {
116+
const entryServerTemplate = fs.readFileSync(
117+
path.resolve(
118+
__dirname,
119+
'templates',
120+
'streamingSsr',
121+
'entry.server.tsx.template'
122+
),
123+
'utf-8'
124+
)
125+
// Can't use rwPaths.web.entryServer because it might not be not created yet
126+
const entryServerPath = path.join(
127+
rwPaths.web.src,
128+
`entry.server${ext}`
129+
)
130+
const entryServerContent = ts
131+
? entryServerTemplate
132+
: transformTSToJS(entryServerPath, entryServerTemplate)
133+
134+
writeFile(entryServerPath, entryServerContent, {
135+
overwriteExisting: force,
136+
})
137+
},
138+
},
139+
{
140+
title: `Adding Document${ext}...`,
141+
task: async () => {
142+
const documentTemplate = fs.readFileSync(
143+
path.resolve(
144+
__dirname,
145+
'templates',
146+
'streamingSsr',
147+
'Document.tsx.template'
148+
),
149+
'utf-8'
150+
)
151+
const documentPath = path.join(rwPaths.web.src, `Document${ext}`)
152+
const documentContent = ts
153+
? documentTemplate
154+
: transformTSToJS(documentPath, documentTemplate)
155+
156+
writeFile(documentPath, documentContent, {
157+
overwriteExisting: force,
158+
})
159+
},
160+
},
161+
{
162+
task: () => {
163+
printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID)
164+
},
165+
},
166+
],
167+
{
168+
rendererOptions: { collapseSubtasks: false, persistentOutput: true },
169+
renderer: verbose ? 'verbose' : 'default',
170+
}
171+
)
172+
173+
try {
174+
await tasks.run()
175+
} catch (e) {
176+
errorTelemetry(process.argv, e.message)
177+
console.error(c.error(e.message))
178+
process.exit(e?.exitCode || 1)
179+
}
54180
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from 'react'
2+
3+
import { Css, Meta } from '@redwoodjs/web'
4+
import type { TagDescriptor } from '@redwoodjs/web'
5+
6+
interface DocumentProps {
7+
children: React.ReactNode
8+
css: string[] // array of css import strings
9+
meta?: TagDescriptor[]
10+
}
11+
12+
export const Document: React.FC<DocumentProps> = ({ children, css, meta }) => {
13+
return (
14+
<html lang="en">
15+
<head>
16+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
17+
<link rel="icon" type="image/png" href="/favicon.png" />
18+
<Css css={css} />
19+
<Meta tags={meta} />
20+
</head>
21+
<body>
22+
<div id="redwood-app">{children}</div>
23+
</body>
24+
</html>
25+
)
26+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { hydrateRoot, createRoot } from 'react-dom/client'
2+
3+
// TODO (STREAMING) This was marked "temporary workaround"
4+
// Need to figure out why it's a temporary workaround and what we
5+
// should do instead.
6+
import { ServerContextProvider } from '@redwoodjs/web/dist/serverContext'
7+
8+
import App from './App'
9+
import { Document } from './Document'
10+
11+
/**
12+
* When `#redwood-app` isn't empty then it's very likely that you're using
13+
* prerendering. So React attaches event listeners to the existing markup
14+
* rather than replacing it.
15+
* https://reactjs.org/docs/react-dom-client.html#hydrateroot
16+
*/
17+
const redwoodAppElement = document.getElementById('redwood-app')
18+
19+
if (redwoodAppElement.children?.length > 0) {
20+
hydrateRoot(
21+
document,
22+
<ServerContextProvider value={{}}>
23+
<Document css={window.__assetMap?.()?.css}>
24+
<App />
25+
</Document>
26+
</ServerContextProvider>
27+
)
28+
} else {
29+
console.log('Rendering from scratch')
30+
const root = createRoot(document)
31+
root.render(
32+
<ServerContextProvider value={{}}>
33+
<Document css={window.__assetMap?.()?.css}>
34+
<App />
35+
</Document>
36+
</ServerContextProvider>
37+
)
38+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { LocationProvider } from '@redwoodjs/router'
2+
import { ServerContextProvider } from '@redwoodjs/web/dist/serverContext'
3+
4+
import App from './App'
5+
import { Document } from './Document'
6+
7+
interface Props {
8+
routeContext: any
9+
url: string
10+
css: string[]
11+
meta?: any[]
12+
}
13+
14+
export const ServerEntry: React.FC<Props> = ({
15+
routeContext,
16+
url,
17+
css,
18+
meta,
19+
}) => {
20+
return (
21+
<ServerContextProvider value={routeContext}>
22+
<LocationProvider location={{ pathname: url }}>
23+
<Document css={css} meta={meta}>
24+
<App />
25+
</Document>
26+
</LocationProvider>
27+
</ServerContextProvider>
28+
)
29+
}

0 commit comments

Comments
 (0)