diff --git a/packages/vite/ambient.d.ts b/packages/vite/ambient.d.ts index 64c58d8e3a00..da81612adf7a 100644 --- a/packages/vite/ambient.d.ts +++ b/packages/vite/ambient.d.ts @@ -1,16 +1,29 @@ /* eslint-disable no-var */ /// +import type { HelmetServerState } from 'react-helmet-async' declare global { + // Provided by Vite.config, or Webpack in the user's project + // but "regsitered" in packages/vite/src/streaming/registerGlobals.ts + // for it to be available to framework code var RWJS_ENV: { - RWJS_API_GRAPHQL_URL?: string + RWJS_API_GRAPHQL_URL: string /** URL or absolute path to serverless functions */ - RWJS_API_URL?: string + RWJS_API_URL: string + RWJS_EXP_STREAMING_SSR: boolean + RWJS_EXP_RSC: boolean - __REDWOOD__APP_TITLE?: string + __REDWOOD__APP_TITLE: string + } + + var RWJS_DEBUG_ENV: { + RWJS_SRC_ROOT: string + REDWOOD_ENV_EDITOR: string } var __REDWOOD__PRERENDER_PAGES: any + + var __REDWOOD__HELMET_CONTEXT: { helmet?: HelmetServerState } } export {} diff --git a/packages/vite/bins/rw-vite-build.mjs b/packages/vite/bins/rw-vite-build.mjs index 158e57b75905..e3aabaf7773e 100755 --- a/packages/vite/bins/rw-vite-build.mjs +++ b/packages/vite/bins/rw-vite-build.mjs @@ -41,13 +41,14 @@ const buildWebSide = async (webDir) => { throw new Error('Could not locate your web/vite.config.{js,ts} file') } - // @NOTE: necessary for keeping the cwd correct for postcss/tailwind - process.chdir(webDir) process.env.NODE_ENV = 'production' if (getConfig().experimental?.streamingSsr?.enabled) { - await buildFeServer({ verbose }) + // Webdir checks handled in the rwjs/vite package in new build system + await buildFeServer({ verbose, webDir }) } else { + // Ensure cwd to be web: required for postcss/tailwind to work correctly + process.chdir(webDir) // Right now, the buildWeb function looks up the config file from project-config // In the future, if we have multiple web spaces we could pass in the cwd here buildWeb({ verbose }) diff --git a/packages/vite/src/buildFeServer.ts b/packages/vite/src/buildFeServer.ts index 2e070f0bdcd5..2cfaca286e94 100644 --- a/packages/vite/src/buildFeServer.ts +++ b/packages/vite/src/buildFeServer.ts @@ -13,12 +13,16 @@ import { getAppRouteHook, getConfig, getPaths } from '@redwoodjs/project-config' import { buildRscFeServer } from './buildRscFeServer' import { RWRouteManifest } from './types' +import { ensureProcessDirWeb } from './utils' export interface BuildOptions { verbose?: boolean + webDir?: string } -export const buildFeServer = async ({ verbose }: BuildOptions) => { +export const buildFeServer = async ({ verbose, webDir }: BuildOptions = {}) => { + ensureProcessDirWeb(webDir) + const rwPaths = getPaths() const rwConfig = getConfig() const viteConfigPath = rwPaths.web.viteConfig @@ -143,7 +147,7 @@ export const buildFeServer = async ({ verbose }: BuildOptions) => { acc[route.path] = { name: route.name, bundle: route.relativeFilePath - ? clientBuildManifest[route.relativeFilePath].file + ? clientBuildManifest[route.relativeFilePath]?.file : null, matchRegexString: route.matchRegexString, // @NOTE this is the path definition, not the actual path diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts index 9adc2519d8e4..462ff2a1b9d0 100644 --- a/packages/vite/src/devFeServer.ts +++ b/packages/vite/src/devFeServer.ts @@ -1,8 +1,6 @@ // TODO (STREAMING) Merge with runFeServer so we only have one file -import path from 'path' import express from 'express' -import { renderToPipeableStream } from 'react-dom/server' import { createServer as createViteServer } from 'vite' import { getProjectRoutes } from '@redwoodjs/internal/dist/routes' @@ -10,29 +8,18 @@ import { getAppRouteHook, getConfig, getPaths } from '@redwoodjs/project-config' import { matchPath } from '@redwoodjs/router' import type { TagDescriptor } from '@redwoodjs/web' -import { loadAndRunRouteHooks } from './triggerRouteHooks' -import { stripQueryStringAndHashFromPath } from './utils' - -// These values are defined in the vite.config.ts -globalThis.RWJS_ENV = {} +import { registerFwGlobals } from './streaming/registerGlobals' +import { reactRenderToStream } from './streaming/streamHelpers' +import { loadAndRunRouteHooks } from './streaming/triggerRouteHooks' +import { ensureProcessDirWeb, stripQueryStringAndHashFromPath } from './utils' // TODO (STREAMING) Just so it doesn't error out. Not sure how to handle this. globalThis.__REDWOOD__PRERENDER_PAGES = {} async function createServer() { - // Check CWD: make sure its the web/ directory - // Without this Postcss can misbehave, and its hard to trace why. - if (process.cwd() !== getPaths().web.base) { - console.error('⚠️ Warning: CWD is ', process.cwd()) - console.warn('~'.repeat(50)) - console.warn( - 'The FE dev server cwd must be web/. Please use `yarn rw dev` or start the server from the web/ directory.' - ) - console.log(`Changing cwd to ${getPaths().web.base}....`) - console.log() + ensureProcessDirWeb() - process.chdir(getPaths().web.base) - } + registerFwGlobals() const app = express() const rwPaths = getPaths() @@ -62,6 +49,7 @@ async function createServer() { app.use('*', async (req, res, next) => { const currentPathName = stripQueryStringAndHashFromPath(req.originalUrl) + globalThis.__REDWOOD__HELMET_CONTEXT = {} try { const routes = getProjectRoutes() @@ -120,46 +108,17 @@ async function createServer() { // required, and provides efficient invalidation similar to HMR. const { ServerEntry } = await vite.ssrLoadModule(rwPaths.web.entryServer) - // TODO (STREAMING) CSS is handled by Vite in dev mode, we don't need to - // worry about it in dev but..... it causes a flash of unstyled content. - // For now I'm just injecting index css here - // We believe we saw a fix for this somewhere in the Waku sources. Maybe - // it was called something like "Capture Css". And it's also mentioned - // in the Vite issues on GitHub - const FIXME_HardcodedIndexCss = ['index.css'] - - const assetMap = JSON.stringify({ - css: FIXME_HardcodedIndexCss, - meta: metaTags, - }) - - const bootstrapModules = [ - path.join(__dirname, '../inject', 'reactRefresh.js'), - ] - const pageWithJs = currentRoute?.renderMode !== 'html' - if (pageWithJs) { - bootstrapModules.push(rwPaths.web.entryClient) - } + res.setHeader('content-type', 'text/html; charset=utf-8') - const { pipe } = renderToPipeableStream( - ServerEntry({ - url: currentPathName, - css: FIXME_HardcodedIndexCss, - meta: metaTags, - }), - { - bootstrapScriptContent: pageWithJs - ? `window.__assetMap = function() { return ${assetMap} }` - : undefined, - bootstrapModules, - onShellReady() { - res.setHeader('content-type', 'text/html; charset=utf-8') - pipe(res) - }, - } - ) + reactRenderToStream({ + ServerEntry, + currentPathName, + metaTags, + includeJs: pageWithJs, + res, + }) } catch (e) { // TODO (STREAMING) Is this what we want to do? // send back a SPA page diff --git a/packages/vite/src/runFeServer.ts b/packages/vite/src/runFeServer.ts index 1bad75e2f16c..3170de8eb25b 100644 --- a/packages/vite/src/runFeServer.ts +++ b/packages/vite/src/runFeServer.ts @@ -18,12 +18,11 @@ import { getConfig, getPaths } from '@redwoodjs/project-config' import { matchPath } from '@redwoodjs/router' import type { TagDescriptor } from '@redwoodjs/web' -import { loadAndRunRouteHooks } from './triggerRouteHooks' +import { registerFwGlobals } from './streaming/registerGlobals' +import { loadAndRunRouteHooks } from './streaming/triggerRouteHooks' import { RWRouteManifest } from './types' import { stripQueryStringAndHashFromPath } from './utils' -globalThis.RWJS_ENV = {} - /** * TODO (STREAMING) * We have this server in the vite package only temporarily. @@ -50,6 +49,8 @@ export async function runFeServer() { const rwPaths = getPaths() const rwConfig = getConfig() + registerFwGlobals() + // TODO When https://github.com/tc39/proposal-import-attributes and // https://github.com/microsoft/TypeScript/issues/53656 have both landed we // should try to do this instead: @@ -211,15 +212,16 @@ export async function runFeServer() { const pageWithJs = currentRoute.renderMode !== 'html' // @NOTE have to add slash so subpaths still pick up the right file - // Vite is currently producing modules not scripts: https://vitejs.dev/config/build-options.html#build-target const bootstrapModules = pageWithJs - ? ['/' + indexEntry.file, '/' + currentRoute.bundle] + ? ([ + '/' + indexEntry.file, + currentRoute.bundle && '/' + currentRoute.bundle, + ].filter(Boolean) as string[]) : undefined const isSeoCrawler = checkUaForSeoCrawler(req.get('user-agent')) const { pipe, abort } = renderToPipeableStream( - // we should use the same shape as Remix or Next for the meta object ServerEntry({ url: currentPathName, css: indexEntry.css, diff --git a/packages/vite/src/runRscFeServer.ts b/packages/vite/src/runRscFeServer.ts index 1b62b7533996..af4cde8bb392 100644 --- a/packages/vite/src/runRscFeServer.ts +++ b/packages/vite/src/runRscFeServer.ts @@ -15,10 +15,9 @@ import type { Manifest as ViteBuildManifest } from 'vite' import { getConfig, getPaths } from '@redwoodjs/project-config' +import { registerFwGlobals } from './streaming/registerGlobals' import { renderRSC, setClientEntries } from './waku-lib/rsc-handler-worker' -globalThis.RWJS_ENV = {} - /** * TODO (STREAMING) * We have this server in the vite package only temporarily. @@ -45,6 +44,8 @@ export async function runFeServer() { const rwPaths = getPaths() const rwConfig = getConfig() + registerFwGlobals() + await setClientEntries('load') // TODO When https://github.com/tc39/proposal-import-attributes and diff --git a/packages/vite/src/streaming/registerGlobals.ts b/packages/vite/src/streaming/registerGlobals.ts new file mode 100644 index 000000000000..fd21a2855dc5 --- /dev/null +++ b/packages/vite/src/streaming/registerGlobals.ts @@ -0,0 +1,34 @@ +import path from 'node:path' + +import { getConfig, getPaths } from '@redwoodjs/project-config' + +/** + * Use this function on the web server + * + * Because although this is defined in Vite/index.ts + * They are only available in the user's code (and not in FW code) + * because define STATICALLY replaces it in user's code, not in node_modules + * + * It's still available on the client side though, probably because its processed by Vite + */ +export const registerFwGlobals = () => { + const rwConfig = getConfig() + const rwPaths = getPaths() + + globalThis.RWJS_ENV = { + // @NOTE we're avoiding process.env here, unlike webpack + RWJS_API_GRAPHQL_URL: + rwConfig.web.apiGraphQLUrl ?? rwConfig.web.apiUrl + '/graphql', + RWJS_API_URL: rwConfig.web.apiUrl, + __REDWOOD__APP_TITLE: rwConfig.web.title || path.basename(rwPaths.base), + RWJS_EXP_STREAMING_SSR: + rwConfig.experimental.streamingSsr && + rwConfig.experimental.streamingSsr.enabled, + RWJS_EXP_RSC: rwConfig.experimental?.rsc?.enabled, + } + + globalThis.RWJS_DEBUG_ENV = { + RWJS_SRC_ROOT: rwPaths.web.src, + REDWOOD_ENV_EDITOR: JSON.stringify(process.env.REDWOOD_ENV_EDITOR), + } +} diff --git a/packages/vite/src/streaming/streamHelpers.ts b/packages/vite/src/streaming/streamHelpers.ts new file mode 100644 index 000000000000..bed2ad5bccf0 --- /dev/null +++ b/packages/vite/src/streaming/streamHelpers.ts @@ -0,0 +1,146 @@ +import path from 'node:path' +import { Writable } from 'node:stream' + +import React from 'react' + +import { renderToPipeableStream, renderToString } from 'react-dom/server' + +import { getPaths } from '@redwoodjs/project-config' +import type { TagDescriptor } from '@redwoodjs/web' +// @TODO (ESM), use exports field. Cannot import from web because of index exports +import { + ServerHtmlProvider, + ServerInjectedHtml, + createInjector, + RenderCallback, +} from '@redwoodjs/web/dist/components/ServerInject' + +interface RenderToStreamArgs { + ServerEntry: any + currentPathName: string + metaTags: TagDescriptor[] + includeJs: boolean + res: Writable +} + +export function reactRenderToStream({ + ServerEntry, + currentPathName, + metaTags, + includeJs, + res, +}: RenderToStreamArgs) { + const rwPaths = getPaths() + + const bootstrapModules = [ + path.join(__dirname, '../../inject', 'reactRefresh.js'), + ] + + if (includeJs) { + // type casting: guaranteed to have entryClient by this stage, because checks run earlier + bootstrapModules.push(rwPaths.web.entryClient as string) + } + + // TODO (STREAMING) CSS is handled by Vite in dev mode, we don't need to + // worry about it in dev but..... it causes a flash of unstyled content. + // For now I'm just injecting index css here + // Looks at collectStyles in packages/vite/src/fully-react/find-styles.ts + const FIXME_HardcodedIndexCss = ['index.css'] + + const assetMap = JSON.stringify({ + css: FIXME_HardcodedIndexCss, + meta: metaTags, + }) + + // This ensures an isolated state for each request + const { injectionState, injectToPage } = createInjector() + + // This is effectively a transformer stream + const intermediateStream = createServerInjectionStream({ + outputStream: res, + onFinal: () => { + res.end() + }, + injectionState, + }) + + const { pipe } = renderToPipeableStream( + React.createElement( + ServerHtmlProvider, + { + value: injectToPage, + }, + ServerEntry({ + url: currentPathName, + css: FIXME_HardcodedIndexCss, + meta: metaTags, + }) + ), + { + bootstrapScriptContent: includeJs + ? `window.__REDWOOD__ASSET_MAP = ${assetMap}` + : undefined, + bootstrapModules, + onShellReady() { + // Pass the react "input" stream to the injection stream + // This intermediate stream will interweave the injected html into the react stream's + pipe(intermediateStream) + }, + } + ) +} +function createServerInjectionStream({ + outputStream, + onFinal, + injectionState, +}: { + outputStream: Writable + onFinal: () => void + injectionState: Set +}) { + return new Writable({ + write(chunk, encoding, next) { + const chunkAsString = chunk.toString() + const split = chunkAsString.split('') + + // If the closing tag exists + if (split.length > 1) { + const [beforeClosingHead, afterClosingHead] = split + + const elementsInjectedToHead = renderToString( + React.createElement(ServerInjectedHtml, { + injectionState, + }) + ) + + const outputBuffer = Buffer.from( + [ + beforeClosingHead, + elementsInjectedToHead, + '', + afterClosingHead, + ].join('') + ) + + outputStream.write(outputBuffer, encoding) + } else { + outputStream.write(chunk, encoding) + } + + next() + }, + final() { + // Before finishing, make sure we flush anything else that has been added to the queue + // Because of the implementation in ServerRenderHtml, its safe to call this multiple times (I think!) + // This is really for the data fetching usecase, where the promise is resolved after is closed + const elementsAtTheEnd = renderToString( + React.createElement(ServerInjectedHtml, { + injectionState, + }) + ) + + outputStream.write(elementsAtTheEnd) + onFinal() + }, + }) +} diff --git a/packages/vite/src/triggerRouteHooks.ts b/packages/vite/src/streaming/triggerRouteHooks.ts similarity index 81% rename from packages/vite/src/triggerRouteHooks.ts rename to packages/vite/src/streaming/triggerRouteHooks.ts index 42d402e7e6a3..285bcd0c6cd3 100644 --- a/packages/vite/src/triggerRouteHooks.ts +++ b/packages/vite/src/streaming/triggerRouteHooks.ts @@ -80,39 +80,38 @@ export const loadAndRunRouteHooks = async ({ } let currentRouteHooks: RouteHooks - + let rhOutput: RouteHookOutput = defaultRouteHookOutput // Pull out the first path // Remember shift will mutate the array const routeHookPath = paths.shift() - if (!routeHookPath) { - return defaultRouteHookOutput - } else { - try { + try { + // Sometimes the appRouteHook is null, so we can skip it + if (routeHookPath) { currentRouteHooks = await loadModule(routeHookPath) // Step 2, run the route hooks - const rhOutput = await triggerRouteHooks({ + rhOutput = await triggerRouteHooks({ routeHooks: currentRouteHooks, req: reqMeta.req, parsedParams: reqMeta.parsedParams, previousOutput, }) + } - if (paths.length > 0) { - // Step 3, recursively call this function - return loadAndRunRouteHooks({ - paths, - reqMeta, - previousOutput: rhOutput, - viteDevServer, - }) - } else { - return rhOutput - } - } catch (e) { - console.error(`Error loading route hooks in ${routeHookPath}}`) - throw new Error(e as any) + if (paths.length > 0) { + // Step 3, recursively call this function + return loadAndRunRouteHooks({ + paths, + reqMeta, + previousOutput: rhOutput, + viteDevServer, + }) + } else { + return rhOutput } + } catch (e) { + console.error(`Error loading route hooks in ${routeHookPath}}`) + throw e } } diff --git a/packages/vite/src/utils.ts b/packages/vite/src/utils.ts index 32da87601cca..e721c32177f3 100644 --- a/packages/vite/src/utils.ts +++ b/packages/vite/src/utils.ts @@ -1,3 +1,21 @@ +import { getPaths } from '@redwoodjs/project-config' + export function stripQueryStringAndHashFromPath(url: string) { return url.split('?')[0].split('#')[0] } + +// Check CWD: make sure its the web/ directory +// Without this Postcss can misbehave, and its hard to trace why. +export function ensureProcessDirWeb(webDir: string = getPaths().web.base) { + if (process.cwd() !== webDir) { + console.error('⚠️ Warning: CWD is ', process.cwd()) + console.warn('~'.repeat(50)) + console.warn( + 'The cwd must be web/. Please use `yarn rw ` or run the command from the web/ directory.' + ) + console.log(`Changing cwd to ${webDir}....`) + console.log() + + process.chdir(webDir) + } +} diff --git a/packages/web/ambient.d.ts b/packages/web/ambient.d.ts index e452d2016432..151c7972b58a 100644 --- a/packages/web/ambient.d.ts +++ b/packages/web/ambient.d.ts @@ -8,6 +8,11 @@ declare global { var __REDWOOD__APP_TITLE: string var __REDWOOD__APOLLO_STATE: NormalizedCacheObject + var __REDWOOD__ASSET_MAP: { + css?: string[] + meta?: TagDescriptor[] + } + // Provided by Vite.config, or Webpack in the user's project var RWJS_ENV: { RWJS_API_GRAPHQL_URL: string diff --git a/packages/web/package.json b/packages/web/package.json index 4d3a24b5775a..8c68dc235408 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -31,7 +31,7 @@ "build:types": "tsc --build --verbose", "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "prepublishOnly": "NODE_ENV=production yarn build", - "test": "jest src", + "test": "jest", "test:watch": "yarn test --watch" }, "dependencies": { diff --git a/packages/web/src/components/MetaTags.tsx b/packages/web/src/components/MetaTags.tsx index 0f207a0f4dac..1b1c5ebd492f 100644 --- a/packages/web/src/components/MetaTags.tsx +++ b/packages/web/src/components/MetaTags.tsx @@ -1,4 +1,8 @@ -import { Head } from '../index' +import { Head as HelmetHead } from '../index' + +// Ideally we wouldn't include this for non experiment builds +// But.... not worth the effort to remove it from bundle atm +import PortalHead from './PortalHead' type RobotsParams = | 'noindex' @@ -78,6 +82,13 @@ export const MetaTags = (props: MetaTagsProps) => { author, children, } = props + + let Head: typeof HelmetHead | typeof PortalHead = HelmetHead + + if (RWJS_ENV.RWJS_EXP_STREAMING_SSR) { + Head = PortalHead + } + return ( <> {title && ( diff --git a/packages/web/src/components/PortalHead.tsx b/packages/web/src/components/PortalHead.tsx new file mode 100644 index 000000000000..829c1ccc5687 --- /dev/null +++ b/packages/web/src/components/PortalHead.tsx @@ -0,0 +1,20 @@ +import { createPortal } from 'react-dom' + +import { useServerInsertedHTML } from './ServerInject' + +const PortalHead: React.FC = ({ children }) => { + useServerInsertedHTML(() => { + // @TODO this component should be wrapped in: document.head.append() + // because its possible for meta tags to be rendered after is closed + return children + }) + + if (typeof window === 'undefined') { + // Don't do anything on the server, handled by above callback + return null + } else { + return createPortal(<>{children}, document.head) + } +} + +export default PortalHead diff --git a/packages/web/src/components/RedwoodProvider.tsx b/packages/web/src/components/RedwoodProvider.tsx index bc84deeb17af..64a4a3308164 100644 --- a/packages/web/src/components/RedwoodProvider.tsx +++ b/packages/web/src/components/RedwoodProvider.tsx @@ -18,12 +18,9 @@ export const RedwoodProvider = ({ } return '' } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - // TODO (STREAMING) need to disable helmet here, because it clashes with meta - // routeHooks but can still leave helmet provider to make it easy to migrate - // to new setup - // TODO (STREAMING) Figure out how the Helmet stuff can live along side - // streaming while streaming is still experimental + + // @TODO (STREAMING): We can remove Helmet, HelmetProvider + // Once we've migrated to using the new PortalHead component return ( diff --git a/packages/web/src/components/ServerInject.tsx b/packages/web/src/components/ServerInject.tsx new file mode 100644 index 000000000000..1e1819243dfd --- /dev/null +++ b/packages/web/src/components/ServerInject.tsx @@ -0,0 +1,82 @@ +import React, { Fragment, ReactNode, useContext, useId } from 'react' + +/** + * + * Inspired by Next's useServerInsertedHTML, originally designed for CSS-in-JS + * for now it seems the only way to inject html with streaming is to use a context + * + * We use this for tags, and for apollo cache hydration + * + * Until https://github.com/reactjs/rfcs/pull/219 makes it into react + * + */ + +export type RenderCallback = () => ReactNode + +export const ServerHtmlContext = React.createContext< + ((things: RenderCallback) => void) | null +>(null) + +/** + * + * Use this factory, once per request. + * This is to ensure that injectionState is isolated to the request + * and not shared between requests. + */ +export const createInjector = () => { + const injectionState: Set = new Set([]) + + const injectToPage = (renderCallback: RenderCallback) => { + injectionState.add(renderCallback) + } + + return { injectToPage, injectionState } +} + +// @NOTE do not instatiate the provider value here, so that we can ensure +// context isolation. This is done in streamHelpers currently, +// using the createInjector factory, once per request +export const ServerHtmlProvider = ServerHtmlContext.Provider + +export const ServerInjectedHtml = ({ + injectionState, +}: { + injectionState: Set +}) => { + const serverInsertedHtml = [] + for (const callback of injectionState) { + serverInsertedHtml.push(callback()) + + // Remove it from the set so its not rendered again + injectionState.delete(callback) + } + + const fragmentId = useId() + + return serverInsertedHtml.map((html, i) => { + return ( + {html} + ) + }) +} + +// Exactly the same as Next's useServerInsertedHTML +export function useServerInsertedHTML(callback: () => React.ReactNode): void { + const addInsertedServerHTMLCallback = useContext(ServerHtmlContext) + + // Should have no effects on client where there's no flush effects provider + if (addInsertedServerHTMLCallback) { + addInsertedServerHTMLCallback(callback) + } +} + +// @TODO use this in streamHelpers final block +export const AppendToHead = ({ tagsToAppend }: { tagsToAppend: string }) => { + return ( +