diff --git a/packages/remix-dev/__tests__/build-test.ts b/packages/remix-dev/__tests__/build-test.ts deleted file mode 100644 index 69595bff34e..00000000000 --- a/packages/remix-dev/__tests__/build-test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import path from "path"; -import type { RollupOutput } from "rollup"; - -import { BuildMode, BuildTarget } from "../build"; -import type { BuildOptions } from "../compiler"; -import { build, generate } from "../compiler"; -import type { RemixConfig } from "../config"; -import { readConfig } from "../config"; - -const remixRoot = path.resolve(__dirname, "../../../fixtures/gists-app"); - -async function generateBuild(config: RemixConfig, options: BuildOptions) { - return await generate(await build(config, options)); -} - -function getFilenames(output: RollupOutput) { - return output.output.map((item) => item.fileName).sort(); -} - -describe.skip("building", () => { - // describe("building", () => { - let config: RemixConfig; - beforeAll(async () => { - config = await readConfig(remixRoot); - }); - - beforeEach(() => { - jest.setTimeout(20000); - }); - - describe("the development server build", () => { - it("generates the correct bundles", async () => { - let output = await generateBuild(config, { - mode: BuildMode.Development, - target: BuildTarget.Server, - }); - - expect(getFilenames(output)).toMatchInlineSnapshot(` - Array [ - "_shared/Shared-072c977d.js", - "_shared/_rollupPluginBabelHelpers-8a275fd9.js", - "entry.server.js", - "index.js", - "pages/one.js", - "pages/two.js", - "root.js", - "routes/404.js", - "routes/gists.js", - "routes/gists.mine.js", - "routes/gists/$username.js", - "routes/gists/index.js", - "routes/index.js", - "routes/links.js", - "routes/loader-errors.js", - "routes/loader-errors/nested.js", - "routes/methods.js", - "routes/page/four.js", - "routes/page/three.js", - "routes/prefs.js", - "routes/render-errors.js", - "routes/render-errors/nested.js", - ] - `); - }); - }); - - describe("the production server build", () => { - it("generates the correct bundles", async () => { - let output = await generateBuild(config, { - mode: BuildMode.Production, - target: BuildTarget.Server, - }); - - expect(getFilenames(output)).toMatchInlineSnapshot(` - Array [ - "_shared/Shared-072c977d.js", - "_shared/_rollupPluginBabelHelpers-8a275fd9.js", - "entry.server.js", - "index.js", - "pages/one.js", - "pages/two.js", - "root.js", - "routes/404.js", - "routes/gists.js", - "routes/gists.mine.js", - "routes/gists/$username.js", - "routes/gists/index.js", - "routes/index.js", - "routes/links.js", - "routes/loader-errors.js", - "routes/loader-errors/nested.js", - "routes/methods.js", - "routes/page/four.js", - "routes/page/three.js", - "routes/prefs.js", - "routes/render-errors.js", - "routes/render-errors/nested.js", - ] - `); - }); - }); - - describe("the development browser build", () => { - it("generates the correct bundles", async () => { - let output = await generateBuild(config, { - mode: BuildMode.Development, - target: BuildTarget.Browser, - }); - - expect(getFilenames(output)).toMatchInlineSnapshot(` - Array [ - "_shared/Shared-7d084ccf.js", - "_shared/__babel/runtime-88c72f87.js", - "_shared/__mdx-js/react-4b004046.js", - "_shared/__remix-run/react-cf018015.js", - "_shared/_rollupPluginBabelHelpers-bfa6c712.js", - "_shared/history-7c196d23.js", - "_shared/object-assign-510802f4.js", - "_shared/prop-types-1122a697.js", - "_shared/react-a3c235ca.js", - "_shared/react-dom-ec89bb6e.js", - "_shared/react-is-6b44b080.js", - "_shared/react-router-dom-ef82d700.js", - "_shared/react-router-e7697632.js", - "_shared/scheduler-8fd1645e.js", - "components/guitar-1080x720-a9c95518.jpg", - "components/guitar-2048x1365-f42efd6b.jpg", - "components/guitar-500x333-3a1a0bd1.jpg", - "components/guitar-500x500-c6f1ab94.jpg", - "components/guitar-600x600-b329e428.jpg", - "components/guitar-720x480-729becce.jpg", - "entry.client.js", - "manifest-8c53378e.js", - "pages/one.js", - "pages/two.js", - "root.js", - "routes/404.js", - "routes/gists.js", - "routes/gists.mine.js", - "routes/gists/$username.js", - "routes/gists/index.js", - "routes/index.js", - "routes/links.js", - "routes/loader-errors.js", - "routes/loader-errors/nested.js", - "routes/methods.js", - "routes/page/four.js", - "routes/page/three.js", - "routes/prefs.js", - "routes/render-errors.js", - "routes/render-errors/nested.js", - "styles/app-72f634dc.css", - "styles/gists-d7ad5f49.css", - "styles/methods-d182a270.css", - "styles/redText-2b391c21.css", - ] - `); - }); - }); - - describe("the production browser build", () => { - it("generates the correct bundles", async () => { - let output = await generateBuild(config, { - mode: BuildMode.Production, - target: BuildTarget.Browser, - }); - - expect(getFilenames(output)).toMatchInlineSnapshot(` - Array [ - "_shared/Shared-bae6070c.js", - "_shared/__babel/runtime-88c72f87.js", - "_shared/__mdx-js/react-a9edf40b.js", - "_shared/__remix-run/react-991ebd19.js", - "_shared/_rollupPluginBabelHelpers-bfa6c712.js", - "_shared/history-e6417d88.js", - "_shared/object-assign-510802f4.js", - "_shared/prop-types-939a16b3.js", - "_shared/react-dom-9dcf9947.js", - "_shared/react-e3656f88.js", - "_shared/react-is-5765fb91.js", - "_shared/react-router-dom-baf54395.js", - "_shared/react-router-fc62a14c.js", - "_shared/scheduler-f1282356.js", - "components/guitar-1080x720-a9c95518.jpg", - "components/guitar-2048x1365-f42efd6b.jpg", - "components/guitar-500x333-3a1a0bd1.jpg", - "components/guitar-500x500-c6f1ab94.jpg", - "components/guitar-600x600-b329e428.jpg", - "components/guitar-720x480-729becce.jpg", - "entry.client-b7de4be6.js", - "manifest-943fff78.js", - "pages/one-829d2fc6.js", - "pages/two-31b88726.js", - "root-de6ed2a5.js", - "routes/404-a4edec5f.js", - "routes/gists-236207fe.js", - "routes/gists.mine-ac017552.js", - "routes/gists/$username-c4819bb8.js", - "routes/gists/index-0f39313f.js", - "routes/index-eb238abf.js", - "routes/links-50cd630a.js", - "routes/loader-errors-e4502176.js", - "routes/loader-errors/nested-741a07ef.js", - "routes/methods-8241c6fa.js", - "routes/page/four-efa66f69.js", - "routes/page/three-dfbf7520.js", - "routes/prefs-12bae83f.js", - "routes/render-errors-cb72f859.js", - "routes/render-errors/nested-ef1c619f.js", - "styles/app-72f634dc.css", - "styles/gists-d7ad5f49.css", - "styles/methods-d182a270.css", - "styles/redText-2b391c21.css", - ] - `); - }); - }); -}); diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts index 90ed4f9b70a..83a1c2de594 100644 --- a/packages/remix-dev/__tests__/cli-test.ts +++ b/packages/remix-dev/__tests__/cli-test.ts @@ -28,7 +28,7 @@ afterAll(async () => { async function execRemix( args: Array, - options: Parameters[2] = {} + options: Exclude[2], null | undefined> = {} ) { if (process.platform === "win32") { let cp = childProcess.spawnSync( @@ -275,7 +275,7 @@ function defer() { return rej(reason); }; }); - return { promise, resolve, reject, state }; + return { promise, resolve: resolve!, reject: reject!, state }; } async function interactWithShell( diff --git a/packages/remix-dev/__tests__/routesConvention-test.ts b/packages/remix-dev/__tests__/routesConvention-test.ts index 599dba44833..1497246ce16 100644 --- a/packages/remix-dev/__tests__/routesConvention-test.ts +++ b/packages/remix-dev/__tests__/routesConvention-test.ts @@ -2,7 +2,7 @@ import { createRoutePath } from "../config/routesConvention"; describe("createRoutePath", () => { describe("creates proper route paths", () => { - let tests = [ + let tests: [string, string | undefined][] = [ ["routes/$", "routes/*"], ["routes/sub/$", "routes/sub/*"], ["routes.sub/$", "routes/sub/*"], diff --git a/packages/remix-dev/build.ts b/packages/remix-dev/build.ts deleted file mode 100644 index a598949dda7..00000000000 --- a/packages/remix-dev/build.ts +++ /dev/null @@ -1,33 +0,0 @@ -export enum BuildMode { - Development = "development", - Production = "production", - Test = "test", -} - -export function isBuildMode(mode: any): mode is BuildMode { - return ( - mode === BuildMode.Development || - mode === BuildMode.Production || - mode === BuildMode.Test - ); -} - -export enum BuildTarget { - Browser = "browser", // TODO: remove - Server = "server", // TODO: remove - CloudflareWorkers = "cloudflare-workers", - Node14 = "node14", -} - -export function isBuildTarget(target: any): target is BuildTarget { - return ( - target === BuildTarget.Browser || - target === BuildTarget.Server || - target === BuildTarget.Node14 - ); -} - -export interface BuildOptions { - mode: BuildMode; - target: BuildTarget; -} diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts index 0aab063d717..8cc345f7e04 100644 --- a/packages/remix-dev/cli/commands.ts +++ b/packages/remix-dev/cli/commands.ts @@ -12,7 +12,6 @@ import type { createApp as createAppType } from "@remix-run/serve"; import getPort, { makeRange } from "get-port"; import * as esbuild from "esbuild"; -import { BuildMode, isBuildMode } from "../build"; import * as colors from "../colors"; import * as compiler from "../compiler"; import type { RemixConfig } from "../config"; @@ -148,11 +147,11 @@ export async function build( modeArg?: string, sourcemap: boolean = false ): Promise { - let mode = isBuildMode(modeArg) ? modeArg : BuildMode.Production; + let mode = compiler.parseMode(modeArg ?? "", "production"); log(`Building Remix app in ${mode} mode...`); - if (modeArg === BuildMode.Production && sourcemap) { + if (modeArg === "production" && sourcemap) { console.warn( "\n⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️" ); @@ -171,10 +170,10 @@ export async function build( let config = await readConfig(remixRoot); fse.emptyDirSync(config.assetsBuildDirectory); await compiler.build(config, { - mode: mode, + mode, sourcemap, - onBuildFailure: (failure: compiler.BuildError) => { - compiler.formatBuildFailure(failure); + onCompileFailure: (failure) => { + compiler.logCompileFailure(failure); throw Error(); }, }); @@ -193,7 +192,7 @@ export async function watch( callbacks?: WatchCallbacks ): Promise { let { onInitialBuild, onRebuildStart } = callbacks || {}; - let mode = isBuildMode(modeArg) ? modeArg : BuildMode.Development; + let mode = compiler.parseMode(modeArg ?? "", "development"); console.log(`Watching Remix app in ${mode} mode...`); let start = Date.now(); @@ -278,7 +277,7 @@ export async function dev( } let config = await readConfig(remixRoot); - let mode = isBuildMode(modeArg) ? modeArg : BuildMode.Development; + let mode = compiler.parseMode(modeArg ?? "", "development"); await loadEnv(config.rootDirectory); diff --git a/packages/remix-dev/compiler.ts b/packages/remix-dev/compiler.ts deleted file mode 100644 index ce1a2ca14ea..00000000000 --- a/packages/remix-dev/compiler.ts +++ /dev/null @@ -1,540 +0,0 @@ -import * as path from "path"; -import { builtinModules as nodeBuiltins } from "module"; -import * as esbuild from "esbuild"; -import * as fse from "fs-extra"; -import debounce from "lodash.debounce"; -import chokidar from "chokidar"; -import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill"; -import { pnpPlugin as yarnPnpPlugin } from "@yarnpkg/esbuild-plugin-pnp"; - -import { BuildMode, BuildTarget } from "./build"; -import type { RemixConfig } from "./config"; -import { readConfig } from "./config"; -import { warnOnce } from "./compiler/warnings"; -import type { AssetsManifest } from "./compiler/assets"; -import { createAssetsManifest } from "./compiler/assets"; -import { getAppDependencies } from "./compiler/dependencies"; -import { loaders } from "./compiler/loaders"; -import { browserRouteModulesPlugin } from "./compiler/plugins/browserRouteModulesPlugin"; -import { emptyModulesPlugin } from "./compiler/plugins/emptyModulesPlugin"; -import { mdxPlugin } from "./compiler/plugins/mdx"; -import type { AssetsManifestPromiseRef } from "./compiler/plugins/serverAssetsManifestPlugin"; -import { serverAssetsManifestPlugin } from "./compiler/plugins/serverAssetsManifestPlugin"; -import { serverBareModulesPlugin } from "./compiler/plugins/serverBareModulesPlugin"; -import { serverEntryModulePlugin } from "./compiler/plugins/serverEntryModulePlugin"; -import { serverRouteModulesPlugin } from "./compiler/plugins/serverRouteModulesPlugin"; -import { cssFilePlugin } from "./compiler/plugins/cssFilePlugin"; -import { writeFileSafe } from "./compiler/utils/fs"; -import { urlImportsPlugin } from "./compiler/plugins/urlImportsPlugin"; - -export interface BuildConfig { - mode: BuildMode; - target: BuildTarget; - sourcemap: boolean; -} - -function defaultWarningHandler(message: string, key: string) { - warnOnce(message, key); -} - -export type BuildError = Error | esbuild.BuildFailure; -function defaultBuildFailureHandler(failure: BuildError) { - formatBuildFailure(failure); -} - -export function formatBuildFailure(failure: BuildError) { - if ("warnings" in failure || "errors" in failure) { - if (failure.warnings) { - let messages = esbuild.formatMessagesSync(failure.warnings, { - kind: "warning", - color: true, - }); - console.warn(...messages); - } - - if (failure.errors) { - let messages = esbuild.formatMessagesSync(failure.errors, { - kind: "error", - color: true, - }); - console.error(...messages); - } - } - - console.error(failure?.message || "An unknown build error occurred"); -} - -interface BuildOptions extends Partial { - onWarning?(message: string, key: string): void; - onBuildFailure?(failure: Error | esbuild.BuildFailure): void; -} - -export async function build( - config: RemixConfig, - { - mode = BuildMode.Production, - target = BuildTarget.Node14, - sourcemap = false, - onWarning = defaultWarningHandler, - onBuildFailure = defaultBuildFailureHandler, - }: BuildOptions = {} -): Promise { - let assetsManifestPromiseRef: AssetsManifestPromiseRef = {}; - - await buildEverything(config, assetsManifestPromiseRef, { - mode, - target, - sourcemap, - onWarning, - onBuildFailure, - }); -} - -interface WatchOptions extends BuildOptions { - onRebuildStart?(): void; - onRebuildFinish?(): void; - onFileCreated?(file: string): void; - onFileChanged?(file: string): void; - onFileDeleted?(file: string): void; - onInitialBuild?(): void; -} - -export async function watch( - config: RemixConfig, - { - mode = BuildMode.Development, - target = BuildTarget.Node14, - sourcemap = true, - onWarning = defaultWarningHandler, - onBuildFailure = defaultBuildFailureHandler, - onRebuildStart, - onRebuildFinish, - onFileCreated, - onFileChanged, - onFileDeleted, - onInitialBuild, - }: WatchOptions = {} -): Promise<() => Promise> { - let options = { - mode, - target, - sourcemap, - onBuildFailure, - onWarning, - incremental: true, - }; - - let assetsManifestPromiseRef: AssetsManifestPromiseRef = {}; - let [browserBuild, serverBuild] = await buildEverything( - config, - assetsManifestPromiseRef, - options - ); - - let initialBuildComplete = !!browserBuild && !!serverBuild; - if (initialBuildComplete) { - onInitialBuild?.(); - } - - function disposeBuilders() { - browserBuild?.rebuild?.dispose(); - serverBuild?.rebuild?.dispose(); - browserBuild = undefined; - serverBuild = undefined; - } - - let restartBuilders = debounce(async (newConfig?: RemixConfig) => { - disposeBuilders(); - try { - newConfig = await readConfig(config.rootDirectory); - } catch (error) { - onBuildFailure(error as Error); - return; - } - - config = newConfig; - if (onRebuildStart) onRebuildStart(); - let builders = await buildEverything( - config, - assetsManifestPromiseRef, - options - ); - if (onRebuildFinish) onRebuildFinish(); - browserBuild = builders[0]; - serverBuild = builders[1]; - }, 500); - - let rebuildEverything = debounce(async () => { - if (onRebuildStart) onRebuildStart(); - - if (!browserBuild?.rebuild || !serverBuild?.rebuild) { - disposeBuilders(); - - try { - [browserBuild, serverBuild] = await buildEverything( - config, - assetsManifestPromiseRef, - options - ); - - if (!initialBuildComplete) { - initialBuildComplete = !!browserBuild && !!serverBuild; - if (initialBuildComplete) { - onInitialBuild?.(); - } - } - if (onRebuildFinish) onRebuildFinish(); - } catch (err: any) { - onBuildFailure(err); - } - return; - } - - // If we get here and can't call rebuild something went wrong and we - // should probably blow as it's not really recoverable. - let browserBuildPromise = browserBuild.rebuild(); - let assetsManifestPromise = browserBuildPromise.then((build) => - generateAssetsManifest(config, build.metafile!) - ); - - // Assign the assetsManifestPromise to a ref so the server build can await - // it when loading the @remix-run/dev/assets-manifest virtual module. - assetsManifestPromiseRef.current = assetsManifestPromise; - - await Promise.all([ - assetsManifestPromise, - serverBuild - .rebuild() - .then((build) => writeServerBuildResult(config, build.outputFiles!)), - ]).catch((err) => { - disposeBuilders(); - onBuildFailure(err); - }); - if (onRebuildFinish) onRebuildFinish(); - }, 100); - - let toWatch = [config.appDirectory]; - if (config.serverEntryPoint) { - toWatch.push(config.serverEntryPoint); - } - - config.watchPaths?.forEach((watchPath) => { - toWatch.push(watchPath); - }); - - let watcher = chokidar - .watch(toWatch, { - persistent: true, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 100, - pollInterval: 100, - }, - }) - .on("error", (error) => console.error(error)) - .on("change", async (file) => { - if (onFileChanged) onFileChanged(file); - await rebuildEverything(); - }) - .on("add", async (file) => { - if (onFileCreated) onFileCreated(file); - let newConfig: RemixConfig; - try { - newConfig = await readConfig(config.rootDirectory); - } catch (error) { - onBuildFailure(error as Error); - return; - } - - if (isEntryPoint(newConfig, file)) { - await restartBuilders(newConfig); - } else { - await rebuildEverything(); - } - }) - .on("unlink", async (file) => { - if (onFileDeleted) onFileDeleted(file); - if (isEntryPoint(config, file)) { - await restartBuilders(); - } else { - await rebuildEverything(); - } - }); - - return async () => { - await watcher.close().catch(() => {}); - disposeBuilders(); - }; -} - -function isEntryPoint(config: RemixConfig, file: string) { - let appFile = path.relative(config.appDirectory, file); - - if ( - appFile === config.entryClientFile || - appFile === config.entryServerFile - ) { - return true; - } - for (let key in config.routes) { - if (appFile === config.routes[key].file) return true; - } - - return false; -} - -/////////////////////////////////////////////////////////////////////////////// - -async function buildEverything( - config: RemixConfig, - assetsManifestPromiseRef: AssetsManifestPromiseRef, - options: Required & { incremental?: boolean } -): Promise<(esbuild.BuildResult | undefined)[]> { - try { - let browserBuildPromise = createBrowserBuild(config, options); - let assetsManifestPromise = browserBuildPromise.then((build) => - generateAssetsManifest(config, build.metafile!) - ); - - // Assign the assetsManifestPromise to a ref so the server build can await - // it when loading the @remix-run/dev/assets-manifest virtual module. - assetsManifestPromiseRef.current = assetsManifestPromise; - - let serverBuildPromise = createServerBuild( - config, - options, - assetsManifestPromiseRef - ); - - return await Promise.all([ - assetsManifestPromise.then(() => browserBuildPromise), - serverBuildPromise, - ]); - } catch (err) { - options.onBuildFailure(err as Error); - return [undefined, undefined]; - } -} - -async function createBrowserBuild( - config: RemixConfig, - options: BuildOptions & { incremental?: boolean } -): Promise { - // For the browser build, exclude node built-ins that don't have a - // browser-safe alternative installed in node_modules. Nothing should - // *actually* be external in the browser build (we want to bundle all deps) so - // this is really just making sure we don't accidentally have any dependencies - // on node built-ins in browser bundles. - let dependencies = Object.keys(getAppDependencies(config)); - let externals = nodeBuiltins.filter((mod) => !dependencies.includes(mod)); - let fakeBuiltins = nodeBuiltins.filter((mod) => dependencies.includes(mod)); - - if (fakeBuiltins.length > 0) { - throw new Error( - `It appears you're using a module that is built in to node, but you installed it as a dependency which could cause problems. Please remove ${fakeBuiltins.join( - ", " - )} before continuing.` - ); - } - - let entryPoints: esbuild.BuildOptions["entryPoints"] = { - "entry.client": path.resolve(config.appDirectory, config.entryClientFile), - }; - for (let id of Object.keys(config.routes)) { - // All route entry points are virtual modules that will be loaded by the - // browserEntryPointsPlugin. This allows us to tree-shake server-only code - // that we don't want to run in the browser (i.e. action & loader). - entryPoints[id] = config.routes[id].file + "?browser"; - } - - let plugins = [ - cssFilePlugin(options), - urlImportsPlugin(), - mdxPlugin(config), - browserRouteModulesPlugin(config, /\?browser$/), - emptyModulesPlugin(config, /\.server(\.[jt]sx?)?$/), - NodeModulesPolyfillPlugin(), - yarnPnpPlugin(), - ]; - - return esbuild.build({ - entryPoints, - outdir: config.assetsBuildDirectory, - platform: "browser", - format: "esm", - external: externals, - loader: loaders, - bundle: true, - logLevel: "silent", - splitting: true, - sourcemap: options.sourcemap, - metafile: true, - incremental: options.incremental, - // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to - // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted - // behavior can only be avoided by creating an empty tsconfig file in the root directory. - tsconfig: config.tsconfigPath, - mainFields: ["browser", "module", "main"], - treeShaking: true, - minify: options.mode === BuildMode.Production, - entryNames: "[dir]/[name]-[hash]", - chunkNames: "_shared/[name]-[hash]", - assetNames: "_assets/[name]-[hash]", - publicPath: config.publicPath, - define: { - "process.env.NODE_ENV": JSON.stringify(options.mode), - "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify( - config.devServerPort - ), - }, - jsx: "automatic", - jsxDev: options.mode !== BuildMode.Production, - plugins, - }); -} - -function createServerBuild( - config: RemixConfig, - options: Required & { incremental?: boolean }, - assetsManifestPromiseRef: AssetsManifestPromiseRef -): Promise { - let stdin: esbuild.StdinOptions | undefined; - let entryPoints: string[] | undefined; - - if (config.serverEntryPoint) { - entryPoints = [config.serverEntryPoint]; - } else { - stdin = { - contents: config.serverBuildTargetEntryModule, - resolveDir: config.rootDirectory, - loader: "ts", - }; - } - - let isCloudflareRuntime = ["cloudflare-pages", "cloudflare-workers"].includes( - config.serverBuildTarget ?? "" - ); - let isDenoRuntime = config.serverBuildTarget === "deno"; - - let plugins: esbuild.Plugin[] = [ - cssFilePlugin(options), - urlImportsPlugin(), - mdxPlugin(config), - emptyModulesPlugin(config, /\.client(\.[jt]sx?)?$/), - serverRouteModulesPlugin(config), - serverEntryModulePlugin(config), - serverAssetsManifestPlugin(assetsManifestPromiseRef), - serverBareModulesPlugin(config, options.onWarning), - yarnPnpPlugin(), - ]; - - if (config.serverPlatform !== "node") { - plugins.unshift(NodeModulesPolyfillPlugin()); - } - - return esbuild - .build({ - absWorkingDir: config.rootDirectory, - stdin, - entryPoints, - outfile: config.serverBuildPath, - write: false, - conditions: isCloudflareRuntime - ? ["worker"] - : isDenoRuntime - ? ["deno", "worker"] - : undefined, - platform: config.serverPlatform, - format: config.serverModuleFormat, - treeShaking: true, - // The type of dead code elimination we want to do depends on the - // minify syntax property: https://github.com/evanw/esbuild/issues/672#issuecomment-1029682369 - // Dev builds are leaving code that should be optimized away in the - // bundle causing server / testing code to be shipped to the browser. - // These are properly optimized away in prod builds today, and this - // PR makes dev mode behave closer to production in terms of dead - // code elimination / tree shaking is concerned. - minifySyntax: true, - minify: options.mode === BuildMode.Production && isCloudflareRuntime, - mainFields: isCloudflareRuntime - ? ["browser", "module", "main"] - : config.serverModuleFormat === "esm" - ? ["module", "main"] - : ["main", "module"], - target: options.target, - loader: loaders, - bundle: true, - logLevel: "silent", - // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to - // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted - // behavior can only be avoided by creating an empty tsconfig file in the root directory. - tsconfig: config.tsconfigPath, - incremental: options.incremental, - sourcemap: options.sourcemap, // use linked (true) to fix up .map file - // The server build needs to know how to generate asset URLs for imports - // of CSS and other files. - assetNames: "_assets/[name]-[hash]", - publicPath: config.publicPath, - define: { - "process.env.NODE_ENV": JSON.stringify(options.mode), - "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify( - config.devServerPort - ), - }, - jsx: "automatic", - jsxDev: options.mode !== BuildMode.Production, - plugins, - }) - .then(async (build) => { - await writeServerBuildResult(config, build.outputFiles); - return build; - }); -} - -async function generateAssetsManifest( - config: RemixConfig, - metafile: esbuild.Metafile -): Promise { - let assetsManifest = await createAssetsManifest(config, metafile); - let filename = `manifest-${assetsManifest.version.toUpperCase()}.js`; - - assetsManifest.url = config.publicPath + filename; - - await writeFileSafe( - path.join(config.assetsBuildDirectory, filename), - `window.__remixManifest=${JSON.stringify(assetsManifest)};` - ); - - return assetsManifest; -} - -async function writeServerBuildResult( - config: RemixConfig, - outputFiles: esbuild.OutputFile[] -) { - await fse.ensureDir(path.dirname(config.serverBuildPath)); - - for (let file of outputFiles) { - if (file.path.endsWith(".js")) { - // fix sourceMappingURL to be relative to current path instead of /build - let filename = file.path.substring(file.path.lastIndexOf(path.sep) + 1); - let escapedFilename = filename.replace(/\./g, "\\."); - let pattern = `(//# sourceMappingURL=)(.*)${escapedFilename}`; - let contents = Buffer.from(file.contents).toString("utf-8"); - contents = contents.replace(new RegExp(pattern), `$1${filename}`); - await fse.writeFile(file.path, contents); - } else if (file.path.endsWith(".map")) { - // remove route: prefix from source filenames so breakpoints work - let contents = Buffer.from(file.contents).toString("utf-8"); - contents = contents.replace(/"route:/gm, '"'); - await fse.writeFile(file.path, contents); - } else { - let assetPath = path.join( - config.assetsBuildDirectory, - file.path.replace(path.dirname(config.serverBuildPath), "") - ); - await fse.ensureDir(path.dirname(assetPath)); - await fse.writeFile(assetPath, file.contents); - } - } -} diff --git a/packages/remix-dev/compiler/assets.ts b/packages/remix-dev/compiler/assets.ts index 678a2f93f7d..5bd5b0a9fa7 100644 --- a/packages/remix-dev/compiler/assets.ts +++ b/packages/remix-dev/compiler/assets.ts @@ -3,7 +3,7 @@ import type * as esbuild from "esbuild"; import type { RemixConfig } from "../config"; import invariant from "../invariant"; -import { getRouteModuleExportsCached } from "./routes"; +import { getRouteModuleExports } from "./routeExports"; import { getHash } from "./utils/crypto"; import { createUrl } from "./utils/url"; @@ -87,7 +87,7 @@ export async function createAssetsManifest( ); let route = routesByFile.get(entryPointFile); invariant(route, `Cannot get route for entry point ${output.entryPoint}`); - let sourceExports = await getRouteModuleExportsCached(config, route.id); + let sourceExports = await getRouteModuleExports(config, route.id); routes[route.id] = { id: route.id, parentId: route.parentId, diff --git a/packages/remix-dev/compiler/build.ts b/packages/remix-dev/compiler/build.ts new file mode 100644 index 00000000000..265709181f7 --- /dev/null +++ b/packages/remix-dev/compiler/build.ts @@ -0,0 +1,25 @@ +import { type RemixConfig } from "../config"; +import { warnOnce } from "./warnings"; +import { logCompileFailure } from "./onCompileFailure"; +import { type CompileOptions } from "./options"; +import { compile, createRemixCompiler } from "./remixCompiler"; + +export async function build( + config: RemixConfig, + { + mode = "production", + target = "node14", + sourcemap = false, + onWarning = warnOnce, + onCompileFailure = logCompileFailure, + }: Partial = {} +): Promise { + let compiler = createRemixCompiler(config, { + mode, + target, + sourcemap, + onWarning, + onCompileFailure, + }); + await compile(compiler, { onCompileFailure }); +} diff --git a/packages/remix-dev/compiler/compileBrowser.ts b/packages/remix-dev/compiler/compileBrowser.ts new file mode 100644 index 00000000000..338dd8a7dd8 --- /dev/null +++ b/packages/remix-dev/compiler/compileBrowser.ts @@ -0,0 +1,143 @@ +import * as path from "path"; +import { builtinModules as nodeBuiltins } from "module"; +import * as esbuild from "esbuild"; +import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill"; +import { pnpPlugin as yarnPnpPlugin } from "@yarnpkg/esbuild-plugin-pnp"; + +import { type RemixConfig } from "../config"; +import { createAssetsManifest, type AssetsManifest } from "./assets"; +import { getAppDependencies } from "./dependencies"; +import { loaders } from "./loaders"; +import { type CompileOptions } from "./options"; +import { browserRouteModulesPlugin } from "./plugins/browserRouteModulesPlugin"; +import { cssFilePlugin } from "./plugins/cssFilePlugin"; +import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin"; +import { mdxPlugin } from "./plugins/mdx"; +import { urlImportsPlugin } from "./plugins/urlImportsPlugin"; +import { type WriteChannel } from "./utils/channel"; +import { writeFileSafe } from "./utils/fs"; + +export type BrowserCompiler = { + // produce ./public/build/ + compile: (manifestChannel: WriteChannel) => Promise; + dispose: () => void; +}; + +const getExternals = (remixConfig: RemixConfig): string[] => { + // For the browser build, exclude node built-ins that don't have a + // browser-safe alternative installed in node_modules. Nothing should + // *actually* be external in the browser build (we want to bundle all deps) so + // this is really just making sure we don't accidentally have any dependencies + // on node built-ins in browser bundles. + let dependencies = Object.keys(getAppDependencies(remixConfig)); + let fakeBuiltins = nodeBuiltins.filter((mod) => dependencies.includes(mod)); + + if (fakeBuiltins.length > 0) { + throw new Error( + `It appears you're using a module that is built in to node, but you installed it as a dependency which could cause problems. Please remove ${fakeBuiltins.join( + ", " + )} before continuing.` + ); + } + return nodeBuiltins.filter((mod) => !dependencies.includes(mod)); +}; + +const writeAssetsManifest = async ( + config: RemixConfig, + assetsManifest: AssetsManifest +) => { + let filename = `manifest-${assetsManifest.version.toUpperCase()}.js`; + + assetsManifest.url = config.publicPath + filename; + + await writeFileSafe( + path.join(config.assetsBuildDirectory, filename), + `window.__remixManifest=${JSON.stringify(assetsManifest)};` + ); +}; + +const createEsbuildConfig = ( + config: RemixConfig, + options: CompileOptions +): esbuild.BuildOptions | esbuild.BuildIncremental => { + let entryPoints: esbuild.BuildOptions["entryPoints"] = { + "entry.client": path.resolve(config.appDirectory, config.entryClientFile), + }; + for (let id of Object.keys(config.routes)) { + // All route entry points are virtual modules that will be loaded by the + // browserEntryPointsPlugin. This allows us to tree-shake server-only code + // that we don't want to run in the browser (i.e. action & loader). + entryPoints[id] = config.routes[id].file + "?browser"; + } + + let plugins = [ + cssFilePlugin(options), + urlImportsPlugin(), + mdxPlugin(config), + browserRouteModulesPlugin(config, /\?browser$/), + emptyModulesPlugin(config, /\.server(\.[jt]sx?)?$/), + NodeModulesPolyfillPlugin(), + yarnPnpPlugin(), + ]; + + return { + entryPoints, + outdir: config.assetsBuildDirectory, + platform: "browser", + format: "esm", + external: getExternals(config), + loader: loaders, + bundle: true, + logLevel: "silent", + splitting: true, + sourcemap: options.sourcemap, + // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to + // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted + // behavior can only be avoided by creating an empty tsconfig file in the root directory. + tsconfig: config.tsconfigPath, + mainFields: ["browser", "module", "main"], + treeShaking: true, + minify: options.mode === "production", + entryNames: "[dir]/[name]-[hash]", + chunkNames: "_shared/[name]-[hash]", + assetNames: "_assets/[name]-[hash]", + publicPath: config.publicPath, + define: { + "process.env.NODE_ENV": JSON.stringify(options.mode), + "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify( + config.devServerPort + ), + }, + jsx: "automatic", + jsxDev: options.mode !== "production", + plugins, + }; +}; + +export const createBrowserCompiler = ( + remixConfig: RemixConfig, + options: CompileOptions +): BrowserCompiler => { + let compiler: esbuild.BuildIncremental; + let esbuildConfig = createEsbuildConfig(remixConfig, options); + let compile = async (manifestChannel: WriteChannel) => { + let metafile: esbuild.Metafile; + if (compiler === undefined) { + compiler = await esbuild.build({ + ...esbuildConfig, + metafile: true, + incremental: true, + }); + metafile = compiler.metafile!; + } else { + metafile = (await compiler.rebuild()).metafile!; + } + let manifest = await createAssetsManifest(remixConfig, metafile); + manifestChannel.write(manifest); + await writeAssetsManifest(remixConfig, manifest); + }; + return { + compile, + dispose: () => compiler?.rebuild.dispose(), + }; +}; diff --git a/packages/remix-dev/compiler/compilerServer.ts b/packages/remix-dev/compiler/compilerServer.ts new file mode 100644 index 00000000000..fa5289b61e9 --- /dev/null +++ b/packages/remix-dev/compiler/compilerServer.ts @@ -0,0 +1,169 @@ +import * as path from "path"; +import * as esbuild from "esbuild"; +import * as fse from "fs-extra"; +import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill"; +import { pnpPlugin as yarnPnpPlugin } from "@yarnpkg/esbuild-plugin-pnp"; + +import { type RemixConfig } from "../config"; +import { type AssetsManifest } from "./assets"; +import { loaders } from "./loaders"; +import { type CompileOptions } from "./options"; +import { cssFilePlugin } from "./plugins/cssFilePlugin"; +import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin"; +import { mdxPlugin } from "./plugins/mdx"; +import { serverAssetsManifestPlugin } from "./plugins/serverAssetsManifestPlugin"; +import { serverBareModulesPlugin } from "./plugins/serverBareModulesPlugin"; +import { serverEntryModulePlugin } from "./plugins/serverEntryModulePlugin"; +import { serverRouteModulesPlugin } from "./plugins/serverRouteModulesPlugin"; +import { urlImportsPlugin } from "./plugins/urlImportsPlugin"; +import { type ReadChannel } from "./utils/channel"; + +export type ServerCompiler = { + // produce ./build/index.js + compile: (manifestChannel: ReadChannel) => Promise; + dispose: () => void; +}; + +const createEsbuildConfig = ( + config: RemixConfig, + assetsManifestChannel: ReadChannel, + options: CompileOptions +): esbuild.BuildOptions => { + let stdin: esbuild.StdinOptions | undefined; + let entryPoints: string[] | undefined; + + if (config.serverEntryPoint) { + entryPoints = [config.serverEntryPoint]; + } else { + stdin = { + contents: config.serverBuildTargetEntryModule, + resolveDir: config.rootDirectory, + loader: "ts", + }; + } + + let isCloudflareRuntime = ["cloudflare-pages", "cloudflare-workers"].includes( + config.serverBuildTarget ?? "" + ); + let isDenoRuntime = config.serverBuildTarget === "deno"; + + let plugins: esbuild.Plugin[] = [ + cssFilePlugin(options), + urlImportsPlugin(), + mdxPlugin(config), + emptyModulesPlugin(config, /\.client(\.[jt]sx?)?$/), + serverRouteModulesPlugin(config), + serverEntryModulePlugin(config), + serverAssetsManifestPlugin(assetsManifestChannel.read()), + serverBareModulesPlugin(config, options.onWarning), + yarnPnpPlugin(), + ]; + + if (config.serverPlatform !== "node") { + plugins.unshift(NodeModulesPolyfillPlugin()); + } + + return { + absWorkingDir: config.rootDirectory, + stdin, + entryPoints, + outfile: config.serverBuildPath, + conditions: isCloudflareRuntime + ? ["worker"] + : isDenoRuntime + ? ["deno", "worker"] + : undefined, + platform: config.serverPlatform, + format: config.serverModuleFormat, + treeShaking: true, + // The type of dead code elimination we want to do depends on the + // minify syntax property: https://github.com/evanw/esbuild/issues/672#issuecomment-1029682369 + // Dev builds are leaving code that should be optimized away in the + // bundle causing server / testing code to be shipped to the browser. + // These are properly optimized away in prod builds today, and this + // PR makes dev mode behave closer to production in terms of dead + // code elimination / tree shaking is concerned. + minifySyntax: true, + minify: options.mode === "production" && isCloudflareRuntime, + mainFields: isCloudflareRuntime + ? ["browser", "module", "main"] + : config.serverModuleFormat === "esm" + ? ["module", "main"] + : ["main", "module"], + target: options.target, + loader: loaders, + bundle: true, + logLevel: "silent", + // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to + // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted + // behavior can only be avoided by creating an empty tsconfig file in the root directory. + tsconfig: config.tsconfigPath, + sourcemap: options.sourcemap, // use linked (true) to fix up .map file + // The server build needs to know how to generate asset URLs for imports + // of CSS and other files. + assetNames: "_assets/[name]-[hash]", + publicPath: config.publicPath, + define: { + "process.env.NODE_ENV": JSON.stringify(options.mode), + "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify( + config.devServerPort + ), + }, + jsx: "automatic", + jsxDev: options.mode !== "production", + plugins, + }; +}; + +async function writeServerBuildResult( + config: RemixConfig, + outputFiles: esbuild.OutputFile[] +) { + await fse.ensureDir(path.dirname(config.serverBuildPath)); + + for (let file of outputFiles) { + if (file.path.endsWith(".js")) { + // fix sourceMappingURL to be relative to current path instead of /build + let filename = file.path.substring(file.path.lastIndexOf(path.sep) + 1); + let escapedFilename = filename.replace(/\./g, "\\."); + let pattern = `(//# sourceMappingURL=)(.*)${escapedFilename}`; + let contents = Buffer.from(file.contents).toString("utf-8"); + contents = contents.replace(new RegExp(pattern), `$1${filename}`); + await fse.writeFile(file.path, contents); + } else if (file.path.endsWith(".map")) { + // remove route: prefix from source filenames so breakpoints work + let contents = Buffer.from(file.contents).toString("utf-8"); + contents = contents.replace(/"route:/gm, '"'); + await fse.writeFile(file.path, contents); + } else { + let assetPath = path.join( + config.assetsBuildDirectory, + file.path.replace(path.dirname(config.serverBuildPath), "") + ); + await fse.ensureDir(path.dirname(assetPath)); + await fse.writeFile(assetPath, file.contents); + } + } +} + +export const createServerCompiler = ( + remixConfig: RemixConfig, + options: CompileOptions +): ServerCompiler => { + let compile = async (manifestChannel: ReadChannel) => { + let esbuildConfig = createEsbuildConfig( + remixConfig, + manifestChannel, + options + ); + let { outputFiles } = await esbuild.build({ + ...esbuildConfig, + write: false, + }); + await writeServerBuildResult(remixConfig, outputFiles!); + }; + return { + compile, + dispose: () => undefined, + }; +}; diff --git a/packages/remix-dev/compiler/index.ts b/packages/remix-dev/compiler/index.ts new file mode 100644 index 00000000000..ff5eb467a35 --- /dev/null +++ b/packages/remix-dev/compiler/index.ts @@ -0,0 +1,5 @@ +export { build } from "./build"; +export { watch } from "./watch"; + +export { type CompileOptions, parseMode } from "./options"; +export { logCompileFailure } from "./onCompileFailure"; diff --git a/packages/remix-dev/compiler/onCompileFailure.ts b/packages/remix-dev/compiler/onCompileFailure.ts new file mode 100644 index 00000000000..0706d63f1e3 --- /dev/null +++ b/packages/remix-dev/compiler/onCompileFailure.ts @@ -0,0 +1,26 @@ +import * as esbuild from "esbuild"; + +export type CompileFailure = Error | esbuild.BuildFailure; +export type OnCompileFailure = (failure: CompileFailure) => void; + +export const logCompileFailure: OnCompileFailure = (failure) => { + if ("warnings" in failure || "errors" in failure) { + if (failure.warnings) { + let messages = esbuild.formatMessagesSync(failure.warnings, { + kind: "warning", + color: true, + }); + console.warn(...messages); + } + + if (failure.errors) { + let messages = esbuild.formatMessagesSync(failure.errors, { + kind: "error", + color: true, + }); + console.error(...messages); + } + } + + console.error(failure?.message || "An unknown build error occurred"); +}; diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts new file mode 100644 index 00000000000..59f6853647a --- /dev/null +++ b/packages/remix-dev/compiler/options.ts @@ -0,0 +1,29 @@ +import type * as esbuild from "esbuild"; + +const modes = ["development", "production", "test"] as const; + +type Mode = typeof modes[number]; + +export const parseMode = (raw: string, fallback?: Mode): Mode => { + if ((modes as readonly string[]).includes(raw)) { + return raw as Mode; + } + if (!fallback) { + throw Error(`Unrecognized mode: '${raw}'`); + } + return fallback; +}; + +type Target = + | "browser" // TODO: remove + | "server" // TODO: remove + | "cloudflare-workers" + | "node14"; + +export type CompileOptions = { + mode: Mode; + target: Target; + sourcemap: boolean; + onWarning?: (message: string, key: string) => void; + onCompileFailure?: (failure: Error | esbuild.BuildFailure) => void; +}; diff --git a/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin.ts b/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin.ts index 6e66da4aef1..ff0bd074b98 100644 --- a/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin.ts +++ b/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin.ts @@ -1,7 +1,7 @@ import type esbuild from "esbuild"; import type { RemixConfig } from "../../config"; -import { getRouteModuleExportsCached } from "../routes"; +import { getRouteModuleExports } from "../routeExports"; import invariant from "../../invariant"; type Route = RemixConfig["routes"][string]; @@ -53,9 +53,9 @@ export function browserRouteModulesPlugin( try { invariant(route, `Cannot get route by path: ${args.path}`); - theExports = ( - await getRouteModuleExportsCached(config, route.id) - ).filter((ex) => !!browserSafeRouteExports[ex]); + theExports = (await getRouteModuleExports(config, route.id)).filter( + (ex) => !!browserSafeRouteExports[ex] + ); } catch (error: any) { return { errors: [ diff --git a/packages/remix-dev/compiler/plugins/cssFilePlugin.ts b/packages/remix-dev/compiler/plugins/cssFilePlugin.ts index b2262c5e6fd..9bee1a89ded 100644 --- a/packages/remix-dev/compiler/plugins/cssFilePlugin.ts +++ b/packages/remix-dev/compiler/plugins/cssFilePlugin.ts @@ -2,9 +2,8 @@ import * as path from "path"; import * as fse from "fs-extra"; import esbuild from "esbuild"; -import { BuildMode } from "../../build"; -import type { BuildConfig } from "../../compiler"; import invariant from "../../invariant"; +import { type CompileOptions } from "../options"; const isExtendedLengthPath = /^\\\\\?\\/; @@ -16,9 +15,9 @@ function normalizePathSlashes(p: string) { * This plugin loads css files with the "css" loader (bundles and moves assets to assets directory) * and exports the url of the css file as its default export. */ -export function cssFilePlugin( - buildConfig: Pick, "mode"> -): esbuild.Plugin { +export function cssFilePlugin(options: { + mode: CompileOptions["mode"]; +}): esbuild.Plugin { return { name: "css-file", @@ -29,7 +28,7 @@ export function cssFilePlugin( let { outfile, outdir, assetNames } = buildOps; let { metafile, outputFiles, warnings, errors } = await esbuild.build({ ...buildOps, - minify: buildConfig.mode === BuildMode.Production, + minify: options.mode === "production", minifySyntax: true, metafile: true, write: false, diff --git a/packages/remix-dev/compiler/plugins/serverAssetsManifestPlugin.ts b/packages/remix-dev/compiler/plugins/serverAssetsManifestPlugin.ts index fd434042ccc..06041f4f292 100644 --- a/packages/remix-dev/compiler/plugins/serverAssetsManifestPlugin.ts +++ b/packages/remix-dev/compiler/plugins/serverAssetsManifestPlugin.ts @@ -1,10 +1,8 @@ import type { Plugin } from "esbuild"; import jsesc from "jsesc"; -import invariant from "../../invariant"; -import { assetsManifestVirtualModule } from "../virtualModules"; - -export type AssetsManifestPromiseRef = { current?: Promise }; +import type { AssetsManifest } from "../../compiler/assets"; +import { assetsManifestVirtualModule } from "../../compiler/virtualModules"; /** * Creates a virtual module called `@remix-run/dev/assets-manifest` that exports @@ -12,7 +10,7 @@ export type AssetsManifestPromiseRef = { current?: Promise }; * assets manifest in the server build. */ export function serverAssetsManifestPlugin( - assetsManifestPromiseRef: AssetsManifestPromiseRef + assetsManifestPromise: Promise ): Plugin { let filter = assetsManifestVirtualModule.filter; @@ -27,15 +25,9 @@ export function serverAssetsManifestPlugin( }); build.onLoad({ filter }, async () => { - invariant( - assetsManifestPromiseRef.current, - "Missing assets manifests in server build." - ); - - let manifest = await assetsManifestPromiseRef.current; - + let assetsManifest = await assetsManifestPromise; return { - contents: `export default ${jsesc(manifest, { es6: true })};`, + contents: `export default ${jsesc(assetsManifest, { es6: true })};`, loader: "js", }; }); diff --git a/packages/remix-dev/compiler/remixCompiler.ts b/packages/remix-dev/compiler/remixCompiler.ts new file mode 100644 index 00000000000..4fb9b9ed9c6 --- /dev/null +++ b/packages/remix-dev/compiler/remixCompiler.ts @@ -0,0 +1,43 @@ +import { type RemixConfig } from "../config"; +import { type AssetsManifest } from "./assets"; +import { type BrowserCompiler, createBrowserCompiler } from "./compileBrowser"; +import { type ServerCompiler, createServerCompiler } from "./compilerServer"; +import { type OnCompileFailure } from "./onCompileFailure"; +import { type CompileOptions } from "./options"; +import { createChannel } from "./utils/channel"; + +type RemixCompiler = { + browser: BrowserCompiler; + server: ServerCompiler; +}; + +export const createRemixCompiler = ( + remixConfig: RemixConfig, + options: CompileOptions +): RemixCompiler => { + return { + browser: createBrowserCompiler(remixConfig, options), + server: createServerCompiler(remixConfig, options), + }; +}; + +export const compile = async ( + compiler: RemixCompiler, + options: { + onCompileFailure?: OnCompileFailure; + } = {} +): Promise => { + try { + let assetsManifestChannel = createChannel(); + let browserPromise = compiler.browser.compile(assetsManifestChannel); + let serverPromise = compiler.server.compile(assetsManifestChannel); + await Promise.all([browserPromise, serverPromise]); + } catch (err) { + options.onCompileFailure?.(err as Error); + } +}; + +export const dispose = (compiler: RemixCompiler): void => { + compiler.browser.dispose(); + compiler.server.dispose(); +}; diff --git a/packages/remix-dev/compiler/routes.ts b/packages/remix-dev/compiler/routeExports.ts similarity index 93% rename from packages/remix-dev/compiler/routes.ts rename to packages/remix-dev/compiler/routeExports.ts index 22764cfb728..febe0b71099 100644 --- a/packages/remix-dev/compiler/routes.ts +++ b/packages/remix-dev/compiler/routeExports.ts @@ -8,7 +8,7 @@ import { getFileHash } from "./utils/crypto"; type CachedRouteExports = { hash: string; exports: string[] }; -export async function getRouteModuleExportsCached( +export async function getRouteModuleExports( config: RemixConfig, routeId: string ): Promise { @@ -24,7 +24,7 @@ export async function getRouteModuleExportsCached( } if (!cached || cached.hash !== hash) { - let exports = await getRouteModuleExports(config, routeId); + let exports = await _getRouteModuleExports(config, routeId); cached = { hash, exports }; try { await cache.putJson(config.cacheDirectory, key, cached); @@ -41,7 +41,7 @@ export async function getRouteModuleExportsCached( return cached.exports; } -export async function getRouteModuleExports( +async function _getRouteModuleExports( config: RemixConfig, routeId: string ): Promise { diff --git a/packages/remix-dev/compiler/utils/channel.ts b/packages/remix-dev/compiler/utils/channel.ts new file mode 100644 index 00000000000..3eacc4a7661 --- /dev/null +++ b/packages/remix-dev/compiler/utils/channel.ts @@ -0,0 +1,20 @@ +export type WriteChannel = { + write: (data: T) => void; +}; +export type ReadChannel = { + read: () => Promise; +}; +export type Channel = WriteChannel & ReadChannel; + +export const createChannel = (): Channel => { + let promiseResolve: (value: T) => void; + + let promise = new Promise((resolve) => { + promiseResolve = resolve; + }); + + return { + write: promiseResolve!, + read: () => promise, + }; +}; diff --git a/packages/remix-dev/compiler/watch.ts b/packages/remix-dev/compiler/watch.ts new file mode 100644 index 00000000000..a20fa52f6a3 --- /dev/null +++ b/packages/remix-dev/compiler/watch.ts @@ -0,0 +1,128 @@ +import chokidar from "chokidar"; +import debounce from "lodash.debounce"; +import * as path from "path"; + +import { type RemixConfig, readConfig } from "../config"; +import { logCompileFailure } from "./onCompileFailure"; +import { type CompileOptions } from "./options"; +import { compile, createRemixCompiler, dispose } from "./remixCompiler"; +import { warnOnce } from "./warnings"; + +function isEntryPoint(config: RemixConfig, file: string): boolean { + let appFile = path.relative(config.appDirectory, file); + let entryPoints = [ + config.entryClientFile, + config.entryServerFile, + ...Object.values(config.routes).map((route) => route.file), + ]; + return entryPoints.includes(appFile); +} + +type WatchOptions = Partial & { + onRebuildStart?(): void; + onRebuildFinish?(durationMs: number): void; + onFileCreated?(file: string): void; + onFileChanged?(file: string): void; + onFileDeleted?(file: string): void; + onInitialBuild?(): void; +}; + +export async function watch( + config: RemixConfig, + { + mode = "development", + target = "node14", + sourcemap = true, + onWarning = warnOnce, + onCompileFailure = logCompileFailure, + onRebuildStart, + onRebuildFinish, + onFileCreated, + onFileChanged, + onFileDeleted, + onInitialBuild, + }: WatchOptions = {} +): Promise<() => Promise> { + let options: CompileOptions = { + mode, + target, + sourcemap, + onCompileFailure, + onWarning, + }; + + let compiler = createRemixCompiler(config, options); + + // initial build + await compile(compiler); + onInitialBuild?.(); + + let restart = debounce(async () => { + onRebuildStart?.(); + let start = Date.now(); + dispose(compiler); + + try { + config = await readConfig(config.rootDirectory); + } catch (error) { + onCompileFailure(error as Error); + return; + } + + compiler = createRemixCompiler(config, options); + await compile(compiler); + onRebuildFinish?.(Date.now() - start); + }, 500); + + let rebuild = debounce(async () => { + onRebuildStart?.(); + let start = Date.now(); + await compile(compiler, { onCompileFailure }); + onRebuildFinish?.(Date.now() - start); + }, 100); + + let toWatch = [config.appDirectory]; + if (config.serverEntryPoint) { + toWatch.push(config.serverEntryPoint); + } + + config.watchPaths?.forEach((watchPath) => { + toWatch.push(watchPath); + }); + + let watcher = chokidar + .watch(toWatch, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 100, + }, + }) + .on("error", (error) => console.error(error)) + .on("change", async (file) => { + onFileChanged?.(file); + await rebuild(); + }) + .on("add", async (file) => { + onFileCreated?.(file); + + try { + config = await readConfig(config.rootDirectory); + } catch (error) { + onCompileFailure(error as Error); + return; + } + + await (isEntryPoint(config, file) ? restart : rebuild)(); + }) + .on("unlink", async (file) => { + onFileDeleted?.(file); + await (isEntryPoint(config, file) ? restart : rebuild)(); + }); + + return async () => { + await watcher.close().catch(() => undefined); + dispose(compiler); + }; +}