diff --git a/packages/internal/src/routes.ts b/packages/internal/src/routes.ts index 21e460cd8fa6..a385018fce88 100644 --- a/packages/internal/src/routes.ts +++ b/packages/internal/src/routes.ts @@ -67,18 +67,23 @@ export function warningForDuplicateRoutes() { return message.trimEnd() } -export interface RouteSpec { +export interface RWRouteManifestItem { name: string - path: string + pathDefinition: string + matchRegexString: string | null + routeHooks: string | null + bundle: string | null hasParams: boolean + redirect: { to: string; permanent: boolean } | null + renderMode: 'html' | 'stream' + // Probably want isNotFound here, so we can attach a separate 404 handler +} + +export interface RouteSpec extends RWRouteManifestItem { id: string isNotFound: boolean filePath: string | undefined relativeFilePath: string | undefined - routeHooks: string | undefined | null - matchRegexString: string | null - redirect: { to: string; permanent: boolean } | null - renderMode: 'stream' | 'html' } export const getProjectRoutes = (): RouteSpec[] => { @@ -92,7 +97,7 @@ export const getProjectRoutes = (): RouteSpec[] => { return { name: route.isNotFound ? 'NotFoundPage' : route.name, - path: route.isNotFound ? 'notfound' : route.path, + pathDefinition: route.isNotFound ? 'notfound' : route.path, hasParams: route.hasParameters, id: route.id, isNotFound: route.isNotFound, diff --git a/packages/project-config/src/paths.ts b/packages/project-config/src/paths.ts index 1a38bb8daba4..84304e5ee4f4 100644 --- a/packages/project-config/src/paths.ts +++ b/packages/project-config/src/paths.ts @@ -262,16 +262,43 @@ export const getRouteHookForPage = (pagePath: string | undefined | null) => { // We just use fg, so if they make typos in the routeHook file name, // it's all good, we'll still find it - return fg - .sync('*.routeHooks.{js,ts,tsx,jsx}', { - absolute: true, - cwd: path.dirname(pagePath), // the page's folder - }) - .at(0) + return ( + fg + .sync('*.routeHooks.{js,ts,tsx,jsx}', { + absolute: true, + cwd: path.dirname(pagePath), // the page's folder + }) + .at(0) || null + ) } -export const getAppRouteHook = () => { - return resolveFile(path.join(getPaths().web.src, 'App.routeHooks')) +/** + * Use this function to find the app route hook. + * If it is present, you get the path to the file - in prod, you get the built version in dist. + * In dev, you get the source version. + * + * @param forProd + * @returns string | null + */ +export const getAppRouteHook = (forProd = false) => { + const rwPaths = getPaths() + + if (forProd) { + const distAppRouteHook = path.join( + rwPaths.web.distRouteHooks, + 'App.routeHooks.js' + ) + + try { + // Stat sync throws if file doesn't exist + fs.statSync(distAppRouteHook).isFile() + return distAppRouteHook + } catch (e) { + return null + } + } + + return resolveFile(path.join(rwPaths.web.src, 'App.routeHooks')) } /** diff --git a/packages/vite/src/buildFeServer.ts b/packages/vite/src/buildFeServer.ts index 2cfaca286e94..9267602c1428 100644 --- a/packages/vite/src/buildFeServer.ts +++ b/packages/vite/src/buildFeServer.ts @@ -144,7 +144,7 @@ export const buildFeServer = async ({ verbose, webDir }: BuildOptions = {}) => { const routesList = getProjectRoutes() const routeManifest = routesList.reduce((acc, route) => { - acc[route.path] = { + acc[route.pathDefinition] = { name: route.name, bundle: route.relativeFilePath ? clientBuildManifest[route.relativeFilePath]?.file @@ -152,7 +152,7 @@ export const buildFeServer = async ({ verbose, webDir }: BuildOptions = {}) => { matchRegexString: route.matchRegexString, // @NOTE this is the path definition, not the actual path // E.g. /blog/post/{id:Int} - pathDefinition: route.path, + pathDefinition: route.pathDefinition, hasParams: route.hasParams, routeHooks: FIXME_constructRouteHookPath(route.routeHooks), redirect: route.redirect diff --git a/packages/vite/src/buildRscFeServer.ts b/packages/vite/src/buildRscFeServer.ts index 77f26d0d0643..e618bcc4034b 100644 --- a/packages/vite/src/buildRscFeServer.ts +++ b/packages/vite/src/buildRscFeServer.ts @@ -218,7 +218,7 @@ export const buildRscFeServer = async ({ // This is all a no-op for now const routeManifest = routesList.reduce((acc, route) => { - acc[route.path] = { + acc[route.pathDefinition] = { name: route.name, bundle: route.relativeFilePath ? clientBuildManifest[route.relativeFilePath].file @@ -226,7 +226,7 @@ export const buildRscFeServer = async ({ matchRegexString: route.matchRegexString, // NOTE this is the path definition, not the actual path // E.g. /blog/post/{id:Int} - pathDefinition: route.path, + pathDefinition: route.pathDefinition, hasParams: route.hasParams, routeHooks: null, redirect: route.redirect diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts index 462ff2a1b9d0..2c22acfb564d 100644 --- a/packages/vite/src/devFeServer.ts +++ b/packages/vite/src/devFeServer.ts @@ -4,14 +4,11 @@ import express from 'express' import { createServer as createViteServer } from 'vite' import { getProjectRoutes } from '@redwoodjs/internal/dist/routes' -import { getAppRouteHook, getConfig, getPaths } from '@redwoodjs/project-config' -import { matchPath } from '@redwoodjs/router' -import type { TagDescriptor } from '@redwoodjs/web' +import { getConfig, getPaths } from '@redwoodjs/project-config' +import { createReactStreamingHandler } from './streaming/createReactStreamingHandler' import { registerFwGlobals } from './streaming/registerGlobals' -import { reactRenderToStream } from './streaming/streamHelpers' -import { loadAndRunRouteHooks } from './streaming/triggerRouteHooks' -import { ensureProcessDirWeb, stripQueryStringAndHashFromPath } from './utils' +import { ensureProcessDirWeb } from './utils' // TODO (STREAMING) Just so it doesn't error out. Not sure how to handle this. globalThis.__REDWOOD__PRERENDER_PAGES = {} @@ -24,14 +21,24 @@ async function createServer() { const app = express() const rwPaths = getPaths() + // ~~~ Dev time validations ~~~~ // TODO (STREAMING) When Streaming is released Vite will be the only bundler, // and this file should always exist. So the error message needs to change // (or be removed perhaps) + if (!rwPaths.web.entryServer || !rwPaths.web.entryClient) { + throw new Error( + 'Vite entry points not found. Please check that your project has ' + + 'an entry.client.{jsx,tsx} and entry.server.{jsx,tsx} file in ' + + 'the web/src directory.' + ) + } + if (!rwPaths.web.viteConfig) { throw new Error( 'Vite config not found. You need to setup your project with Vite using `yarn rw setup vite`' ) } + // ~~~~ Dev time validations ~~~~ // Create Vite server in middleware mode and configure the app type as // 'custom', disabling Vite's own HTML serving logic so parent server @@ -47,89 +54,35 @@ async function createServer() { // use vite's connect instance as middleware app.use(vite.middlewares) - app.use('*', async (req, res, next) => { - const currentPathName = stripQueryStringAndHashFromPath(req.originalUrl) - globalThis.__REDWOOD__HELMET_CONTEXT = {} - - try { - const routes = getProjectRoutes() - - // Do a simple match with regex, don't bother parsing params yet - const currentRoute = routes.find((route) => { - if (!route.matchRegexString) { - // This is the 404/NotFoundPage case - return false - } - - const matches = [ - ...currentPathName.matchAll(new RegExp(route.matchRegexString, 'g')), - ] - - return matches.length > 0 - }) - - let metaTags: TagDescriptor[] = [] - - if (currentRoute?.redirect) { - return res.redirect(currentRoute.redirect.to) - } - - if (currentRoute) { - const parsedParams = currentRoute.hasParams - ? matchPath(currentRoute.path, currentPathName).params - : undefined - - const routeHookOutput = await loadAndRunRouteHooks({ - paths: [getAppRouteHook(), currentRoute.routeHooks], - reqMeta: { - req, - parsedParams, - }, - viteDevServer: vite, // because its dev - }) - - metaTags = routeHookOutput.meta - } - - if (!currentRoute) { - // TODO (STREAMING) do something - } - - if (!rwPaths.web.entryServer || !rwPaths.web.entryClient) { - throw new Error( - 'Vite entry points not found. Please check that your project has ' + - 'an entry.client.{jsx,tsx} and entry.server.{jsx,tsx} file in ' + - 'the web/src directory.' - ) - } - - // 3. Load the server entry. vite.ssrLoadModule automatically transforms - // your ESM source code to be usable in Node.js! There is no bundling - // required, and provides efficient invalidation similar to HMR. - const { ServerEntry } = await vite.ssrLoadModule(rwPaths.web.entryServer) - - const pageWithJs = currentRoute?.renderMode !== 'html' - - res.setHeader('content-type', 'text/html; charset=utf-8') - - reactRenderToStream({ - ServerEntry, - currentPathName, - metaTags, - includeJs: pageWithJs, - res, - }) - } catch (e) { - // TODO (STREAMING) Is this what we want to do? - // send back a SPA page - // res.status(200).set({ 'Content-Type': 'text/html' }).end(template) - - // If an error is caught, let Vite fix the stack trace so it maps back to - // your actual source code. - vite.ssrFixStacktrace(e as any) - next(e) + const routes = getProjectRoutes() + + // 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 + // Look at collectStyles in packages/vite/src/fully-react/find-styles.ts + const FIXME_HardcodedIndexCss = ['index.css'] + + for (const route of routes) { + const routeHandler = await createReactStreamingHandler( + { + route, + clientEntryPath: rwPaths.web.entryClient as string, + cssLinks: FIXME_HardcodedIndexCss, + }, + vite + ) + + // @TODO if it is a 404, hand over to 404 handler + if (!route.matchRegexString) { + continue } - }) + + const expressPathDef = route.hasParams + ? route.matchRegexString + : route.pathDefinition + + app.get(expressPathDef, routeHandler) + } const port = getConfig().web.port console.log(`Started server on http://localhost:${port}`) @@ -141,7 +94,9 @@ let devApp = createServer() process.stdin.on('data', async (data) => { const str = data.toString().trim().toLowerCase() if (str === 'rs' || str === 'restart') { - ;(await devApp).close() - devApp = createServer() + console.log('Restarting dev web server.....') + ;(await devApp).close(() => { + devApp = createServer() + }) } }) diff --git a/packages/vite/src/runFeServer.ts b/packages/vite/src/runFeServer.ts index 3170de8eb25b..3954cb5e6f9d 100644 --- a/packages/vite/src/runFeServer.ts +++ b/packages/vite/src/runFeServer.ts @@ -10,18 +10,13 @@ import path from 'path' import { config as loadDotEnv } from 'dotenv-defaults' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' -import isbot from 'isbot' -import { renderToPipeableStream } from 'react-dom/server' import type { Manifest as ViteBuildManifest } from 'vite' import { getConfig, getPaths } from '@redwoodjs/project-config' -import { matchPath } from '@redwoodjs/router' -import type { TagDescriptor } from '@redwoodjs/web' +import { createReactStreamingHandler } from './streaming/createReactStreamingHandler' import { registerFwGlobals } from './streaming/registerGlobals' -import { loadAndRunRouteHooks } from './streaming/triggerRouteHooks' import { RWRouteManifest } from './types' -import { stripQueryStringAndHashFromPath } from './utils' /** * TODO (STREAMING) @@ -41,9 +36,6 @@ loadDotEnv({ }) //------------------------------------------------ -const checkUaForSeoCrawler = isbot.spawn() -checkUaForSeoCrawler.exclude(['chrome-lighthouse']) - export async function runFeServer() { const app = express() const rwPaths = getPaths() @@ -84,7 +76,7 @@ export async function runFeServer() { // 👉 1. Use static handler for assets // For CF workers, we'd need an equivalent of this - app.use('/assets', express.static(rwPaths.web.dist + '/assets')) + app.use('/', express.static(rwPaths.web.dist, { index: false })) // 👉 2. Proxy the api server // TODO (STREAMING) we need to be able to specify whether proxying is required or not @@ -105,168 +97,27 @@ export async function runFeServer() { }) ) - // 👉 3. Handle all other requests with the server entry - // This is where we match the url to the correct route, and render it - // We also call the relevant routeHooks here - app.use('*', async (req, res) => { - const currentPathName = stripQueryStringAndHashFromPath(req.originalUrl) - - try { - const { ServerEntry } = await import(rwPaths.web.distEntryServer) - - // TODO (STREAMING) should we generate individual express Routes for each Route? - // This would make handling 404s and favicons / public assets etc. easier - const currentRoute = Object.values(routeManifest).find((route) => { - if (!route.matchRegexString) { - // This is the 404/NotFoundPage case - return false - } - - const matches = [ - ...currentPathName.matchAll(new RegExp(route.matchRegexString, 'g')), - ] - return matches.length > 0 - }) - - // Doesn't match any of the defined Routes - // Render 404 page, and send back 404 status - if (!currentRoute) { - // TODO (STREAMING) should we CONST it? - const fourOhFourRoute = routeManifest['notfound'] - - if (!fourOhFourRoute) { - return res.sendStatus(404) - } - - const assetMap = JSON.stringify({ css: indexEntry.css }) - - const { pipe } = renderToPipeableStream( - ServerEntry({ - url: currentPathName, - routeContext: null, - css: indexEntry.css, - }), - { - bootstrapScriptContent: `window.__assetMap = function() { return ${assetMap} }`, - // @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 - bootstrapModules: [ - '/' + indexEntry.file, - '/' + fourOhFourRoute.bundle, - ], - onShellReady() { - res.setHeader('content-type', 'text/html') - res.status(404) - pipe(res) - }, - } - ) - - return - } - - let metaTags: TagDescriptor[] = [] - - if (currentRoute?.redirect) { - // TODO (STREAMING) deal with permanent/temp - // Short-circuit, and return a 301 or 302 - return res.redirect(currentRoute.redirect.to) - } - - if (currentRoute) { - // TODO (STREAMING) hardcoded JS file, watchout if we switch to ESM! - const appRouteHooksPath = path.join( - rwPaths.web.distRouteHooks, - 'App.routeHooks.js' - ) + const collectedCss = indexEntry.css || [] + const clientEntry = '/' + indexEntry.file - let appRouteHooksExists = false - try { - appRouteHooksExists = (await fs.stat(appRouteHooksPath)).isFile() - } catch { - // noop - } - - // Make sure we access the dist routeHooks! - const routeHookPaths = [ - appRouteHooksExists ? appRouteHooksPath : null, - currentRoute.routeHooks - ? path.join(rwPaths.web.distRouteHooks, currentRoute.routeHooks) - : null, - ] - - const parsedParams = currentRoute.hasParams - ? matchPath(currentRoute.pathDefinition, currentPathName).params - : undefined - - const routeHookOutput = await loadAndRunRouteHooks({ - paths: routeHookPaths, - reqMeta: { - req, - parsedParams, - }, - }) - - metaTags = routeHookOutput.meta - } - - const pageWithJs = currentRoute.renderMode !== 'html' - // @NOTE have to add slash so subpaths still pick up the right file - const bootstrapModules = pageWithJs - ? ([ - '/' + indexEntry.file, - currentRoute.bundle && '/' + currentRoute.bundle, - ].filter(Boolean) as string[]) - : undefined - - const isSeoCrawler = checkUaForSeoCrawler(req.get('user-agent')) - - const { pipe, abort } = renderToPipeableStream( - ServerEntry({ - url: currentPathName, - css: indexEntry.css, - meta: metaTags, - }), - { - bootstrapScriptContent: pageWithJs - ? `window.__assetMap = function() { return ${JSON.stringify({ - css: indexEntry.css, - meta: metaTags, - })} }` - : undefined, - bootstrapModules, - onShellReady() { - if (!isSeoCrawler) { - res.setHeader('content-type', 'text/html; charset=utf-8') - pipe(res) - } - }, - onAllReady() { - if (isSeoCrawler) { - res.setHeader('content-type', 'text/html; charset=utf-8') - pipe(res) - } - }, - onError(error) { - console.error(error) - }, - } - ) - - // TODO (STREAMING) make the timeout configurable - setTimeout(() => { - abort() - }, 10_000) - } catch (e) { - console.error(e) + for (const route of Object.values(routeManifest)) { + const routeHandler = await createReactStreamingHandler({ + route, + clientEntryPath: clientEntry, + cssLinks: collectedCss, + }) - // streaming no longer requires us to send back a blank page - // React will automatically switch to client rendering on error - return res.sendStatus(500) + // if it is a 404, register it at the end somehow. + if (!route.matchRegexString) { + continue } - return - }) + const expressPathDef = route.hasParams + ? route.matchRegexString + : route.pathDefinition + + app.get(expressPathDef, routeHandler) + } app.listen(rwConfig.web.port) console.log( diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts new file mode 100644 index 000000000000..c259516ff78e --- /dev/null +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -0,0 +1,102 @@ +import path from 'path' + +import type { Request, Response } from 'express' +import isbot from 'isbot' +import type { ViteDevServer } from 'vite' + +import type { RWRouteManifestItem } from '@redwoodjs/internal' +import { getAppRouteHook, getPaths } from '@redwoodjs/project-config' +import type { TagDescriptor } from '@redwoodjs/web' + +// import { stripQueryStringAndHashFromPath } from '../utils' + +import { reactRenderToStream } from './streamHelpers' +import { loadAndRunRouteHooks } from './triggerRouteHooks' + +interface CreateReactStreamingHandlerOptions { + route: RWRouteManifestItem + clientEntryPath: string + cssLinks: string[] +} + +const checkUaForSeoCrawler = isbot.spawn() +checkUaForSeoCrawler.exclude(['chrome-lighthouse']) + +export const createReactStreamingHandler = async ( + { route, clientEntryPath, cssLinks }: CreateReactStreamingHandlerOptions, + viteDevServer?: ViteDevServer +) => { + const { redirect, routeHooks, bundle } = route + const rwPaths = getPaths() + + const isProd = !viteDevServer + + let entryServerImport: any + + if (isProd) { + entryServerImport = await import(rwPaths.web.distEntryServer) + } + + return async (req: Request, res: Response) => { + if (redirect) { + res.redirect(redirect.to) + } + + // Do this inside the handler for **dev-only**. + // This makes sure that changes to entry-server are picked up on refresh + if (!isProd) { + entryServerImport = await viteDevServer.ssrLoadModule( + rwPaths.web.entryServer as string // already validated in dev server + ) + } + + const ServerEntry = + entryServerImport.ServerEntry || entryServerImport.default + + const currentPathName = req.path + + let metaTags: TagDescriptor[] = [] + + let routeHookPath = routeHooks + + if (isProd) { + routeHookPath = routeHooks + ? path.join(rwPaths.web.distRouteHooks, routeHooks) + : null + } + + // @TODO can we load the route hook outside the handler? + const routeHookOutput = await loadAndRunRouteHooks({ + paths: [getAppRouteHook(isProd), routeHookPath], + reqMeta: { + req, + parsedParams: req.params, + }, + viteDevServer, + }) + + metaTags = routeHookOutput.meta + + const jsBundles = [ + clientEntryPath, // @NOTE: must have slash in front + bundle && '/' + bundle, + ].filter(Boolean) as string[] + + const isSeoCrawler = checkUaForSeoCrawler(req.headers['user-agent'] || '') + + reactRenderToStream( + { + ServerEntry, + currentPathName, + metaTags, + cssLinks, + isProd, + jsBundles, + res, + }, + { + waitForAllReady: isSeoCrawler, + } + ) + } +} diff --git a/packages/vite/src/streaming/registerGlobals.ts b/packages/vite/src/streaming/registerGlobals.ts index 467920923251..b1c87b51480a 100644 --- a/packages/vite/src/streaming/registerGlobals.ts +++ b/packages/vite/src/streaming/registerGlobals.ts @@ -33,8 +33,36 @@ export const registerFwGlobals = () => { if (/^[a-zA-Z][a-zA-Z\d+\-.]*?:/.test(apiPath)) { return apiPath } else { + const proxiedApiUrl = + 'http://' + rwConfig.web.host + ':' + rwConfig.web.port + apiPath + + if ( + process.env.NODE_ENV === 'production' && + !process.env.RWJS_EXP_SSR_GRAPHQL_ENDPOINT?.length + ) { + console.log('------------------ WARNING ! -------------------------') + console.warn() + console.warn() + + console.warn( + `You haven't configured your API absolute url. Localhost is unlikely to work in production` + ) + + console.warn(`Using ${proxiedApiUrl}`) + console.warn() + + console.warn( + 'You can override this for by setting RWJS_EXP_SSR_GRAPHQL_ENDPOINT in your environment vars' + ) + console.warn() + + console.log('------------------ WARNING ! -------------------------') + + return proxiedApiUrl + } + return ( - 'http://' + rwConfig.api.host + ':' + rwConfig.api.port + '/graphql' + proxiedApiUrl || (process.env.RWJS_EXP_SSR_GRAPHQL_ENDPOINT as string) ) } })(), diff --git a/packages/vite/src/streaming/streamHelpers.ts b/packages/vite/src/streaming/streamHelpers.ts index 4b57a24c0d33..a51ccf727daf 100644 --- a/packages/vite/src/streaming/streamHelpers.ts +++ b/packages/vite/src/streaming/streamHelpers.ts @@ -5,7 +5,6 @@ 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 { @@ -19,36 +18,38 @@ interface RenderToStreamArgs { ServerEntry: any currentPathName: string metaTags: TagDescriptor[] - includeJs: boolean + cssLinks: string[] + isProd: boolean + jsBundles?: string[] 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) - } +interface StreamOptions { + waitForAllReady?: boolean +} - // 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'] +export function reactRenderToStream( + renderOptions: RenderToStreamArgs, + streamOptions: StreamOptions +) { + const { waitForAllReady = false } = streamOptions + const { + ServerEntry, + currentPathName, + metaTags, + cssLinks, + isProd, + jsBundles = [], + res, + } = renderOptions + + if (!isProd) { + // For development, we need to inject the react-refresh runtime + jsBundles.push(path.join(__dirname, '../../inject', 'reactRefresh.js')) + } const assetMap = JSON.stringify({ - css: FIXME_HardcodedIndexCss, + css: cssLinks, meta: metaTags, }) @@ -69,23 +70,34 @@ export function reactRenderToStream({ }, ServerEntry({ url: currentPathName, - css: FIXME_HardcodedIndexCss, + css: cssLinks, meta: metaTags, }) ), { - bootstrapScriptContent: includeJs - ? `window.__REDWOOD__ASSET_MAP = ${assetMap}` - : undefined, - bootstrapModules, + bootstrapScriptContent: + // Only insert assetMap if clientside JS will be loaded + jsBundles.length > 0 + ? `window.__REDWOOD__ASSET_MAP = ${assetMap}` + : undefined, + bootstrapModules: jsBundles, 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) + + if (!waitForAllReady) { + pipe(intermediateStream) + } + }, + onAllReady() { + if (waitForAllReady) { + pipe(intermediateStream) + } }, } ) } + function createServerInjectionStream({ outputStream, injectionState, diff --git a/packages/vite/src/types.ts b/packages/vite/src/types.ts index 445f3226a572..8d29722d3863 100644 --- a/packages/vite/src/types.ts +++ b/packages/vite/src/types.ts @@ -9,16 +9,7 @@ * * **All** of these properties are used by the prod FE server */ -export interface RWRouteManifestItem { - matchRegexString: string | null // xAR, RouteSpec.matchRegexString - routeHooks: string | null // xAR, RouteSpec.routeHooks BUT in RouteSpec its the src path, here its the dist path - bundle: string | null // xAR, xRS - pathDefinition: PathDefinition // <-- AnalyzedRoute.path, RouteSpec.path - hasParams: boolean // xAR, RouteSpec.hasParams - name: string // <-- AnalyzedRoute.name, RouteSpec.name - redirect: { to: string; permanent: boolean } | null // xAR (not same type), RouteSpec.redirect - renderMode: 'html' | 'stream' // x, RouteSpec.renderMode -} +import type { RWRouteManifestItem } from '@redwoodjs/internal' export type RWRouteManifest = Record diff --git a/packages/web/src/apollo/links.tsx b/packages/web/src/apollo/links.tsx new file mode 100644 index 000000000000..04721652d896 --- /dev/null +++ b/packages/web/src/apollo/links.tsx @@ -0,0 +1,117 @@ +import type { HttpOptions } from '@apollo/client' +import { ApolloLink, HttpLink } from '@apollo/client' +import { setContext } from '@apollo/client/link/context' +import { print } from 'graphql/language/printer' + +export function createHttpLink( + uri: string, + httpLinkConfig: HttpOptions | undefined +) { + return new HttpLink({ + // @MARK: we have to construct the absoltue url for SSR + uri, + ...httpLinkConfig, + // you can disable result caching here if you want to + // (this does not work if you are rendering your page with `export const dynamic = "force-static"`) + fetchOptions: { cache: 'no-store' }, + }) +} +export function createUpdateDataLink(data: any) { + return new ApolloLink((operation, forward) => { + const { operationName, query, variables } = operation + + data.mostRecentRequest = {} + data.mostRecentRequest.operationName = operationName + data.mostRecentRequest.operationKind = query?.kind.toString() + data.mostRecentRequest.variables = variables + data.mostRecentRequest.query = query && print(operation.query) + + return forward(operation).map((result) => { + data.mostRecentResponse = result + + return result + }) + }) +} +export function createAuthApolloLink( + authProviderType: string, + headers: + | { + 'auth-provider'?: string | undefined + authorization?: string | undefined + } + | undefined +) { + return new ApolloLink((operation, forward) => { + const { token } = operation.getContext() + + // Only add auth headers when there's a token. `token` is `null` when `!isAuthenticated`. + const authHeaders = token + ? { + 'auth-provider': authProviderType, + authorization: `Bearer ${token}`, + } + : {} + + operation.setContext(() => ({ + headers: { + ...operation.getContext().headers, + ...headers, + // Duped auth headers, because we may remove the `FetchConfigProvider` at a later date. + ...authHeaders, + }, + })) + + return forward(operation) + }) +} +export function createTokenLink(getToken: () => Promise) { + return setContext(async () => { + const token = await getToken() + + return { token } + }) +} + +export function createFinalLink({ + userConfiguredLink, + defaultLinks, +}: { + userConfiguredLink?: ApolloLink | RedwoodApolloLinkFactory + defaultLinks: RedwoodApolloLinks +}): ApolloLink { + if (userConfiguredLink) { + if (typeof userConfiguredLink === 'function') { + return userConfiguredLink(defaultLinks) + } else { + return userConfiguredLink + } + } + + return ApolloLink.from(defaultLinks.map((l) => l.link)) +} + +// ~~~ Types ~~~ + +export type RedwoodApolloLinkName = + | 'withToken' + | 'authMiddleware' + | 'updateDataApolloLink' + | 'httpLink' + +export type RedwoodApolloLink< + Name extends RedwoodApolloLinkName, + Link extends ApolloLink = ApolloLink +> = { + name: Name + link: Link +} + +export type RedwoodApolloLinks = [ + RedwoodApolloLink<'withToken'>, + RedwoodApolloLink<'authMiddleware'>, + RedwoodApolloLink<'updateDataApolloLink'>, + RedwoodApolloLink<'httpLink', HttpLink> +] + +export type RedwoodApolloLinkFactory = (links: RedwoodApolloLinks) => ApolloLink diff --git a/packages/web/src/apollo/suspense.tsx b/packages/web/src/apollo/suspense.tsx index 5ad29d3e8cbf..838ad998ac53 100644 --- a/packages/web/src/apollo/suspense.tsx +++ b/packages/web/src/apollo/suspense.tsx @@ -9,26 +9,22 @@ import type { ApolloCache, ApolloClientOptions, + HttpOptions, + InMemoryCacheConfig, setLogVerbosity, } from '@apollo/client' -import * as apolloClient from '@apollo/client' -import { setContext } from '@apollo/client/link/context' +import { + ApolloLink, + setLogVerbosity as apolloSetLogVerbosity, + useMutation, + useSubscription, +} from '@apollo/client' import { ApolloNextAppProvider, NextSSRApolloClient, NextSSRInMemoryCache, useSuspenseQuery, } from '@apollo/experimental-nextjs-app-support/ssr' -import { print } from 'graphql/language/printer' - -// Note: Importing directly from `apollo/client` doesn't work properly in Storybook. -const { - ApolloLink, - HttpLink, - useSubscription, - useMutation, - setLogVerbosity: apolloSetLogVerbosity, -} = apolloClient import { UseAuth, useNoAuth } from '@redwoodjs/auth' import './typeOverride' @@ -39,33 +35,29 @@ import { } from '../components/FetchConfigProvider' import { GraphQLHooksProvider } from '../components/GraphQLHooksProvider' -export type ApolloClientCacheConfig = apolloClient.InMemoryCacheConfig - -export type RedwoodApolloLinkName = - | 'withToken' - | 'authMiddleware' - | 'updateDataApolloLink' - | 'httpLink' - -export type RedwoodApolloLink< - Name extends RedwoodApolloLinkName, - Link extends apolloClient.ApolloLink = apolloClient.ApolloLink -> = { - name: Name - link: Link +import type { + RedwoodApolloLink, + RedwoodApolloLinkFactory, + RedwoodApolloLinkName, + RedwoodApolloLinks, +} from './links' +import { + createAuthApolloLink, + createFinalLink, + createHttpLink, + createTokenLink, + createUpdateDataLink, +} from './links' + +export type ApolloClientCacheConfig = InMemoryCacheConfig + +export type { + RedwoodApolloLink, + RedwoodApolloLinkFactory, + RedwoodApolloLinkName, + RedwoodApolloLinks, } -export type RedwoodApolloLinks = [ - RedwoodApolloLink<'withToken'>, - RedwoodApolloLink<'authMiddleware'>, - RedwoodApolloLink<'updateDataApolloLink'>, - RedwoodApolloLink<'httpLink', apolloClient.HttpLink> -] - -export type RedwoodApolloLinkFactory = ( - links: RedwoodApolloLinks -) => apolloClient.ApolloLink - export type GraphQLClientConfigProp = Omit< ApolloClientOptions, 'cache' | 'link' @@ -88,7 +80,7 @@ export type GraphQLClientConfigProp = Omit< * }}> * ``` */ - httpLinkConfig?: apolloClient.HttpOptions + httpLinkConfig?: HttpOptions /** * Extend or overwrite `RedwoodApolloProvider`'s Apollo Link. * @@ -112,7 +104,7 @@ export type GraphQLClientConfigProp = Omit< * - your function should return a single link (e.g., using `ApolloLink.from`; see https://www.apollographql.com/docs/react/api/link/introduction/#additive-composition) * - the `HttpLink` should come last (https://www.apollographql.com/docs/react/api/link/introduction/#the-terminating-link) */ - link?: apolloClient.ApolloLink | RedwoodApolloLinkFactory + link?: ApolloLink | RedwoodApolloLinkFactory } const ApolloProviderWithFetchConfig: React.FunctionComponent<{ @@ -127,10 +119,6 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{ // See https://github.com/redwoodjs/redwood/issues/2473. apolloSetLogVerbosity(logLevel) - // Here we're using Apollo Link to customize Apollo Client's data flow. - // Although we're sending conventional HTTP-based requests and could just pass `uri` instead of `link`, - // we need to fetch a new token on every request, making middleware a good fit for this. - // // See https://www.apollographql.com/docs/react/api/link/introduction. const { getToken, type: authProviderType } = useAuth() @@ -141,28 +129,6 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{ mostRecentResponse: undefined, } as any - const updateDataApolloLink = new ApolloLink((operation, forward) => { - const { operationName, query, variables } = operation - - data.mostRecentRequest = {} - data.mostRecentRequest.operationName = operationName - data.mostRecentRequest.operationKind = query?.kind.toString() - data.mostRecentRequest.variables = variables - data.mostRecentRequest.query = query && print(operation.query) - - return forward(operation).map((result) => { - data.mostRecentResponse = result - - return result - }) - }) - - const withToken = setContext(async () => { - const token = await getToken() - - return { token } - }) - const { headers, uri } = useFetchConfig() const getGraphqlUrl = () => { @@ -177,51 +143,20 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{ : uri } - const authMiddleware = new ApolloLink((operation, forward) => { - const { token } = operation.getContext() - - // Only add auth headers when there's a token. `token` is `null` when `!isAuthenticated`. - const authHeaders = token - ? { - 'auth-provider': authProviderType, - authorization: `Bearer ${token}`, - } - : {} - - operation.setContext(() => ({ - headers: { - ...operation.getContext().headers, - ...headers, - // Duped auth headers, because we may remove the `FetchConfigProvider` at a later date. - ...authHeaders, - }, - })) - - return forward(operation) - }) + const { httpLinkConfig, link: userPassedLink, ...otherConfig } = config ?? {} - const { httpLinkConfig, link: redwoodApolloLink, ...rest } = config ?? {} - - // A terminating link. Apollo Client uses this to send GraphQL operations to a server over HTTP. - // See https://www.apollographql.com/docs/react/api/link/introduction/#the-terminating-link. - const httpLink = new HttpLink({ uri, ...httpLinkConfig }) - - // The order here is important. The last link *must* be a terminating link like HttpLink. + // We use this object, because that's the shape of what we pass to the config.link factory const redwoodApolloLinks: RedwoodApolloLinks = [ - { name: 'withToken', link: withToken }, - { name: 'authMiddleware', link: authMiddleware }, - { name: 'updateDataApolloLink', link: updateDataApolloLink }, - { name: 'httpLink', link: httpLink }, + { name: 'withToken', link: createTokenLink(getToken) }, + { + name: 'authMiddleware', + link: createAuthApolloLink(authProviderType, headers), + }, + // @TODO: do we need this in prod? I think it's only for dev errors + { name: 'updateDataApolloLink', link: createUpdateDataLink(data) }, + { name: 'httpLink', link: createHttpLink(getGraphqlUrl(), httpLinkConfig) }, ] - let link = redwoodApolloLink - - link ??= ApolloLink.from(redwoodApolloLinks.map((l) => l.link)) - - if (typeof link === 'function') { - link = link(redwoodApolloLinks) - } - const extendErrorAndRethrow = (error: any, _errorInfo: React.ErrorInfo) => { error['mostRecentRequest'] = data.mostRecentRequest error['mostRecentResponse'] = data.mostRecentResponse @@ -229,18 +164,13 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{ } function makeClient() { - const httpLink = new HttpLink({ - // @MARK: we have to construct the absoltue url for SSR - uri: getGraphqlUrl(), - // you can disable result caching here if you want to - // (this does not work if you are rendering your page with `export const dynamic = "force-static"`) - fetchOptions: { cache: 'no-store' }, - }) - // @MARK use special Apollo client return new NextSSRApolloClient({ - link: httpLink, - ...rest, + link: createFinalLink({ + userConfiguredLink: userPassedLink, + defaultLinks: redwoodApolloLinks, + }), + ...otherConfig, }) } diff --git a/tasks/framework-tools/frameworkSyncToProject.mjs b/tasks/framework-tools/frameworkSyncToProject.mjs index fb8af9ac88d2..9b21ee65bb8e 100644 --- a/tasks/framework-tools/frameworkSyncToProject.mjs +++ b/tasks/framework-tools/frameworkSyncToProject.mjs @@ -61,6 +61,12 @@ async function main() { type: 'boolean', default: true, }) + .option('addFwDeps', { + description: + 'Modify the projects package.json to include fw dependencies', + type: 'boolean', + default: true, + }) .option('watch', { description: 'Watch for changes to the framework packages', type: 'boolean', @@ -162,8 +168,13 @@ async function main() { process.on('exit', cleanUp) } - logStatus("Adding the Redwood framework's dependencies...") - addDependenciesToPackageJson(redwoodProjectPackageJsonPath) + if (options.addFwDeps) { + // Rare case, but sometimes we don't want to modify any dependency versions + logStatus("Adding the Redwood framework's dependencies...") + addDependenciesToPackageJson(redwoodProjectPackageJsonPath) + } else { + logStatus("Skipping adding framework's dependencies...") + } try { execSync('yarn install', {