diff --git a/.changesets/10469.md b/.changesets/10469.md
new file mode 100644
index 000000000000..7b2c8a51ca2b
--- /dev/null
+++ b/.changesets/10469.md
@@ -0,0 +1,45 @@
+- feat(og-gen): Implement middleware and hooks (#10469) by @dac09
+
+The OG Gen saga continues with @cannikin and @dac09 ⚔️
+
+This PR:
+- adds OgImageMiddleware and Hooks to `@redwoodjs/og-gen`, complete with tests
+
+⚠️ Template changes:
+- updates entry.client template to pass in Routes to App
+- updates App to take children (i.e. Routes)
+
+This is so that we can pass the OG component to be rendered _with_ your App's CSS setup.
+
+
+**How to use this?**
+
+1. **Registering the middleware:**
+ ```ts
+ import OgImageMiddleware from '@redwoodjs/ogimage-gen/middleware'
+
+ export const registerMiddleware = () => {
+ const ogMw = new OgImageMiddleware({
+ App,
+ Document,
+ })
+
+ return [ogMw]
+ }
+ ```
+
+2. Configure your `vite.config.ts`
+ ```ts
+ import vitePluginOgImageGen from '@redwoodjs/ogimage-gen/plugin'
+
+ const viteConfig: UserConfig = {
+ // 👇 so it builds your OG components
+ plugins: [redwood(), vitePluginOgImageGen()],
+ }
+
+ export default defineConfig(viteConfig)
+ ```
+3. Add your OG Image component next to the page it's for
+e.g. web/src/pages/AboutPage/AboutPage.png.tsx
+
+4. Use hooks on AboutPage to generate the ogURL
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 13e9aaaf3fc8..aee87baa83b8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -31,6 +31,7 @@
"Flightcontrol",
"graphiql",
"memfs",
+ "OGIMAGE",
"opentelemetry",
"pino",
"Pistorius",
diff --git a/__fixtures__/test-project/web/src/App.tsx b/__fixtures__/test-project/web/src/App.tsx
index 0c5a48d728bf..c6774d98a561 100644
--- a/__fixtures__/test-project/web/src/App.tsx
+++ b/__fixtures__/test-project/web/src/App.tsx
@@ -4,12 +4,12 @@ import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web'
import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
-import Routes from 'src/Routes'
import { AuthProvider, useAuth } from './auth'
-import './scaffold.css'
import './index.css'
+import './scaffold.css'
+
interface AppProps {
children?: ReactNode
}
@@ -19,7 +19,7 @@ const App = ({ children }: AppProps) => (
- {children ? children : }
+ {children}
diff --git a/__fixtures__/test-project/web/src/entry.client.tsx b/__fixtures__/test-project/web/src/entry.client.tsx
index aad2a9f6ea3a..113506b2b65d 100644
--- a/__fixtures__/test-project/web/src/entry.client.tsx
+++ b/__fixtures__/test-project/web/src/entry.client.tsx
@@ -1,6 +1,8 @@
import { hydrateRoot, createRoot } from 'react-dom/client'
import App from './App'
+import Routes from './Routes'
+
/**
* When `#redwood-app` isn't empty then it's very likely that you're using
* prerendering. So React attaches event listeners to the existing markup
@@ -16,8 +18,17 @@ if (!redwoodAppElement) {
}
if (redwoodAppElement.children?.length > 0) {
- hydrateRoot(redwoodAppElement, )
+ hydrateRoot(
+ redwoodAppElement,
+
+
+
+ )
} else {
const root = createRoot(redwoodAppElement)
- root.render()
+ root.render(
+
+
+
+ )
}
diff --git a/packages/cli/src/commands/experimental/templates/streamingSsr/entry.client.tsx.template b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.client.tsx.template
index da6e98c19640..deb874f0da34 100644
--- a/packages/cli/src/commands/experimental/templates/streamingSsr/entry.client.tsx.template
+++ b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.client.tsx.template
@@ -2,6 +2,7 @@ import { hydrateRoot, createRoot } from 'react-dom/client'
import App from './App'
import { Document } from './Document'
+import Routes from './Routes'
/**
* When `#redwood-app` isn't empty then it's very likely that you're using
@@ -15,14 +16,18 @@ if (redwoodAppElement.children?.length > 0) {
hydrateRoot(
document,
-
+
+
+
)
} else {
const root = createRoot(document)
root.render(
-
+
+
+
)
}
diff --git a/packages/cli/src/commands/experimental/templates/streamingSsr/entry.server.tsx.template b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.server.tsx.template
index 2ef279387fd2..7a06fc348ac6 100644
--- a/packages/cli/src/commands/experimental/templates/streamingSsr/entry.server.tsx.template
+++ b/packages/cli/src/commands/experimental/templates/streamingSsr/entry.server.tsx.template
@@ -2,6 +2,7 @@ import type { TagDescriptor } from '@redwoodjs/web'
import App from './App'
import { Document } from './Document'
+import Routes from './Routes'
interface Props {
css: string[]
@@ -11,7 +12,9 @@ interface Props {
export const ServerEntry: React.FC = ({ css, meta }) => {
return (
-
+
+
+
)
}
diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts
index 53bea19bec66..12779cec934d 100644
--- a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts
+++ b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts
@@ -70,12 +70,12 @@ describe('fragments graphQLClientConfig', () => {
import { RedwoodApolloProvider } from \"@redwoodjs/web/apollo\";
import FatalErrorPage from \"src/pages/FatalErrorPage\";
- import Routes from \"src/Routes\";
import { AuthProvider, useAuth } from \"./auth\";
- import \"./scaffold.css\";
import \"./index.css\";
+ import \"./scaffold.css\";
+
interface AppProps {
children?: ReactNode;
}
@@ -94,7 +94,7 @@ describe('fragments graphQLClientConfig', () => {
useAuth={useAuth}
graphQLClientConfig={graphQLClientConfig}
>
- {children ? children : }
+ {children}
diff --git a/packages/cli/src/lib/index.js b/packages/cli/src/lib/index.js
index 65abae010e21..43063e6f7f2f 100644
--- a/packages/cli/src/lib/index.js
+++ b/packages/cli/src/lib/index.js
@@ -441,8 +441,8 @@ export const addScaffoldImport = () => {
}
appJsContents = appJsContents.replace(
- "import Routes from 'src/Routes'\n",
- "import Routes from 'src/Routes'\n\nimport './scaffold.css'",
+ "import './index.css'",
+ "import './index.css'\nimport './scaffold.css'\n",
)
writeFile(appJsPath, appJsContents, { overwriteExisting: true })
diff --git a/packages/core/config/webpack.common.js b/packages/core/config/webpack.common.js
index c9c8d046681a..e5476c69d47b 100644
--- a/packages/core/config/webpack.common.js
+++ b/packages/core/config/webpack.common.js
@@ -240,6 +240,7 @@ module.exports = (webpackEnv) => {
'styled-components',
),
'~redwood-app-root': path.resolve(redwoodPaths.web.app),
+ '~redwood-app-routes': path.resolve(redwoodPaths.web.routes),
react: path.resolve(redwoodPaths.base, 'node_modules', 'react'),
'react-hook-form': path.resolve(
redwoodPaths.base,
diff --git a/packages/create-redwood-app/templates/js/web/src/App.jsx b/packages/create-redwood-app/templates/js/web/src/App.jsx
index ad3ce3697d95..e570410a46cf 100644
--- a/packages/create-redwood-app/templates/js/web/src/App.jsx
+++ b/packages/create-redwood-app/templates/js/web/src/App.jsx
@@ -2,16 +2,13 @@ import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web'
import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
-import Routes from 'src/Routes'
import './index.css'
const App = ({ children }) => (
-
- {children ? children : }
-
+ {children}
)
diff --git a/packages/create-redwood-app/templates/js/web/src/entry.client.jsx b/packages/create-redwood-app/templates/js/web/src/entry.client.jsx
index aad2a9f6ea3a..113506b2b65d 100644
--- a/packages/create-redwood-app/templates/js/web/src/entry.client.jsx
+++ b/packages/create-redwood-app/templates/js/web/src/entry.client.jsx
@@ -1,6 +1,8 @@
import { hydrateRoot, createRoot } from 'react-dom/client'
import App from './App'
+import Routes from './Routes'
+
/**
* When `#redwood-app` isn't empty then it's very likely that you're using
* prerendering. So React attaches event listeners to the existing markup
@@ -16,8 +18,17 @@ if (!redwoodAppElement) {
}
if (redwoodAppElement.children?.length > 0) {
- hydrateRoot(redwoodAppElement, )
+ hydrateRoot(
+ redwoodAppElement,
+
+
+
+ )
} else {
const root = createRoot(redwoodAppElement)
- root.render()
+ root.render(
+
+
+
+ )
}
diff --git a/packages/create-redwood-app/templates/ts/web/src/App.tsx b/packages/create-redwood-app/templates/ts/web/src/App.tsx
index 235df87826da..85501949a0b5 100644
--- a/packages/create-redwood-app/templates/ts/web/src/App.tsx
+++ b/packages/create-redwood-app/templates/ts/web/src/App.tsx
@@ -4,7 +4,6 @@ import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web'
import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
-import Routes from 'src/Routes'
import './index.css'
interface AppProps {
@@ -14,9 +13,7 @@ interface AppProps {
const App = ({ children }: AppProps) => (
-
- {children ? children : }
-
+ {children}
)
diff --git a/packages/create-redwood-app/templates/ts/web/src/entry.client.tsx b/packages/create-redwood-app/templates/ts/web/src/entry.client.tsx
index aad2a9f6ea3a..113506b2b65d 100644
--- a/packages/create-redwood-app/templates/ts/web/src/entry.client.tsx
+++ b/packages/create-redwood-app/templates/ts/web/src/entry.client.tsx
@@ -1,6 +1,8 @@
import { hydrateRoot, createRoot } from 'react-dom/client'
import App from './App'
+import Routes from './Routes'
+
/**
* When `#redwood-app` isn't empty then it's very likely that you're using
* prerendering. So React attaches event listeners to the existing markup
@@ -16,8 +18,17 @@ if (!redwoodAppElement) {
}
if (redwoodAppElement.children?.length > 0) {
- hydrateRoot(redwoodAppElement, )
+ hydrateRoot(
+ redwoodAppElement,
+
+
+
+ )
} else {
const root = createRoot(redwoodAppElement)
- root.render()
+ root.render(
+
+
+
+ )
}
diff --git a/packages/internal/src/routes.ts b/packages/internal/src/routes.ts
index c30903165db6..48505f8b9230 100644
--- a/packages/internal/src/routes.ts
+++ b/packages/internal/src/routes.ts
@@ -71,7 +71,7 @@ export interface RWRouteManifestItem {
routeHooks: string | null
bundle: string | null
hasParams: boolean
- relativeFilePath: string | undefined
+ relativeFilePath: string
redirect: { to: string; permanent: boolean } | null
// Probably want isNotFound here, so we can attach a separate 404 handler
}
@@ -80,7 +80,7 @@ export interface RouteSpec extends RWRouteManifestItem {
id: string
isNotFound: boolean
filePath: string | undefined
- relativeFilePath: string | undefined
+ relativeFilePath: string
}
export const getProjectRoutes = (): RouteSpec[] => {
diff --git a/packages/ogimage-gen/cjsWrappers/hooks.js b/packages/ogimage-gen/cjsWrappers/hooks.js
new file mode 100644
index 000000000000..f5359a3bd371
--- /dev/null
+++ b/packages/ogimage-gen/cjsWrappers/hooks.js
@@ -0,0 +1,3 @@
+/* eslint-env node */
+
+module.exports = require('../dist/hooks.js').default
diff --git a/packages/ogimage-gen/cjsWrappers/middleware.js b/packages/ogimage-gen/cjsWrappers/middleware.js
new file mode 100644
index 000000000000..ff6dff8dda2c
--- /dev/null
+++ b/packages/ogimage-gen/cjsWrappers/middleware.js
@@ -0,0 +1,3 @@
+/* eslint-env node */
+
+module.exports = require('../dist/OgImageMiddleware.js').default
diff --git a/packages/ogimage-gen/package.json b/packages/ogimage-gen/package.json
index be4ddc47b08d..21005c954d2a 100644
--- a/packages/ogimage-gen/package.json
+++ b/packages/ogimage-gen/package.json
@@ -8,12 +8,20 @@
},
"license": "MIT",
"exports": {
- ".": {
- "default": "./dist/index.js"
- },
"./plugin": {
"import": "./dist/vite-plugin-ogimage-gen.js",
- "default": "./cjsWrappers/plugin.js"
+ "default": "./cjsWrappers/plugin.js",
+ "types": "./dist/vite-plugin-ogimage-gen.d.ts"
+ },
+ "./middleware": {
+ "import": "./dist/OgImageMiddleware.js",
+ "default": "./cjsWrappers/middleware.js",
+ "types": "./dist/OgImageMiddleware.d.ts"
+ },
+ "./hooks": {
+ "import": "./dist/hooks.js",
+ "default": "./cjsWrappers/hooks.js",
+ "types": "./dist/hooks.d.ts"
}
},
"files": [
@@ -34,10 +42,12 @@
"@redwoodjs/router": "workspace:*",
"@redwoodjs/vite": "workspace:*",
"fast-glob": "3.3.2",
+ "lodash": "4.17.21",
"react": "19.0.0-canary-cb151849e1-20240424",
"react-dom": "19.0.0-canary-cb151849e1-20240424"
},
"devDependencies": {
+ "@playwright/test": "1.42.1",
"@redwoodjs/framework-tools": "workspace:*",
"ts-toolbelt": "9.6.0",
"tsx": "4.7.1",
diff --git a/packages/ogimage-gen/src/OgImageMiddleware.test.ts b/packages/ogimage-gen/src/OgImageMiddleware.test.ts
new file mode 100644
index 000000000000..c6824b7d5cd0
--- /dev/null
+++ b/packages/ogimage-gen/src/OgImageMiddleware.test.ts
@@ -0,0 +1,346 @@
+import path from 'node:path'
+
+import React from 'react'
+
+import { vol, fs as memfs } from 'memfs'
+import { vi, describe, beforeEach, afterEach, test, expect } from 'vitest'
+
+import type { RWRouteManifestItem } from '@redwoodjs/internal'
+import { ensurePosixPath } from '@redwoodjs/project-config'
+import { MiddlewareResponse } from '@redwoodjs/vite/middleware'
+
+import OgImageMiddleware from './OgImageMiddleware'
+
+// Memfs mocks the redwood project-config stuff
+vi.mock('fs', () => ({ ...memfs, default: { ...memfs } }))
+vi.mock('node:fs', () => ({ ...memfs, default: { ...memfs } }))
+
+// Mock getRoutesList function
+vi.mock('./getRoutesList', () => ({
+ getRoutesList: vi.fn().mockResolvedValue([
+ {
+ name: 'contact',
+ bundle: 'assets/ContactPage-DjZx8IRT.js',
+ matchRegexString: '^/contacts/(\\d+)$',
+ pathDefinition: '/contacts/{id:Int}',
+ hasParams: true,
+ redirect: null,
+ relativeFilePath: 'pages/Contact/ContactPage/ContactPage.tsx',
+ },
+ {
+ name: 'home',
+ bundle: null,
+ matchRegexString: '^/$',
+ pathDefinition: '/',
+ hasParams: false,
+ routeHooks: null,
+ redirect: null,
+ relativeFilePath: 'pages/HomePage/HomePage.tsx',
+ },
+ ]),
+}))
+
+const setContentMock = vi.fn()
+const goToMock = vi.fn()
+const screenshotMock = vi.fn().mockResolvedValue('FAKE_IMAGE_CONTENT')
+
+const newPageMock = vi.fn().mockResolvedValue({
+ goto: goToMock,
+ setContent: setContentMock,
+ screenshot: screenshotMock,
+})
+
+vi.mock('playwright', () => {
+ return {
+ chromium: {
+ launch: vi.fn().mockResolvedValue({
+ close: vi.fn(),
+ newPage: newPageMock,
+ }),
+ },
+ }
+})
+
+describe('OgImageMiddleware', () => {
+ let middleware: OgImageMiddleware
+ let original_RWJS_CWD: string | undefined
+
+ beforeEach(() => {
+ const options = {
+ App: ({ children }) =>
+ React.createElement('div', { id: 'app' }, children),
+ Document: ({ children }) =>
+ React.createElement('div', { id: 'document' }, children),
+ }
+ middleware = new OgImageMiddleware(options)
+
+ original_RWJS_CWD = process.env.RWJS_CWD
+ process.env.RWJS_CWD = '/redwood-app'
+ // Mock the file system using memfs
+ vol.fromJSON(
+ {
+ 'redwood.toml': '',
+ 'web/src/pages/Contact/ContactPage.jsx': 'ContactPage',
+ 'web/src/pages/Contact/ContactPage.png.jsx': 'ContactOG',
+ },
+ '/redwood-app',
+ )
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ process.env.RWJS_CWD = original_RWJS_CWD
+ })
+
+ test('invoke should return mwResponse if not a file request', async () => {
+ const req = { url: 'http://example.com' }
+ const mwResponse = {}
+ const invokeOptions = {}
+
+ const result = await middleware.invoke(req, mwResponse, invokeOptions)
+
+ expect(result).toBe(mwResponse)
+ })
+
+ test('invoke should return a pass through mwResponse if no match with the router', async () => {
+ const req = { url: 'http://example.com/file.png' }
+ const mwResponse = {
+ passthrough: 'yeah',
+ }
+ const invokeOptions = {}
+
+ const result = await middleware.invoke(req, mwResponse, invokeOptions)
+
+ expect(result).toBe(mwResponse)
+ })
+
+ test('invoke should return bypass response if not a supported extension', async () => {
+ const req = { url: 'http://example.com/favicon.ico' }
+ const mwResponse = {
+ passthrough: 'yeah',
+ }
+
+ const invokeOptions = {}
+
+ const result = await middleware.invoke(req, mwResponse, invokeOptions)
+
+ expect(result).toBe(mwResponse)
+ })
+
+ test('invoke should passthrough a response if the component file does not exist', async () => {
+ const req = { url: 'http://example.com/contact/1.png' }
+ const passthroughRes = {
+ passthrough: 'yeah',
+ }
+ const invokeOptions = {}
+
+ const result = await middleware.invoke(req, passthroughRes, invokeOptions)
+
+ expect(result).toEqual(passthroughRes)
+ })
+
+ test('getOgComponentPath should return the correct OG image file path', async () => {
+ const commonRouteInfo = {
+ name: 'contact',
+ bundle: 'assets/ContactPage-DjZx8IRT.js',
+ matchRegexString: '^/contacts/(\\d+)$',
+ pathDefinition: '/contacts/{id:Int}',
+ hasParams: true,
+ routeHooks: null,
+ redirect: null,
+ }
+
+ const tsxRoute: RWRouteManifestItem = {
+ ...commonRouteInfo,
+ relativeFilePath: path.join(
+ 'pages',
+ 'Contact',
+ 'ContactPage',
+ 'ContactPage.tsx',
+ ),
+ }
+
+ const jsxRoute: RWRouteManifestItem = {
+ ...commonRouteInfo,
+ relativeFilePath: path.join(
+ 'pages',
+ 'Contact',
+ 'ContactPage',
+ 'ContactPage.jsx',
+ ),
+ }
+
+ const extension = 'png'
+ const expectedFilePath =
+ '/redwood-app/web/dist/server/ogImage/pages/Contact/ContactPage/ContactPage.png.mjs'
+
+ const tsxResult = middleware.getOgComponentPath(tsxRoute, extension)
+ const jsxResult = middleware.getOgComponentPath(jsxRoute, extension)
+
+ expect(ensurePosixPath(tsxResult)).toBe(expectedFilePath)
+ expect(ensurePosixPath(jsxResult)).toBe(expectedFilePath)
+ })
+
+ test('importComponent should import the component using viteDevServer', async () => {
+ const filePath = '/path/to/component.js'
+ const invokeOptions = {
+ viteDevServer: {
+ ssrLoadModule: vi.fn().mockResolvedValue({
+ data: 'some data',
+ output: 'Component output',
+ }),
+ },
+ }
+
+ const result = await middleware.importComponent(filePath, invokeOptions)
+
+ expect(result).toEqual({
+ data: 'some data',
+ Component: 'Component output',
+ })
+
+ expect(invokeOptions.viteDevServer.ssrLoadModule).toHaveBeenCalledWith(
+ filePath,
+ )
+ })
+
+ test('importComponent should import the component using import', async () => {
+ const filePath = '/path/to/component.js'
+ const invokeOptions = {}
+
+ vi.mock('/path/to/component.js', () => ({
+ data: () => 'mocked data function',
+ output: () => 'Mocked component render',
+ }))
+
+ const result = await middleware.importComponent(filePath, invokeOptions)
+
+ expect(result.data()).toBe('mocked data function')
+ expect(result.Component()).toBe('Mocked component render')
+ })
+
+ // Full flow tests!
+ test('invoke should call playwright setContent with the correct params for "/contact/555"', async () => {
+ const req = { url: 'https://example.com/contacts/555.png?bazinga=kittens' }
+ const mwResponse = MiddlewareResponse.next()
+ const invokeOptions = {}
+
+ // The memfs mocks don't seem to work for this file
+
+ vi.mock(
+ '/redwood-app/web/dist/server/ogImage/pages/Contact/ContactPage/ContactPage.png.mjs',
+ () => ({
+ data: () => 'mocked data function',
+ output: () => 'Mocked component render',
+ }),
+ )
+
+ await middleware.invoke(req, mwResponse, invokeOptions)
+
+ expect(goToMock).toHaveBeenCalledWith('https://example.com')
+
+ // Default recommended og:image size
+ expect(newPageMock).toHaveBeenCalledWith({
+ viewport: {
+ height: 630,
+ width: 1200,
+ },
+ })
+ // Notice the nesting here! Wrapping everything in Document and App
+ // allows us to reuse the project's CSS setup!
+ expect(setContentMock).toHaveBeenCalledWith(
+ '',
+ )
+
+ expect(mwResponse.body).toBe('FAKE_IMAGE_CONTENT')
+ expect(mwResponse.headers.get('Content-Type')).toBe('image/png')
+ })
+
+ test('handles index og images', async () => {
+ const req = { url: 'https://www.darkmatter.berlin/index.jpg' }
+ const mwResponse = MiddlewareResponse.next()
+ const invokeOptions = {}
+
+ // The memfs mocks don't seem to work for this file
+ vi.mock(
+ '/redwood-app/web/dist/server/ogImage/pages/HomePage/HomePage.jpg.mjs',
+ () => ({
+ data: () => 'mocked data function',
+ output: () => 'Mocked component render',
+ }),
+ )
+
+ await middleware.invoke(req, mwResponse, invokeOptions)
+
+ expect(goToMock).toHaveBeenCalledWith('https://www.darkmatter.berlin')
+ // Notice the nesting here! Wrapping everything in Document and App
+ // allows us to reuse the project's CSS setup!
+ expect(setContentMock).toHaveBeenCalledWith(
+ '',
+ )
+
+ expect(mwResponse.body).toBe('FAKE_IMAGE_CONTENT')
+ expect(mwResponse.headers.get('Content-Type')).toBe('image/jpeg')
+ })
+
+ test('Debug Mode: Appending ?debug param will render HTML instead!', async () => {
+ const req = { url: 'https://bazinga.kittens/index.png?debug=true' }
+ const mwResponse = MiddlewareResponse.next()
+ const invokeOptions = {}
+
+ // The memfs mocks don't seem to work for this file
+ vi.mock(
+ '/redwood-app/web/dist/server/ogImage/pages/HomePage/HomePage.png.mjs',
+ () => ({
+ data: () => 'mocked data function',
+ output: () => 'Mocked component render',
+ }),
+ )
+
+ await middleware.invoke(req, mwResponse, invokeOptions)
+
+ expect(mwResponse.body).toMatchInlineSnapshot(
+ `""`,
+ )
+ expect(mwResponse.headers.get('Content-Type')).toBe('text/html')
+ })
+
+ test('middleware implementation should pass on image size query parameters (useful for twitter)', async () => {
+ const req = {
+ url: 'https://example.com/contacts/555.png?bazinga=kittens&width=4545&height=9898',
+ }
+ const mwResponse = MiddlewareResponse.next()
+ const invokeOptions = {}
+
+ // The memfs mocks don't seem to work for this file
+
+ vi.mock(
+ '/redwood-app/web/dist/server/ogImage/pages/Contact/ContactPage/ContactPage.png.mjs',
+ () => ({
+ data: () => 'mocked data function',
+ output: () => 'Mocked component render',
+ }),
+ )
+
+ await middleware.invoke(req, mwResponse, invokeOptions)
+
+ expect(goToMock).toHaveBeenCalledWith('https://example.com')
+ expect(newPageMock).toHaveBeenCalledWith({
+ viewport: {
+ height: 9898,
+ width: 4545,
+ },
+ })
+ expect(screenshotMock).toHaveBeenCalledWith({
+ type: 'png',
+ })
+ // Notice the nesting here! Wrapping everything in Document and App
+ // allows us to reuse the project's CSS setup!
+ expect(setContentMock).toHaveBeenCalledWith(
+ '',
+ )
+
+ expect(mwResponse.body).toBe('FAKE_IMAGE_CONTENT')
+ expect(mwResponse.headers.get('Content-Type')).toBe('image/png')
+ })
+})
diff --git a/packages/ogimage-gen/src/OgImageMiddleware.ts b/packages/ogimage-gen/src/OgImageMiddleware.ts
new file mode 100644
index 000000000000..ca7f5b86353d
--- /dev/null
+++ b/packages/ogimage-gen/src/OgImageMiddleware.ts
@@ -0,0 +1,326 @@
+import path from 'node:path'
+
+import { createElement } from 'react'
+
+import memoize from 'lodash/memoize.js'
+import mime from 'mime-types'
+import type { PageScreenshotOptions } from 'playwright'
+import { renderToString } from 'react-dom/server'
+
+import type { RWRouteManifestItem } from '@redwoodjs/internal'
+import { getPaths } from '@redwoodjs/project-config'
+import { LocationProvider, matchPath } from '@redwoodjs/router'
+import type {
+ MiddlewareInvokeOptions,
+ MiddlewareRequest,
+ MiddlewareResponse,
+} from '@redwoodjs/vite/dist/middleware'
+
+import { getRoutesList } from './getRoutesList.js'
+import { OGIMAGE_DEFAULTS } from './hooks.js'
+
+interface MwOptions {
+ App: React.FC
+ Document: React.FC
+ /**
+ * Override the css paths that'll be included
+ */
+ cssPaths?: string[]
+}
+
+const supportedExtensions = ['jpg', 'png']
+
+type SUPPORTED_EXT = (typeof supportedExtensions)[number]
+
+interface ComponentElementProps {
+ Component: React.FC<{ data: unknown }>
+ data: unknown
+ routeParams: Record
+ debug: boolean
+}
+
+export default class OgImageMiddleware {
+ options: MwOptions
+ App: React.FC
+ Document: React.FC<{ css: string[]; meta: string[] }>
+
+ // Initialized in invoke() 👇
+ imageProps?: {
+ width: number
+ height: number
+ quality: number
+ }
+
+ constructor(options: MwOptions) {
+ this.options = options
+
+ this.App = options.App
+ this.Document = options.Document
+ }
+
+ async invoke(
+ req: MiddlewareRequest,
+ mwResponse: MiddlewareResponse,
+ invokeOptions: MiddlewareInvokeOptions,
+ ) {
+ const url = new URL(req.url)
+ const { pathname } = url
+
+ let currentRoute: RWRouteManifestItem | undefined = undefined
+ let parsedParams: {
+ params?: Record
+ } = {}
+
+ // Skip processing if not a file request
+ if (!pathname.includes('.')) {
+ return mwResponse
+ }
+
+ // Remove the extension for the match
+ const [routePathname, extension] = pathname.split('.')
+
+ ;({ currentRoute, parsedParams } = await this.matchRoute(
+ // This is a special case for the index route
+ // because they can't go to mywebsite.com/.png -> mywebsite.com/index.png instead
+ routePathname === '/index' ? '/' : routePathname,
+ parsedParams,
+ ))
+
+ // If no match with the router, or not a supported extension bail
+ if (!currentRoute || !supportedExtensions.includes(extension)) {
+ console.log(
+ 'OGMiddleware: No match with the Routes, or not a supported extension',
+ )
+ return mwResponse
+ }
+
+ // Combine search params and params from route pattern
+ // /user/{id:Int} => /user/1?background=red => { id: 1, background: 'red'}
+ const mergedParams = {
+ ...Object.fromEntries(url.searchParams.entries()),
+ ...(parsedParams.params || {}),
+ }
+
+ this.imageProps = {
+ width: mergedParams.width
+ ? parseInt(mergedParams.width as string)
+ : OGIMAGE_DEFAULTS.width,
+ height: mergedParams.height
+ ? parseInt(mergedParams.height as string)
+ : OGIMAGE_DEFAULTS.height,
+ quality: mergedParams.quality
+ ? parseInt(mergedParams.quality as string)
+ : OGIMAGE_DEFAULTS.quality,
+ }
+
+ const debug = !!mergedParams.debug
+
+ const screenshotOptionsByFormat: Record<
+ SUPPORTED_EXT,
+ PageScreenshotOptions
+ > = {
+ png: { type: 'png' },
+ jpg: {
+ type: 'jpeg',
+ quality: this.imageProps.quality,
+ },
+ }
+
+ const pageViewPort = {
+ width: this.imageProps.width,
+ height: this.imageProps.height,
+ }
+
+ const ogImgFilePath = this.getOgComponentPath(currentRoute, extension)
+
+ const { data, Component } = await this.importComponent(
+ ogImgFilePath,
+ invokeOptions,
+ )
+
+ let dataOut
+ if (data && typeof data === 'function') {
+ dataOut = await data(mergedParams)
+ }
+
+ const { chromium } = await import('playwright')
+ const browser = await chromium.launch()
+ const page = await browser.newPage({ viewport: pageViewPort })
+
+ // If the user overrides the cssPaths, use them. Otherwise use the default css list
+ // That gets passed from createReactStreamingHandler
+ const cssPathsToUse = this.options.cssPaths || invokeOptions.cssPaths || []
+
+ const htmlOutput = renderToString(
+ createElement(
+ LocationProvider,
+ {
+ location: url,
+ },
+ createElement(
+ this.Document,
+ {
+ css: cssPathsToUse,
+ meta: [],
+ },
+ createElement(
+ this.App,
+ {},
+ this.componentElements({
+ Component,
+ data: dataOut,
+ routeParams: mergedParams,
+ debug,
+ }),
+ ),
+ ),
+ ),
+ )
+
+ if (debug) {
+ mwResponse.headers.append('Content-Type', 'text/html')
+ mwResponse.body = htmlOutput
+ } else {
+ // This is a very important step! We set the page URL to the origin (root of your website)
+ // This allows assets like CSS, Images, etc. to be loaded with just relative paths
+ const baseUrl = url.origin
+
+ await page.goto(baseUrl)
+
+ await page.setContent(htmlOutput)
+ const image = await page.screenshot(screenshotOptionsByFormat[extension])
+ await browser.close()
+
+ mwResponse.headers.append(
+ 'Content-Type',
+ // as string, because the lookup is guaranteed in this case
+ mime.lookup(extension) as string,
+ )
+
+ mwResponse.body = image
+ }
+
+ return mwResponse
+ }
+
+ private matchRoute = memoize(
+ async (
+ routePathname: string,
+ parsedParams: { params?: Record | undefined },
+ ) => {
+ let currentRoute: RWRouteManifestItem | undefined
+ const routes = await getRoutesList()
+ for (const route of routes) {
+ const { match, ...rest } = matchPath(
+ route.pathDefinition,
+ routePathname,
+ )
+ if (match) {
+ currentRoute = route
+ parsedParams = rest
+ break
+ }
+ }
+ return { currentRoute, parsedParams }
+ },
+ )
+
+ public getOgComponentPath(
+ currentRoute: RWRouteManifestItem,
+ extension: SUPPORTED_EXT,
+ ) {
+ if (process.env.NODE_ENV === 'development') {
+ return path.join(
+ getPaths().web.src,
+ currentRoute.relativeFilePath.replace(/\.([jt]sx)/, `.${extension}.$1`),
+ )
+ } else {
+ return `${path.join(
+ getPaths().web.distServer,
+ 'ogImage',
+ currentRoute.relativeFilePath.replace(/\.([jt]sx)/, ''),
+ )}.${extension}.mjs` // @MARK: Hardcoded mjs!
+ }
+ }
+
+ get debugElement() {
+ return createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute',
+ top: '0',
+ left: '0',
+ border: '1px dashed red',
+ pointerEvents: 'none',
+ width: this.imageProps?.width,
+ height: this.imageProps?.height,
+ },
+ },
+ createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute',
+ left: '0',
+ right: '0',
+ bottom: '-1.5rem',
+ textAlign: 'center',
+ color: 'red',
+ fontWeight: 'normal',
+ },
+ },
+ `${this.imageProps?.width} x ${this.imageProps?.height}`,
+ ),
+ )
+ }
+
+ componentElements({
+ Component,
+ data,
+ routeParams,
+ debug,
+ }: ComponentElementProps) {
+ const element = createElement(Component, {
+ data,
+ ...routeParams,
+ })
+
+ if (debug) {
+ return [
+ createElement(
+ 'div',
+ {
+ style: { width: this.imageProps?.width },
+ },
+ element,
+ ),
+ this.debugElement,
+ ]
+ } else {
+ return element
+ }
+ }
+
+ public async importComponent(
+ filePath: string,
+ invokeOptions: MiddlewareInvokeOptions,
+ ) {
+ try {
+ if (invokeOptions.viteDevServer) {
+ const { data, output } =
+ await invokeOptions.viteDevServer.ssrLoadModule(filePath)
+ return { data, Component: output }
+ } else {
+ const { data, output } = await import(filePath)
+ return { data, Component: output }
+ }
+ } catch (e) {
+ console.error(
+ `OGMiddleware: OG Image component import failed: ${filePath}`,
+ )
+ console.error(e)
+ throw e
+ }
+ }
+}
diff --git a/packages/ogimage-gen/src/getRoutesList.ts b/packages/ogimage-gen/src/getRoutesList.ts
new file mode 100644
index 000000000000..ff7cd5cbed84
--- /dev/null
+++ b/packages/ogimage-gen/src/getRoutesList.ts
@@ -0,0 +1,22 @@
+import url from 'node:url'
+
+import type { RWRouteManifestItem } from '@redwoodjs/internal'
+import { getPaths } from '@redwoodjs/project-config'
+
+export const getRoutesList = async () => {
+ const rwPaths = getPaths()
+
+ if (process.env.NODE_ENV === 'development') {
+ const { getProjectRoutes } = await import(
+ '@redwoodjs/internal/dist/routes.js'
+ )
+ return getProjectRoutes()
+ } else {
+ const routeManifestUrl = url.pathToFileURL(rwPaths.web.routeManifest).href
+ const routeManifest: Record = (
+ await import(routeManifestUrl, { with: { type: 'json' } })
+ ).default
+
+ return Object.values(routeManifest)
+ }
+}
diff --git a/packages/ogimage-gen/src/hooks.test.ts b/packages/ogimage-gen/src/hooks.test.ts
new file mode 100644
index 000000000000..29f425a9f76c
--- /dev/null
+++ b/packages/ogimage-gen/src/hooks.test.ts
@@ -0,0 +1,178 @@
+import { vi, describe, afterEach, test, expect } from 'vitest'
+
+import { useOgImage, OGIMAGE_DEFAULTS } from './hooks'
+
+const mockLocation = vi.fn()
+
+vi.mock('@redwoodjs/router', () => {
+ return {
+ useLocation: () => mockLocation(),
+ }
+})
+
+describe('useOgImage', () => {
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test('returns a plain URL with a default extension', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { url } = useOgImage()
+
+ expect(url).toBe('http://localhost/user/1.png')
+ })
+
+ test('returns the default width of the image', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { width } = useOgImage()
+
+ expect(width).toBe(OGIMAGE_DEFAULTS.width)
+ })
+
+ test('returns the default height of the image', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { height } = useOgImage()
+
+ expect(height).toBe(OGIMAGE_DEFAULTS.height)
+ })
+
+ test('returns the default quality of the image', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { quality } = useOgImage()
+
+ expect(quality).toBe(OGIMAGE_DEFAULTS.quality)
+ })
+
+ test('returns all the props necessary to build the og:image meta tags', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { ogProps } = useOgImage()
+
+ expect(ogProps).toEqual({
+ image: ['http://localhost/user/1.png', { width: 1200, height: 630 }],
+ })
+ })
+
+ test('returns index.png if at the root', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { url } = useOgImage()
+
+ expect(url).toBe('http://localhost/index.png')
+ })
+
+ test('preserves existing query variables', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/about',
+ searchParams: new URLSearchParams('foo=bar'),
+ })
+
+ const { url } = useOgImage()
+
+ expect(url).toBe('http://localhost/about.png?foo=bar')
+ })
+
+ test('allows setting a custom extension', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1/edit',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { url } = useOgImage({ extension: 'jpg' })
+
+ expect(url).toBe('http://localhost/user/1/edit.jpg')
+ })
+
+ test('allows setting a custom width', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { url, width, height } = useOgImage({ width: 1000 })
+
+ expect(url).toBe('http://localhost/user/1.png?width=1000')
+ expect(width).toBe(1000)
+ expect(height).toBe(OGIMAGE_DEFAULTS.height)
+ })
+
+ test('allows setting a custom height', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { url, width, height } = useOgImage({ height: 500 })
+
+ expect(url).toBe('http://localhost/user/1.png?height=500')
+ expect(width).toBe(OGIMAGE_DEFAULTS.width)
+ expect(height).toBe(500)
+ })
+
+ test('allows setting a custom quality', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams(),
+ })
+
+ const { url, quality } = useOgImage({ quality: 50 })
+
+ expect(url).toBe('http://localhost/user/1.png?quality=50')
+ expect(quality).toBe(50)
+ })
+
+ test('merges existing query variables with custom ones', () => {
+ mockLocation.mockReturnValue({
+ origin: 'http://localhost',
+ pathname: '/user/1',
+ searchParams: new URLSearchParams('foo=bar'),
+ })
+
+ const { url, width, height, quality } = useOgImage({
+ extension: 'png',
+ width: 1024,
+ height: 768,
+ quality: 75,
+ })
+
+ expect(url).toBe(
+ 'http://localhost/user/1.png?foo=bar&width=1024&height=768&quality=75',
+ )
+ expect(width).toBe(1024)
+ expect(height).toBe(768)
+ expect(quality).toBe(75)
+ })
+})
diff --git a/packages/ogimage-gen/src/hooks.ts b/packages/ogimage-gen/src/hooks.ts
new file mode 100644
index 000000000000..2b9c1cd73d67
--- /dev/null
+++ b/packages/ogimage-gen/src/hooks.ts
@@ -0,0 +1,66 @@
+import { useLocation } from '@redwoodjs/router'
+
+export type OgImageUrlOptions = {
+ extension?: 'png' | 'jpg'
+ width?: number
+ height?: number
+ quality?: number
+}
+
+export const OGIMAGE_DEFAULTS = {
+ extension: 'png',
+ width: 1200,
+ height: 630,
+ quality: 100,
+}
+
+export const useOgImage = (options?: OgImageUrlOptions) => {
+ const { origin, pathname, searchParams } = useLocation()
+ const ext = options?.extension || OGIMAGE_DEFAULTS.extension
+ const width = options?.width
+ const height = options?.height
+ const quality = options?.quality
+ const output = [origin]
+
+ // special case if we're at the root, image is available at /index.ext
+ if (pathname === '/') {
+ output.push('/index')
+ } else {
+ output.push(pathname)
+ }
+
+ output.push(`.${ext}`)
+
+ if (width) {
+ searchParams.append('width', width.toString())
+ }
+ if (height) {
+ searchParams.append('height', height.toString())
+ }
+ if (quality) {
+ searchParams.append('quality', quality.toString())
+ }
+
+ // only append search params if there are any, so we don't up with a trailing `?`
+ if (searchParams.size) {
+ output.push(`?${searchParams}`)
+ }
+
+ return {
+ url: output.join(''),
+ width: width || OGIMAGE_DEFAULTS.width,
+ height: height || OGIMAGE_DEFAULTS.height,
+ quality: quality || OGIMAGE_DEFAULTS.quality,
+ extension: ext,
+ ogProps: {
+ image: [
+ output.join(''),
+ {
+ width: width || OGIMAGE_DEFAULTS.width,
+ height: height || OGIMAGE_DEFAULTS.height,
+ },
+ ],
+ // twitter: { image: { src: output.join('') } },
+ },
+ }
+}
diff --git a/packages/ogimage-gen/src/vite-plugin-ogimage-gen.test.ts b/packages/ogimage-gen/src/vite-plugin-ogimage-gen.test.ts
index a44e0540f3d7..e1c2e6684656 100644
--- a/packages/ogimage-gen/src/vite-plugin-ogimage-gen.test.ts
+++ b/packages/ogimage-gen/src/vite-plugin-ogimage-gen.test.ts
@@ -1,4 +1,5 @@
import { vol, fs as memfs } from 'memfs'
+import type { ConfigEnv } from 'vite'
import { describe, expect, test, vi, beforeAll, afterAll } from 'vitest'
import { ensurePosixPath } from '@redwoodjs/project-config'
@@ -41,10 +42,17 @@ describe('vitePluginOgGen', () => {
// Type cast so TS doesn't complain calling config below
// because config can be of many types!
const plugin = (await vitePluginOgGen()) as {
- config: (...args: any) => any
+ config: (config: any, env: ConfigEnv) => any
}
- const rollupInputs = plugin.config().build?.rollupOptions?.input
+ const rollupInputs = plugin.config(
+ {},
+ {
+ isSsrBuild: true,
+ command: 'build',
+ mode: 'production',
+ },
+ ).build?.rollupOptions?.input
const inputKeys = Object.keys(rollupInputs)
diff --git a/packages/ogimage-gen/src/vite-plugin-ogimage-gen.ts b/packages/ogimage-gen/src/vite-plugin-ogimage-gen.ts
index af9e0ae6d111..3e6b2fbd5185 100644
--- a/packages/ogimage-gen/src/vite-plugin-ogimage-gen.ts
+++ b/packages/ogimage-gen/src/vite-plugin-ogimage-gen.ts
@@ -41,13 +41,17 @@ function vitePluginOgImageGen(): ConfigPlugin {
return {
name: 'rw-vite-plugin-ogimage-gen',
apply: 'build', // We only need to update rollup inputs for build
- config: () => {
- return {
- build: {
- rollupOptions: {
- input: ogComponentInput,
+ config: (_config, env) => {
+ if (env.isSsrBuild) {
+ return {
+ build: {
+ rollupOptions: {
+ input: ogComponentInput,
+ },
},
- },
+ }
+ } else {
+ return
}
},
}
diff --git a/packages/ogimage-gen/tsconfig.json b/packages/ogimage-gen/tsconfig.json
index 4dc4212ab88a..9e1360fa227c 100644
--- a/packages/ogimage-gen/tsconfig.json
+++ b/packages/ogimage-gen/tsconfig.json
@@ -2,7 +2,7 @@
"extends": "../../tsconfig.compilerOption.json",
"compilerOptions": {
"rootDir": "src",
- "outDir": "dist",
+ "outDir": "dist"
},
"include": [
"src"
diff --git a/packages/prerender/src/runPrerender.tsx b/packages/prerender/src/runPrerender.tsx
index dd7316bd531d..cce03e057784 100644
--- a/packages/prerender/src/runPrerender.tsx
+++ b/packages/prerender/src/runPrerender.tsx
@@ -39,6 +39,7 @@ const prerenderApolloClient = new ApolloClient({ cache: new InMemoryCache() })
async function recursivelyRender(
App: React.ElementType,
+ Routes: React.ElementType,
renderPath: string,
gqlHandler: any,
queryCache: Record,
@@ -135,7 +136,9 @@ async function recursivelyRender(
const componentAsHtml = ReactDOMServer.renderToString(
-
+
+
+
,
)
@@ -143,7 +146,7 @@ async function recursivelyRender(
if (Object.values(queryCache).some((value) => !value.hasProcessed)) {
// We found new queries that we haven't fetched yet. Execute all new
// queries and render again
- return recursivelyRender(App, renderPath, gqlHandler, queryCache)
+ return recursivelyRender(App, Routes, renderPath, gqlHandler, queryCache)
} else {
if (shouldShowGraphqlHandlerNotFoundWarn) {
console.warn(
@@ -349,9 +352,11 @@ export const runPrerender = async ({
const indexContent = fs.readFileSync(getRootHtmlPath()).toString()
const { default: App } = require(getPaths().web.app)
+ const { default: Routes } = require(getPaths().web.routes)
const componentAsHtml = await recursivelyRender(
App,
+ Routes,
renderPath,
gqlHandler,
queryCache,
diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts
index 3c46f940b892..0e83cc7d1be7 100644
--- a/packages/vite/src/devFeServer.ts
+++ b/packages/vite/src/devFeServer.ts
@@ -84,7 +84,10 @@ async function createServer() {
return new Response('No middleware found', { status: 404 })
}
- const [mwRes] = await invoke(req, middleware, route ? { route } : {})
+ const [mwRes] = await invoke(req, middleware, {
+ route,
+ viteDevServer: vite,
+ })
return mwRes.toResponse()
})
@@ -103,11 +106,8 @@ async function createServer() {
routes,
clientEntryPath: rwPaths.web.entryClient as string,
getStylesheetLinks: (route) => {
- if (!route) {
- return []
- }
// In dev route is a RouteSpec, with additional properties
- return getCssLinks(rwPaths, route as RouteSpec, vite)
+ return getCssLinks({ rwPaths, route: route as RouteSpec, vite })
},
// Recreate middleware router on each request in dev
getMiddlewareRouter: async () => createMiddlewareRouter(vite),
@@ -143,9 +143,17 @@ process.stdin.on('data', async (data) => {
* Passed as a getter to the createReactStreamingHandler function, because
* at the time of creating the handler, the ViteDevServer hasn't analysed the module graph yet
*/
-function getCssLinks(rwPaths: Paths, route: RouteSpec, vite: ViteDevServer) {
+function getCssLinks({
+ rwPaths,
+ route,
+ vite,
+}: {
+ rwPaths: Paths
+ route?: RouteSpec
+ vite: ViteDevServer
+}) {
const appAndRouteModules = componentsModules(
- [rwPaths.web.app, route.filePath].filter(Boolean) as string[],
+ [rwPaths.web.app, route && route.filePath].filter(Boolean) as string[],
vite,
)
diff --git a/packages/vite/src/middleware/index.ts b/packages/vite/src/middleware/index.ts
index ff745f6c59a5..6ea8a596ce7c 100644
--- a/packages/vite/src/middleware/index.ts
+++ b/packages/vite/src/middleware/index.ts
@@ -1,3 +1,4 @@
export * from './CookieJar.js'
export * from './MiddlewareRequest.js'
export * from './MiddlewareResponse.js'
+export * from './types.js'
diff --git a/packages/vite/src/middleware/types.ts b/packages/vite/src/middleware/types.ts
index 68cbce1ef0ee..d718110cdc5c 100644
--- a/packages/vite/src/middleware/types.ts
+++ b/packages/vite/src/middleware/types.ts
@@ -1,3 +1,5 @@
+import type { ViteDevServer } from 'vite'
+
import type { RWRouteManifestItem } from '@redwoodjs/internal/dist/routes'
import type { MiddlewareRequest } from './MiddlewareRequest.js'
@@ -17,6 +19,7 @@ export type MiddlewareInvokeOptions = {
route?: RWRouteManifestItem
cssPaths?: Array
params?: Record
+ viteDevServer?: ViteDevServer
}
// Tuple of [mw, '*.{extension}']
diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts
index 174820ec7c84..763dad50e3ea 100644
--- a/packages/vite/src/streaming/createReactStreamingHandler.ts
+++ b/packages/vite/src/streaming/createReactStreamingHandler.ts
@@ -24,7 +24,7 @@ import { loadAndRunRouteHooks } from './triggerRouteHooks.js'
interface CreateReactStreamingHandlerOptions {
routes: RWRouteManifestItem[]
clientEntryPath: string
- getStylesheetLinks: (route: RWRouteManifestItem | RouteSpec) => string[]
+ getStylesheetLinks: (route?: RWRouteManifestItem | RouteSpec) => string[]
getMiddlewareRouter: () => Promise>
}
@@ -93,17 +93,12 @@ export const createReactStreamingHandler = async (
if (middlewareRouter) {
const matchedMw = middlewareRouter.find(req.method as HTTPMethod, req.url)
;[mwResponse, decodedAuthState = middlewareDefaultAuthProviderState] =
- await invoke(
- req,
- matchedMw?.handler as Middleware | undefined,
- currentRoute
- ? {
- route: currentRoute,
- cssPaths: getStylesheetLinks(currentRoute),
- params: matchedMw?.params,
- }
- : {},
- )
+ await invoke(req, matchedMw?.handler as Middleware | undefined, {
+ route: currentRoute,
+ cssPaths: getStylesheetLinks(currentRoute),
+ params: matchedMw?.params,
+ viteDevServer,
+ })
// If mwResponse is a redirect, short-circuit here, and skip React rendering
// If the response has a body, no need to render react.
diff --git a/packages/web/src/entry/index.js b/packages/web/src/entry/index.js
index a52eae143d1b..5f900939821f 100644
--- a/packages/web/src/entry/index.js
+++ b/packages/web/src/entry/index.js
@@ -1,6 +1,7 @@
import { hydrateRoot, createRoot } from 'react-dom/client'
import App from '~redwood-app-root'
+import Routes from '~redwood-app-routes'
/**
* When `#redwood-app` isn't empty then it's very likely that you're using
* prerendering. So React attaches event listeners to the existing markup
@@ -10,8 +11,17 @@ import App from '~redwood-app-root'
const redwoodAppElement = document.getElementById('redwood-app')
if (redwoodAppElement.children?.length > 0) {
- hydrateRoot(redwoodAppElement, )
+ hydrateRoot(
+ redwoodAppElement,
+
+
+ ,
+ )
} else {
const root = createRoot(redwoodAppElement)
- root.render()
+ root.render(
+
+
+ ,
+ )
}
diff --git a/yarn.lock b/yarn.lock
index d0d94eee3487..9468c1c40845 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8581,12 +8581,14 @@ __metadata:
version: 0.0.0-use.local
resolution: "@redwoodjs/ogimage-gen@workspace:packages/ogimage-gen"
dependencies:
+ "@playwright/test": "npm:1.42.1"
"@redwoodjs/framework-tools": "workspace:*"
"@redwoodjs/internal": "workspace:*"
"@redwoodjs/project-config": "workspace:*"
"@redwoodjs/router": "workspace:*"
"@redwoodjs/vite": "workspace:*"
fast-glob: "npm:3.3.2"
+ lodash: "npm:4.17.21"
react: "npm:19.0.0-canary-cb151849e1-20240424"
react-dom: "npm:19.0.0-canary-cb151849e1-20240424"
ts-toolbelt: "npm:9.6.0"