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

Adds dynamic og:image generation using middleware and plain components #10075

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a78508a
Update prerender path to be URL
cannikin Feb 15, 2024
413294f
useLocation will return a full URL object instead of just three parts
cannikin Feb 15, 2024
52cb903
Include additional options when calling middleware
cannikin Feb 15, 2024
7891b35
Add server route for .png files
cannikin Feb 15, 2024
68b9ad0
Update App template to render children if present
cannikin Feb 29, 2024
5be594a
Tie into all extensions, not just png
cannikin Feb 29, 2024
4f97bb4
Move createServerAdapter calls for middleware to shared function, use…
cannikin Feb 29, 2024
1d62b74
Update types
cannikin Feb 29, 2024
2e58781
Updates App.tsx for test project fixture and rsc template
cannikin Feb 29, 2024
c627b31
Merge branch 'main' into rc-og-image-support
cannikin Feb 29, 2024
53c53e9
Adds useOgImageUrl() hook to return the URL to generate for the og:im…
cannikin Feb 29, 2024
c020090
Reorganize other web tests into __tests__ dir
cannikin Feb 29, 2024
4b9d39c
Modified to return separate width, height and ogProps in case you wan…
cannikin Feb 29, 2024
cc8ad07
Adds quality setting to useOgImage()
cannikin Mar 1, 2024
409357b
Merge branch 'main' of github.com:redwoodjs/redwood into rc-og-image-…
dac09 Mar 6, 2024
05e33d2
Update invokeMiddleware comments
dac09 Mar 6, 2024
e15fb10
Lint
dac09 Mar 6, 2024
5afcec2
Fix type linting warnings in skipNav
dac09 Mar 6, 2024
2be1e63
update fixture
dac09 Mar 6, 2024
dcdb43a
Add cssPaths to options
cannikin Mar 7, 2024
4ee6065
Merge branch 'main' into rc-og-image-support
dac09 Mar 7, 2024
04e7c49
Removes useOgImage hook code
cannikin Mar 7, 2024
5e4a453
Merge main | Fix conflicts
dac09 Mar 29, 2024
a44f28f
Bad merge
dac09 Mar 29, 2024
4b9f068
Update types in app template
dac09 Mar 29, 2024
787fa56
Lint fixes, Update project fixture
dac09 Mar 29, 2024
4f58b38
Merge branch 'main' into rc-og-image-support
dac09 Mar 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/create-redwood-app/templates/js/web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import Routes from 'src/Routes'

import './index.css'

const App = () => (
const App = ({ children }) => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<RedwoodApolloProvider>
<Routes />
{children ? children : <Routes />}
</RedwoodApolloProvider>
</RedwoodProvider>
</FatalErrorBoundary>
Expand Down
4 changes: 2 additions & 2 deletions packages/create-redwood-app/templates/ts/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import Routes from 'src/Routes'

import './index.css'

const App = () => (
const App = ({ children }) => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<RedwoodApolloProvider>
<Routes />
{children ? children : <Routes />}
</RedwoodApolloProvider>
</RedwoodProvider>
</FatalErrorBoundary>
Expand Down
10 changes: 9 additions & 1 deletion packages/prerender/src/runPrerender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,16 @@ async function recursivelyRender(
})
)

// `renderPath` is *just* a path, but the LocationProvider needs a full URL
// object so if you need the domain to be something specific when
// pre-rendering (because you're showing it in HTML output or the og:image
// uses useLocation().host) you can set the RWJS_PRERENDER_ORIGIN env variable
// so that it doesn't just default to localhost
const prerenderUrl =
process.env.RWJS_PRERENDER_ORIGIN || 'http://localhost' + renderPath

const componentAsHtml = ReactDOMServer.renderToString(
<LocationProvider location={{ pathname: renderPath }}>
<LocationProvider location={new URL(prerenderUrl)}>
<CellCacheContextProvider queryCache={queryCache}>
<App />
</CellCacheContextProvider>
Expand Down
31 changes: 8 additions & 23 deletions packages/router/src/location.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,20 @@ import { createNamedContext } from './createNamedContext'
import { gHistory } from './history'
import type { TrailingSlashesTypes } from './util'

export interface LocationContextType {
pathname: string
search?: string
hash?: string
}
export interface LocationContextType extends URL {}

const LocationContext = createNamedContext<LocationContextType>('Location')

interface Location {
pathname: string
search?: string
hash?: string
}
interface Location extends URL {}

interface LocationProviderProps {
location?: Location
trailingSlashes?: TrailingSlashesTypes
children?: React.ReactNode
}

interface LocationProviderState {
context: Location
context: Location | undefined
}

class LocationProvider extends React.Component<
Expand Down Expand Up @@ -75,27 +68,19 @@ class LocationProvider extends React.Component<
break
}

windowLocation = window.location
} else {
windowLocation = {
pathname: this.context?.pathname || '',
search: this.context?.search || '',
hash: this.context?.hash || '',
}
windowLocation = new URL(window.location.href)
}

const { pathname, search, hash } = this.props.location || windowLocation

