Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active September 16, 2024 11:26
Show Gist options
  • Save mizchi/23e092ed16020718dd3b23cdf4aec4d1 to your computer and use it in GitHub Desktop.
Save mizchi/23e092ed16020718dd3b23cdf4aec4d1 to your computer and use it in GitHub Desktop.
Remix 本体のコードを読んだメモ

Code Reading のメモ

7dece0e 時点

目標

  • vite dev 起動時のシーケンスを確認
  • react-router になるとはどういうことか

setup

DEVELOPMENT.md

$ pnpm install
$ pnpm build
$ pnpm test:primary
...
Test Suites: 31 passed, 31 total
Tests:       1 skipped, 562 passed, 563 total
Snapshots:   86 passed, 86 total
Time:        45.22 s

it works

REMIX_DEBUG

By default, the Remix rollup build will strip any console.debug calls to avoid cluttering up the console during application usage. These console.debug statements can be preserved by setting REMIX_DEBUG=true during your local build.

REMIX_DEBUG=true pnpm watch

remix dev:vite

開発サーバー起動時の CLI のエントリポイントを探す

// packages/remix/index.ts

// This class exists to prevent https://github.com/remix-run/remix/issues/2031 from occurring
export class RemixPackageNotUsedError extends Error {
  constructor() {
    super(
      "The `remix` package is no longer used for Remix modules and should be removed " +
        "from your project dependencies. See " +
        "https://github.com/remix-run/remix/releases/tag/remix%402.0.0" +
        " for more information."
    );
  }
}

throw new RemixPackageNotUsedError();

違う。

packages/create-remix を覗いたが、これはセットアップ用のCLIヘルパがほとんど

ここで一旦 create-remix を叩いてみて、 bin がどこに刺さってるかを確認する。

npx create-remix して node_modules/.bin/remix の向いている先を確認すると、 @remix-run/dev であることを確認。

  • packages/remix-dev/cli/index.ts => ./run.ts
// packages/remix-dev/cli/run.ts L282
    case "vite:dev":
      await commands.viteDev(input[1], flags);
      break;
// packages/remix-dev/cli/commands.ts L212
export async function viteDev(root: string, options: ViteDevOptions = {}) {
  let { dev } = await import("../vite/dev");
  if (options.profile) {
    await profiler.start();
  }
  exitHook(() => profiler.stop(console.info));
  await dev(root, options);

  // keep `remix vite-dev` alive by waiting indefinitely
  await new Promise(() => {});
}
// packages/remix-dev/vite/dev.ts
export async function dev(/*...*/) {
  // Ensure Vite's ESM build is preloaded at the start of the process
  // so it can be accessed synchronously via `importViteEsmSync`
  await preloadViteEsm();

  let vite = await import("vite");
  let server = await vite.createServer({
    root,
    mode,
    configFile,
    server: { open, cors, host, port, strictPort },
    optimizeDeps: { force },
    clearScreen,
    logLevel,
  });

要は vite.createServer してるだけ。これは vite サーバーをCLIではなく Node から起動するためのオプション。

おそらく server 周りに開発用のヘルパかリローダーが注入されてる可能性があるが、一旦飛ばす。

remix as vite plugin

プラグインを読み解く前に、事前知識として、 vite で SSR するサーバーアセットを吐くための事前知識が必要そう。(自分は知ってた)

https://vitejs.dev/guide/ssr

https://vitejs.dev/guide/ssr#ssr-specific-plugin-logic

export function mySSRPlugin() {
  return {
    name: 'my-ssr',
    transform(code, id, options) {
      if (options?.ssr) {
        // perform ssr-specific transform...
      }
    },
  }
}

というわけで、コードを読みに行く。 スキャッフォルドした vite.config.ts を確認。

// $ cat vite.config.ts 
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
    tsconfigPaths(),
  ],
});

package.json

  "main": "dist/index.js",
  "typings": "dist/index.d.ts",

rollup.config.js を確認すると、これは packages/remix-dev/index.ts だった。

import "./modules";

