Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion waspc/cli/src/Wasp/Cli/Command/BuildStart/Client.hs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ startClient config =
projectDir
"npx"
[ "vite",
"preview", -- `preview` launches a static file server for the built client.
-- `preview` serves the client build. Nitro hooks into it and serves its
-- whole output: the prerendered pages and static assets first, then the
-- renderer (which produces the SPA shell) for everything else.
"preview",
"--port",
port,
"--strictPort" -- This will make it fail if the port is already in use.
Expand Down
28 changes: 13 additions & 15 deletions waspc/data/Generator/templates/sdk/wasp/client/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { StrictMode, type ReactNode, useSyncExternalStore } from "react";
export function Layout({
children,
isFallbackPage = false,
clientEntrySrc,
headChildren,
}: {
children?: ReactNode;
isFallbackPage?: boolean;
clientEntrySrc?: string;
headChildren?: ReactNode;
}) {
const shouldRenderAppContent = useShouldRenderAppContent(isFallbackPage);

Expand All @@ -28,31 +28,29 @@ export function Layout({

{
/*
This script tag's job is to load the client entry so the browser
downloads and runs it, hydrating the prerendered HTML.
The tags that load the app in the browser: the client entry
script (so the browser downloads and runs it, hydrating the
rendered HTML) and its stylesheets. The server passes them in,
the client renders nothing here: by the time this code runs in
the browser, the app is already loaded.

We only need it in SSR builds, as by the time the client is
running this code, it doesn't need to run itself again (and could
lead to duplication).

Rendering it only on the server and not on the client would
Rendering them only on the server and not on the client would
normally cause a hydration mismatch, but React skips erroring on
server-only nodes if they are **direct children** of `<head>` and
`<body>`, to support this kind of usecase. (See
`<body>`, to support this kind of usecase. So keep them here,
directly inside `<head>`. (See
https://react.dev/reference/react-dom/static/prerenderToNodeStream)

We'd usually inject this via React prerender's `bootstrapModules`
option, but that has two problems:
We'd usually inject the entry script via React prerender's
`bootstrapModules` option, but that has two problems:
1. React also emits a `<link rel="modulepreload"
href="@/wasp/client">` for the bootstrap scripts, but Vite
doesn't rewrite `link.href`s, so it would end up as a broken
link.
2. It hardcodes `async` on the script, which in dev races the
`@vitejs/plugin-react` refresh preamble (see #4258).
*/
clientEntrySrc ? (
<script type="module" src={clientEntrySrc} />
) : null
headChildren
}
</head>
<body>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { fetchViteEnv } from "nitro/vite/runtime";

/**
* Nitro's renderer: the catch-all handler that runs for requests that didn't
* match a static asset or a route.
*
* It only forwards to the `ssr` Vite environment, where `ssr-entry.tsx` does
* the actual rendering. The split is not optional: Nitro bundles this module
* twice, once with Vite and once with a bare Rollup/Rolldown build (for
* prerendering) that has none of Vite's plugins. So this file must stay plain
* TypeScript: no JSX, no CSS imports, no `?assets` imports, no Vite-only
* features.
*/
export default function renderer({ req }: { req: Request }): Promise<Response> {
return fetchViteEnv("ssr", req);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export function detectServerImports(): Plugin {
return {
name: 'wasp:detect-server-imports',
enforce: 'pre',
// The rule is about the app that runs in the browser, which lives in the
// `client` environment and is rendered in the `ssr` one. Nitro's own
// environment is server code by definition, so we leave it alone.
applyToEnvironment: (environment) => environment.name !== 'nitro',
configResolved(config) {
parsePathToUserCode = createPathToUserCodeParser(config.root)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export function envFile(): Plugin {

return {
// Disable Vite's default .env loading.
//
// Note that this doesn't stop Nitro, which loads the project's `.env`
// files into `process.env` on its own. It can't affect the variables
// we expose to the client though: this plugin reads `process.env`
// in its `config` hook, which runs before Nitro's.
envDir: false,
define: prefixedVars,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
{{={= =}=}}
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { type Plugin } from "vite";
// Teaches Vite's `UserConfig` about the `nitro` key we return below.
import type {} from "nitro/vite";
import { getVirtualFileAbsPath } from "../virtual-files/resolver.js";

const clientEntryPointPath = "{= clientEntryPointPath =}";
const ssrEntryPointPath = "{= ssrEntryPointPath =}";

/**
* The path Nitro's renderer is served from. It is a real file in the SDK, not
* one of our virtual files, because Nitro bundles it twice: once with Vite (for
* the server it builds) and once with a bare Rollup/Rolldown build that knows
* nothing about our plugins (for prerendering). See `nitroRenderer.ts`.
*/
const rendererPath = fileURLToPath(new URL("../nitroRenderer.js", import.meta.url));

/**
* Translates the Wasp app's configuration into the Vite environments and the
* Nitro configuration that build and serve the client.
*
* It must come before `nitro()` in the plugin array: Vite feeds each plugin's
* `config()` result into the config object the next plugin sees, and Nitro
* reads its own configuration from the `nitro` key there.
*/
export function waspNitroBridge(): Plugin {
return {
name: "wasp:nitro-bridge",
config(config, { command }) {
const rootDir = path.resolve(config.root ?? ".");

return {
environments: {
// Nitro doesn't fall back to an `index.html`, so it needs to be told
// explicitly where the browser app starts.
client: {
build: {
rollupOptions: {
input: getVirtualFileAbsPath(rootDir, clientEntryPointPath),
},
},
},
// Everything the renderer needs (React, the routes, the asset tags)
// lives in the `ssr` environment, so that it goes through Vite.
ssr: {
build: {
rollupOptions: {
input: getVirtualFileAbsPath(rootDir, ssrEntryPointPath),
},
},
resolve: {
// The SDK is a symlinked workspace package, which Vite bundles
// instead of externalizing. We say so explicitly because
// externalizing it would make Node load its CSS imports.
noExternal: ["wasp"],
},
},
},
nitro: {
preset: "node-server",

// Nitro serves the app from the same subdirectory Vite builds it for.
// Vite's `base` alone isn't enough, these are separate options.
baseURL: "{= baseDir =}",

renderer: { handler: rendererPath },

// We don't have any server code running through Nitro yet (the Wasp
// server is still its own Express process). Both of these are off by
// default, but we say so explicitly: left to auto-detection, Nitro
// would pick up a `server.ts` lying around in the project, or treat
// directories that happen to follow its conventions (`routes/`,
// `api/`, `middleware/`, `plugins/`, `tasks/`) as server code and
// let them shadow the app's pages.
serverEntry: false,
serverDir: false,

prerender: {
routes: [
...{=& prerenderPaths =},
// The SPA shell. Static hosts serve it for any path they don't
// have a prerendered file for.
"{= spaFallbackFilePath =}",
],
// Wasp's list of prerendered paths is authoritative, we don't want
// Nitro discovering more of them by following links.
crawlLinks: false,
failOnError: true,
},

// Only when building. In dev, Nitro keeps its output in a scratch
// directory inside its build cache, and we want to leave it there:
// it serves everything in its public output directory as a static
// file, so pointing it at the build output would make the dev server
// serve (and choke on) the files of the last build.
...(command === "build"
? {
output: {
dir: "{= nitroOutputDirPath =}",
publicDir: "{= clientBuildDirPath =}",
},
}
: {}),
},
};
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export function typescriptCheck(options: TypeScriptCheckOptions): Plugin {
return {
name: 'wasp:typescript-check',
apply: 'build',
// `buildStart` runs once per environment, and a build has several of them
// (`client`, `ssr` and Nitro's). Type checking the user's source doesn't
// depend on the environment, so we only do it in one of them.
applyToEnvironment: (environment) => environment.name === 'client',
async buildStart() {
await runTsc(options.srcTsConfigPath)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export function validateEnv(): Plugin {

return {
name: PLUGIN_NAME,
// `buildStart` runs once per environment, and there are several of them
// (`client`, `ssr` and Nitro's). The env schema doesn't depend on the
// environment, so we only validate it in one of them.
applyToEnvironment: (environment) => environment.name === "client",
configResolved(config) {
resolvedConfig = config;
},
Expand All @@ -41,6 +45,18 @@ export function validateEnv(): Plugin {
// the temporary server anyway.
.filter((plugin) => !plugin.name.startsWith("vite:"))

// Ignore Nitro's plugins (`nitro:` and the `fullstack:` ones it
// bundles). They hold on to a single Nitro instance and replace the
// `ssr` environment with one that runs in Nitro's own worker
// process, which we can't import a module through. Without them, we
// get back Vite's plain (runnable) `ssr` environment, which is all
// we need here.
.filter(
(plugin) =>
!plugin.name.startsWith("nitro:") &&
!plugin.name.startsWith("fullstack:"),
)

// Vite's `configureServer`/`configurePreviewServer` hooks let plugins
// wire long-lived behavior into a dev or preview server: middleware,
// websocket handlers, file watchers, and similar background tasks.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ export function virtualWaspModules(): Plugin {
configResolved(config) {
virtualFiles = resolveVirtualFiles(config.root);
},
resolveId: (id) => virtualFiles.ids.get(id),
load(id) {
const loader = virtualFiles.loaders.get(id);
return loader?.();
},
resolveId: (id) => virtualFiles.resolveId(id),
load: (id) => virtualFiles.load(id),
};
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{{={= =}=}}
import { type PluginOption } from "vite";
import react, { type Options as ReactOptions } from "@vitejs/plugin-react";
import ssr from "@wasp.sh/lib-vite-ssr";
import { nitro } from "nitro/vite";
import { validateEnv } from "./validateEnv.js";
import { envFile } from "./envFile.js";
import { detectServerImports } from "./detectServerImports.js";
import { virtualWaspModules } from "./virtualWaspModules.js";
import { virtualUserModules } from "./virtualUserModules.js";
import { typescriptCheck } from "./typescriptCheck.js";
import { waspConfig } from "./waspConfig.js";
import { waspNitroBridge } from "./nitroBridge.js";

export interface WaspPluginOptions {
reactOptions?: ReactOptions;
Expand All @@ -32,11 +33,12 @@ export function wasp(options?: WaspPluginOptions): PluginOption {
typescriptCheck({ srcTsConfigPath: "{= srcTsConfigPath =}" }),
validateEnv(),
react(options?.reactOptions),
ssr({
clientEntrySrc: "{= clientEntryPointPath =}",
ssrEntrySrc: "{= ssrEntryPointPath =}",
ssrPaths: {=& ssrPaths =},
spaFallbackFile: "{= spaFallbackFile =}",
}),
/**
* Nitro builds and serves the app. The bridge translates the Wasp app's
* configuration into Nitro's, so it must come right before `nitro()`,
* which must come last.
*/
waspNitroBridge(),
nitro(),
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { defaultExclude } from "vitest/config";
const forcedOptions = {
base: "{= baseDir =}",
envPrefix: "REACT_APP_",
"build.outDir": "{= clientBuildDirPath =}",
} as const;

const forcedOptionHints: Partial<Record<keyof typeof forcedOptions, string>> = {
Expand All @@ -30,23 +29,28 @@ export function waspConfig(): PluginOption {
return {
name: "wasp:config",
enforce: "pre",
config(config) {
config(config, env) {
throwIfOverridingForcedOptions(config);

const devServerPort = useUserValue(config.server?.port, {= defaultClientPort =});
if (env.command === "serve" && !env.isPreview) {
pinDevServerPort(devServerPort);
}

// Returned config is merged with the user's config by Vite (mergeConfig).
return {
base: forcedOptions["base"],
optimizeDeps: {
exclude: {=& depsExcludedFromOptimization =}
},
server: {
port: useUserValue(config.server?.port, {= defaultClientPort =}),
port: devServerPort,
host: useUserValue(config.server?.host, "0.0.0.0"),
},
envPrefix: forcedOptions["envPrefix"],
build: {
outDir: forcedOptions["build.outDir"],
},
// We don't set `build.outDir`: Nitro owns the build output and forces
// the client's `outDir` to its own public directory. See the
// `wasp:nitro-bridge` plugin.
resolve: {
// These packages rely on a single instance per page. Not deduping them
// causes runtime errors (e.g., hook rule violation in react, QueryClient
Expand Down Expand Up @@ -90,6 +94,21 @@ function useUserValue<T>(userValue: T | undefined, defaultValue: T): T {
return userValue ?? defaultValue;
}

/**
* Nitro's dev server picks its port with `process.env.PORT || server.port ||
* 3000`, so `PORT` wins over the port we (or the user) configured. It also
* loads the project's `.env` files into `process.env` before reading it, and
* `PORT` is a variable Wasp users commonly set for the server. Left alone, a
* `PORT=3001` in a `.env` file would silently move the client's dev server.
*
* So we write the port we settled on into `process.env` ourselves. We set it
* instead of deleting it because Nitro's `.env` loader never overwrites a
* variable that is already defined.
*/
function pinDevServerPort(port: number): void {
process.env.PORT = String(port);
}

function throwIfOverridingForcedOptions(config: Record<string, any>): void {
const conflicts: string[] = [];
for (const [path, forcedValue] of Object.entries(forcedOptions)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{{={= =}=}}
// This must be the first import: it installs React Fast Refresh's global hook
// before any component module runs. Without it, the dev server fails with
// "@vitejs/plugin-react can't detect preamble" and the app never hydrates.
// In builds, it compiles to an empty module.
import "@vitejs/plugin-react/preamble";

import { startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
Expand Down
Loading
Loading