diff --git a/.changeset/perfect-rockets-approve.md b/.changeset/perfect-rockets-approve.md new file mode 100644 index 00000000000..e7d51ca2cba --- /dev/null +++ b/.changeset/perfect-rockets-approve.md @@ -0,0 +1,18 @@ +--- +"integration-tests": minor +"create-remix": minor +"@remix-run/dev": minor +"@remix-run/react": minor +"@remix-run/server-runtime": minor +"@remix-run/testing": minor +--- + +Unstable Vite support for Node-based Remix apps + +- `remix build` πŸ‘‰ `vite build && vite build --ssr` +- `remix dev` πŸ‘‰ `vite dev` + +Other runtimes (e.g. Deno, Cloudflare) not yet supported. +Custom server (e.g. Express) not yet supported. + +See "Future > Vite" in the Remix Docs for details. diff --git a/docs/future/index.md b/docs/future/index.md new file mode 100644 index 00000000000..ff789c6d3c4 --- /dev/null +++ b/docs/future/index.md @@ -0,0 +1,3 @@ +--- +title: Future +--- diff --git a/docs/future/vite.md b/docs/future/vite.md new file mode 100644 index 00000000000..9690279e4b6 --- /dev/null +++ b/docs/future/vite.md @@ -0,0 +1,517 @@ +--- +title: Vite (Unstable) +toc: false +--- + +# Vite (Unstable) + +Vite support is currently unstable and only intended to gather early feedback. We don't yet recommend using this in production. + +[Vite][vite] is a powerful, performant and extensible development environment for JavaScript projects. In order to improve and extend Remix's bundling capabilities, we're currently exploring the use of Vite as an alternative compiler to esbuild. + +**Legend**: βœ… (Tested),❓ (Untested), ⏳ (Not Yet Supported) + +| Feature | Node | Deno | Cloudflare | Notes | +| ---------------------------- | ---- | ---- | ---------- | ----------------------------------------- | +| Built-in dev server | βœ… | ❓ | ⏳ | | +| Other servers (e.g. Express) | ⏳ | ⏳ | ⏳ | | +| HMR | βœ… | ❓ | ⏳ | | +| HDR | βœ… | ❓ | ⏳ | | +| MDX | ⏳ | ⏳ | ⏳ | https://github.com/vitejs/vite/pull/14560 | + +## Getting started + +To get started with Vite in an existing Remix project (or a new one created with [create-remix]), first install Vite as a dev dependency: + +```shellscript nonumber +npm install -D vite +``` + +Then add `vite.config.mjs` to the project root, providing the Remix plugin to the `plugins` array: + +```js filename=vite.config.mjs +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [remix()], +}); +``` + +The Vite plugin accepts the following subset of Remix config options: + +Note that `remix.config.js` is not used by the Remix Vite plugin unless you manually import it in your Vite config and pass it to the plugin. + +- [appDirectory][appdirectory] +- [assetsBuildDirectory][assetsbuilddirectory] +- [ignoredRouteFiles][ignoredroutefiles] +- [publicPath][publicpath] +- [routes][routes] +- [serverBuildPath][serverbuildpath] +- [serverModuleFormat][servermoduleformat] + +For example: + +```js filename=vite.config.mjs +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix({ + ignoredRouteFiles: ["**/.*"], + }), + ], +}); +``` + +All other bundling-related options are now [configured with Vite][vite-config]. This means you have much greater control over the bundling process. + +To start a development server, just run Vite's `dev` command directly. + +```shellscript nonumber +vite dev +``` + +To run a production build, first run Vite's `build` command for the client, then for the server using the `--ssr` flag. + +```shellscript nonumber +vite build && vite build --ssr +``` + +## Differences When Using Vite + +Since Vite is now responsible for bundling your app, there are some differences between Vite and the Remix compiler that you'll need to be aware of. + +### New Bundling Features + +Vite has many [features][vite-features] and [plugins][vite-plugins] that are not built into the Remix compiler. Any use of these features will break backwards compatibility with the Remix compiler and should only be used if you intend to use Vite exclusively. + +### Path Aliases + +The Remix compiler leverages the `paths` option in your `tsconfig.json` to resolve path aliases. This is commonly used in the Remix community to define `~` as an alias for the `app` directory. + +Vite does not provide any path aliases by default. You can install the [vite-tsconfig-paths][vite-tsconfig-paths] plugin to automatically resolve path aliases from your `tsconfig.json` in Vite, matching the behavior of the Remix compiler: + +```shellscript nonumber +npm install -D vite-tsconfig-paths +``` + +Then add it to your Vite config: + +```js filename=vite.config.mjs +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [remix(), tsconfigPaths()], +}); +``` + +Alternatively, you can define path aliases without referencing `tsconfig.json` by using Vite's [`resolve.alias`][vite-resolve-alias] option directly: + +```js filename=vite.config.mjs +import { fileURLToPath, URL } from "node:url"; + +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + resolve: { + alias: { + "~": fileURLToPath(new URL("./app", import.meta.url)), + }, + }, + plugins: [remix()], +}); +``` + +### Regular CSS Imports + +When importing a CSS file in Vite, its default export is its file contents as a string. This differs from the Remix compiler which provides the file's URL. To import the URL of a CSS file in Vite, you'll need to explicitly add `?url` to the end of the import path: + +```diff +-import styles from "./styles.css"; ++import styles from "./styles.css?url"; +``` + +For example: + +```ts filename=app/dashboard/route.tsx +import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno + +import styles from "./dashboard.css?url"; + +export const links: LinksFunction = () => [ + { rel: "stylesheet", href: styles }, +]; +``` + +If you're using Vite and the Remix compiler in the same project, you can enable `legacyCssImports` in the Remix Vite plugin which will automatically append `?url` to all relevant CSS imports: + +This option is only intended for use during the transition to Vite and will be removed in the future. + +```js filename=vite.config.mjs +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix({ + legacyCssImports: true, + }), + ], +}); +``` + +### CSS Bundling + +Vite has built-in support for CSS side-effect imports, PostCSS and CSS Modules, among other CSS bundling features. The Remix Vite plugin automatically attaches bundled CSS to the relevant routes so the [`@remix-run/css-bundle`][css-bundling] package is no longer required. + +If you're using Vite and the Remix compiler in the same project, you can continue to use `@remix-run/css-bundle` as long as you check for the existence of `cssBundleHref` before using it: + +```ts +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno + +export const links: LinksFunction = () => [ + ...(cssBundleHref + ? [{ rel: "stylesheet", href: cssBundleHref }] + : []), + // ... +]; +``` + +### Tailwind + +To use [Tailwind][tailwind] in Vite, first install the required dependencies: + +```shellscript nonumber +npm install -D tailwindcss postcss autoprefixer +``` + +Then generate config files for both Tailwind and PostCSS: + +```shellscript nonumber +npx tailwindcss init --ts -p +``` + +If your Remix project already has a PostCSS config file, you'll need to ensure that the `tailwindcss` plugin has been configured. This plugin was previously being injected by the Remix compiler if it was missing. + +Now we can tell it which files to generate classes from: + +```ts filename=tailwind.config.ts lines=[4] +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; +``` + +Then include the `@tailwind` directives somewhere in your app CSS. For example, you could create a `tailwind.css` file at the root of your app: + +```css filename=app/tailwind.css +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +### Vanilla Extract + +To use [Vanilla Extract][vanilla-extract] in Vite, install the official [Vite plugin][vanilla-extract-vite-plugin]. + +```shellscript nonumber +npm install -D @vanilla-extract/vite-plugin +``` + +Then add the plugin to your Vite config: + +```js filename=vite.config.mjs +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [remix(), vanillaExtractPlugin()], +}); +``` + +### MDX + +Since Vite's plugin API is an extension of the Rollup plugin API, you can use the official [MDX Rollup plugin][mdx-rollup-plugin]: + +```shellscript nonumber +npm install -D @mdx-js/rollup +``` + +Then add the Rollup plugin to your Vite config: + +```js filename=vite.config.mjs +import mdx from "@mdx-js/rollup"; +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [remix(), mdx()], +}); +``` + +#### MDX Frontmatter + +The Remix compiler allowed you to define [frontmatter in MDX][mdx-frontmatter]. You can achieve this in Vite using [remark-mdx-frontmatter]. + +First, install the required [Remark][remark] plugins: + +```shellscript nonumber +npm install -D remark-frontmatter remark-mdx-frontmatter +``` + +Then provide these plugins to the MDX Rollup plugin: + +```js filename=vite.config.mjs +import mdx from "@mdx-js/rollup"; +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import remarkFrontmatter from "remark-frontmatter"; +import remarkMdxFrontmatter from "remark-mdx-frontmatter"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix(), + mdx({ + remarkPlugins: [ + remarkFrontmatter, + remarkMdxFrontmatter, + ], + }), + ], +}); +``` + +In the Remix compiler, the frontmatter export was named `attributes`. This differs from the frontmatter plugin's default export name of `frontmatter`. To maintain backwards compatibility with the Remix compiler, you can override this via the `name` option: + +```js filename=vite.config.mjs +import mdx from "@mdx-js/rollup"; +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import remarkFrontmatter from "remark-frontmatter"; +import remarkMdxFrontmatter from "remark-mdx-frontmatter"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix(), + mdx({ + remarkPlugins: [ + remarkFrontmatter, + [remarkMdxFrontmatter, { name: "attributes" }], + ], + }), + ], +}); +``` + +##### MDX Route Frontmatter + +The Remix compiler allowed you to define `headers`, `meta` and `handle` route exports in your frontmatter. This Remix-specific feature is obviously not supported by the `remark-mdx-frontmatter` plugin, but you can manually map frontmatter to route exports yourself: + +```mdx +--- +meta: + - title: My First Post + - name: description + content: Isn't this awesome? +headers: + Cache-Control: no-cache +--- + +export const meta = frontmatter.meta; +export const headers = frontmatter.headers; + +# Hello World +``` + +By writing these MDX route exports yourself, you're free to use whatever frontmatter structure you like. + +```mdx +--- +title: My First Post +description: Isn't this awesome? +--- + +export const meta = () => { + return [ + { title: frontmatter.title }, + { + name: "description", + content: frontmatter.description, + }, + ]; +}; + +# Hello World +``` + +##### MDX Filename Export + +The Remix compiler also provided a `filename` export from all MDX files. This was primarily designed to enable linking to collections of MDX routes. In Vite, you should achieve this via [glob imports][glob-imports] which give you a handy data structure that maps file names to modules. This makes it much easier to maintain a list of MDX files since you no longer need to import each one manually. + +For example, to import all MDX files in the `posts` directory: + +```ts +const posts = import.meta.glob("./posts/*.mdx"); +``` + +This is equivalent to writing this by hand: + +```ts +const posts = { + "./posts/a.mdx": () => import("./posts/a.mdx"), + "./posts/b.mdx": () => import("./posts/b.mdx"), + "./posts/c.mdx": () => import("./posts/c.mdx"), + // etc. +}; +``` + +You can also eagerly import all MDX files if you'd prefer: + +```ts +const posts = import.meta.glob("./posts/*.mdx", { + eager: true, +}); +``` + +## HMR & HDR + +### React Fast Refresh Limitations + +[React Fast Refresh][react_refresh] has some limitations that are worth being aware of. + +#### Class Component State + +React Fast Refresh does not preserve state for class components. +This includes higher-order components that internally return classes: + +```ts +export class ComponentA extends Component {} // ❌ + +export const ComponentB = HOC(ComponentC); // ❌ Won't work if HOC returns a class component + +export function ComponentD() {} // βœ… +export const ComponentE = () => {}; // βœ… +export default function ComponentF() {} // βœ… +``` + +#### Named Function Components + +Function components must be named, not anonymous, for React Fast Refresh to track changes: + +```ts +export default () => {}; // ❌ +export default function () {} // ❌ + +const ComponentA = () => {}; +export default ComponentA; // βœ… + +export default function ComponentB() {} // βœ… +``` + +#### Supported Exports + +React Fast Refresh can only handle component exports. While Remix manages special route exports like `meta`, `links`, and `header` for you, any user-defined exports will cause full reloads: + +```ts +// These exports are handled by the Remix Vite plugin +// to be HMR-compatible +export const meta = { title: "Home" }; // βœ… +export const links = [ + { rel: "stylesheet", href: "style.css" }, +]; // βœ… + +// These exports are removed by the Remix Vite plugin +// so they never affect HMR +export const headers = { "Cache-Control": "max-age=3600" }; // βœ… +export const loader = () => {}; // βœ… +export const action = () => {}; // βœ… + +// This is not a Remix export, nor a component export, +// so it will cause a full reload for this route +export const myValue = "some value"; // ❌ + +export default function Route() {} // βœ… +``` + +πŸ‘† Routes probably shouldn't be exporting random values like that anyway. +If you want to reuse values across routes, stick them in their own non-route module: + +```ts filename=my-custom-value.ts +export const myValue = "some value"; +``` + +#### Adding and Removing Hooks + +React Fast Refresh cannot track changes for a component when hooks are being added or removed from it, causing full reloads just for the next render. After the hooks have been updated, changes should result in hot updates again. For example, if you add [`useLoaderData`][use_loader_data] to your component, you may lose state local to that component for that render. + +#### Component Keys + +In some cases, React cannot distinguish between existing components being changed and new components being added. [React needs `key`s][react_keys] to disambiguate these cases and track changes when sibling elements are modified. + +## Acknowledgements + +Vite is an amazing project and we're grateful to the Vite team for their work. +Special thanks to [Matias Capeletto, Arnaud BarrΓ©, and Bjorn Lu from the Vite team][vite-team] for their guidance. + +The Remix community was quick to explore Vite support and we're grateful for their contributions: + +- [Discussion: Consider using Vite][consider-using-vite] +- [remix-kit][remix-kit] +- [remix-vite][remix-vite] +- [vite-plugin-remix][vite-plugin-remix] + +Finally, we were inspired by how other frameworks implemented Vite support: + +- [Astro][astro] +- [SolidStart][solidstart] +- [SvelteKit][sveltekit] + +We're definitely late to the Vite party, but we're excited to be here now! + +[vite]: https://vitejs.dev +[create-remix]: ../other-api/create-remix +[remix_config]: ../file-conventions/remix-config +[appdirectory]: ../file-conventions/remix-config#appdirectory +[assetsbuilddirectory]: ../file-conventions/remix-config#assetsbuilddirectory +[ignoredroutefiles]: ../file-conventions/remix-config#ignoredroutefiles +[publicpath]: ../file-conventions/remix-config#publicpath +[routes]: ../file-conventions/remix-config#routes +[serverbuildpath]: ../file-conventions/remix-config#serverbuildpath +[servermoduleformat]: ../file-conventions/remix-config#servermoduleformat +[vite-config]: https://vitejs.dev/config +[vite-features]: https://vitejs.dev/guide/features.html +[vite-plugins]: https://vitejs.dev/plugins +[vite-tsconfig-paths]: https://github.com/aleclarson/vite-tsconfig-paths +[vite-resolve-alias]: https://vitejs.dev/config/shared-options.html#resolve-alias +[css-bundling]: ../styling/bundling +[tailwind]: https://tailwindcss.com +[tailwind-postcss]: https://tailwindcss.com/docs/installation/using-postcss +[vanilla-extract]: https://vanilla-extract.style +[vanilla-extract-vite-plugin]: https://vanilla-extract.style/documentation/integrations/vite +[mdx-rollup-plugin]: https://mdxjs.com/packages/rollup +[mdx-frontmatter]: https://mdxjs.com/guides/frontmatter +[remark-mdx-frontmatter]: https://github.com/remcohaszing/remark-mdx-frontmatter +[glob-imports]: https://vitejs.dev/guide/features.html#glob-import +[use_loader_data]: ../hooks/use-loader-data +[react_refresh]: https://github.com/facebook/react/tree/main/packages/react-refresh +[vite-team]: https://vitejs.dev/team.html +[consider-using-vite]: https://github.com/remix-run/remix/discussions/2427 +[remix-kit]: https://github.com/jrestall/remix-kit +[remix-vite]: https://github.com/sudomf/remix-vite +[vite-plugin-remix]: https://github.com/yracnet/vite-plugin-remix +[astro]: https://astro.build/ +[solidstart]: https://start.solidjs.com/getting-started/what-is-solidstart +[sveltekit]: https://kit.svelte.dev/ diff --git a/docs/styling/bundling.md b/docs/styling/bundling.md index 6dc45a70369..cb9ccc3a5ab 100644 --- a/docs/styling/bundling.md +++ b/docs/styling/bundling.md @@ -29,7 +29,10 @@ import { cssBundleHref } from "@remix-run/css-bundle"; import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno export const links: LinksFunction = () => [ - { rel: "stylesheet", href: cssBundleHref }, + ...(cssBundleHref + ? [{ rel: "stylesheet", href: cssBundleHref }] + : []), + // ... ]; ``` diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 39061195064..3ba594cf237 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -5,6 +5,7 @@ import fse from "fs-extra"; import express from "express"; import getPort from "get-port"; import dedent from "dedent"; +import resolveBin from "resolve-bin"; import stripIndent from "strip-indent"; import serializeJavaScript from "serialize-javascript"; import { sync as spawnSync, spawn } from "cross-spawn"; @@ -21,6 +22,8 @@ import { installGlobals } from "../../build/node_modules/@remix-run/node/dist/in const TMP_DIR = path.join(process.cwd(), ".tmp", "integration"); const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const viteBin = resolveBin.sync("vite"); + export interface FixtureInit { buildStdio?: Writable; sourcemap?: boolean; @@ -28,6 +31,7 @@ export interface FixtureInit { template?: "cf-template" | "deno-template" | "node-template"; config?: Partial; useRemixServe?: boolean; + compiler?: "remix" | "vite"; } export type Fixture = Awaited>; @@ -215,6 +219,7 @@ export async function createFixtureProject( let integrationTemplateDir = path.resolve(__dirname, template); let projectName = `remix-${template}-${Math.random().toString(32).slice(2)}`; let projectDir = path.join(TMP_DIR, projectName); + let compiler = init.compiler ?? "remix"; await fse.ensureDir(projectDir); await fse.copy(integrationTemplateDir, projectDir); @@ -272,7 +277,7 @@ export async function createFixtureProject( ); fse.writeFileSync(path.join(projectDir, "remix.config.js"), contents); - build(projectDir, init.buildStdio, init.sourcemap, mode); + build(projectDir, init.buildStdio, init.sourcemap, mode, compiler); return projectDir; } @@ -281,7 +286,8 @@ function build( projectDir: string, buildStdio?: Writable, sourcemap?: boolean, - mode?: ServerMode + mode?: ServerMode, + compiler?: "remix" | "vite" ) { // We have a "require" instead of a dynamic import in readConfig gated // behind mode === ServerMode.Test to make jest happy, but that doesn't @@ -289,36 +295,46 @@ function build( // force the mode to be production for ESM configs when runtime mode is // tested. mode = mode === ServerMode.Test ? ServerMode.Production : mode; - let buildArgs = ["node_modules/@remix-run/dev/dist/cli.js", "build"]; - if (sourcemap) { - buildArgs.push("--sourcemap"); - } - let buildSpawn = spawnSync("node", buildArgs, { - cwd: projectDir, - env: { - ...process.env, - NODE_ENV: mode || ServerMode.Production, - }, - }); + let remixBin = "node_modules/@remix-run/dev/dist/cli.js"; + + let commands: string[][] = + compiler === "vite" + ? [ + [viteBin, "build"], + [viteBin, "build", "--ssr"], + ] + : [[remixBin, "build", ...(sourcemap ? ["--sourcemap"] : [])]]; + + commands.forEach((buildArgs) => { + let buildSpawn = spawnSync("node", buildArgs, { + cwd: projectDir, + env: { + ...process.env, + NODE_ENV: mode || ServerMode.Production, + }, + }); - // These logs are helpful for debugging. Remove comments if needed. - // console.log("spawning @remix-run/dev/cli.js `build`:\n"); - // console.log(" STDOUT:"); - // console.log(" " + buildSpawn.stdout.toString("utf-8")); - // console.log(" STDERR:"); - // console.log(" " + buildSpawn.stderr.toString("utf-8")); - - if (buildStdio) { - buildStdio.write(buildSpawn.stdout.toString("utf-8")); - buildStdio.write(buildSpawn.stderr.toString("utf-8")); - buildStdio.end(); - } + // These logs are helpful for debugging. Remove comments if needed. + // console.log("spawning node " + buildArgs.join(" ") + ":\n"); + // console.log(" STDOUT:"); + // console.log(" " + buildSpawn.stdout.toString("utf-8")); + // console.log(" STDERR:"); + // console.log(" " + buildSpawn.stderr.toString("utf-8")); + + if (buildStdio) { + buildStdio.write(buildSpawn.stdout.toString("utf-8")); + buildStdio.write(buildSpawn.stderr.toString("utf-8")); + buildStdio.end(); + } - if (buildSpawn.error || buildSpawn.status) { - console.error(buildSpawn.stderr.toString("utf-8")); - throw buildSpawn.error || new Error(`Build failed, check the output above`); - } + if (buildSpawn.error || buildSpawn.status) { + console.error(buildSpawn.stderr.toString("utf-8")); + throw ( + buildSpawn.error || new Error(`Build failed, check the output above`) + ); + } + }); } async function writeTestFiles(init: FixtureInit, dir: string) { diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts new file mode 100644 index 00000000000..387c37c022f --- /dev/null +++ b/integration/vite-build-test.ts @@ -0,0 +1,97 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture, selectHtml } from "./helpers/playwright-fixture.js"; + +test.describe("Vite build", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + "vite.config.mjs": js` + import { defineConfig } from "vite"; + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + ); + } + `, + "app/routes/_index.tsx": js` + import { useState, useEffect } from "react"; + + export default function() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( + <> +

