|
| 1 | +import fs from 'fs/promises' |
| 2 | +import path from 'path' |
| 3 | + |
| 4 | +import react from '@vitejs/plugin-react' |
| 5 | +import { build as viteBuild } from 'vite' |
| 6 | +import type { Manifest as ViteBuildManifest } from 'vite' |
| 7 | + |
| 8 | +import { RouteSpec } from '@redwoodjs/internal/dist/routes' |
| 9 | +import { getAppRouteHook, getPaths } from '@redwoodjs/project-config' |
| 10 | + |
| 11 | +import { RWRouteManifest } from './types' |
| 12 | +import { serverBuild } from './waku-lib/build-server' |
| 13 | +import { rscAnalyzePlugin, rscIndexPlugin } from './waku-lib/vite-plugin-rsc' |
| 14 | + |
| 15 | +interface BuildOptions { |
| 16 | + verbose?: boolean |
| 17 | +} |
| 18 | + |
| 19 | +export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => { |
| 20 | + const rwPaths = getPaths() |
| 21 | + |
| 22 | + const clientEntryFileSet = new Set<string>() |
| 23 | + const serverEntryFileSet = new Set<string>() |
| 24 | + |
| 25 | + /** |
| 26 | + * RSC build |
| 27 | + * Uses rscAnalyzePlugin to collect client and server entry points |
| 28 | + * Starts building the AST in entries.ts |
| 29 | + * Doesn't output any files, only collects a list of RSCs and RSFs |
| 30 | + */ |
| 31 | + await viteBuild({ |
| 32 | + // ...configFileConfig, |
| 33 | + root: rwPaths.base, |
| 34 | + plugins: [ |
| 35 | + react(), |
| 36 | + { |
| 37 | + name: 'rsc-test-plugin', |
| 38 | + transform(_code, id) { |
| 39 | + console.log('rsc-test-plugin id', id) |
| 40 | + }, |
| 41 | + }, |
| 42 | + rscAnalyzePlugin( |
| 43 | + (id) => clientEntryFileSet.add(id), |
| 44 | + (id) => serverEntryFileSet.add(id) |
| 45 | + ), |
| 46 | + ], |
| 47 | + // ssr: { |
| 48 | + // // FIXME Without this, waku/router isn't considered to have client |
| 49 | + // // entries, and "No client entry" error occurs. |
| 50 | + // // Unless we fix this, RSC-capable packages aren't supported. |
| 51 | + // // This also seems to cause problems with pnpm. |
| 52 | + // // noExternal: ['@redwoodjs/web', '@redwoodjs/router'], |
| 53 | + // }, |
| 54 | + build: { |
| 55 | + write: false, |
| 56 | + ssr: true, |
| 57 | + rollupOptions: { |
| 58 | + input: { |
| 59 | + // entries: rwPaths.web.entryServer, |
| 60 | + entries: path.join(rwPaths.web.src, 'entries.ts'), |
| 61 | + }, |
| 62 | + }, |
| 63 | + }, |
| 64 | + }) |
| 65 | + |
| 66 | + const clientEntryFiles = Object.fromEntries( |
| 67 | + Array.from(clientEntryFileSet).map((filename, i) => [`rsc${i}`, filename]) |
| 68 | + ) |
| 69 | + const serverEntryFiles = Object.fromEntries( |
| 70 | + Array.from(serverEntryFileSet).map((filename, i) => [`rsf${i}`, filename]) |
| 71 | + ) |
| 72 | + |
| 73 | + console.log('clientEntryFileSet', Array.from(clientEntryFileSet)) |
| 74 | + console.log('serverEntryFileSet', Array.from(serverEntryFileSet)) |
| 75 | + console.log('clientEntryFiles', clientEntryFiles) |
| 76 | + console.log('serverEntryFiles', serverEntryFiles) |
| 77 | + |
| 78 | + const clientEntryPath = rwPaths.web.entryClient |
| 79 | + |
| 80 | + if (!clientEntryPath) { |
| 81 | + throw new Error( |
| 82 | + 'Vite client entry point not found. Please check that your project ' + |
| 83 | + 'has an entry.client.{jsx,tsx} file in the web/src directory.' |
| 84 | + ) |
| 85 | + } |
| 86 | + |
| 87 | + const clientBuildOutput = await viteBuild({ |
| 88 | + // ...configFileConfig, |
| 89 | + root: rwPaths.web.src, |
| 90 | + plugins: [ |
| 91 | + // TODO (RSC) Update index.html to include the entry.client.js script |
| 92 | + // TODO (RSC) Do the above in the exp-rsc setup command |
| 93 | + // { |
| 94 | + // name: 'redwood-plugin-vite', |
| 95 | + |
| 96 | + // // ---------- Bundle injection ---------- |
| 97 | + // // Used by rollup during build to inject the entrypoint |
| 98 | + // // but note index.html does not come through as an id during dev |
| 99 | + // transform: (code: string, id: string) => { |
| 100 | + // if ( |
| 101 | + // existsSync(clientEntryPath) && |
| 102 | + // // TODO (RSC) Is this even needed? We throw if we can't find it above |
| 103 | + // // TODO (RSC) Consider making this async (if we do need it) |
| 104 | + // normalizePath(id) === normalizePath(rwPaths.web.html) |
| 105 | + // ) { |
| 106 | + // const newCode = code.replace( |
| 107 | + // '</head>', |
| 108 | + // '<script type="module" src="entry.client.jsx"></script></head>' |
| 109 | + // ) |
| 110 | + // |
| 111 | + // return { code: newCode, map: null } |
| 112 | + // } else { |
| 113 | + // // Returning null as the map preserves the original sourcemap |
| 114 | + // return { code, map: null } |
| 115 | + // } |
| 116 | + // }, |
| 117 | + // }, |
| 118 | + react(), |
| 119 | + rscIndexPlugin(), |
| 120 | + ], |
| 121 | + build: { |
| 122 | + outDir: rwPaths.web.dist, |
| 123 | + emptyOutDir: true, // Needed because `outDir` is not inside `root` |
| 124 | + // TODO (RSC) Enable this when we switch to a server-first approach |
| 125 | + // emptyOutDir: false, // Already done when building server |
| 126 | + rollupOptions: { |
| 127 | + input: { |
| 128 | + main: rwPaths.web.html, |
| 129 | + ...clientEntryFiles, |
| 130 | + }, |
| 131 | + preserveEntrySignatures: 'exports-only', |
| 132 | + }, |
| 133 | + manifest: 'build-manifest.json', |
| 134 | + }, |
| 135 | + esbuild: { |
| 136 | + logLevel: 'debug', |
| 137 | + }, |
| 138 | + }) |
| 139 | + |
| 140 | + if (!('output' in clientBuildOutput)) { |
| 141 | + throw new Error('Unexpected vite client build output') |
| 142 | + } |
| 143 | + |
| 144 | + const serverBuildOutput = await serverBuild( |
| 145 | + // rwPaths.web.entryServer, |
| 146 | + path.join(rwPaths.web.src, 'entries.ts'), |
| 147 | + clientEntryFiles, |
| 148 | + serverEntryFiles, |
| 149 | + {} |
| 150 | + ) |
| 151 | + |
| 152 | + const clientEntries: Record<string, string> = {} |
| 153 | + for (const item of clientBuildOutput.output) { |
| 154 | + const { name, fileName } = item |
| 155 | + const entryFile = |
| 156 | + name && |
| 157 | + serverBuildOutput.output.find( |
| 158 | + (item) => |
| 159 | + 'moduleIds' in item && |
| 160 | + item.moduleIds.includes(clientEntryFiles[name] as string) |
| 161 | + )?.fileName |
| 162 | + if (entryFile) { |
| 163 | + clientEntries[entryFile] = fileName |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + console.log('clientEntries', clientEntries) |
| 168 | + |
| 169 | + await fs.appendFile( |
| 170 | + path.join(rwPaths.web.distServer, 'entries.js'), |
| 171 | + `export const clientEntries=${JSON.stringify(clientEntries)};` |
| 172 | + ) |
| 173 | + |
| 174 | + // // Step 1A: Generate the client bundle |
| 175 | + // await buildWeb({ verbose }) |
| 176 | + |
| 177 | + // const rollupInput = { |
| 178 | + // entries: rwPaths.web.entryServer, |
| 179 | + // ...clientEntryFiles, |
| 180 | + // ...serverEntryFiles, |
| 181 | + // } |
| 182 | + |
| 183 | + // Step 1B: Generate the server output |
| 184 | + // await build({ |
| 185 | + // // TODO (RSC) I had this marked as 'FIXME'. I guess I just need to make |
| 186 | + // // sure we still include it, or at least make it possible for users to pass |
| 187 | + // // in their own config |
| 188 | + // // configFile: viteConfig, |
| 189 | + // ssr: { |
| 190 | + // noExternal: Array.from(clientEntryFileSet).map( |
| 191 | + // // TODO (RSC) I think the comment below is from waku. We don't care |
| 192 | + // // about pnpm, do we? Does it also affect yarn? |
| 193 | + // // FIXME this might not work with pnpm |
| 194 | + // // TODO (RSC) No idea what's going on here |
| 195 | + // (filename) => { |
| 196 | + // const nodeModulesPath = path.join(rwPaths.base, 'node_modules') |
| 197 | + // console.log('nodeModulesPath', nodeModulesPath) |
| 198 | + // const relativePath = path.relative(nodeModulesPath, filename) |
| 199 | + // console.log('relativePath', relativePath) |
| 200 | + // console.log('first split', relativePath.split('/')[0]) |
| 201 | + |
| 202 | + // return relativePath.split('/')[0] |
| 203 | + // } |
| 204 | + // ), |
| 205 | + // }, |
| 206 | + // build: { |
| 207 | + // // Because we configure the root to be web/src, we need to go up one level |
| 208 | + // outDir: rwPaths.web.distServer, |
| 209 | + // // TODO (RSC) Maybe we should re-enable this. I can't remember anymore) |
| 210 | + // // What does 'ssr' even mean? |
| 211 | + // // ssr: rwPaths.web.entryServer, |
| 212 | + // rollupOptions: { |
| 213 | + // input: { |
| 214 | + // // TODO (RSC) entries: rwPaths.web.entryServer, |
| 215 | + // ...clientEntryFiles, |
| 216 | + // ...serverEntryFiles, |
| 217 | + // }, |
| 218 | + // output: { |
| 219 | + // banner: (chunk) => { |
| 220 | + // console.log('chunk', chunk) |
| 221 | + |
| 222 | + // // HACK to bring directives to the front |
| 223 | + // let code = '' |
| 224 | + |
| 225 | + // if (chunk.moduleIds.some((id) => clientEntryFileSet.has(id))) { |
| 226 | + // code += '"use client";' |
| 227 | + // } |
| 228 | + |
| 229 | + // if (chunk.moduleIds.some((id) => serverEntryFileSet.has(id))) { |
| 230 | + // code += '"use server";' |
| 231 | + // } |
| 232 | + |
| 233 | + // console.log('code', code) |
| 234 | + // return code |
| 235 | + // }, |
| 236 | + // entryFileNames: (chunkInfo) => { |
| 237 | + // console.log('chunkInfo', chunkInfo) |
| 238 | + |
| 239 | + // // TODO (RSC) Don't hardcode 'entry.server' |
| 240 | + // if (chunkInfo.name === 'entry.server') { |
| 241 | + // return '[name].js' |
| 242 | + // } |
| 243 | + |
| 244 | + // return 'assets/[name].js' |
| 245 | + // }, |
| 246 | + // }, |
| 247 | + // }, |
| 248 | + // }, |
| 249 | + // envFile: false, |
| 250 | + // logLevel: verbose ? 'info' : 'warn', |
| 251 | + // }) |
| 252 | + |
| 253 | + // Step 3: Generate route-manifest.json |
| 254 | + |
| 255 | + // TODO When https://github.com/tc39/proposal-import-attributes and |
| 256 | + // https://github.com/microsoft/TypeScript/issues/53656 have both landed we |
| 257 | + // should try to do this instead: |
| 258 | + // const clientBuildManifest: ViteBuildManifest = await import( |
| 259 | + // path.join(getPaths().web.dist, 'build-manifest.json'), |
| 260 | + // { with: { type: 'json' } } |
| 261 | + // ) |
| 262 | + // NOTES: |
| 263 | + // * There's a related babel plugin here |
| 264 | + // https://babeljs.io/docs/babel-plugin-syntax-import-attributes |
| 265 | + // * Included in `preset-env` if you set `shippedProposals: true` |
| 266 | + // * We had this before, but with `assert` instead of `with`. We really |
| 267 | + // should be using `with`. See motivation in issues linked above. |
| 268 | + // * With `assert` and `@babel/plugin-syntax-import-assertions` the |
| 269 | + // code compiled and ran properly, but Jest tests failed, complaining |
| 270 | + // about the syntax. |
| 271 | + const manifestPath = path.join(getPaths().web.dist, 'build-manifest.json') |
| 272 | + const buildManifestStr = await fs.readFile(manifestPath, 'utf-8') |
| 273 | + const clientBuildManifest: ViteBuildManifest = JSON.parse(buildManifestStr) |
| 274 | + |
| 275 | + // TODO (RSC) We don't have support for a router yet, so skip all routes |
| 276 | + const routesList = [] as RouteSpec[] // getProjectRoutes() |
| 277 | + |
| 278 | + // This is all a no-op for now |
| 279 | + const routeManifest = routesList.reduce<RWRouteManifest>((acc, route) => { |
| 280 | + acc[route.path] = { |
| 281 | + name: route.name, |
| 282 | + bundle: route.relativeFilePath |
| 283 | + ? clientBuildManifest[route.relativeFilePath].file |
| 284 | + : null, |
| 285 | + matchRegexString: route.matchRegexString, |
| 286 | + // NOTE this is the path definition, not the actual path |
| 287 | + // E.g. /blog/post/{id:Int} |
| 288 | + pathDefinition: route.path, |
| 289 | + hasParams: route.hasParams, |
| 290 | + routeHooks: FIXME_constructRouteHookPath(route.routeHooks), |
| 291 | + redirect: route.redirect |
| 292 | + ? { |
| 293 | + to: route.redirect?.to, |
| 294 | + permanent: false, |
| 295 | + } |
| 296 | + : null, |
| 297 | + renderMode: route.renderMode, |
| 298 | + } |
| 299 | + |
| 300 | + return acc |
| 301 | + }, {}) |
| 302 | + |
| 303 | + await fs.writeFile(rwPaths.web.routeManifest, JSON.stringify(routeManifest)) |
| 304 | +} |
| 305 | + |
| 306 | +// TODO (STREAMING) Hacky work around because when you don't have a App.routeHook, esbuild doesn't create |
| 307 | +// the pages folder in the dist/server/routeHooks directory. |
| 308 | +// @MARK need to change to .mjs here if we use esm |
| 309 | +const FIXME_constructRouteHookPath = (rhSrcPath: string | null | undefined) => { |
| 310 | + const rwPaths = getPaths() |
| 311 | + if (!rhSrcPath) { |
| 312 | + return null |
| 313 | + } |
| 314 | + |
| 315 | + if (getAppRouteHook()) { |
| 316 | + return path.relative(rwPaths.web.src, rhSrcPath).replace('.ts', '.js') |
| 317 | + } else { |
| 318 | + return path |
| 319 | + .relative(path.join(rwPaths.web.src, 'pages'), rhSrcPath) |
| 320 | + .replace('.ts', '.js') |
| 321 | + } |
| 322 | +} |
| 323 | + |
| 324 | +if (require.main === module) { |
| 325 | + const verbose = process.argv.includes('--verbose') |
| 326 | + buildFeServer({ verbose }) |
| 327 | +} |
0 commit comments