//...
export { vitePlugin, cloudflareDevProxyVitePlugin } from "./vite";
// packages/remix-dev/vite/index.ts
export const vitePlugin: RemixVitePlugin = (...args) => {
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
  let { remixVitePlugin } = require("./plugin") as typeof import("./plugin");
  return remixVitePlugin(...args);
};
// packages/remix-dev/vite/plugin.ts L604
export type RemixVitePlugin = (config?: VitePluginConfig) => Vite.Plugin[];
export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => {
  // Prevent mutations to the user config
  remixUserConfig = deepFreeze(remixUserConfig);

ここで Vite Plugin を生成している。ここに SSR 分岐とクライアント分岐もあるはず。 雑に上から読んだだけだと、難しい!

// L619
  let ctx: RemixPluginContext;

  /** Mutates `ctx` as a side-effect */
  let updateRemixPluginContext = async (): Promise<void> => {
    // ...リロード時に config の変更がないか確認して適応する処理
    
    ctx = {
      remixConfig,
      rootDirectory,
      entryClientFilePath,
      entryServerFilePath,
      viteManifestEnabled,
      ...ssrBuildCtx,
    };
  }

読み下して、おそらくここに実際のビルド時の処理が注入されている。

  // L853
  let generateRemixManifestsForBuild = async (): Promise<{
    remixBrowserManifest: RemixManifest;
    remixServerManifest: RemixManifest;
  }> => {

    // L872
    let routeManifestExports = await getRouteManifestModuleExports(
      viteChildCompiler,
      ctx
    );

    // ここが多分重要な部分
    // ファイルごとに静的解析して、その export で必要な処理のフラグを立てているように見える
    // あとで重点的に読む
    for (let [key, route] of Object.entries(ctx.remixConfig.routes)) {
      let routeFilePath = path.join(ctx.remixConfig.appDirectory, route.file);
      let sourceExports = routeManifestExports[key];
      let isRootRoute = route.parentId === undefined;

      let routeManifestEntry = {
        id: route.id,
        parentId: route.parentId,
        path: route.path,
        index: route.index,
        caseSensitive: route.caseSensitive,
        hasAction: sourceExports.includes("action"),
        hasLoader: sourceExports.includes("loader"),
        hasClientAction: sourceExports.includes("clientAction"),
        hasClientLoader: sourceExports.includes("clientLoader"),
        hasErrorBoundary: sourceExports.includes("ErrorBoundary"),
        ...getRemixManifestBuildAssets(
          ctx,
          viteManifest,
          routeFilePath,
          // If this is the root route, we also need to include assets from the
          // client entry file as this is a common way for consumers to import
          // global reset styles, etc.
          isRootRoute ? [ctx.entryClientFilePath] : []
        ),
      };

      browserRoutes[key] = routeManifestEntry;

      let serverBundleRoutes = ctx.serverBundleBuildConfig?.routes;
      if (!serverBundleRoutes || serverBundleRoutes[key]) {
        serverRoutes[key] = routeManifestEntry;
      }
    }

    // L926
    // Write the browser manifest to disk as part of the build process
    await writeFileSafe(
      path.join(getClientBuildDirectory(ctx.remixConfig), manifestPath),
      `window.__remixManifest=${JSON.stringify(remixBrowserManifest)};`
    );
    // ...
    return {
      remixBrowserManifest,
      remixServerManifest,
    };
  }

この読み飛ばした中身の routeManifestExports が多分大事 TODO: config に明示的にレイアウトを指定するということだが、これ新しい layout 用じゃないか?あとで確認する

  // L946
  // In dev, the server and browser Remix manifests are the same
  let getRemixManifestForDev = async (): Promise<RemixManifest> => {

1002行から Vite Plugin の配列を返す。これは注入された順に実行される。

Vite Plugin は Rollup Plugin の拡張インターフェースで、次のルールに従うことを念頭に置く。

https://rollupjs.org/plugin-development/

とりあえず最初の remix 本体?っぽい plugin を読む。

// L1002
  return [
    {
      name: "remix",
      config: async (_viteUserConfig, _viteConfigEnv) => {
        // 本体プラグイン
        return {
          __remixPluginContext: ctx,
          // ...
          // ビルド時の設定
          ...(viteCommand === "build"
            ? {
                build: {
                  cssMinify: viteUserConfig.build?.cssMinify ?? true,
                  ...(!viteConfigEnv.isSsrBuild
                    ? {
                        // ブラウザ側のビルド設定
                        manifest: true,
                        outDir: getClientBuildDirectory(ctx.remixConfig),
                        rollupOptions: {
                          ...baseRollupOptions,
                          preserveEntrySignatures: "exports-only",
                          input: [
                            ctx.entryClientFilePath,
                            ...Object.values(ctx.remixConfig.routes).map(
                              (route) =>
                                `${path.resolve(
                                  ctx.remixConfig.appDirectory,
                                  route.file
                                )}${BUILD_CLIENT_ROUTE_QUERY_STRING}`
                            ),
                          ],
                        },
                      }
                    : {
                        // サーバー側のビルド設定
                        // We move SSR-only assets to client assets. Note that the
                        // SSR build can also emit code-split JS files (e.g. by
                        // dynamic import) under the same assets directory
                        // regardless of "ssrEmitAssets" option, so we also need to
                        // keep these JS files have to be kept as-is.
                        ssrEmitAssets: true,
                        copyPublicDir: false, // Assets in the public directory are only used by the client
                        manifest: true, // We need the manifest to detect SSR-only assets
                        outDir: getServerBuildDirectory(ctx),
                        rollupOptions: {
                          ...baseRollupOptions,
                          preserveEntrySignatures: "exports-only",
                          input: serverBuildId,
                          output: {
                            entryFileNames: ctx.remixConfig.serverBuildFile,
                            format: ctx.remixConfig.serverModuleFormat,
                          },
                        },
                      }),
                },
              }
            : undefined),
          // ...
        };
      },
      /// vite の開発サーバー上の振る舞い動き。ここで開発時の SSR 実装が行われる
      async configureServer(viteDevServer) {
        // ...
        return () => {
          // SSR した 初期HTMLをミドルウェアとして返却するサーバーの処理
          if (!viteDevServer.config.server.middlewareMode) {
            viteDevServer.middlewares.use(async (req, res, next) => {
              // SSR 用のモジュールチャンクを取得し、たぶん HTML に展開
              // それを node.js 用のリクエストに変換
              try {
                let build = (await viteDevServer.ssrLoadModule(
                  serverBuildId
                )) as ServerBuild;
                let handler = createRequestHandler(build, "development");
                let nodeHandler: NodeRequestHandler = async (
                  nodeReq,
                  nodeRes
                ) => {
                  let req = fromNodeRequest(nodeReq);
                  let res = await handler(req, await remixDevLoadContext(req));
                  await toNodeRequest(res, nodeRes);
                };
                await nodeHandler(req, res);
              } catch (error) {
                next(error);
              }
            });
          }
        };
      },
      // アセットを書き出す設定
      writeBundle: {
        // After the SSR build is finished, we inspect the Vite manifest for
        // the SSR build and move server-only assets to client assets directory
        // ...
      },
      async buildEnd() {
        await viteChildCompiler?.close();
      },
    },

大事なのは configureServervite.ssrLoadModulehttps://ja.vitejs.dev/guide/ssr の SSR 実装サンプルのここに相当する。(のを知っていた)

    // 2. Vite の HTML の変換を適用します。これにより Vite の HMR クライアントが定義され
    //    Vite プラグインからの HTML 変換も適用します。 e.g. global preambles
    //    from @vitejs/plugin-react
    template = await vite.transformIndexHtml(url, template)

    // 3. サーバーサイドのエントリーポイントを読み込みます。 ssrLoadModule は自動的に
    //    ESM を Node.js で使用できるコードに変換します! ここではバンドルは必要ありません
    //    さらに HMR と同様な効率的な無効化を提供します。
    const { render } = await vite.ssrLoadModule('/src/entry-server.js')

ここでクライアント・サーバーで結果整合な感じで頑張ってアセットを吐き出している。

続きを見る。

    {
      name: "remix-virtual-modules",
      // vite plugin で他のものより優先する設定
      enforce: "pre",
      // rollup で id からソースコード実体をロードするフック
      async load(id) {
        switch (id) {
          // ...
          // Remix アプリケーション用の動的データをここで作って返している
          case VirtualModule.resolve(serverManifestId): {
            let remixManifest = ctx.isSsrBuild
              ? await ctx.getRemixServerManifest()
              : await getRemixManifestForDev();

            return `export default ${jsesc(remixManifest, { es6: true })};`;
          }
          // マニフェストファイルをここでグローバル変数に注入アセットとして返す
          case VirtualModule.resolve(browserManifestId): {
            // ...
            return `window.__remixManifest=${remixManifestString};`;
          }
        }
      },
    },
    {
      // `.server.tsx` の解決
      name: "remix-dot-server",
      enforce: "pre",
      async resolveId(id, importer, options) {
        //...
      },
    },
    {
      // `.client.tsx` の解決
      name: "remix-dot-client",
      async transform(code, id, options) {
        // ...
      }
    },
    {
      /// routes/* ごとの処理
      name: "remix-route-exports",
      async transform(code, id, options) {
        if (options?.ssr) return;
        let route = getRoute(ctx.remixConfig, id);
        if (!route) return;
        /// ...
        /// 規約に無関係な export を削除
        return removeExports(code, SERVER_ONLY_ROUTE_EXPORTS, {
          sourceMaps: true,
          filename: id,
          sourceFileName: filepath,
        });
      },
    },
    {
      // HMR用のフックの挿入
      name: "remix-inject-hmr-runtime",
      enforce: "pre",
      // ...
      async load(id) {
        //...
        return [
          `import RefreshRuntime from "${hmrRuntimeId}"`,
          "RefreshRuntime.injectIntoGlobalHook(window)",
          "window.$RefreshReg$ = () => {}",
          "window.$RefreshSig$ = () => (type) => type",
          "window.__vite_plugin_react_preamble_installed__ = true",
        ].join("\n");
      },
    },
    {
      /// HMR のランタイムホストの注入
      name: "remix-hmr-runtime",
      /// ...
      async load(id) {
        /// ...
        return [
          "const exports = {}",
          await fse.readFile(reactRefreshRuntimePath, "utf8"),
          await fse.readFile(
            require.resolve("./static/refresh-utils.cjs"),
            "utf8"
          ),
          "export default exports",
        ].join("\n");
      },
    },
    {
      /// babel のHMR用の変形
      name: "remix-react-refresh-babel",
      async transform(code, id, options) {
        /// ...
      },
    },
    {
      /// HMR 受付時のフック
      name: "remix-hmr-updates",
      async handleHotUpdate({ server, file, modules, read }) {
        /// ...色々コードを返す
        /// 開発サーバーの websocket へHMRデータを送信
        server.ws.send({
          type: "custom",
          event: "remix:hmr",
          data: hmrEventData,
        });
        return modules;
      },
    },
  ];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment