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( + '
Mocked component render
', + ) + + 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( + '
Mocked component render
', + ) + + 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( + `"
Mocked component render
1200 x 630
"`, + ) + 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( + '
Mocked component render
', + ) + + 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"