return { pathname, search, hash }
return this.props.location || this.context || windowLocation
}

componentDidMount() {
this.HISTORY_LISTENER_ID = gHistory.listen(() => {
const context = this.getContext()
this.setState((lastState) => {
if (
context.pathname !== lastState.context.pathname ||
context.search !== lastState.context.search
context?.pathname !== lastState?.context?.pathname ||
context?.search !== lastState?.context?.search
) {
globalThis?.scrollTo(0, 0)
}
Expand Down
38 changes: 24 additions & 14 deletions packages/vite/src/devFeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Paths } from '@redwoodjs/project-config'
import { getConfig, getPaths } from '@redwoodjs/project-config'

import { registerFwGlobals } from './lib/registerGlobals'
import { createExtensionRouteDef } from './middleware/extensionRouteDef'
import { invoke } from './middleware/invokeMiddleware'
import { collectCssPaths, componentsModules } from './streaming/collectCss'
import { createReactStreamingHandler } from './streaming/createReactStreamingHandler'
Expand All @@ -17,13 +18,14 @@ import { ensureProcessDirWeb } from './utils'
// TODO (STREAMING) Just so it doesn't error out. Not sure how to handle this.
globalThis.__REDWOOD__PRERENDER_PAGES = {}

const rwPaths = getPaths()

async function createServer() {
ensureProcessDirWeb()

registerFwGlobals()

const app = express()
const rwPaths = getPaths()

// ~~~ Dev time validations ~~~~
// TODO (STREAMING) When Streaming is released Vite will be the only bundler,
Expand Down Expand Up @@ -55,6 +57,21 @@ async function createServer() {
appType: 'custom',
})

// create a handler that will invoke middleware with or without a route
const handleWithMiddleware = (route?: RouteSpec) => {
return createServerAdapter(async (req: Request) => {
const entryServerImport = await vite.ssrLoadModule(
rwPaths.web.entryServer as string // already validated in dev server
)

const middleware = entryServerImport.middleware

const [mwRes] = await invoke(req, middleware, route ? { route } : {})

return mwRes.toResponse()
})
}

// use vite's connect instance as middleware
app.use(vite.middlewares)

Expand Down Expand Up @@ -82,22 +99,15 @@ async function createServer() {

app.get(expressPathDef, createServerAdapter(routeHandler))

app.post(
'*',
createServerAdapter(async (req: Request) => {
const entryServerImport = await vite.ssrLoadModule(
rwPaths.web.entryServer as string // already validated in dev server
)

const middleware = entryServerImport.middleware

const [mwRes] = await invoke(req, middleware)

return mwRes.toResponse()
})
app.get(
createExtensionRouteDef(route.matchRegexString),
handleWithMiddleware(route)
)
}

// invokes middleware for any POST request for auth
app.post('*', handleWithMiddleware())

const port = getConfig().web.port
console.log(`Started server on http://localhost:${port}`)
return await app.listen(port)
Expand Down
9 changes: 9 additions & 0 deletions packages/vite/src/middleware/extensionRouteDef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const createExtensionRouteDef = (matchRegexString: string): RegExp => {
if (matchRegexString.endsWith('/$')) {
// url is something like /
return new RegExp(matchRegexString.replace('$', 'index.*$'))
Dismissed Show dismissed Hide dismissed
} else {
// url is something like /about
return new RegExp(matchRegexString.replace('$', '.*$'))
Dismissed Show dismissed Hide dismissed
}
}
17 changes: 13 additions & 4 deletions packages/vite/src/middleware/invokeMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { defaultAuthProviderState, type ServerAuthState } from '@redwoodjs/auth'
import type { RWRouteManifestItem } from '@redwoodjs/internal/dist/routes'

import { MiddlewareRequest } from './MiddlewareRequest'
import { MiddlewareResponse } from './MiddlewareResponse'

type Middleware = (
req: MiddlewareRequest,
res?: MiddlewareResponse
res?: MiddlewareResponse,
route?: any
) => Promise<MiddlewareResponse> | Response | void

/**
Expand All @@ -18,7 +20,8 @@ type Middleware = (
*/
export const invoke = async (
req: Request,
middleware?: Middleware
middleware?: Middleware,
options?: { route?: RWRouteManifestItem }
): Promise<[MiddlewareResponse, ServerAuthState]> => {
if (typeof middleware !== 'function') {
return [MiddlewareResponse.next(), defaultAuthProviderState]
Expand All @@ -28,13 +31,19 @@ export const invoke = async (
let mwRes: MiddlewareResponse = MiddlewareResponse.next()

try {
const output = (await middleware(mwReq)) || MiddlewareResponse.next()
const output =
(await middleware(mwReq, MiddlewareResponse.next(), options)) ||
MiddlewareResponse.next()

if (output instanceof MiddlewareResponse) {
mwRes = output
} else {
} else if (typeof output === 'object' && output instanceof Response) {
// If it was a WebAPI Response
mwRes = MiddlewareResponse.fromResponse(output)
} else {
throw new Error(
'Middleware must return a MiddlewareResponse or a Response'
)
}
} catch (e) {
console.error('Error executing middleware > \n')
Expand Down
35 changes: 23 additions & 12 deletions packages/vite/src/runFeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@
import path from 'node:path'
import url from 'node:url'

import { createServerAdapter } from '@whatwg-node/server'

Check failure on line 11 in packages/vite/src/runFeServer.ts

View workflow job for this annotation

GitHub Actions / 🏗 Build, lint, test / ubuntu-latest / node 20 latest

There should be no empty line within import group

// @ts-expect-error We will remove dotenv-defaults from this package anyway
import { config as loadDotEnv } from 'dotenv-defaults'
import express from 'express'
import { createProxyMiddleware } from 'http-proxy-middleware'
import type { Manifest as ViteBuildManifest } from 'vite'

import type { RWRouteManifestItem } from '@redwoodjs/internal/dist/routes'
import { getConfig, getPaths } from '@redwoodjs/project-config'

import { registerFwGlobals } from './lib/registerGlobals'
import { createExtensionRouteDef } from './middleware/extensionRouteDef'
import { invoke } from './middleware/invokeMiddleware'
import { createRscRequestHandler } from './rsc/rscRequestHandler'
import { setClientEntries } from './rsc/rscWorkerCommunication'
Expand Down Expand Up @@ -84,6 +87,18 @@
return manifestItem.isEntry
})

const handleWithMiddleware = (route?: RWRouteManifestItem) => {
return createServerAdapter(async (req: Request) => {
const entryServerImport = await import(rwPaths.web.entryServer as string)

const middleware = entryServerImport.middleware

const [mwRes] = await invoke(req, middleware, route ? { route } : {})

return mwRes.toResponse()
})
}

if (!indexEntry) {
throw new Error('Could not find index.html in build manifest')
}
Expand Down Expand Up @@ -156,24 +171,20 @@
return express.static(rwPaths.web.dist)(req, res, next)
})
}

// add express routes to capture extension requests and give them to middleware
// ie. /about.json, /about.png, etc
app.get(
createExtensionRouteDef(route.matchRegexString),
handleWithMiddleware(route)
)
}

// Mounting middleware at /rw-rsc will strip /rw-rsc from req.url
app.use('/rw-rsc', createRscRequestHandler())

// @MARK: put this after rw-rsc!
app.post(
'*',
createServerAdapter(async (req: Request) => {
const entryServerImport = await import(rwPaths.web.distEntryServer)

const { middleware } = entryServerImport

const [mwRes] = await invoke(req, middleware)

return mwRes.toResponse()
})
)
app.post('*', handleWithMiddleware())

app.listen(rwConfig.web.port)
console.log(
Expand Down
6 changes: 3 additions & 3 deletions packages/vite/src/streaming/createReactStreamingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ export const createReactStreamingHandler = async (
const FallbackDocument =
fallbackDocumentImport.Document || fallbackDocumentImport.default

const { pathname: currentPathName } = new URL(req.url)
const currentUrl = new URL(req.url)

// @TODO validate this is correct
const parsedParams = matchPath(route.pathDefinition, currentPathName)
const parsedParams = matchPath(route.pathDefinition, currentUrl.pathname)

let metaTags: TagDescriptor[] = []

Expand Down Expand Up @@ -147,7 +147,7 @@ export const createReactStreamingHandler = async (
{
ServerEntry,
FallbackDocument,
currentPathName,
currentUrl,
metaTags,
cssLinks,
isProd,
Expand Down
13 changes: 5 additions & 8 deletions packages/vite/src/streaming/streamHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { createServerInjectionTransform } from './transforms/serverInjectionTran
interface RenderToStreamArgs {
ServerEntry: any
FallbackDocument: any
currentPathName: string
currentUrl: URL
metaTags: TagDescriptor[]
cssLinks: string[]
isProd: boolean
Expand All @@ -48,7 +48,7 @@ export async function reactRenderToStreamResponse(
const {
ServerEntry,
FallbackDocument,
currentPathName,
currentUrl,
metaTags,
cssLinks,
isProd,
Expand Down Expand Up @@ -91,7 +91,7 @@ export async function reactRenderToStreamResponse(
// @ts-expect-error Something in React's packages mean types dont come through
const { renderToReadableStream } = await import('react-dom/server.edge')

const renderRoot = (path: string) => {
const renderRoot = (url: URL) => {
return React.createElement(
ServerAuthProvider,
{
Expand All @@ -100,17 +100,14 @@ export async function reactRenderToStreamResponse(
React.createElement(
LocationProvider,
{
location: {
pathname: path,
},
location: url,
},
React.createElement(
ServerHtmlProvider,
{
value: injectToPage,
},
ServerEntry({
url: path,
css: cssLinks,
meta: metaTags,
})
Expand Down Expand Up @@ -146,7 +143,7 @@ export async function reactRenderToStreamResponse(
},
}

const root = renderRoot(currentPathName)
const root = renderRoot(currentUrl)

const reactStream: ReactDOMServerReadableStream =
await renderToReadableStream(root, renderToStreamOptions)
Expand Down
Loading