Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(streaming): Cleanup/Unify streaming dev and prod server #9047

Merged
merged 16 commits into from
Aug 21, 2023
Merged
19 changes: 12 additions & 7 deletions packages/internal/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] => {
Expand All @@ -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,
Expand Down
43 changes: 35 additions & 8 deletions packages/project-config/src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/vite/src/buildFeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,15 @@ export const buildFeServer = async ({ verbose, webDir }: BuildOptions = {}) => {
const routesList = getProjectRoutes()

const routeManifest = routesList.reduce<RWRouteManifest>((acc, route) => {
acc[route.path] = {
acc[route.pathDefinition] = {
name: route.name,
bundle: route.relativeFilePath
? clientBuildManifest[route.relativeFilePath]?.file
: null,
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
Expand Down
4 changes: 2 additions & 2 deletions packages/vite/src/buildRscFeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,15 +218,15 @@ export const buildRscFeServer = async ({

// This is all a no-op for now
const routeManifest = routesList.reduce<RWRouteManifest>((acc, route) => {
acc[route.path] = {
acc[route.pathDefinition] = {
name: route.name,
bundle: route.relativeFilePath
? clientBuildManifest[route.relativeFilePath].file
: null,
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
Expand Down
135 changes: 45 additions & 90 deletions packages/vite/src/devFeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand All @@ -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
Expand All @@ -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}`)
Expand All @@ -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()
})
}
})
Loading