Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detecting server imports on the client #2442

Open
wants to merge 25 commits into
base: main
Choose a base branch
from

Conversation

infomiho
Copy link
Contributor

@infomiho infomiho commented Jan 9, 2025

We want to detect imports from wasp/server/* in users' client code because we know it's incorrect. It's better to give users a nice error message than users receiving some (often hard to understand) runtime error message.

This is a draft initial implementation, I'll iterate on this a bit more.

Closes #2067

@infomiho infomiho marked this pull request as ready for review January 20, 2025 12:13
moduleName: string;
}

const importStatementRegex = /\s*import\s+(?:(?:[\w*\s{},]*)\s+from\s+)?(['"`])([^'"`]+)\1\s*/g;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't dive deep, just wanted to check, since this is regex, is there a chance of false positives? If so, in which situations, and are we ok with that? If not, can we do it better?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, could we maybe parse the import and do something with its AST (I'm not sure how powerful these plugins are, just throwing out ideas)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vite doesn't expose AST information to the plugins. One the popular plugins unplugin-ast uses the Babel parser under the hood to parse files into AST.

I didn't want to go down this route due to potential performance issues, but I have to admit that I haven't timed anything. I just assumed that regexes will be good enough.

I went with a good enough approach because we are not catch all server imports anyways. We are only focusing on wasp/server/* modules (for which we know they shouldn't be imported on the client). But... we don't catch stuff like some-node-dep.

I will explore the AST approach and see how it works and report back. I'll also explore the JS imports syntax and see if our regex matches all the cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out we don't need to parse or use regexes at all. @sodic pointed me in the right direction, there is a different plugin hook called resolveId which is called when Vite wants to resolve an import (so it already parsed everything) and we can decide if we want to raise a meaningful error if we notice it's a wasp/server/* import :)

Copy link
Contributor

@sodic sodic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love the change, but have some reserverations about the implementation.

return {
name: 'wasp-detect-server-imports',
transform(code, filePath) {
const isInDotWaspFolder = filePath.includes("/.wasp/");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a more appropriate way to do this? For example, by saying "check only src"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now reversed the logic and only check the files in src/ dir 👍

Comment on lines 38 to 39
importStatement: match[0].trim(),
moduleName: match[2],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to destructure the array and name each segment instead of using indexes.

}

function getServerImportErrorMessage(imp: Import, filePath: string): string {
return `Client module "${getRelativeFilePath(filePath)}" imports server code:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a double space, don't forget to run a formatter :)

Comment on lines 75 to 76
-- Vite plugins
genFileCopy [relfile|vite/detectServerImports.ts|]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of writing a comment, we should scope the file under an appropriately named function/variable. For example:

genVitePlugins :: ...
genVitePlugins = ...

Or

genViteFiles :: ...
genViteFiles ...
  where config = ...
        plugins = ...

// user running the tests, which is different on different machines.
function getWaspProjectDirAbsPathFromCwd(): string {
const webAppDirAbsPath = process.cwd();
const waspProjectDirAbsPath = path.join(webAppDirAbsPath, "../../../");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already defined this relative path somewhere, probably in multiple places.

I know it's defined in one of our JS files. I think even Haskell code defines a constant under Project.Common (but perhaps this wasn't merged)

In any case, this knowledge should definitely come from Haskell. If you're up for it, you could change it in those other places too.

Also, I don't understand the reason we couldn't pass waspProjectDir. Could we add some more details in the comment? Perhaps an example (what it is vs. what we need)?

@@ -6,5 +6,5 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "./src/ext-src/vite.config.ts"]
"include": ["vite.config.ts", "./src/ext-src/vite.config.ts", "./vite/detectServerImports.ts"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, this is starting to feel like something we should pass in through the template.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I now pass it from Haskell and I've made the Vite plugin paths "dynamic" i.e. based on a data type.

Comment on lines 54 to 56
function getRelativeFilePath(filePath: string): string {
return filePath.replace(waspProjectDirAbsPath, "");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we improve this function's name?

moduleName: string;
}

const importStatementRegex = /\s*import\s+(?:(?:[\w*\s{},]*)\s+from\s+)?(['"`])([^'"`]+)\1\s*/g;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, could we maybe parse the import and do something with its AST (I'm not sure how powerful these plugins are, just throwing out ideas)?

Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
Copy link
Contributor

@sodic sodic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the improvements, but still have a few more ideas. Lemme kno what u think :)

