From 8610d58e1207779dfe561b96f7625b20d2a8f400 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 12 Jul 2023 13:26:14 +0200 Subject: [PATCH] RSC: Initial css support (#8887) --- packages/vite/package.json | 8 ++ packages/vite/src/buildRscFeServer.ts | 30 ++++- .../src/fully-react/DevRwRscServerGlobal.ts | 63 +++++++++++ .../src/fully-react/ProdRwRscServerGlobal.ts | 53 +++++++++ .../vite/src/fully-react/RwRscServerGlobal.ts | 20 ++++ packages/vite/src/fully-react/assets.tsx | 83 ++++++++++++++ packages/vite/src/fully-react/find-styles.ts | 106 ++++++++++++++++++ .../src/fully-react/findAssetsInManifest.ts | 47 ++++++++ packages/vite/src/fully-react/rwRscGlobal.ts | 10 ++ packages/vite/src/runRscFeServer.ts | 5 +- packages/vite/src/waku-lib/build-server.ts | 2 + 11 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 packages/vite/src/fully-react/DevRwRscServerGlobal.ts create mode 100644 packages/vite/src/fully-react/ProdRwRscServerGlobal.ts create mode 100644 packages/vite/src/fully-react/RwRscServerGlobal.ts create mode 100644 packages/vite/src/fully-react/assets.tsx create mode 100644 packages/vite/src/fully-react/find-styles.ts create mode 100644 packages/vite/src/fully-react/findAssetsInManifest.ts create mode 100644 packages/vite/src/fully-react/rwRscGlobal.ts diff --git a/packages/vite/package.json b/packages/vite/package.json index b457dad1a822..bed1d3e34a76 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -26,6 +26,14 @@ "types": "./dist/client.d.ts", "default": "./dist/client.js" }, + "./assets": { + "types": "./dist/fully-react/assets.d.ts", + "default": "./dist/fully-react/assets.js" + }, + "./rwRscGlobal": { + "types": "./dist/fully-react/rwRscGlobal.d.ts", + "default": "./dist/fully-react/rwRscGlobal.js" + }, "./buildFeServer": { "types": "./dist/buildFeServer.d.ts", "default": "./dist/buildFeServer.js" diff --git a/packages/vite/src/buildRscFeServer.ts b/packages/vite/src/buildRscFeServer.ts index 5da1580075da..3cf4a75049b8 100644 --- a/packages/vite/src/buildRscFeServer.ts +++ b/packages/vite/src/buildRscFeServer.ts @@ -57,6 +57,7 @@ export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => { // // noExternal: ['@redwoodjs/web', '@redwoodjs/router'], // }, build: { + manifest: 'rsc-build-manifest.json', write: false, ssr: true, rollupOptions: { @@ -135,7 +136,7 @@ export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => { }, preserveEntrySignatures: 'exports-only', }, - manifest: 'build-manifest.json', + manifest: 'client-build-manifest.json', }, esbuild: { logLevel: 'debug', @@ -154,11 +155,29 @@ export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => { {} ) + // TODO (RSC) Some css is now duplicated in two files (i.e. for client + // components). Probably don't want that. + // Also not sure if this works on "soft" rerenders (i.e. not a full page + // load) + await Promise.all( + serverBuildOutput.output + .filter((item) => { + return item.type === 'asset' && item.fileName.endsWith('.css') + }) + .map((cssAsset) => { + return fs.copyFile( + path.join(rwPaths.web.distServer, cssAsset.fileName), + path.join(rwPaths.web.dist, cssAsset.fileName) + ) + }) + ) + const clientEntries: Record = {} for (const item of clientBuildOutput.output) { const { name, fileName } = item const entryFile = name && + // TODO (RSC) Can't we just compare the names? `item.name === name` serverBuildOutput.output.find( (item) => 'moduleIds' in item && @@ -273,9 +292,12 @@ export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => { // * With `assert` and `@babel/plugin-syntax-import-assertions` the // code compiled and ran properly, but Jest tests failed, complaining // about the syntax. - const manifestPath = path.join(getPaths().web.dist, 'build-manifest.json') - const buildManifestStr = await fs.readFile(manifestPath, 'utf-8') - const clientBuildManifest: ViteBuildManifest = JSON.parse(buildManifestStr) + const manifestPath = path.join( + getPaths().web.dist, + 'client-build-manifest.json' + ) + const manifestStr = await fs.readFile(manifestPath, 'utf-8') + const clientBuildManifest: ViteBuildManifest = JSON.parse(manifestStr) // TODO (RSC) We don't have support for a router yet, so skip all routes const routesList = [] as RouteSpec[] // getProjectRoutes() diff --git a/packages/vite/src/fully-react/DevRwRscServerGlobal.ts b/packages/vite/src/fully-react/DevRwRscServerGlobal.ts new file mode 100644 index 000000000000..c540d2ea5cb4 --- /dev/null +++ b/packages/vite/src/fully-react/DevRwRscServerGlobal.ts @@ -0,0 +1,63 @@ +import { relative } from 'node:path' + +import { lazy } from 'react' + +import { getPaths } from '@redwoodjs/project-config' + +import { collectStyles } from './find-styles' +import { RwRscServerGlobal } from './RwRscServerGlobal' + +// import viteDevServer from '../dev-server' +const viteDevServer: any = {} + +export class DevRwRscServerGlobal extends RwRscServerGlobal { + /** @type {import('vite').ViteDevServer} */ + viteServer + + constructor() { + super() + this.viteServer = viteDevServer + // this.routeManifest = viteDevServer.routesManifest + } + + bootstrapModules() { + // return [`/@fs${import.meta.env.CLIENT_ENTRY}`] + // TODO (RSC) No idea if this is correct or even what format CLIENT_ENTRY has. + return [`/@fs${getPaths().web.entryClient}`] + } + + bootstrapScriptContent() { + return undefined + } + + async loadModule(id: string) { + return await viteDevServer.ssrLoadModule(id) + } + + lazyComponent(id: string) { + const importPath = `/@fs${id}` + return lazy( + async () => + await this.viteServer.ssrLoadModule(/* @vite-ignore */ importPath) + ) + } + + chunkId(chunk: string) { + // return relative(this.srcAppRoot, chunk) + return relative(getPaths().web.src, chunk) + } + + async findAssetsForModules(modules: string[]) { + const styles = await collectStyles( + this.viteServer, + modules.filter((i) => !!i) + ) + + return [...Object.entries(styles ?? {}).map(([key, _value]) => key)] + } + + async findAssets() { + const deps = this.getDependenciesForURL('/') + return await this.findAssetsForModules(deps) + } +} diff --git a/packages/vite/src/fully-react/ProdRwRscServerGlobal.ts b/packages/vite/src/fully-react/ProdRwRscServerGlobal.ts new file mode 100644 index 000000000000..8260bd6f869b --- /dev/null +++ b/packages/vite/src/fully-react/ProdRwRscServerGlobal.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs' +import { join, relative } from 'node:path' + +import type { Manifest as BuildManifest } from 'vite' + +import { getPaths } from '@redwoodjs/project-config' + +import { findAssetsInManifest } from './findAssetsInManifest' +import { RwRscServerGlobal } from './RwRscServerGlobal' + +function readJSON(path: string) { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +export class ProdRwRscServerGlobal extends RwRscServerGlobal { + serverManifest: BuildManifest + + constructor() { + super() + + const rwPaths = getPaths() + + this.serverManifest = readJSON( + join(rwPaths.web.distServer, 'server-build-manifest.json') + ) + } + + chunkId(chunk: string) { + return relative(getPaths().web.src, chunk) + } + + async findAssetsForModules(modules: string[]) { + return modules?.map((i) => this.findAssetsForModule(i)).flat() ?? [] + } + + findAssetsForModule(module: string) { + return [ + ...findAssetsInManifest(this.serverManifest, module).filter( + (asset) => !asset.endsWith('.js') && !asset.endsWith('.mjs') + ), + ] + } + + async findAssets(): Promise { + // TODO (RSC) This is a hack. We need to figure out how to get the + // dependencies for the current page. + const deps = Object.keys(this.serverManifest).filter((name) => + /\.(tsx|jsx|js)$/.test(name) + ) + + return await this.findAssetsForModules(deps) + } +} diff --git a/packages/vite/src/fully-react/RwRscServerGlobal.ts b/packages/vite/src/fully-react/RwRscServerGlobal.ts new file mode 100644 index 000000000000..4240d232c9c9 --- /dev/null +++ b/packages/vite/src/fully-react/RwRscServerGlobal.ts @@ -0,0 +1,20 @@ +import { lazy } from 'react' + +export class RwRscServerGlobal { + async loadModule(id: string) { + return await import(/* @vite-ignore */ id) + } + + lazyComponent(id: string) { + return lazy(() => this.loadModule(id)) + } + + // Will be implemented by subclasses + async findAssets(_id: string): Promise { + return [] + } + + getDependenciesForURL(_route: string): string[] { + return [] + } +} diff --git a/packages/vite/src/fully-react/assets.tsx b/packages/vite/src/fully-react/assets.tsx new file mode 100644 index 000000000000..3695ddb48a18 --- /dev/null +++ b/packages/vite/src/fully-react/assets.tsx @@ -0,0 +1,83 @@ +// Copied from +// https://github.com/nksaraf/fully-react/blob/4f738132a17d94486c8da19d8729044c3998fc54/packages/fully-react/src/shared/assets.tsx +// And then modified to work with our codebase + +import React, { use } from 'react' + +const linkProps = [ + ['js', { rel: 'modulepreload', crossOrigin: '' }], + ['jsx', { rel: 'modulepreload', crossOrigin: '' }], + ['ts', { rel: 'modulepreload', crossOrigin: '' }], + ['tsx', { rel: 'modulepreload', crossOrigin: '' }], + ['css', { rel: 'stylesheet', precedence: 'high' }], + ['woff', { rel: 'preload', as: 'font', type: 'font/woff', crossOrigin: '' }], + [ + 'woff2', + { rel: 'preload', as: 'font', type: 'font/woff2', crossOrigin: '' }, + ], + ['gif', { rel: 'preload', as: 'image', type: 'image/gif' }], + ['jpg', { rel: 'preload', as: 'image', type: 'image/jpeg' }], + ['jpeg', { rel: 'preload', as: 'image', type: 'image/jpeg' }], + ['png', { rel: 'preload', as: 'image', type: 'image/png' }], + ['webp', { rel: 'preload', as: 'image', type: 'image/webp' }], + ['svg', { rel: 'preload', as: 'image', type: 'image/svg+xml' }], + ['ico', { rel: 'preload', as: 'image', type: 'image/x-icon' }], + ['avif', { rel: 'preload', as: 'image', type: 'image/avif' }], + ['mp4', { rel: 'preload', as: 'video', type: 'video/mp4' }], + ['webm', { rel: 'preload', as: 'video', type: 'video/webm' }], +] as const + +type Linkprop = (typeof linkProps)[number][1] + +const linkPropsMap = new Map(linkProps) + +/** + * Generates a link tag for a given file. This will load stylesheets and preload + * everything else. It uses the file extension to determine the type. + */ +export const Asset = ({ file }: { file: string }) => { + const ext = file.split('.').pop() + const props = ext ? linkPropsMap.get(ext) : null + + if (!props) { + return null + } + + return +} + +export function Assets() { + // TODO (RSC) Currently we only handle server assets. + // Will probably need to handle client assets as well. + // Do we also need special code for SSR? + // if (isClient) return + + // @ts-expect-error Need experimental types here for this to work + return +} + +const findAssets = async () => { + return [...new Set([...(await rwRscGlobal.findAssets(''))]).values()] +} + +const AssetList = ({ assets }: { assets: string[] }) => { + return ( + <> + {assets.map((asset) => { + return + })} + + ) +} + +async function ServerAssets() { + const allAssets = await findAssets() + + return +} + +export function ClientAssets() { + const allAssets = use(findAssets()) + + return +} diff --git a/packages/vite/src/fully-react/find-styles.ts b/packages/vite/src/fully-react/find-styles.ts new file mode 100644 index 000000000000..cdab15e02f0e --- /dev/null +++ b/packages/vite/src/fully-react/find-styles.ts @@ -0,0 +1,106 @@ +import path from 'node:path' + +import type { ModuleNode, ViteDevServer } from 'vite' + +async function find_deps( + vite: import('vite').ViteDevServer, + node: import('vite').ModuleNode, + deps: Set +) { + // since `ssrTransformResult.deps` contains URLs instead of `ModuleNode`s, this process is asynchronous. + // instead of using `await`, we resolve all branches in parallel. + const branches: Promise[] = [] + + async function add(node: import('vite').ModuleNode) { + if (!deps.has(node)) { + deps.add(node) + await find_deps(vite, node, deps) + } + } + + async function add_by_url(url: string) { + const node = await vite.moduleGraph.getModuleByUrl(url) + + if (node) { + await add(node) + } + } + + if (node.ssrTransformResult) { + if (node.ssrTransformResult.deps) { + node.ssrTransformResult.deps.forEach((url) => + branches.push(add_by_url(url)) + ) + } + + // if (node.ssrTransformResult.dynamicDeps) { + // node.ssrTransformResult.dynamicDeps.forEach(url => branches.push(add_by_url(url))); + // } + } else { + node.importedModules.forEach((node) => branches.push(add(node))) + } + + await Promise.all(branches) +} + +// Vite doesn't expose this so we just copy the list for now +// https://github.com/vitejs/vite/blob/3edd1af56e980aef56641a5a51cf2932bb580d41/packages/vite/src/node/plugins/css.ts#L96 +const style_pattern = /\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/ +// TODO (RSC) fully-react didn't use this anywhere. But do we need it for module support? +// const module_style_pattern = +// /\.module\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/ + +export async function collectStyles(devServer: ViteDevServer, match: string[]) { + const styles: { [key: string]: string } = {} + const deps = new Set() + try { + for (const file of match) { + const resolvedId = await devServer.pluginContainer.resolveId(file) + + if (!resolvedId) { + console.log('not found') + continue + } + + const id = resolvedId.id + + const normalizedPath = path.resolve(id).replace(/\\/g, '/') + let node = devServer.moduleGraph.getModuleById(normalizedPath) + if (!node) { + const absolutePath = path.resolve(file) + await devServer.ssrLoadModule(absolutePath) + node = await devServer.moduleGraph.getModuleByUrl(absolutePath) + + if (!node) { + console.log('not found') + return + } + } + + await find_deps(devServer, node, deps) + } + } catch (e) { + console.error(e) + } + + for (const dep of deps) { + // const parsed = new URL(dep.url, 'http://localhost/') + // const query = parsed.searchParams + + if (style_pattern.test(dep.file ?? '')) { + try { + const mod = await devServer.ssrLoadModule(dep.url) + // if (module_style_pattern.test(dep.file)) { + // styles[dep.url] = env.cssModules?.[dep.file]; + // } else { + styles[dep.url] = mod.default + // } + } catch { + // this can happen with dynamically imported modules, I think + // because the Vite module graph doesn't distinguish between + // static and dynamic imports? TODO investigate, submit fix + } + } + } + return styles +} diff --git a/packages/vite/src/fully-react/findAssetsInManifest.ts b/packages/vite/src/fully-react/findAssetsInManifest.ts new file mode 100644 index 000000000000..ca7641a1b42f --- /dev/null +++ b/packages/vite/src/fully-react/findAssetsInManifest.ts @@ -0,0 +1,47 @@ +import type { Manifest as BuildManifest } from 'vite' + +/** + * Traverses the module graph and collects assets for a given chunk + * + * @param manifest Client manifest + * @param id Chunk id + * @returns Array of asset URLs + */ +export const findAssetsInManifest = ( + manifest: BuildManifest, + id: string +): Array => { + // TODO (RSC) Can we take assetMap as a parameter to reuse it across calls? + // It's what the original implementation of this function does. But no + // callers pass it in where we currently use this function. + const assetMap: Map> = new Map() + + function traverse(id: string): Array { + const cached = assetMap.get(id) + if (cached) { + return cached + } + + const chunk = manifest[id] + if (!chunk) { + return [] + } + + const assets = [ + ...(chunk.assets || []), + ...(chunk.css || []), + ...(chunk.imports?.flatMap(traverse) || []), + ] + const imports = chunk.imports?.flatMap(traverse) || [] + const all = [...assets, ...imports].filter( + Boolean as unknown as (a: string | undefined) => a is string + ) + + all.push(chunk.file) + assetMap.set(id, all) + + return Array.from(new Set(all)) + } + + return traverse(id) +} diff --git a/packages/vite/src/fully-react/rwRscGlobal.ts b/packages/vite/src/fully-react/rwRscGlobal.ts new file mode 100644 index 000000000000..1a54d996ecc8 --- /dev/null +++ b/packages/vite/src/fully-react/rwRscGlobal.ts @@ -0,0 +1,10 @@ +import { RwRscServerGlobal } from './RwRscServerGlobal' +export { RwRscServerGlobal } from './RwRscServerGlobal' +export { DevRwRscServerGlobal } from './DevRwRscServerGlobal' +export { ProdRwRscServerGlobal } from './ProdRwRscServerGlobal' +export type AssetDesc = string | { type: 'style'; style: string; src?: string } + +declare global { + /* eslint-disable no-var */ + var rwRscGlobal: RwRscServerGlobal +} diff --git a/packages/vite/src/runRscFeServer.ts b/packages/vite/src/runRscFeServer.ts index e6f7d4620460..5a51439a09ff 100644 --- a/packages/vite/src/runRscFeServer.ts +++ b/packages/vite/src/runRscFeServer.ts @@ -66,7 +66,10 @@ export async function runFeServer() { // const routeManifest: RWRouteManifest = JSON.parse(routeManifestStr) // TODO See above about using `import { with: { type: 'json' } }` instead - const manifestPath = path.join(getPaths().web.dist, 'build-manifest.json') + const manifestPath = path.join( + getPaths().web.dist, + 'client-build-manifest.json' + ) const buildManifestStr = await fs.readFile(manifestPath, 'utf-8') const buildManifest: ViteBuildManifest = JSON.parse(buildManifestStr) diff --git a/packages/vite/src/waku-lib/build-server.ts b/packages/vite/src/waku-lib/build-server.ts index 09ef7b4be399..ac8169a5cdda 100644 --- a/packages/vite/src/waku-lib/build-server.ts +++ b/packages/vite/src/waku-lib/build-server.ts @@ -38,10 +38,12 @@ export async function serverBuild( plugins: [react()], build: { ssr: true, + ssrEmitAssets: true, // TODO (RSC) Change output dir to just dist. We should be "server // first". Client components are the "special case" and should be output // to dist/client outDir: rwPaths.web.distServer, + manifest: 'server-build-manifest.json', rollupOptions: { input, output: {