Index

+ {!mounted ?

Loading...

:

Mounted

} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("server renders matching routes", async () => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(selectHtml(await res.text(), "#content")).toBe(`
+

Root

+

Index

+

Loading...

+
`); + }); + + test("hydrates", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#content h2").textContent()).toBe("Index"); + expect(await page.locator("#content h3[data-mounted]").textContent()).toBe( + "Mounted" + ); + }); +}); diff --git a/integration/vite-dev-test.ts b/integration/vite-dev-test.ts new file mode 100644 index 00000000000..1f0eb4a9c8b --- /dev/null +++ b/integration/vite-dev-test.ts @@ -0,0 +1,209 @@ +import { test, expect } from "@playwright/test"; +import type { Readable } from "node:stream"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import resolveBin from "resolve-bin"; +import execa from "execa"; +import pidtree from "pidtree"; +import getPort from "get-port"; +import waitOn from "wait-on"; + +import { createFixtureProject, js } from "./helpers/create-fixture.js"; + +test.describe("Vite dev", () => { + let projectDir: string; + let devProc: ChildProcessWithoutNullStreams; + let devPort: number; + + test.beforeAll(async () => { + devPort = await getPort(); + projectDir = await createFixtureProject({ + compiler: "vite", + files: { + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + "vite.config.mjs": js` + import { defineConfig } from "vite"; + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + optimizeDeps: { + include: ["react", "react-dom/client"], + }, + server: { + port: ${devPort}, + strictPort: true, + }, + plugins: [remix()], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, LiveReload } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + + ); + } + `, + "app/routes/_index.tsx": js` + import { useState, useEffect } from "react"; + + export default function IndexRoute() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +
+

Index

+ +

Mounted: {mounted ? "yes" : "no"}

+

HMR updated: no

+
+ ); + } + `, + }, + }); + + let nodeBin = process.argv[0]; + let viteBin = resolveBin.sync("vite"); + devProc = spawn(nodeBin, [viteBin, "dev"], { + cwd: projectDir, + env: process.env, + stdio: "pipe", + }); + let devStdout = bufferize(devProc.stdout); + let devStderr = bufferize(devProc.stderr); + + await waitOn({ + resources: [`http://localhost:${devPort}/`], + timeout: 10000, + }).catch((err) => { + let stdout = devStdout(); + let stderr = devStderr(); + throw new Error( + [ + err.message, + "", + "exit code: " + devProc.exitCode, + "stdout: " + stdout ? `\n${stdout}\n` : "", + "stderr: " + stderr ? `\n${stderr}\n` : "", + ].join("\n") + ); + }); + }); + + test.afterAll(async () => { + devProc.pid && (await killtree(devProc.pid)); + }); + + test("renders matching routes", async ({ page }) => { + await page.goto(`http://localhost:${devPort}/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes" + ); + + let hmrStatus = page.locator("#index [data-hmr]"); + await expect(hmrStatus).toHaveText("HMR updated: no"); + + let input = page.locator("#index input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + + let indexRouteContents = await fs.readFile( + path.join(projectDir, "app/routes/_index.tsx"), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, "app/routes/_index.tsx"), + indexRouteContents.replace("HMR updated: no", "HMR updated: yes"), + "utf8" + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: yes"); + await expect(input).toHaveValue("stateful"); + }); +}); + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; + +let isWindows = process.platform === "win32"; + +let kill = async (pid: number) => { + if (!isAlive(pid)) return; + if (isWindows) { + await execa("taskkill", ["/F", "/PID", pid.toString()]).catch((error) => { + // taskkill 128 -> the process is already dead + if (error.exitCode === 128) return; + if (/There is no running instance of the task./.test(error.message)) + return; + console.warn(error.message); + }); + return; + } + await execa("kill", ["-9", pid.toString()]).catch((error) => { + // process is already dead + if (/No such process/.test(error.message)) return; + console.warn(error.message); + }); +}; + +let isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +let killtree = async (pid: number) => { + let descendants = await pidtree(pid).catch(() => undefined); + if (descendants === undefined) return; + let pids = [pid, ...descendants]; + + await Promise.all(pids.map(kill)); + + return new Promise((resolve, reject) => { + let check = setInterval(() => { + pids = pids.filter(isAlive); + if (pids.length === 0) { + clearInterval(check); + resolve(); + } + }, 50); + + setTimeout(() => { + clearInterval(check); + reject( + new Error("Timeout: Processes did not exit within the specified time.") + ); + }, 2000); + }); +}; diff --git a/package.json b/package.json index 8087cf155d3..e619a5de393 100644 --- a/package.json +++ b/package.json @@ -84,10 +84,12 @@ "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "@types/react-test-renderer": "^18.0.0", + "@types/resolve-bin": "^0.4.1", "@types/retry": "^0.12.0", "@types/semver": "^7.3.4", "@types/serialize-javascript": "^5.0.2", "@types/ssri": "^7.1.0", + "@types/wait-on": "^5.3.2", "babel-jest": "^29.6.4", "babel-plugin-transform-remove-console": "^6.9.4", "chalk": "^4.1.2", @@ -121,9 +123,12 @@ "simple-git": "^3.16.0", "to-vfile": "7.2.3", "typescript": "^5.1.0", + "resolve-bin": "^1.0.1", "unified": "^10.1.2", "unist-util-remove": "^3.1.0", - "unist-util-visit": "^4.1.1" + "unist-util-visit": "^4.1.1", + "vite": "^4.4.9", + "wait-on": "^7.0.1" }, "engines": { "node": ">=18.0.0" diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 79b05e36efa..dd328b68942 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -355,12 +355,8 @@ export interface RemixConfig { */ export async function readConfig( remixRoot?: string, - serverMode = ServerMode.Production + serverMode?: ServerMode ): Promise { - if (!isValidServerMode(serverMode)) { - throw new Error(`Invalid server mode "${serverMode}"`); - } - if (!remixRoot) { remixRoot = process.env.REMIX_ROOT || process.cwd(); } @@ -393,6 +389,26 @@ export async function readConfig( } } + return await resolveConfig(appConfig, { + rootDirectory, + serverMode, + }); +} + +export async function resolveConfig( + appConfig: AppConfig, + { + rootDirectory, + serverMode = ServerMode.Production, + }: { + rootDirectory: string; + serverMode?: ServerMode; + } +): Promise { + if (!isValidServerMode(serverMode)) { + throw new Error(`Invalid server mode "${serverMode}"`); + } + let serverBuildPath = path.resolve( rootDirectory, appConfig.serverBuildPath ?? "build/index.js" @@ -436,7 +452,7 @@ export async function readConfig( let entryServerFile: string; let entryClientFile = userEntryClientFile || "entry.client.tsx"; - let pkgJson = await PackageJson.load(remixRoot); + let pkgJson = await PackageJson.load(rootDirectory); let deps = pkgJson.content.dependencies ?? {}; if (userEntryServerFile) { @@ -479,7 +495,7 @@ export async function readConfig( let packageManager = detectPackageManager() ?? "npm"; execSync(`${packageManager} install`, { - cwd: remixRoot, + cwd: rootDirectory, stdio: "inherit", }); } diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index 4c369df9dd5..4dbdb3faf21 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -6,3 +6,4 @@ export * as cli from "./cli/index"; export type { Manifest as AssetsManifest } from "./manifest"; export { getDependenciesToBundle } from "./dependencies"; +export { unstable_vitePlugin } from "./vite"; diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 7a6279be278..e5a3f5de03d 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -25,8 +25,11 @@ "@babel/plugin-syntax-jsx": "^7.21.4", "@babel/preset-typescript": "^7.21.5", "@babel/traverse": "^7.21.5", + "@babel/types": "^7.22.5", "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", + "@remix-run/node": "2.0.1", + "@remix-run/router": "1.10.0-pre.0", "@remix-run/server-runtime": "2.0.1", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", @@ -34,7 +37,9 @@ "cacache": "^17.1.3", "chalk": "^4.1.2", "chokidar": "^3.5.1", + "cross-spawn": "^7.0.3", "dotenv": "^16.0.0", + "es-module-lexer": "^1.3.1", "esbuild": "0.17.6", "esbuild-plugins-node-modules-polyfill": "^1.6.0", "execa": "5.1.1", @@ -50,6 +55,7 @@ "minimatch": "^9.0.0", "node-fetch": "^2.6.9", "ora": "^5.4.1", + "parse-multipart-data": "^1.5.0", "picocolors": "^1.0.0", "picomatch": "^2.3.1", "pidtree": "^0.6.0", @@ -63,13 +69,16 @@ "remark-frontmatter": "4.0.1", "remark-mdx-frontmatter": "^1.0.1", "semver": "^7.3.7", + "set-cookie-parser": "^2.6.0", "tar-fs": "^2.1.1", "tsconfig-paths": "^4.0.0", + "undici": "^5.22.1", "ws": "^7.4.5" }, "devDependencies": { "@remix-run/serve": "2.0.1", "@types/cacache": "^17.0.0", + "@types/cross-spawn": "^6.0.2", "@types/gunzip-maybe": "^1.4.0", "@types/jsesc": "^3.0.1", "@types/lodash.debounce": "^4.0.6", @@ -85,11 +94,13 @@ "fast-glob": "3.2.11", "msw": "^1.2.3", "strip-ansi": "^6.0.1", - "tiny-invariant": "^1.2.0" + "tiny-invariant": "^1.2.0", + "vite": "^4.4.9" }, "peerDependencies": { "@remix-run/serve": "^2.0.1", - "typescript": "^5.1.0" + "typescript": "^5.1.0", + "vite": "^4.4.9" }, "peerDependenciesMeta": { "@remix-run/serve": { @@ -97,6 +108,9 @@ }, "typescript": { "optional": true + }, + "vite": { + "optional": true } }, "engines": { diff --git a/packages/remix-dev/rollup.config.js b/packages/remix-dev/rollup.config.js index 9ffa2108934..b1d5fd10951 100644 --- a/packages/remix-dev/rollup.config.js +++ b/packages/remix-dev/rollup.config.js @@ -23,7 +23,12 @@ module.exports = function rollup() { external(id) { return isBareModuleId(id); }, - input: `${sourceDir}/index.ts`, + input: [ + `${sourceDir}/index.ts`, + // Since we're using a dynamic require for the Vite plugin, we + // need to tell Rollup it's an entry point + `${sourceDir}/vite/plugin.ts`, + ], output: { banner: createBanner("@remix-run/dev", version), dir: outputDist, @@ -43,6 +48,7 @@ module.exports = function rollup() { { src: `LICENSE.md`, dest: [outputDir, sourceDir] }, { src: `${sourceDir}/package.json`, dest: [outputDir, outputDist] }, { src: `${sourceDir}/README.md`, dest: outputDir }, + { src: `${sourceDir}/vite/static`, dest: `${outputDist}/vite` }, { src: `${sourceDir}/config/defaults`, dest: [`${outputDir}/config`, `${outputDist}/config`], diff --git a/packages/remix-dev/vite/babel.ts b/packages/remix-dev/vite/babel.ts new file mode 100644 index 00000000000..7f7467905aa --- /dev/null +++ b/packages/remix-dev/vite/babel.ts @@ -0,0 +1,9 @@ +import type { NodePath } from "@babel/traverse"; +import type { types as BabelTypes } from "@babel/core"; +import { parse } from "@babel/parser"; +import * as t from "@babel/types"; +import traverse from "@babel/traverse"; +import generate from "@babel/generator"; + +export { traverse, generate, parse, t }; +export type { BabelTypes, NodePath }; diff --git a/packages/remix-dev/vite/index.ts b/packages/remix-dev/vite/index.ts new file mode 100644 index 00000000000..1bcb08bb692 --- /dev/null +++ b/packages/remix-dev/vite/index.ts @@ -0,0 +1,10 @@ +// This file allows us to dynamically require the plugin so non-Vite consumers +// don't need to have Vite installed as a peer dependency. Only types should +// be imported at the top level. +import type { RemixVitePlugin } from "./plugin"; + +export const unstable_vitePlugin: RemixVitePlugin = (...args) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let { remixVitePlugin } = require("./plugin") as typeof import("./plugin"); + return remixVitePlugin(...args); +}; diff --git a/packages/remix-dev/vite/legacy-css-imports.ts b/packages/remix-dev/vite/legacy-css-imports.ts new file mode 100644 index 00000000000..b0ba00f2910 --- /dev/null +++ b/packages/remix-dev/vite/legacy-css-imports.ts @@ -0,0 +1,28 @@ +import { parse, traverse, generate, t } from "./babel"; + +export const transformLegacyCssImports = (source: string) => { + let ast = parse(source, { + sourceType: "module", + plugins: ["typescript", "jsx"], + }); + + traverse(ast, { + // Handle `import styles from "./styles.css"` + ImportDeclaration(path) { + if ( + path.node.source.value.endsWith(".css") && + // CSS Modules are bundled in the Remix compiler so they're already + // compatible with Vite's default CSS handling + !path.node.source.value.endsWith(".module.css") && + t.isImportDefaultSpecifier(path.node.specifiers[0]) + ) { + path.node.source.value += "?url"; + } + }, + }); + + return { + code: generate(ast, { retainLines: true }).code, + map: null, + }; +}; diff --git a/packages/remix-dev/vite/node/adapter.ts b/packages/remix-dev/vite/node/adapter.ts new file mode 100644 index 00000000000..3377163431d --- /dev/null +++ b/packages/remix-dev/vite/node/adapter.ts @@ -0,0 +1,205 @@ +// @ts-nocheck +// adapted from https://github.com/solidjs/solid-start/blob/ccff60ce75e066f6613daf0272dbb43a196235a4/packages/start/node/fetch.js +import { once } from "events"; +import { type IncomingMessage, type ServerResponse } from "http"; +import multipart from "parse-multipart-data"; +import { splitCookiesString } from "set-cookie-parser"; +import { Readable } from "stream"; +import { File, FormData, Headers, Request as BaseNodeRequest } from "undici"; +import { type ServerBuild, installGlobals } from "@remix-run/node"; +import { createRequestHandler as createBaseRequestHandler } from "@remix-run/server-runtime"; + +installGlobals(); + +function nodeToWeb(nodeStream) { + let destroyed = false; + let listeners = {}; + + function start(controller) { + listeners["data"] = onData; + listeners["end"] = onData; + listeners["end"] = onDestroy; + listeners["close"] = onDestroy; + listeners["error"] = onDestroy; + for (let name in listeners) nodeStream.on(name, listeners[name]); + + nodeStream.pause(); + + function onData(chunk) { + if (destroyed) return; + controller.enqueue(chunk); + nodeStream.pause(); + } + + function onDestroy(err) { + if (destroyed) return; + destroyed = true; + + for (let name in listeners) + nodeStream.removeListener(name, listeners[name]); + + if (err) controller.error(err); + else controller.close(); + } + } + + function pull() { + if (destroyed) return; + nodeStream.resume(); + } + + function cancel() { + destroyed = true; + + for (let name in listeners) + nodeStream.removeListener(name, listeners[name]); + + nodeStream.push(null); + nodeStream.pause(); + if (nodeStream.destroy) nodeStream.destroy(); + else if (nodeStream.close) nodeStream.close(); + } + + return new ReadableStream({ start: start, pull: pull, cancel: cancel }); +} + +function createHeaders(requestHeaders) { + let headers = new Headers(); + + for (let [key, values] of Object.entries(requestHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (let value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); + } + } + } + + return headers; +} + +class NodeRequest extends BaseNodeRequest { + constructor(input, init) { + if (init && init.data && init.data.on) { + init = { + duplex: "half", + ...init, + body: init.data.headers["content-type"]?.includes("x-www") + ? init.data + : nodeToWeb(init.data), + }; + } + + super(input, init); + } + + // async json() { + // return JSON.parse(await this.text()); + // } + + async buffer() { + return Buffer.from(await super.arrayBuffer()); + } + + // async text() { + // return (await this.buffer()).toString(); + // } + + // @ts-ignore + async formData() { + if ( + this.headers.get("content-type") === "application/x-www-form-urlencoded" + ) { + return await super.formData(); + } else { + let data = await this.buffer(); + let input = multipart.parse( + data, + this.headers + .get("content-type") + .replace("multipart/form-data; boundary=", "") + ); + let form = new FormData(); + input.forEach(({ name, data, filename, type }) => { + // file fields have Content-Type set, + // whereas non-file fields must not + // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data + let isFile = type !== undefined; + if (isFile) { + let value = new File([data], filename, { type }); + form.append(name, value, filename); + } else { + let value = data.toString("utf-8"); + form.append(name, value); + } + }); + return form; + } + } + + // @ts-ignore + clone() { + /** @type {BaseNodeRequest & { buffer?: () => Promise; formData?: () => Promise }} */ + let el = super.clone(); + el.buffer = this.buffer.bind(el); + el.formData = this.formData.bind(el); + return el; + } +} + +function createRequest(req: IncomingMessage): Request { + let origin = + req.headers.origin && "null" !== req.headers.origin + ? req.headers.origin + : `http://${req.headers.host}`; + let url = new URL(req.url, origin); + + let init = { + method: req.method, + headers: createHeaders(req.headers), + // POST, PUT, & PATCH will be read as body by NodeRequest + data: req.method.indexOf("P") === 0 ? req : null, + }; + + return new NodeRequest(url.href, init); +} + +async function handleNodeResponse(webRes: Response, res: ServerResponse) { + res.statusCode = webRes.status; + res.statusMessage = webRes.statusText; + + for (let [name, value] of webRes.headers) { + if (name === "set-cookie") { + res.setHeader(name, splitCookiesString(value)); + } else res.setHeader(name, value); + } + + if (webRes.body) { + let readable = Readable.from(webRes.body); + readable.pipe(res); + await once(readable, "end"); + } else { + res.end(); + } +} + +export let createRequestHandler = ( + build: ServerBuild, + { + mode = "production", + criticalCss, + }: { + mode?: string; + criticalCss?: string; + } +) => { + let handler = createBaseRequestHandler(build, mode); + return async (req: IncomingMessage, res: ServerResponse) => { + let request = createRequest(req); + let response = await handler(request, {}, { criticalCss }); + handleNodeResponse(response, res); + }; +}; diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts new file mode 100644 index 00000000000..8b6d89fe6ce --- /dev/null +++ b/packages/remix-dev/vite/plugin.ts @@ -0,0 +1,884 @@ +import { type BinaryLike, createHash } from "node:crypto"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import babel from "@babel/core"; +import { type ServerBuild } from "@remix-run/server-runtime"; +import { + type Plugin as VitePlugin, + type Manifest as ViteManifest, + type ResolvedConfig as ResolvedViteConfig, + type ViteDevServer, + type UserConfig as ViteUserConfig, + normalizePath as viteNormalizePath, + createServer as createViteDevServer, +} from "vite"; +import { + init as initEsModuleLexer, + parse as esModuleLexer, +} from "es-module-lexer"; +import jsesc from "jsesc"; +import pick from "lodash/pick"; +import colors from "picocolors"; + +import { type RouteManifest } from "../config/routes"; +import { + type AppConfig as RemixUserConfig, + type RemixConfig as ResolvedRemixConfig, + resolveConfig, +} from "../config"; +import { type Manifest } from "../manifest"; +import { createRequestHandler } from "./node/adapter"; +import { getStylesForUrl, isCssModulesFile } from "./styles"; +import * as VirtualModule from "./vmod"; +import { removeExports } from "./remove-exports"; +import { transformLegacyCssImports } from "./legacy-css-imports"; +import { replaceImportSpecifier } from "./replace-import-specifier"; + +const supportedRemixConfigKeys = [ + "appDirectory", + "assetsBuildDirectory", + "ignoredRouteFiles", + "publicPath", + "routes", + "serverBuildPath", + "serverModuleFormat", +] as const satisfies ReadonlyArray; +type SupportedRemixConfigKey = typeof supportedRemixConfigKeys[number]; + +export type RemixVitePluginOptions = Pick< + RemixUserConfig, + SupportedRemixConfigKey +> & { + legacyCssImports?: boolean; +}; + +type ResolvedRemixVitePluginConfig = Pick< + ResolvedRemixConfig, + | "appDirectory" + | "rootDirectory" + | "assetsBuildDirectory" + | "entryClientFilePath" + | "entryServerFilePath" + | "future" + | "publicPath" + | "relativeAssetsBuildDirectory" + | "routes" + | "serverBuildPath" + | "serverModuleFormat" +>; + +let serverEntryId = VirtualModule.id("server-entry"); +let serverManifestId = VirtualModule.id("server-manifest"); +let browserManifestId = VirtualModule.id("browser-manifest"); +let remixReactProxyId = VirtualModule.id("remix-react-proxy"); +let hmrRuntimeId = VirtualModule.id("hmr-runtime"); + +const normalizePath = (p: string) => { + let unixPath = p.replace(/[\\/]+/g, "/").replace(/^([a-zA-Z]+:|\.\/)/, ""); + return viteNormalizePath(unixPath); +}; + +const resolveFileUrl = ( + { rootDirectory }: Pick, + filePath: string +) => { + let relativePath = path.relative(rootDirectory, filePath); + + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error( + `Cannot resolve asset path "${filePath}" outside of root directory "${rootDirectory}".` + ); + } + + return `/${normalizePath(relativePath)}`; +}; + +const isJsFile = (filePath: string) => /\.[cm]?[jt]sx?$/i.test(filePath); + +type Route = RouteManifest[string]; +const resolveRelativeRouteFilePath = ( + route: Route, + pluginConfig: ResolvedRemixVitePluginConfig +) => { + let file = route.file; + let fullPath = path.resolve(pluginConfig.appDirectory, file); + + return normalizePath(fullPath); +}; + +let vmods = [serverEntryId, serverManifestId, browserManifestId]; + +const getHash = (source: BinaryLike, maxLength?: number): string => { + let hash = createHash("sha256").update(source).digest("hex"); + return typeof maxLength === "number" ? hash.slice(0, maxLength) : hash; +}; + +const resolveBuildAssetPaths = ( + pluginConfig: ResolvedRemixVitePluginConfig, + manifest: ViteManifest, + absoluteFilePath: string +): Manifest["entry"] & { css: string[] } => { + let rootRelativeFilePath = path.relative( + pluginConfig.rootDirectory, + absoluteFilePath + ); + let manifestKey = normalizePath(rootRelativeFilePath); + let manifestEntry = manifest[manifestKey]; + + if (!manifestEntry) { + let knownManifestKeys = Object.keys(manifest) + .map((key) => '"' + key + '"') + .join(", "); + throw new Error( + `No manifest entry found for "${manifestKey}". Known manifest keys: ${knownManifestKeys}` + ); + } + + return { + module: `${pluginConfig.publicPath}${manifestEntry.file}`, + imports: + manifestEntry.imports?.map((imported) => { + return `${pluginConfig.publicPath}${manifest[imported].file}`; + }) ?? [], + css: + manifestEntry.css?.map((href) => { + return `${pluginConfig.publicPath}${href}`; + }) ?? [], + }; +}; + +const writeFileSafe = async (file: string, contents: string): Promise => { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, contents); +}; + +const getRouteModuleExports = async ( + viteChildCompiler: ViteDevServer | null, + pluginConfig: ResolvedRemixVitePluginConfig, + routeFile: string +): Promise => { + if (!viteChildCompiler) { + throw new Error("Vite child compiler not found"); + } + + // We transform the route module code with the Vite child compiler so that we + // can parse the exports from non-JS files like MDX. This ensures that we can + // understand the exports from anything that Vite can compile to JS, not just + // the route file formats that the Remix compiler historically supported. + + let ssr = true; + let { pluginContainer, moduleGraph } = viteChildCompiler; + let routePath = path.join(pluginConfig.appDirectory, routeFile); + let url = resolveFileUrl(pluginConfig, routePath); + + let resolveId = async () => { + let result = await pluginContainer.resolveId(url, undefined, { ssr }); + if (!result) throw new Error(`Could not resolve module ID for ${url}`); + return result.id; + }; + + let [id, code] = await Promise.all([ + resolveId(), + fs.readFile(routePath, "utf-8"), + // pluginContainer.transform(...) fails if we don't do this first: + moduleGraph.ensureEntryFromUrl(url, ssr), + ]); + + let transformed = await pluginContainer.transform(code, id, { ssr }); + let [, exports] = esModuleLexer(transformed.code); + let exportNames = exports.map((e) => e.n); + + return exportNames; +}; + +const showUnstableWarning = () => { + console.warn( + colors.yellow( + "\n ⚠️ Remix support for Vite is unstable\n and not recommended for production\n" + ) + ); +}; + +export type RemixVitePlugin = ( + options?: RemixVitePluginOptions +) => VitePlugin[]; +export const remixVitePlugin: RemixVitePlugin = (options = {}) => { + let viteCommand: ResolvedViteConfig["command"]; + let viteUserConfig: ViteUserConfig; + + let cssModulesManifest: Record = {}; + let ssrBuildContext: + | { isSsrBuild: false } + | { isSsrBuild: true; getManifest: () => Promise }; + + let viteChildCompiler: ViteDevServer | null = null; + + let resolvePluginConfig = + async (): Promise => { + let rootDirectory = + viteUserConfig.root ?? process.env.REMIX_ROOT ?? process.cwd(); + + // Avoid leaking any config options that the Vite plugin doesn't support + let config = pick(options, supportedRemixConfigKeys); + + // Only select the Remix config options that the Vite plugin uses + let { + appDirectory, + assetsBuildDirectory, + entryClientFilePath, + publicPath, + routes, + entryServerFilePath, + serverBuildPath, + serverModuleFormat, + relativeAssetsBuildDirectory, + } = await resolveConfig(config, { rootDirectory }); + + return { + appDirectory, + rootDirectory, + assetsBuildDirectory, + entryClientFilePath, + publicPath, + routes, + entryServerFilePath, + serverBuildPath, + serverModuleFormat, + relativeAssetsBuildDirectory, + future: {}, + }; + }; + + let getServerEntry = async () => { + let pluginConfig = await resolvePluginConfig(); + + return ` + import * as entryServer from ${JSON.stringify( + resolveFileUrl(pluginConfig, pluginConfig.entryServerFilePath) + )}; + ${Object.keys(pluginConfig.routes) + .map((key, index) => { + let route = pluginConfig.routes[key]!; + return `import * as route${index} from ${JSON.stringify( + resolveFileUrl( + pluginConfig, + resolveRelativeRouteFilePath(route, pluginConfig) + ) + )};`; + }) + .join("\n")} + export { default as assets } from ${JSON.stringify(serverManifestId)}; + export const assetsBuildDirectory = ${JSON.stringify( + pluginConfig.relativeAssetsBuildDirectory + )}; + ${ + pluginConfig.future + ? `export const future = ${JSON.stringify(pluginConfig.future)}` + : "" + }; + export const publicPath = ${JSON.stringify(pluginConfig.publicPath)}; + export const entry = { module: entryServer }; + export const routes = { + ${Object.keys(pluginConfig.routes) + .map((key, index) => { + let route = pluginConfig.routes[key]!; + return `${JSON.stringify(key)}: { + id: ${JSON.stringify(route.id)}, + parentId: ${JSON.stringify(route.parentId)}, + path: ${JSON.stringify(route.path)}, + index: ${JSON.stringify(route.index)}, + caseSensitive: ${JSON.stringify(route.caseSensitive)}, + module: route${index} + }`; + }) + .join(",\n ")} + };`; + }; + + let createBuildManifest = async (): Promise => { + let pluginConfig = await resolvePluginConfig(); + let viteManifest = JSON.parse( + await fs.readFile( + path.resolve(pluginConfig.assetsBuildDirectory, "manifest.json"), + "utf-8" + ) + ) as ViteManifest; + + let entry: Manifest["entry"] = resolveBuildAssetPaths( + pluginConfig, + viteManifest, + pluginConfig.entryClientFilePath + ); + + let routes: Manifest["routes"] = {}; + for (let [key, route] of Object.entries(pluginConfig.routes)) { + let routeFilePath = path.join(pluginConfig.appDirectory, route.file); + let sourceExports = await getRouteModuleExports( + viteChildCompiler, + pluginConfig, + route.file + ); + + routes[key] = { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + hasAction: sourceExports.includes("action"), + hasLoader: sourceExports.includes("loader"), + hasErrorBoundary: sourceExports.includes("ErrorBoundary"), + ...resolveBuildAssetPaths(pluginConfig, viteManifest, routeFilePath), + }; + } + + let fingerprintedValues = { entry, routes }; + let version = getHash(JSON.stringify(fingerprintedValues), 8); + let manifestFilename = `manifest-${version}.js`; + let url = `${pluginConfig.publicPath}${manifestFilename}`; + let nonFingerprintedValues = { url, version }; + + let manifest: Manifest = { + ...fingerprintedValues, + ...nonFingerprintedValues, + }; + + await writeFileSafe( + path.join(pluginConfig.assetsBuildDirectory, manifestFilename), + `window.__remixManifest=${JSON.stringify(manifest)};` + ); + + return manifest; + }; + + let getDevManifest = async (): Promise => { + let pluginConfig = await resolvePluginConfig(); + let routes: Manifest["routes"] = {}; + + for (let [key, route] of Object.entries(pluginConfig.routes)) { + let sourceExports = await getRouteModuleExports( + viteChildCompiler, + pluginConfig, + route.file + ); + + routes[key] = { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: `${resolveFileUrl( + pluginConfig, + resolveRelativeRouteFilePath(route, pluginConfig) + )}${ + isJsFile(route.file) ? "" : "?import" // Ensure the Vite dev server responds with a JS module + }`, + hasAction: sourceExports.includes("action"), + hasLoader: sourceExports.includes("loader"), + hasErrorBoundary: sourceExports.includes("ErrorBoundary"), + imports: [], + }; + } + + return { + version: String(Math.random()), + url: VirtualModule.url(browserManifestId), + entry: { + module: resolveFileUrl(pluginConfig, pluginConfig.entryClientFilePath), + imports: [], + }, + routes, + }; + }; + + return [ + { + name: "remix", + config: async (_viteUserConfig, viteConfigEnv) => { + viteUserConfig = _viteUserConfig; + viteCommand = viteConfigEnv.command; + + let pluginConfig = await resolvePluginConfig(); + + return { + appType: "custom", + experimental: { hmrPartialAccept: true }, + ...(viteCommand === "build" && { + base: pluginConfig.publicPath, + build: { + ...viteUserConfig.build, + ...(!viteConfigEnv.ssrBuild + ? { + manifest: true, + outDir: pluginConfig.assetsBuildDirectory, + rollupOptions: { + ...viteUserConfig.build?.rollupOptions, + preserveEntrySignatures: "exports-only", + input: [ + pluginConfig.entryClientFilePath, + ...Object.values(pluginConfig.routes).map((route) => + path.resolve(pluginConfig.appDirectory, route.file) + ), + ], + }, + } + : { + outDir: path.dirname(pluginConfig.serverBuildPath), + rollupOptions: { + ...viteUserConfig.build?.rollupOptions, + preserveEntrySignatures: "exports-only", + input: serverEntryId, + output: { + entryFileNames: path.basename( + pluginConfig.serverBuildPath + ), + format: pluginConfig.serverModuleFormat, + }, + }, + }), + }, + }), + }; + }, + async configResolved(viteConfig) { + await initEsModuleLexer; + + viteChildCompiler = await createViteDevServer({ + ...viteUserConfig, + configFile: false, + envFile: false, + plugins: [ + ...(viteUserConfig.plugins ?? []) + .flat() + // Exclude this plugin from the child compiler to prevent an + // infinite loop (plugin creates a child compiler with the same + // plugin that creates another child compiler, repeat ad + // infinitum), and to prevent the manifest from being written to + // disk from the child compiler. This is important in the + // production build because the child compiler is a Vite dev + // server and will generate incorrect manifests. + .filter( + (plugin) => + typeof plugin === "object" && + plugin !== null && + "name" in plugin && + plugin.name !== "remix" && + plugin.name !== "remix-hmr-updates" + ), + { + name: "no-hmr", + handleHotUpdate() { + // parent vite server is already sending HMR updates + // do not send duplicate HMR updates from child server + // which log confusing "page reloaded" messages that aren't true + return []; + }, + }, + ], + }); + await viteChildCompiler.pluginContainer.buildStart({}); + + ssrBuildContext = + viteConfig.build.ssr && viteCommand === "build" + ? { isSsrBuild: true, getManifest: createBuildManifest } + : { isSsrBuild: false }; + }, + transform(code, id) { + if (isCssModulesFile(id)) { + cssModulesManifest[id] = code; + } + }, + buildStart() { + if (viteCommand === "build") { + showUnstableWarning(); + } + }, + configureServer(vite) { + vite.httpServer?.on("listening", () => { + setTimeout(showUnstableWarning, 50); + }); + return () => { + vite.middlewares.use(async (req, res, next) => { + try { + // Invalidate all virtual modules + vmods.forEach((vmod) => { + let mod = vite.moduleGraph.getModuleById( + VirtualModule.resolve(vmod) + ); + + if (mod) { + vite.moduleGraph.invalidateModule(mod); + } + }); + + let { url } = req; + let [pluginConfig, build] = await Promise.all([ + resolvePluginConfig(), + vite.ssrLoadModule(serverEntryId) as Promise, + ]); + + let handle = createRequestHandler(build, { + mode: "development", + criticalCss: await getStylesForUrl( + vite, + pluginConfig, + cssModulesManifest, + build, + url + ), + }); + + await handle(req, res); + } catch (error) { + next(error); + } + }); + }; + }, + async buildEnd() { + await viteChildCompiler?.close(); + }, + }, + { + name: "remix-virtual-modules", + enforce: "pre", + resolveId(id) { + if (vmods.includes(id)) return VirtualModule.resolve(id); + }, + async load(id) { + switch (id) { + case VirtualModule.resolve(serverEntryId): { + return await getServerEntry(); + } + case VirtualModule.resolve(serverManifestId): { + let manifest = ssrBuildContext.isSsrBuild + ? await ssrBuildContext.getManifest() + : await getDevManifest(); + + return `export default ${jsesc(manifest, { es6: true })};`; + } + case VirtualModule.resolve(browserManifestId): { + if (viteCommand === "build") { + throw new Error("This module only exists in development"); + } + + let manifest = await getDevManifest(); + + return `window.__remixManifest=${jsesc(manifest, { es6: true })};`; + } + } + }, + }, + { + name: "remix-empty-server-modules", + enforce: "pre", + async transform(_code, id, options) { + if (!options?.ssr && /\.server(\.[cm]?[jt]sx?)?$/.test(id)) + return { + code: "export default {}", + map: null, + }; + }, + }, + { + name: "remix-empty-client-modules", + enforce: "pre", + async transform(_code, id, options) { + if (options?.ssr && /\.client(\.[cm]?[jt]sx?)?$/.test(id)) + return { + code: "export default {}", + map: null, + }; + }, + }, + { + name: "remix-remove-server-exports", + enforce: "post", // Ensure we're operating on the transformed code to support MDX etc. + async transform(code, id, options) { + if (options?.ssr) return; + + let pluginConfig = await resolvePluginConfig(); + + let route = getRoute(pluginConfig, id); + if (!route) return; + + let serverExports = ["loader", "action", "headers"]; + + let routeExports = await getRouteModuleExports( + viteChildCompiler, + pluginConfig, + route.file + ); + + // ignore resource routes that only have server exports + // note: resource routes for fullstack components don't need a `default` export + // but still need their server exports removed + let browserExports = routeExports.filter( + (x) => !serverExports.includes(x) + ); + if (browserExports.length === 0) return; + + return { + code: removeExports(code, serverExports), + map: null, + }; + }, + }, + { + name: "remix-remix-react-proxy", + enforce: "post", // Ensure we're operating on the transformed code to support MDX etc. + resolveId(id) { + if (id === remixReactProxyId) { + return VirtualModule.resolve(remixReactProxyId); + } + }, + transform(code, id) { + // Don't transform the proxy itself, otherwise it will import itself + if (id === VirtualModule.resolve(remixReactProxyId)) { + return; + } + + // Don't transform files that don't need the proxy + if ( + !code.includes("@remix-run/react") && + !code.includes("LiveReload") + ) { + return; + } + + // Rewrite imports to use the proxy + return replaceImportSpecifier({ + code, + specifier: "@remix-run/react", + replaceWith: remixReactProxyId, + }); + }, + load(id) { + if (id === VirtualModule.resolve(remixReactProxyId)) { + // TODO: ensure react refresh is initialized before `` + return [ + 'import { createElement } from "react";', + 'export * from "@remix-run/react";', + 'export const LiveReload = process.env.NODE_ENV !== "development" ? () => null : ', + '() => createElement("script", {', + ' type: "module",', + " suppressHydrationWarning: true,", + " dangerouslySetInnerHTML: { __html: `", + ` import RefreshRuntime from "${VirtualModule.url( + hmrRuntimeId + )}"`, + " RefreshRuntime.injectIntoGlobalHook(window)", + " window.$RefreshReg$ = () => {}", + " window.$RefreshSig$ = () => (type) => type", + " window.__vite_plugin_react_preamble_installed__ = true", + " `}", + "});", + ].join("\n"); + } + }, + }, + { + name: "remix-hmr-runtime", + enforce: "pre", + resolveId(id) { + if (id === hmrRuntimeId) return VirtualModule.resolve(hmrRuntimeId); + }, + async load(id) { + if (id !== VirtualModule.resolve(hmrRuntimeId)) return; + + let reactRefreshDir = path.dirname( + require.resolve("react-refresh/package.json") + ); + let reactRefreshRuntimePath = path.join( + reactRefreshDir, + "cjs/react-refresh-runtime.development.js" + ); + + return [ + "const exports = {}", + await fs.readFile(reactRefreshRuntimePath, "utf8"), + await fs.readFile( + require.resolve("./static/refresh-utils.cjs"), + "utf8" + ), + "export default exports", + ].join("\n"); + }, + }, + { + name: "remix-react-refresh-babel", + enforce: "post", + async transform(code, id, options) { + if (viteCommand !== "serve") return; + if (id.includes("/node_modules/")) return; + + let [filepath] = id.split("?"); + if (!/.[tj]sx?$/.test(filepath)) return; + + let devRuntime = "react/jsx-dev-runtime"; + let ssr = options?.ssr === true; + let isJSX = filepath.endsWith("x"); + let useFastRefresh = !ssr && (isJSX || code.includes(devRuntime)); + if (!useFastRefresh) return; + + let result = await babel.transformAsync(code, { + filename: id, + sourceFileName: filepath, + parserOpts: { + sourceType: "module", + allowAwaitOutsideFunction: true, + plugins: ["jsx", "typescript"], + }, + plugins: ["react-refresh/babel"], + sourceMaps: true, + }); + if (result === null) return; + + code = result.code!; + let refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/; + if (refreshContentRE.test(code)) { + let pluginConfig = await resolvePluginConfig(); + code = addRefreshWrapper(pluginConfig, code, id); + } + return { code, map: result.map }; + }, + }, + { + name: "remix-hmr-updates", + async handleHotUpdate({ server, file, modules }) { + let pluginConfig = await resolvePluginConfig(); + let route = getRoute(pluginConfig, file); + if (route) { + server.ws.send({ + type: "custom", + event: "remix:hmr-route", + data: { + route: await getRouteMetadata( + pluginConfig, + viteChildCompiler, + route + ), + }, + }); + return modules; + } + return modules; + }, + }, + ...((options.legacyCssImports + ? [ + { + name: "remix-legacy-css-imports", + enforce: "pre", + transform(code) { + if (code.includes('.css"') || code.includes(".css'")) { + return transformLegacyCssImports(code); + } + }, + }, + ] + : []) satisfies VitePlugin[]), + ]; +}; + +function addRefreshWrapper( + pluginConfig: ResolvedRemixVitePluginConfig, + code: string, + id: string +): string { + let isRoute = getRoute(pluginConfig, id); + let acceptExports = isRoute ? ["meta", "links", "shouldRevalidate"] : []; + return ( + REACT_REFRESH_HEADER.replace("__SOURCE__", JSON.stringify(id)) + + code + + REACT_REFRESH_FOOTER.replace("__SOURCE__", JSON.stringify(id)).replace( + "__ACCEPT_EXPORTS__", + JSON.stringify(acceptExports) + ) + ); +} + +const REACT_REFRESH_HEADER = ` +import RefreshRuntime from "${hmrRuntimeId}"; + +const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; +let prevRefreshReg; +let prevRefreshSig; + +if (import.meta.hot && !inWebWorker) { + if (!window.__vite_plugin_react_preamble_installed__) { + throw new Error( + "@vitejs/plugin-react can't detect preamble. Something is wrong. " + + "See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201" + ); + } + + prevRefreshReg = window.$RefreshReg$; + prevRefreshSig = window.$RefreshSig$; + window.$RefreshReg$ = (type, id) => { + RefreshRuntime.register(type, __SOURCE__ + " " + id) + }; + window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; +}`.replace(/\n+/g, ""); + +const REACT_REFRESH_FOOTER = ` +if (import.meta.hot && !inWebWorker) { + window.$RefreshReg$ = prevRefreshReg; + window.$RefreshSig$ = prevRefreshSig; + RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { + RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports); + import.meta.hot.accept((nextExports) => { + if (!nextExports) return; + const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports, __ACCEPT_EXPORTS__); + if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); + }); + }); +}`; + +function getRoute( + pluginConfig: ResolvedRemixVitePluginConfig, + file: string +): Route | undefined { + if (!file.startsWith(pluginConfig.appDirectory)) return; + let routePath = path.relative(pluginConfig.appDirectory, file); + let route = Object.values(pluginConfig.routes).find( + (r) => r.file === routePath + ); + return route; +} + +async function getRouteMetadata( + pluginConfig: ResolvedRemixVitePluginConfig, + viteChildCompiler: ViteDevServer | null, + route: Route +) { + let sourceExports = await getRouteModuleExports( + viteChildCompiler, + pluginConfig, + route.file + ); + + let info = { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + url: + "/" + + path.relative( + pluginConfig.rootDirectory, + resolveRelativeRouteFilePath(route, pluginConfig) + ), + module: `${resolveFileUrl( + pluginConfig, + resolveRelativeRouteFilePath(route, pluginConfig) + )}?import`, // Ensure the Vite dev server responds with a JS module + hasAction: sourceExports.includes("action"), + hasLoader: sourceExports.includes("loader"), + hasErrorBoundary: sourceExports.includes("ErrorBoundary"), + imports: [], + }; + return info; +} diff --git a/packages/remix-dev/vite/remove-exports-test.ts b/packages/remix-dev/vite/remove-exports-test.ts new file mode 100644 index 00000000000..bb52e1f8822 --- /dev/null +++ b/packages/remix-dev/vite/remove-exports-test.ts @@ -0,0 +1,399 @@ +import { removeExports } from "./remove-exports"; + +describe("removeExports", () => { + test("arrow function", () => { + let result = removeExports( + ` + export const serverExport_1 = () => {} + export const serverExport_2 = () => {} + + export const clientExport_1 = () => {} + export const clientExport_2 = () => {} + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export const clientExport_1 = () => {}; + export const clientExport_2 = () => {};" + `); + expect(result).not.toMatch(/server/i); + }); + + test("arrow function with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = () => serverUtil() + export const serverExport_2 = () => serverUtil() + + export const clientExport_1 = () => clientUtil() + export const clientExport_2 = () => clientUtil() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = () => clientUtil(); + export const clientExport_2 = () => clientUtil();" + `); + expect(result).not.toMatch(/server/i); + }); + + test("function statement", () => { + let result = removeExports( + ` + export function serverExport_1(){} + export function serverExport_2(){} + + export function clientExport_1(){} + export function clientExport_2(){} + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export function clientExport_1() {} + export function clientExport_2() {}" + `); + expect(result).not.toMatch(/server/i); + }); + + test("function statement with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + function sharedUtil() { return sharedLib() } + function serverUtil() { return sharedUtil(serverLib(SERVER_STRING)) } + function clientUtil() { return sharedUtil(clientLib()) } + + export function serverExport_1() { return serverUtil() } + export function serverExport_2() { return serverUtil() } + + export function clientExport_1() { return clientUtil() } + export function clientExport_2() { return clientUtil() } + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + function sharedUtil() { + return sharedLib(); + } + function clientUtil() { + return sharedUtil(clientLib()); + } + export function clientExport_1() { + return clientUtil(); + } + export function clientExport_2() { + return clientUtil(); + }" + `); + expect(result).not.toMatch(/server/i); + }); + + test("object", () => { + let result = removeExports( + ` + export const serverExport_1 = {} + export const serverExport_2 = {} + + export const clientExport_1 = {} + export const clientExport_2 = {} + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export const clientExport_1 = {}; + export const clientExport_2 = {};" + `); + expect(result).not.toMatch(/server/i); + }); + + test("object with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = { value: serverUtil() } + export const serverExport_2 = { value: serverUtil() } + + export const clientExport_1 = { value: clientUtil() } + export const clientExport_2 = { value: clientUtil() } + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = { + value: clientUtil() + }; + export const clientExport_2 = { + value: clientUtil() + };" + `); + expect(result).not.toMatch(/server/i); + }); + + test("function call", () => { + let result = removeExports( + ` + export const serverExport_1 = globalFunction() + export const serverExport_2 = globalFunction() + + export const clientExport_1 = globalFunction() + export const clientExport_2 = globalFunction() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export const clientExport_1 = globalFunction(); + export const clientExport_2 = globalFunction();" + `); + expect(result).not.toMatch(/server/i); + }); + + test("function call with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = serverUtil() + export const serverExport_2 = serverUtil() + + export const clientExport_1 = clientUtil() + export const clientExport_2 = clientUtil() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = clientUtil(); + export const clientExport_2 = clientUtil();" + `); + expect(result).not.toMatch(/server/i); + }); + + test("iife", () => { + let result = removeExports( + ` + export const serverExport_1 = (() => {})() + export const serverExport_2 = (() => {})() + + export const clientExport_1 = (() => {})() + export const clientExport_2 = (() => {})() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export const clientExport_1 = (() => {})(); + export const clientExport_2 = (() => {})();" + `); + expect(result).not.toMatch(/server/i); + }); + + test("iife with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = (() => serverUtil())() + export const serverExport_2 = (() => serverUtil())() + + export const clientExport_1 = (() => clientUtil())() + export const clientExport_2 = (() => clientUtil())() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = (() => clientUtil())(); + export const clientExport_2 = (() => clientUtil())();" + `); + expect(result).not.toMatch(/server/i); + }); + + test("re-export", () => { + let result = removeExports( + ` + export { serverExport_1 } from './server/1' + export { serverExport_2 } from './server/2' + + export { clientExport_1 } from './client/1' + export { clientExport_2 } from './client/2' + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export { clientExport_1 } from './client/1'; + export { clientExport_2 } from './client/2';" + `); + expect(result).not.toMatch(/server/i); + }); + + test("re-export multiple", () => { + let result = removeExports( + ` + export { serverExport_1, serverExport_2 } from './server' + + export { clientExport_1, clientExport_2 } from './client' + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot( + "\"export { clientExport_1, clientExport_2 } from './client';\"" + ); + expect(result).not.toMatch(/server/i); + }); + + test("re-export manual", () => { + let result = removeExports( + ` + import { serverExport_1 } from './server/1' + import { serverExport_2 } from './server/2' + import { clientExport_1 } from './client/1' + import { clientExport_2 } from './client/2' + + export { serverExport_1 } + export { serverExport_2 } + + export { clientExport_1 } + export { clientExport_2 } + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "import { clientExport_1 } from './client/1'; + import { clientExport_2 } from './client/2'; + export { clientExport_1 }; + export { clientExport_2 };" + `); + expect(result).not.toMatch(/server/i); + }); + + test("number", () => { + let result = removeExports( + ` + export const serverExport_1 = 123 + export const serverExport_2 = 123 + + export const clientExport_1 = 123 + export const clientExport_2 = 123 + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export const clientExport_1 = 123; + export const clientExport_2 = 123;" + `); + expect(result).not.toMatch(/server/i); + }); + + test("string", () => { + let result = removeExports( + ` + export const serverExport_1 = 'string' + export const serverExport_2 = 'string' + + export const clientExport_1 = 'string' + export const clientExport_2 = 'string' + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export const clientExport_1 = 'string'; + export const clientExport_2 = 'string';" + `); + expect(result).not.toMatch(/server/i); + }); + + test("string reference", () => { + let result = removeExports( + ` + const SERVER_STRING = 'SERVER_STRING'; + const CLIENT_STRING = 'CLIENT_STRING'; + + export const serverExport_1 = SERVER_STRING + export const serverExport_2 = SERVER_STRING + + export const clientExport_1 = CLIENT_STRING + export const clientExport_2 = CLIENT_STRING + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "const CLIENT_STRING = 'CLIENT_STRING'; + export const clientExport_1 = CLIENT_STRING; + export const clientExport_2 = CLIENT_STRING;" + `); + expect(result).not.toMatch(/server/i); + }); + + test("null", () => { + let result = removeExports( + ` + export const serverExport_1 = null + export const serverExport_2 = null + + export const clientExport_1 = null + export const clientExport_2 = null + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result).toMatchInlineSnapshot(` + "export const clientExport_1 = null; + export const clientExport_2 = null;" + `); + expect(result).not.toMatch(/server/i); + }); +}); diff --git a/packages/remix-dev/vite/remove-exports.ts b/packages/remix-dev/vite/remove-exports.ts new file mode 100644 index 00000000000..cb5da87b9a6 --- /dev/null +++ b/packages/remix-dev/vite/remove-exports.ts @@ -0,0 +1,363 @@ +// Adapted from https://github.com/egoist/babel-plugin-eliminator/blob/d29859396b7708b7f7abbacdd951cbbc80902f00/src/index.ts +// Which was originally adapted from https://github.com/vercel/next.js/blob/574fe0b582d5cc1b13663121fd47a3d82deaaa17/packages/next/build/babel/plugins/next-ssg-transform.ts +import { + type BabelTypes, + type NodePath, + parse, + traverse, + generate, + t, +} from "./babel"; + +function getIdentifier( + path: NodePath< + | BabelTypes.FunctionDeclaration + | BabelTypes.FunctionExpression + | BabelTypes.ArrowFunctionExpression + > +): NodePath | null { + let parentPath = path.parentPath; + if (parentPath.type === "VariableDeclarator") { + let variablePath = parentPath as NodePath; + let name = variablePath.get("id"); + return name.node.type === "Identifier" + ? (name as NodePath) + : null; + } + + if (parentPath.type === "AssignmentExpression") { + let variablePath = parentPath as NodePath; + let name = variablePath.get("left"); + return name.node.type === "Identifier" + ? (name as NodePath) + : null; + } + + if (path.node.type === "ArrowFunctionExpression") { + return null; + } + + return path.node.id && path.node.id.type === "Identifier" + ? (path.get("id") as NodePath) + : null; +} + +function isIdentifierReferenced( + ident: NodePath +): boolean { + let binding = ident.scope.getBinding(ident.node.name); + if (binding?.referenced) { + // Functions can reference themselves, so we need to check if there's a + // binding outside the function scope or not. + if (binding.path.type === "FunctionDeclaration") { + return !binding.constantViolations + .concat(binding.referencePaths) + // Check that every reference is contained within the function: + .every((ref) => ref.findParent((parent) => parent === binding?.path)); + } + + return true; + } + return false; +} + +export const removeExports = (source: string, exportsToRemove: string[]) => { + let document = parse(source, { sourceType: "module" }); + let generateCode = () => generate(document).code; + + let referencedIdentifiers = new Set>(); + let removedExports = new Set(); + + let markImport = ( + path: NodePath< + | BabelTypes.ImportSpecifier + | BabelTypes.ImportDefaultSpecifier + | BabelTypes.ImportNamespaceSpecifier + > + ) => { + let local = path.get("local"); + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + }; + + let markFunction = ( + path: NodePath< + | BabelTypes.FunctionDeclaration + | BabelTypes.FunctionExpression + | BabelTypes.ArrowFunctionExpression + > + ) => { + let identifier = getIdentifier(path); + if (identifier?.node && isIdentifierReferenced(identifier)) { + referencedIdentifiers.add(identifier); + } + }; + + traverse(document, { + VariableDeclarator(variablePath) { + if (variablePath.node.id.type === "Identifier") { + let local = variablePath.get("id") as NodePath; + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + } else if (variablePath.node.id.type === "ObjectPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let properties = pattern.get("properties"); + properties.forEach((p) => { + let local = p.get( + p.node.type === "ObjectProperty" + ? "value" + : p.node.type === "RestElement" + ? "argument" + : (function () { + throw new Error("invariant"); + })() + ) as NodePath; + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + }); + } else if (variablePath.node.id.type === "ArrayPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let elements = pattern.get("elements"); + elements.forEach((element) => { + let local: NodePath; + if (element.node?.type === "Identifier") { + local = element as NodePath; + } else if (element.node?.type === "RestElement") { + local = element.get("argument") as NodePath; + } else { + return; + } + + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + }); + } + }, + + FunctionDeclaration: markFunction, + FunctionExpression: markFunction, + ArrowFunctionExpression: markFunction, + ImportSpecifier: markImport, + ImportDefaultSpecifier: markImport, + ImportNamespaceSpecifier: markImport, + + ExportNamedDeclaration(path) { + let shouldRemove = false; + + // Handle re-exports: export { preload } from './foo' + path.node.specifiers = path.node.specifiers.filter((spec) => { + if (spec.exported.type !== "Identifier") { + return true; + } + + let { name } = spec.exported; + for (let namedExport of exportsToRemove) { + if (name === namedExport) { + removedExports.add(namedExport); + return false; + } + } + + return true; + }); + + let { declaration } = path.node; + + // When no re-exports are left, remove the path + if (!declaration && path.node.specifiers.length === 0) { + shouldRemove = true; + } + + if (declaration && declaration.type === "VariableDeclaration") { + declaration.declarations = declaration.declarations.filter( + (declarator: BabelTypes.VariableDeclarator) => { + for (let name of exportsToRemove) { + if ((declarator.id as BabelTypes.Identifier).name === name) { + removedExports.add(name); + return false; + } + } + return true; + } + ); + if (declaration.declarations.length === 0) { + shouldRemove = true; + } + } + + if (declaration && declaration.type === "FunctionDeclaration") { + for (let name of exportsToRemove) { + if (declaration.id?.name === name) { + shouldRemove = true; + removedExports.add(name); + } + } + } + + if (shouldRemove) { + path.remove(); + } + }, + }); + + if (removedExports.size === 0) { + // No server-specific exports found so there's + // no need to remove unused references + return generateCode(); + } + + let referencesRemovedInThisPass: number; + + let sweepFunction = ( + path: NodePath< + | BabelTypes.FunctionDeclaration + | BabelTypes.FunctionExpression + | BabelTypes.ArrowFunctionExpression + > + ) => { + let identifier = getIdentifier(path); + if ( + identifier?.node && + referencedIdentifiers.has(identifier) && + !isIdentifierReferenced(identifier) + ) { + ++referencesRemovedInThisPass; + + if ( + t.isAssignmentExpression(path.parentPath.node) || + t.isVariableDeclarator(path.parentPath.node) + ) { + path.parentPath.remove(); + } else { + path.remove(); + } + } + }; + + let sweepImport = ( + path: NodePath< + | BabelTypes.ImportSpecifier + | BabelTypes.ImportDefaultSpecifier + | BabelTypes.ImportNamespaceSpecifier + > + ) => { + let local = path.get("local"); + if (referencedIdentifiers.has(local) && !isIdentifierReferenced(local)) { + ++referencesRemovedInThisPass; + path.remove(); + if ( + (path.parent as BabelTypes.ImportDeclaration).specifiers.length === 0 + ) { + path.parentPath.remove(); + } + } + }; + + // Traverse again to remove unused references. This happens at least once, + // then repeats until no more references are removed. + do { + referencesRemovedInThisPass = 0; + + traverse(document, { + Program(path) { + path.scope.crawl(); + }, + // eslint-disable-next-line no-loop-func + VariableDeclarator(variablePath) { + if (variablePath.node.id.type === "Identifier") { + let local = variablePath.get("id") as NodePath; + if ( + referencedIdentifiers.has(local) && + !isIdentifierReferenced(local) + ) { + ++referencesRemovedInThisPass; + variablePath.remove(); + } + } else if (variablePath.node.id.type === "ObjectPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let beforeCount = referencesRemovedInThisPass; + let properties = pattern.get("properties"); + properties.forEach((property) => { + let local = property.get( + property.node.type === "ObjectProperty" + ? "value" + : property.node.type === "RestElement" + ? "argument" + : (function () { + throw new Error("invariant"); + })() + ) as NodePath; + + if ( + referencedIdentifiers.has(local) && + !isIdentifierReferenced(local) + ) { + ++referencesRemovedInThisPass; + property.remove(); + } + }); + + if ( + beforeCount !== referencesRemovedInThisPass && + pattern.get("properties").length < 1 + ) { + variablePath.remove(); + } + } else if (variablePath.node.id.type === "ArrayPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let beforeCount = referencesRemovedInThisPass; + let elements = pattern.get("elements"); + elements.forEach((e) => { + let local: NodePath; + if (e.node?.type === "Identifier") { + local = e as NodePath; + } else if (e.node?.type === "RestElement") { + local = e.get("argument") as NodePath; + } else { + return; + } + + if ( + referencedIdentifiers.has(local) && + !isIdentifierReferenced(local) + ) { + ++referencesRemovedInThisPass; + e.remove(); + } + }); + + if ( + beforeCount !== referencesRemovedInThisPass && + pattern.get("elements").length < 1 + ) { + variablePath.remove(); + } + } + }, + FunctionDeclaration: sweepFunction, + FunctionExpression: sweepFunction, + ArrowFunctionExpression: sweepFunction, + ImportSpecifier: sweepImport, + ImportDefaultSpecifier: sweepImport, + ImportNamespaceSpecifier: sweepImport, + }); + } while (referencesRemovedInThisPass); + + return generateCode(); +}; diff --git a/packages/remix-dev/vite/replace-import-specifier.ts b/packages/remix-dev/vite/replace-import-specifier.ts new file mode 100644 index 00000000000..8f1fd59c07c --- /dev/null +++ b/packages/remix-dev/vite/replace-import-specifier.ts @@ -0,0 +1,26 @@ +import { parse, traverse, generate } from "./babel"; + +export const replaceImportSpecifier = ({ + code, + specifier, + replaceWith, +}: { + code: string; + specifier: string; + replaceWith: string; +}) => { + let ast = parse(code, { sourceType: "module" }); + + traverse(ast, { + ImportDeclaration(path) { + if (path.node.source.value === specifier) { + path.node.source.value = replaceWith; + } + }, + }); + + return { + code: generate(ast, { retainLines: true }).code, + map: null, + }; +}; diff --git a/packages/remix-dev/vite/static/refresh-utils.cjs b/packages/remix-dev/vite/static/refresh-utils.cjs new file mode 100644 index 00000000000..bb0d2721cdf --- /dev/null +++ b/packages/remix-dev/vite/static/refresh-utils.cjs @@ -0,0 +1,170 @@ +// adapted from https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/src/refreshUtils.js +// This file gets injected into the browser as a part of the HMR runtime + +function debounce(fn, delay) { + let handle; + return () => { + clearTimeout(handle); + handle = setTimeout(fn, delay); + }; +} + +/* eslint-disable no-undef */ +const enqueueUpdate = debounce(async () => { + let manifest; + if (routeUpdates.size > 0) { + manifest = JSON.parse(JSON.stringify(__remixManifest)); + + routeUpdates.forEach(async (route) => { + manifest[route.id] = route; + + let imported = await __hmr_import(route.url + "?t=" + Date.now()); + let routeModule = { + ...imported, + // react-refresh takes care of updating these in-place, + // if we don't preserve existing values we'll loose state. + default: imported.default + ? window.__remixRouteModules[route.id]?.default ?? imported.default + : imported.default, + ErrorBoundary: imported.ErrorBoundary + ? window.__remixRouteModules[route.id]?.ErrorBoundary ?? + imported.ErrorBoundary + : imported.ErrorBoundary, + }; + window.__remixRouteModules[route.id] = routeModule; + }); + + let needsRevalidation = new Set( + Array.from(routeUpdates.values()) + .filter((route) => route.hasLoader) + .map((route) => route.id) + ); + + let routes = __remixRouter.createRoutesForHMR( + needsRevalidation, + manifest.routes, + window.__remixRouteModules, + window.__remixContext.future + ); + __remixRouter._internalSetRoutes(routes); + routeUpdates.clear(); + } + + await revalidate(); + if (manifest) { + Object.assign(window.__remixManifest, manifest); + } + exports.performReactRefresh(); +}, 16); + +// Taken from https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/lib/runtime/RefreshUtils.js#L141 +// This allows to resister components not detected by SWC like styled component +function registerExportsForReactRefresh(filename, moduleExports) { + for (let key in moduleExports) { + if (key === "__esModule") continue; + let exportValue = moduleExports[key]; + if (exports.isLikelyComponentType(exportValue)) { + // 'export' is required to avoid key collision when renamed exports that + // shadow a local component name: https://github.com/vitejs/vite-plugin-react/issues/116 + // The register function has an identity check to not register twice the same component, + // so this is safe to not used the same key here. + exports.register(exportValue, filename + " export " + key); + } + } +} + +function validateRefreshBoundaryAndEnqueueUpdate( + prevExports, + nextExports, + // non-component exports that are handled by the framework (e.g. `meta` and `links` for route modules) + acceptExports = [] +) { + if ( + !predicateOnExport( + prevExports, + (key) => key in nextExports || acceptExports.includes(key) + ) + ) { + return "Could not Fast Refresh (export removed)"; + } + if ( + !predicateOnExport( + nextExports, + (key) => key in prevExports || acceptExports.includes(key) + ) + ) { + return "Could not Fast Refresh (new export)"; + } + + let hasExports = false; + let allExportsAreHandledOrUnchanged = predicateOnExport( + nextExports, + (key, value) => { + hasExports = true; + // Remix can handle Remix-specific exports (e.g. `meta` and `links`) + if (acceptExports.includes(key)) return true; + // React Fast Refresh can handle component exports + if (exports.isLikelyComponentType(value)) return true; + // Unchanged exports are implicitly handled + return prevExports[key] === nextExports[key]; + } + ); + if (hasExports && allExportsAreHandledOrUnchanged) { + enqueueUpdate(); + } else { + return "Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports"; + } +} + +function predicateOnExport(moduleExports, predicate) { + for (let key in moduleExports) { + if (key === "__esModule") continue; + let desc = Object.getOwnPropertyDescriptor(moduleExports, key); + if (desc && desc.get) return false; + if (!predicate(key, moduleExports[key])) return false; + } + return true; +} + +// Hides vite-ignored dynamic import so that Vite can skip analysis if no other +// dynamic import is present (https://github.com/vitejs/vite/pull/12732) +function __hmr_import(module) { + return import(/* @vite-ignore */ module); +} + +const routeUpdates = new Map(); + +async function revalidate() { + let { promise, resolve } = channel(); + let unsub = __remixRouter.subscribe((state) => { + if (state.revalidation === "idle") { + unsub(); + // Ensure RouterProvider setState has flushed before re-rendering + resolve(); + } + }); + window.__remixRevalidation = (window.__remixRevalidation || 0) + 1; + __remixRouter.revalidate(); + return promise; +} + +function channel() { + let resolve; + let reject; + + let promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { promise, resolve, reject }; +} + +import.meta.hot.on("remix:hmr-route", async ({ route }) => { + routeUpdates.set(route.id, route); +}); + +exports.__hmr_import = __hmr_import; +exports.registerExportsForReactRefresh = registerExportsForReactRefresh; +exports.validateRefreshBoundaryAndEnqueueUpdate = + validateRefreshBoundaryAndEnqueueUpdate; +exports.enqueueUpdate = enqueueUpdate; diff --git a/packages/remix-dev/vite/styles.ts b/packages/remix-dev/vite/styles.ts new file mode 100644 index 00000000000..c2421a545ee --- /dev/null +++ b/packages/remix-dev/vite/styles.ts @@ -0,0 +1,183 @@ +import * as path from "node:path"; +import { type ServerBuild } from "@remix-run/server-runtime"; +import { matchRoutes } from "@remix-run/router"; +import { type ModuleNode, type ViteDevServer } from "vite"; + +import { type RemixConfig as ResolvedRemixConfig } from "../config"; + +type ServerRouteManifest = ServerBuild["routes"]; +type ServerRoute = ServerRouteManifest[string]; + +// Style collection logic adapted from solid-start: https://github.com/solidjs/solid-start + +// Vite doesn't expose these so we just copy the list for now +// https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/constants.ts#L49C23-L50 +const cssFileRegExp = + /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; +// https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/plugins/css.ts#L160 +const cssModulesRegExp = new RegExp(`\\.module${cssFileRegExp.source}`); + +const isCssFile = (file: string) => cssFileRegExp.test(file); +export const isCssModulesFile = (file: string) => cssModulesRegExp.test(file); + +const getStylesForFiles = async ( + viteServer: ViteDevServer, + cssModulesManifest: Record, + files: string[] +): Promise => { + let styles: Record = {}; + let deps = new Set(); + + try { + for (let file of files) { + let normalizedPath = path.resolve(file).replace(/\\/g, "/"); + let node = await viteServer.moduleGraph.getModuleById(normalizedPath); + if (!node) { + let absolutePath = path.resolve(file); + await viteServer.ssrLoadModule(absolutePath); + node = await viteServer.moduleGraph.getModuleByUrl(absolutePath); + + if (!node) { + console.log(`Could not resolve module for file: ${file}`); + continue; + } + } + + await findDeps(viteServer, node, deps); + } + } catch (e) { + console.error(e); + } + + for (let dep of deps) { + if ( + dep.file && + isCssFile(dep.file) && + !dep.url.endsWith("?url") // Ignore styles that resolved as URLs, otherwise we'll end up injecting URLs into the style tag contents + ) { + try { + let css = isCssModulesFile(dep.file) + ? cssModulesManifest[dep.file] + : (await viteServer.ssrLoadModule(dep.url)).default; + + if (css === undefined) { + throw new Error(); + } + + styles[dep.url] = css; + } catch { + console.warn(`Could not load ${dep.file}`); + // 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 ( + Object.entries(styles) + .map(([fileName, css], i) => [ + `\n/* ${fileName + // Escape comment syntax in file paths + .replace(/\/\*/g, "/\\*") + .replace(/\*\//g, "*\\/")} */`, + css, + ]) + .flat() + .join("\n") || undefined + ); +}; + +const findDeps = async ( + vite: ViteDevServer, + node: 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. + let branches: Promise[] = []; + + async function addFromNode(node: ModuleNode) { + if (!deps.has(node)) { + deps.add(node); + await findDeps(vite, node, deps); + } + } + + async function addFromUrl(url: string) { + let node = await vite.moduleGraph.getModuleByUrl(url); + + if (node) { + await addFromNode(node); + } + } + + if (node.ssrTransformResult) { + if (node.ssrTransformResult.deps) { + node.ssrTransformResult.deps.forEach((url) => + branches.push(addFromUrl(url)) + ); + } + } else { + node.importedModules.forEach((node) => branches.push(addFromNode(node))); + } + + await Promise.all(branches); +}; + +const groupRoutesByParentId = (manifest: ServerRouteManifest) => { + let routes: Record[]> = {}; + + Object.values(manifest).forEach((route) => { + let parentId = route.parentId || ""; + if (!routes[parentId]) { + routes[parentId] = []; + } + routes[parentId].push(route); + }); + + return routes; +}; + +// Create a map of routes by parentId to use recursively instead of +// repeatedly filtering the manifest. +const createRoutes = ( + manifest: ServerRouteManifest, + parentId: string = "", + routesByParentId: Record< + string, + Omit[] + > = groupRoutesByParentId(manifest) +): ServerRoute[] => { + return (routesByParentId[parentId] || []).map((route) => ({ + ...route, + children: createRoutes(manifest, route.id, routesByParentId), + })); +}; + +export const getStylesForUrl = async ( + vite: ViteDevServer, + config: Pick, + cssModulesManifest: Record, + build: ServerBuild, + url: string | undefined +): Promise => { + if (url === undefined || url.includes("?_data=")) { + return undefined; + } + + let routes = createRoutes(build.routes); + let appPath = path.relative(process.cwd(), config.appDirectory); + let documentRouteFiles = + matchRoutes(routes, url)?.map((match) => + path.join(appPath, config.routes[match.route.id].file) + ) ?? []; + + let styles = await getStylesForFiles( + vite, + cssModulesManifest, + documentRouteFiles + ); + + return styles; +}; diff --git a/packages/remix-dev/vite/vmod.ts b/packages/remix-dev/vite/vmod.ts new file mode 100644 index 00000000000..7f7c9ba736f --- /dev/null +++ b/packages/remix-dev/vite/vmod.ts @@ -0,0 +1,3 @@ +export let id = (name: string) => `virtual:${name}` +export let resolve = (id: string) => `\0${id}` +export let url = (id: string) => `/@id/__x00__${id}` diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 904b80e20ea..5b63a583c55 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -18,6 +18,7 @@ declare global { var __remixContext: { url: string; state: HydrationState; + criticalCss?: string; future: FutureConfig; // The number of active deferred keys rendered on the server a?: number; @@ -26,6 +27,7 @@ declare global { hmrRuntime?: string; }; }; + var __remixRouter: Router; var __remixRouteModules: RouteModules; var __remixManifest: EntryContext["manifest"]; var __remixRevalidation: number | undefined; @@ -209,6 +211,9 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { v7_normalizeFormMethod: true, }, }); + // @ts-ignore + router.createRoutesForHMR = createClientRoutesWithHMRRevalidationOptOut; + window.__remixRouter = router; // Notify that the router is ready for HMR if (hmrRouterReadyResolve) { @@ -240,6 +245,7 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { manifest: window.__remixManifest, routeModules: window.__remixRouteModules, future: window.__remixContext.future, + criticalCss: window.__remixContext.criticalCss, }} > diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index c6961b0841f..0e9654eb02a 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -272,7 +272,7 @@ export function composeEventHandlers< * @see https://remix.run/components/links */ export function Links() { - let { manifest, routeModules } = useRemixContext(); + let { manifest, routeModules, criticalCss } = useRemixContext(); let { errors, matches: routerMatches } = useDataRouterStateContext(); let matches = errors @@ -289,6 +289,7 @@ export function Links() { return ( <> + {criticalCss ? : null} {keyedLinks.map(({ key, link }) => isPageLinkDescriptor(link) ? ( diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index e81d6d4227d..ded8cd267a3 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -12,6 +12,7 @@ type SerializedError = { export interface RemixContextObject { manifest: AssetsManifest; routeModules: RouteModules; + criticalCss?: string; serverHandoffString?: string; future: FutureConfig; abortDelay?: number; diff --git a/packages/remix-react/links.ts b/packages/remix-react/links.ts index e9724dfc3e5..c8d3a10e292 100644 --- a/packages/remix-react/links.ts +++ b/packages/remix-react/links.ts @@ -4,6 +4,7 @@ import { parsePath } from "react-router-dom"; import type { AssetsManifest } from "./entry"; import type { RouteModules, RouteModule } from "./routeModules"; +import type { EntryRoute } from "./routes"; import { loadRouteModule } from "./routeModules"; type Primitive = null | undefined | string | number | boolean | symbol | bigint; @@ -211,22 +212,31 @@ export function getKeyedLinksForMatches( manifest: AssetsManifest ): KeyedLinkDescriptor[] { let descriptors = matches - .map((match): LinkDescriptor[] => { + .map((match): LinkDescriptor[][] => { let module = routeModules[match.route.id]; - return module.links?.() || []; + let route = manifest.routes[match.route.id]; + return [ + route.css ? route.css.map((href) => ({ rel: "stylesheet", href })) : [], + module.links?.() || [], + ]; }) - .flat(1); + .flat(2); let preloads = getCurrentPageModulePreloadHrefs(matches, manifest); return dedupeLinkDescriptors(descriptors, preloads); } export async function prefetchStyleLinks( + route: EntryRoute, routeModule: RouteModule ): Promise { - if (!routeModule.links || !isPreloadSupported()) return; - let descriptors = routeModule.links(); - if (!descriptors) return; + if ((!route.css && !routeModule.links) || !isPreloadSupported()) return; + + let descriptors = [ + route.css?.map((href) => ({ rel: "stylesheet", href })) ?? [], + routeModule.links?.() ?? [], + ].flat(1); + if (descriptors.length === 0) return; let styleLinks: HtmlLinkDescriptor[] = []; for (let descriptor of descriptors) { diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 074b56b239d..3aee8c494c0 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -38,6 +38,7 @@ export interface EntryRoute extends Route { hasLoader: boolean; hasErrorBoundary: boolean; imports?: string[]; + css?: string[]; module: string; parentId?: string; } @@ -135,7 +136,7 @@ export function createClientRoutes( // Only prefetch links if we've been loaded into the cache, route.lazy // will handle initial loads let routeModulePromise = routeModulesCache[route.id] - ? prefetchStyleLinks(routeModulesCache[route.id]) + ? prefetchStyleLinks(route, routeModulesCache[route.id]) : Promise.resolve(); try { if (!route.hasLoader) return null; @@ -148,7 +149,7 @@ export function createClientRoutes( // Only prefetch links if we've been loaded into the cache, route.lazy // will handle initial loads let routeModulePromise = routeModulesCache[route.id] - ? prefetchStyleLinks(routeModulesCache[route.id]) + ? prefetchStyleLinks(route, routeModulesCache[route.id]) : Promise.resolve(); try { if (!route.hasAction) { @@ -241,7 +242,7 @@ async function loadRouteModuleWithBlockingLinks( routeModules: RouteModules ) { let routeModule = await loadRouteModule(route, routeModules); - await prefetchStyleLinks(routeModule); + await prefetchStyleLinks(route, routeModule); // Resource routes are built with an empty object as the default export - // ignore those when setting the Component diff --git a/packages/remix-react/server.tsx b/packages/remix-react/server.tsx index 47c4b576116..aa6042deb62 100644 --- a/packages/remix-react/server.tsx +++ b/packages/remix-react/server.tsx @@ -30,7 +30,7 @@ export function RemixServer({ url = new URL(url); } - let { manifest, routeModules, serverHandoffString } = context; + let { manifest, routeModules, criticalCss, serverHandoffString } = context; let routes = createServerRoutes( manifest.routes, routeModules, @@ -43,6 +43,7 @@ export function RemixServer({ value={{ manifest, routeModules, + criticalCss, serverHandoffString, future: context.future, serializeError: context.serializeError, diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts index 593f87f4785..90a9ba37908 100644 --- a/packages/remix-server-runtime/entry.ts +++ b/packages/remix-server-runtime/entry.ts @@ -7,6 +7,7 @@ import type { RouteModules, EntryRouteModule } from "./routeModules"; export interface EntryContext { manifest: AssetsManifest; routeModules: RouteModules; + criticalCss?: string; serverHandoffString?: string; staticHandlerContext: StaticHandlerContext; future: FutureConfig; diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts index ef430fdd317..4acbcb469cf 100644 --- a/packages/remix-server-runtime/routes.ts +++ b/packages/remix-server-runtime/routes.ts @@ -29,6 +29,7 @@ export interface EntryRoute extends Route { hasLoader: boolean; hasErrorBoundary: boolean; imports?: string[]; + css?: string[]; module: string; parentId?: string; } diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index de63313644f..c405a2fc4d2 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -30,7 +30,8 @@ import { createServerHandoffString } from "./serverHandoff"; export type RequestHandler = ( request: Request, - loadContext?: AppLoadContext + loadContext?: AppLoadContext, + args?: { criticalCss?: string } ) => Promise; export type CreateRequestHandlerFunction = ( @@ -58,7 +59,11 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( } }); - return async function requestHandler(request, loadContext = {}) { + return async function requestHandler( + request, + loadContext = {}, + { criticalCss } = {} + ) { let url = new URL(request.url); let matches = matchServerRoutes(routes, url.pathname); @@ -109,7 +114,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( staticHandler, request, loadContext, - handleError + handleError, + criticalCss ); } @@ -209,7 +215,8 @@ async function handleDocumentRequestRR( staticHandler: StaticHandler, request: Request, loadContext: AppLoadContext, - handleError: (err: unknown) => void + handleError: (err: unknown) => void, + criticalCss?: string ) { let context; try { @@ -242,8 +249,10 @@ async function handleDocumentRequestRR( manifest: build.assets, routeModules: createEntryRouteModules(build.routes), staticHandlerContext: context, + criticalCss, serverHandoffString: createServerHandoffString({ url: context.location.pathname, + criticalCss, state: { loaderData: context.loaderData, actionData: context.actionData, diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts index 583f2a21db1..85aed8e376f 100644 --- a/packages/remix-server-runtime/serverHandoff.ts +++ b/packages/remix-server-runtime/serverHandoff.ts @@ -19,6 +19,7 @@ export function createServerHandoffString(serverHandoff: { // Don't allow StaticHandlerContext to be passed in verbatim, since then // we'd end up including duplicate info state: ValidateShape; + criticalCss?: string; url: string; future: FutureConfig; }): string { diff --git a/yarn.lock b/yarn.lock index c2f9cb20c20..99539c45c82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1322,6 +1322,11 @@ resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.6.tgz#b11bd4e4d031bb320c93c83c137797b2be5b403b" integrity sha512-YnYSCceN/dUzUr5kdtUzB+wZprCafuD89Hs0Aqv9QSdwhYQybhXTaSTcrl6X/aWThn1a/j0eEpUBGOE7269REg== +"@esbuild/android-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" + integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== + "@esbuild/android-arm@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz#5898f7832c2298bc7d0ab53701c57beb74d78b4d" @@ -1332,6 +1337,11 @@ resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.6.tgz#ac6b5674da2149997f6306b3314dae59bbe0ac26" integrity sha512-bSC9YVUjADDy1gae8RrioINU6e1lCkg3VGVwm0QQ2E1CWcC4gnMce9+B6RpxuSsrsXsk1yojn7sp1fnG8erE2g== +"@esbuild/android-arm@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" + integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== + "@esbuild/android-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz#658368ef92067866d95fb268719f98f363d13ae1" @@ -1342,6 +1352,11 @@ resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.6.tgz#18c48bf949046638fc209409ff684c6bb35a5462" integrity sha512-MVcYcgSO7pfu/x34uX9u2QIZHmXAB7dEiLQC5bBl5Ryqtpj9lT2sg3gNDEsrPEmimSJW2FXIaxqSQ501YLDsZQ== +"@esbuild/android-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" + integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== + "@esbuild/darwin-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz#584c34c5991b95d4d48d333300b1a4e2ff7be276" @@ -1352,6 +1367,11 @@ resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.6.tgz#b3fe19af1e4afc849a07c06318124e9c041e0646" integrity sha512-bsDRvlbKMQMt6Wl08nHtFz++yoZHsyTOxnjfB2Q95gato+Yi4WnRl13oC2/PJJA9yLCoRv9gqT/EYX0/zDsyMA== +"@esbuild/darwin-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" + integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== + "@esbuild/darwin-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz#7751d236dfe6ce136cce343dce69f52d76b7f6cb" @@ -1362,6 +1382,11 @@ resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.6.tgz#f4dacd1ab21e17b355635c2bba6a31eba26ba569" integrity sha512-xh2A5oPrYRfMFz74QXIQTQo8uA+hYzGWJFoeTE8EvoZGHb+idyV4ATaukaUvnnxJiauhs/fPx3vYhU4wiGfosg== +"@esbuild/darwin-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" + integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== + "@esbuild/freebsd-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz#cacd171665dd1d500f45c167d50c6b7e539d5fd2" @@ -1372,6 +1397,11 @@ resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.6.tgz#ea4531aeda70b17cbe0e77b0c5c36298053855b4" integrity sha512-EnUwjRc1inT4ccZh4pB3v1cIhohE2S4YXlt1OvI7sw/+pD+dIE4smwekZlEPIwY6PhU6oDWwITrQQm5S2/iZgg== +"@esbuild/freebsd-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" + integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== + "@esbuild/freebsd-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz#0769456eee2a08b8d925d7c00b79e861cb3162e4" @@ -1382,6 +1412,11 @@ resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.6.tgz#1896170b3c9f63c5e08efdc1f8abc8b1ed7af29f" integrity sha512-Uh3HLWGzH6FwpviUcLMKPCbZUAFzv67Wj5MTwK6jn89b576SR2IbEp+tqUHTr8DIl0iDmBAf51MVaP7pw6PY5Q== +"@esbuild/freebsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" + integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== + "@esbuild/linux-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz#38e162ecb723862c6be1c27d6389f48960b68edb" @@ -1392,6 +1427,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.6.tgz#967dfb951c6b2de6f2af82e96e25d63747f75079" integrity sha512-bUR58IFOMJX523aDVozswnlp5yry7+0cRLCXDsxnUeQYJik1DukMY+apBsLOZJblpH+K7ox7YrKrHmJoWqVR9w== +"@esbuild/linux-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" + integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== + "@esbuild/linux-arm@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz#1a2cd399c50040184a805174a6d89097d9d1559a" @@ -1402,6 +1442,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.6.tgz#097a0ee2be39fed3f37ea0e587052961e3bcc110" integrity sha512-7YdGiurNt7lqO0Bf/U9/arrPWPqdPqcV6JCZda4LZgEn+PTQ5SMEI4MGR52Bfn3+d6bNEGcWFzlIxiQdS48YUw== +"@esbuild/linux-arm@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" + integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== + "@esbuild/linux-ia32@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz#e28c25266b036ce1cabca3c30155222841dc035a" @@ -1412,6 +1457,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.6.tgz#a38a789d0ed157495a6b5b4469ec7868b59e5278" integrity sha512-ujp8uoQCM9FRcbDfkqECoARsLnLfCUhKARTP56TFPog8ie9JG83D5GVKjQ6yVrEVdMie1djH86fm98eY3quQkQ== +"@esbuild/linux-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" + integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== + "@esbuild/linux-loong64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz#0f887b8bb3f90658d1a0117283e55dbd4c9dcf72" @@ -1422,6 +1472,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.6.tgz#ae3983d0fb4057883c8246f57d2518c2af7cf2ad" integrity sha512-y2NX1+X/Nt+izj9bLoiaYB9YXT/LoaQFYvCkVD77G/4F+/yuVXYCWz4SE9yr5CBMbOxOfBcy/xFL4LlOeNlzYQ== +"@esbuild/linux-loong64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" + integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== + "@esbuild/linux-mips64el@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz#f5d2a0b8047ea9a5d9f592a178ea054053a70289" @@ -1432,6 +1487,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.6.tgz#15fbbe04648d944ec660ee5797febdf09a9bd6af" integrity sha512-09AXKB1HDOzXD+j3FdXCiL/MWmZP0Ex9eR8DLMBVcHorrWJxWmY8Nms2Nm41iRM64WVx7bA/JVHMv081iP2kUA== +"@esbuild/linux-mips64el@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" + integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== + "@esbuild/linux-ppc64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz#876590e3acbd9fa7f57a2c7d86f83717dbbac8c7" @@ -1442,6 +1502,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.6.tgz#38210094e8e1a971f2d1fd8e48462cc65f15ef19" integrity sha512-AmLhMzkM8JuqTIOhxnX4ubh0XWJIznEynRnZAVdA2mMKE6FAfwT2TWKTwdqMG+qEaeyDPtfNoZRpJbD4ZBv0Tg== +"@esbuild/linux-ppc64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" + integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== + "@esbuild/linux-riscv64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz#7f49373df463cd9f41dc34f9b2262d771688bf09" @@ -1452,6 +1517,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.6.tgz#bc3c66d5578c3b9951a6ed68763f2a6856827e4a" integrity sha512-Y4Ri62PfavhLQhFbqucysHOmRamlTVK10zPWlqjNbj2XMea+BOs4w6ASKwQwAiqf9ZqcY9Ab7NOU4wIgpxwoSQ== +"@esbuild/linux-riscv64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" + integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== + "@esbuild/linux-s390x@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz#e2afd1afcaf63afe2c7d9ceacd28ec57c77f8829" @@ -1462,6 +1532,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.6.tgz#d7ba7af59285f63cfce6e5b7f82a946f3e6d67fc" integrity sha512-SPUiz4fDbnNEm3JSdUW8pBJ/vkop3M1YwZAVwvdwlFLoJwKEZ9L98l3tzeyMzq27CyepDQ3Qgoba44StgbiN5Q== +"@esbuild/linux-s390x@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" + integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== + "@esbuild/linux-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz#8a0e9738b1635f0c53389e515ae83826dec22aa4" @@ -1472,6 +1547,11 @@ resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.6.tgz#ba51f8760a9b9370a2530f98964be5f09d90fed0" integrity sha512-a3yHLmOodHrzuNgdpB7peFGPx1iJ2x6m+uDvhP2CKdr2CwOaqEFMeSqYAHU7hG+RjCq8r2NFujcd/YsEsFgTGw== +"@esbuild/linux-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" + integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== + "@esbuild/netbsd-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz#c29fb2453c6b7ddef9a35e2c18b37bda1ae5c462" @@ -1482,6 +1562,11 @@ resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.6.tgz#e84d6b6fdde0261602c1e56edbb9e2cb07c211b9" integrity sha512-EanJqcU/4uZIBreTrnbnre2DXgXSa+Gjap7ifRfllpmyAU7YMvaXmljdArptTHmjrkkKm9BK6GH5D5Yo+p6y5A== +"@esbuild/netbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" + integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== + "@esbuild/openbsd-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz#95e75a391403cb10297280d524d66ce04c920691" @@ -1492,6 +1577,11 @@ resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.6.tgz#cf4b9fb80ce6d280a673d54a731d9c661f88b083" integrity sha512-xaxeSunhQRsTNGFanoOkkLtnmMn5QbA0qBhNet/XLVsc+OVkpIWPHcr3zTW2gxVU5YOHFbIHR9ODuaUdNza2Vw== +"@esbuild/openbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" + integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== + "@esbuild/sunos-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz#722eaf057b83c2575937d3ffe5aeb16540da7273" @@ -1502,6 +1592,11 @@ resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.6.tgz#a6838e246079b24d962b9dcb8d208a3785210a73" integrity sha512-gnMnMPg5pfMkZvhHee21KbKdc6W3GR8/JuE0Da1kjwpK6oiFU3nqfHuVPgUX2rsOx9N2SadSQTIYV1CIjYG+xw== +"@esbuild/sunos-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" + integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== + "@esbuild/win32-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz#9aa9dc074399288bdcdd283443e9aeb6b9552b6f" @@ -1512,6 +1607,11 @@ resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.6.tgz#ace0186e904d109ea4123317a3ba35befe83ac21" integrity sha512-G95n7vP1UnGJPsVdKXllAJPtqjMvFYbN20e8RK8LVLhlTiSOH1sd7+Gt7rm70xiG+I5tM58nYgwWrLs6I1jHqg== +"@esbuild/win32-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" + integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== + "@esbuild/win32-ia32@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz#95ad43c62ad62485e210f6299c7b2571e48d2b03" @@ -1522,6 +1622,11 @@ resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.6.tgz#7fb3f6d4143e283a7f7dffc98a6baf31bb365c7e" integrity sha512-96yEFzLhq5bv9jJo5JhTs1gI+1cKQ83cUpyxHuGqXVwQtY5Eq54ZEsKs8veKtiKwlrNimtckHEkj4mRh4pPjsg== +"@esbuild/win32-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" + integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== + "@esbuild/win32-x64@0.17.19": version "0.17.19" resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061" @@ -1532,6 +1637,11 @@ resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.6.tgz#563ff4277f1230a006472664fa9278a83dd124da" integrity sha512-n6d8MOyUrNp6G4VSpRcgjs5xj4A91svJSaiwLIDWVWEsZtpN5FA9NlBbZHDmAJc2e8e6SF4tkBD3HAvPF+7igA== +"@esbuild/win32-x64@0.18.20": + version "0.18.20" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" + integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1569,6 +1679,23 @@ resolved "https://registry.npmjs.org/@extra-number/significant-digits/-/significant-digits-1.3.9.tgz" integrity sha512-E5PY/bCwrNqEHh4QS6AQBinLZ+sxM1lT8tsSVYk8VwhWIPp6fCU/BMRVq0V8iJ8LwS3FHmaA4vUzb78s4BIIyA== +"@fastify/busboy@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" + integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== + +"@hapi/hoek@^9.0.0": + version "9.3.0" + resolved "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.0.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -2259,6 +2386,23 @@ resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz" integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg== +"@sideway/address@^4.1.3": + version "4.1.4" + resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0" + integrity sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -2798,6 +2942,11 @@ dependencies: "@types/node" "*" +"@types/resolve-bin@^0.4.1": + version "0.4.1" + resolved "https://registry.npmjs.org/@types/resolve-bin/-/resolve-bin-0.4.1.tgz#e5f2c2de1de8b77f2222a9e28e56ecc37028116c" + integrity sha512-5bWIuv+28N2+WxIQNdLwkzD+THqH221tR1rsjHV23CiM46+AZZ/U5V5KsuoBywkB/x7gK/xEBW1l9R7Ucdkcvg== + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz" @@ -2929,6 +3078,13 @@ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/wait-on@^5.3.2": + version "5.3.2" + resolved "https://registry.npmjs.org/@types/wait-on/-/wait-on-5.3.2.tgz#f3722017a2bfacdc9bee095185a312befecea28c" + integrity sha512-7NBSJs/YvbHlaYCJ7wIUF6t7ct3OMt525NmZ+US73pPlkmpxd9ADwfNxrRAmg8nWlcTMqR0PkhW7aYk3FLlvrQ== + dependencies: + "@types/node" "*" + "@types/ws@^7.4.1": version "7.4.7" resolved "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz" @@ -3657,6 +3813,14 @@ axe-core@^4.6.2: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece" integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg== +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + axobject-query@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz#3b6e5c6d4e43ca7ba51c5babf99d22a9c68485e1" @@ -5116,6 +5280,11 @@ es-get-iterator@^1.1.2: isarray "^2.0.5" stop-iteration-iterator "^1.0.0" +es-module-lexer@^1.3.1: + version "1.3.1" + resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1" + integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q== + es-set-tostringtag@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8" @@ -5211,6 +5380,34 @@ esbuild@^0.17.5: "@esbuild/win32-ia32" "0.17.19" "@esbuild/win32-x64" "0.17.19" +esbuild@^0.18.10: + version "0.18.20" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" + integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== + optionalDependencies: + "@esbuild/android-arm" "0.18.20" + "@esbuild/android-arm64" "0.18.20" + "@esbuild/android-x64" "0.18.20" + "@esbuild/darwin-arm64" "0.18.20" + "@esbuild/darwin-x64" "0.18.20" + "@esbuild/freebsd-arm64" "0.18.20" + "@esbuild/freebsd-x64" "0.18.20" + "@esbuild/linux-arm" "0.18.20" + "@esbuild/linux-arm64" "0.18.20" + "@esbuild/linux-ia32" "0.18.20" + "@esbuild/linux-loong64" "0.18.20" + "@esbuild/linux-mips64el" "0.18.20" + "@esbuild/linux-ppc64" "0.18.20" + "@esbuild/linux-riscv64" "0.18.20" + "@esbuild/linux-s390x" "0.18.20" + "@esbuild/linux-x64" "0.18.20" + "@esbuild/netbsd-x64" "0.18.20" + "@esbuild/openbsd-x64" "0.18.20" + "@esbuild/sunos-x64" "0.18.20" + "@esbuild/win32-arm64" "0.18.20" + "@esbuild/win32-ia32" "0.18.20" + "@esbuild/win32-x64" "0.18.20" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz" @@ -5866,6 +6063,11 @@ finalhandler@~1.1.2: statuses "~1.5.0" unpipe "~1.0.0" +find-parent-dir@~0.3.0: + version "0.3.1" + resolved "https://registry.npmjs.org/find-parent-dir/-/find-parent-dir-0.3.1.tgz#c5c385b96858c3351f95d446cab866cbf9f11125" + integrity sha512-o4UcykWV/XN9wm+jMEtWLPlV8RXCZnMhQI6F6OdHeSez7iiJWePw8ijOlskJZMsaQoGR/b7dH6lO02HhaTN7+A== + find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz" @@ -5910,6 +6112,11 @@ flatted@^3.1.0: resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.2.tgz" integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA== +follow-redirects@^1.14.9: + version "1.15.3" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -7638,6 +7845,17 @@ jiti@^1.17.2: resolved "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz#80c3ef3d486ebf2450d9335122b32d121f2a83cd" integrity sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg== +joi@^17.7.0: + version "17.11.0" + resolved "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz#aa9da753578ec7720e6f0ca2c7046996ed04fc1a" + integrity sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.3" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + js-levenshtein@^1.1.6: version "1.1.6" resolved "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz" @@ -8866,6 +9084,11 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +minimist@^1.2.7: + version "1.2.8" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + minipass-collect@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz" @@ -9521,6 +9744,11 @@ parse-ms@^2.1.0: resolved "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz" integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== +parse-multipart-data@^1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz#ab894cc6c40229d0a2042500e120df7562d94b87" + integrity sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw== + parse5-htmlparser2-tree-adapter@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" @@ -9831,6 +10059,15 @@ postcss@^8.0.9, postcss@^8.4.19, postcss@^8.4.23: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.27: + version "8.4.31" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + preferred-pm@^3.0.0: version "3.0.3" resolved "https://registry.npmjs.org/preferred-pm/-/preferred-pm-3.0.3.tgz" @@ -10477,6 +10714,13 @@ requires-port@^1.0.0: resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +resolve-bin@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/resolve-bin/-/resolve-bin-1.0.1.tgz#795255591443e7007b21f2eadd8baa39b7378e50" + integrity sha512-4G9C3udcDB1c9qaopB+9dygm2bMyF2LeJ2JHBIc24N7ob+UuSSwX3ID1hQwpDEQep9ZRNdhT//rgEd6xbWA/SA== + dependencies: + find-parent-dir "~0.3.0" + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" @@ -10599,6 +10843,13 @@ rollup@^3.21.0: optionalDependencies: fsevents "~2.3.2" +rollup@^3.27.1: + version "3.29.4" + resolved "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" + integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== + optionalDependencies: + fsevents "~2.3.2" + rrweb-cssom@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" @@ -10635,6 +10886,13 @@ rxjs@^7.5.1, rxjs@^7.5.5: dependencies: tslib "^2.1.0" +rxjs@^7.8.0: + version "7.8.1" + resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + sade@^1.7.3: version "1.8.1" resolved "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz" @@ -10743,6 +11001,11 @@ set-cookie-parser@^2.4.6, set-cookie-parser@^2.4.8: resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.4.8.tgz" integrity sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg== +set-cookie-parser@^2.6.0: + version "2.6.0" + resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz#131921e50f62ff1a66a461d7d62d7b21d5d15a51" + integrity sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ== + set-getter@^0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz" @@ -11803,6 +12066,13 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici@^5.22.1: + version "5.25.4" + resolved "https://registry.npmjs.org/undici/-/undici-5.25.4.tgz#7d8ef81d94f84cd384986271e5e5599b6dff4296" + integrity sha512-450yJxT29qKMf3aoudzFpIciqpx6Pji3hEWaXqXmanbXF58LTAGCKxcJjxMXWu3iG+Mudgo3ZUfDB6YDFd/dAw== + dependencies: + "@fastify/busboy" "^2.0.0" + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz" @@ -12133,6 +12403,17 @@ vite-node@^0.28.5: optionalDependencies: fsevents "~2.3.2" +vite@^4.4.9: + version "4.4.10" + resolved "https://registry.npmjs.org/vite/-/vite-4.4.10.tgz#3794639cc433f7cb33ad286930bf0378c86261c8" + integrity sha512-TzIjiqx9BEXF8yzYdF2NTf1kFFbjMjUSV0LFZ3HyHoI3SGSPLnnFUKiIQtL3gl2AjHvMrprOvQ3amzaHgQlAxw== + dependencies: + esbuild "^0.18.10" + postcss "^8.4.27" + rollup "^3.27.1" + optionalDependencies: + fsevents "~2.3.2" + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" @@ -12140,6 +12421,17 @@ w3c-xmlserializer@^4.0.0: dependencies: xml-name-validator "^4.0.0" +wait-on@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz#5cff9f8427e94f4deacbc2762e6b0a489b19eae9" + integrity sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog== + dependencies: + axios "^0.27.2" + joi "^17.7.0" + lodash "^4.17.21" + minimist "^1.2.7" + rxjs "^7.8.0" + walker@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f"