@@ -10,6 +10,10 @@ import { getName } from './user'
// Necessary to trigger type tests.
import './testTypes/operations/client'

import { HttpError } from 'wasp/server'

HttpError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accident?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was testing the plugin, but didn't clean this up 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another accident (the .js file too)? 😄

Comment on lines 76 to 82
genViteConfig spec,
genNodeTsConfigJson
]
<++> genSrcDir spec
<++> genPublicDir spec
<++> genDotEnv spec
<++> genVitePlugins
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't think these two should be handeled separately (you put a 🚀 on the previous comment but it seems we misunderstood each other: #2442 (comment))

Node ts config, vite config, vite plugins... These are all coupled together and related to Vite It seems better to have a single Vite module/function that generates all the Vite stuff (you already have it, but just for plugins).

And here you'd just call genViteFiles spec.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I misunderstood 👍 You wanted to introduce a Vite module as a concept, makes sense.

@@ -280,3 +282,12 @@ genViteConfig spec = return $ C.mkTmplFdWithData tmplFile tmplData
SP.fromRelDir (Project.dotWaspDirInWaspProjectDir </> Project.generatedCodeDirInDotWaspDir </> C.webAppRootDirInProjectRootDir)

importName = JsImportModule "customViteConfig"

genNodeTsConfigJson :: Generator FileDraft
genNodeTsConfigJson = return $ C.mkTmplFdWithData [relfile|tsconfig.node.json|] tmplData
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're at it, can we rename this to tsconfig.vite.json? I know Vite uses Node, but I've always found it confusing.

Not 100% on this though, there are benefits to keeping it similar to the original.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the intention behind their naming was "runtime name" e.g. Node or App vs. "purpose of files" e.g. Vite or source files. I might be wrong. I can rename it now if you want, I could go either way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we rename it to be specific to Vite, then I can pull this into the Vite module as well which is a nice way to encapsulate Vite stuff. I'll go for it.

Comment on lines 93 to 94
vitePluginsDirInWebAppDir :: Path' (Rel WebAppRootDir) (Dir WebAppVitePluginsDir)
vitePluginsDirInWebAppDir = [reldir|vite|]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this needs to be in Common. Vite is the only one using it, so best to keep it in Vite's module.

If we end up needing it somwhere else, we'll have to hoist it. But that's a good thing - It will make us think twice about breaking encapsulation.

Comment on lines 33 to 41
function ensureNoServerImports(source: string, relativeImporter: RelativePath): void {
for (const check of serverImportChecks) {
if (check(source)) {
throw new Error(
`Server code cannot be imported in the client code. Import from "${source}" in "${relativeImporter.relativePath}" is not allowed.`
)
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This thing will fail on the first server import it detects. Is that on purpose?

Can we maybe collect and report all of them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook runs once per import, so we can't really enumerate the server imports before reporting back. If we didn't throw the error here, it'll probably fail by trying to import the server module.

Comment on lines 43 to 45
type RelativePath = {
relativePath: string
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a branded type to avoid redundant nesting:

type RelativePath = string & { _brand: 'relativePath' }

Effective TS talks about this I think, but I don't remember which item it is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Item 64 :D

Comment on lines 18 to 20
if (!isPathToUserCode(importerRelativePath)) {
return
}
Copy link
Contributor

@sodic sodic Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To go a step further with the "Parse, don't validate" philosohpy, we could do this:

type RelativePathToUserCode = string & { _brand: 'relativePathToUserCode' }

// I used null, but you can also create a Result type
function parsePathToUserCode(importerRelativePath: RelativePath): RelativePathToUserCode | null

// ...

function ensureNoServerImports(source: string, relativeImporter: RelativePathToUserCode): void

This "ensures" (module type assertions) we never call ensureNoServerImports without first verifying that we're dealing with user code.

Comment on lines +29 to +31
const serverImportChecks: ImportCheckPredicate[] = [
(moduleName: string) => moduleName.startsWith('wasp/server'),
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this an array? Do we expect to have multiple ways of deciding whether something is a server import?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to expand how we detect server imports, we can e.g. imports starting with node:. I didn't want to do it because users only reported problems with wasp/server/* confusion.

Comment on lines 61 to 62
const waspProjectDirAbsPath = path.join(webAppDirAbsPath, '{= waspProjectDirFromWebAppDir =}')
return waspProjectDirAbsPath
Copy link
Contributor

@sodic sodic Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably return right away, without the extra variable - the function's name already tells us what we're getting.

Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Detect server imports on the client
3 participants