-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
base: main
Are you sure you want to change the base?
Conversation
Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
moduleName: string; | ||
} | ||
|
||
const importStatementRegex = /\s*import\s+(?:(?:[\w*\s{},]*)\s+from\s+)?(['"`])([^'"`]+)\1\s*/g; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 :)
There was a problem hiding this 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/"); |
There was a problem hiding this comment.
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
"?
There was a problem hiding this comment.
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 👍
importStatement: match[0].trim(), | ||
moduleName: match[2], |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 :)
-- Vite plugins | ||
genFileCopy [relfile|vite/detectServerImports.ts|] |
There was a problem hiding this comment.
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, "../../../"); |
There was a problem hiding this comment.
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"] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
function getRelativeFilePath(filePath: string): string { | ||
return filePath.replace(waspProjectDirAbsPath, ""); | ||
} |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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)?
There was a problem hiding this 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 :)
waspc/examples/todoApp/src/App.tsx
Outdated
@@ -10,6 +10,10 @@ import { getName } from './user' | |||
// Necessary to trigger type tests. | |||
import './testTypes/operations/client' | |||
|
|||
import { HttpError } from 'wasp/server' | |||
|
|||
HttpError |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accident?
There was a problem hiding this comment.
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 👍
There was a problem hiding this comment.
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)? 😄
genViteConfig spec, | ||
genNodeTsConfigJson | ||
] | ||
<++> genSrcDir spec | ||
<++> genPublicDir spec | ||
<++> genDotEnv spec | ||
<++> genVitePlugins |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
vitePluginsDirInWebAppDir :: Path' (Rel WebAppRootDir) (Dir WebAppVitePluginsDir) | ||
vitePluginsDirInWebAppDir = [reldir|vite|] |
There was a problem hiding this comment.
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.
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.` | ||
) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
type RelativePath = { | ||
relativePath: string | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Item 64 :D
if (!isPathToUserCode(importerRelativePath)) { | ||
return | ||
} |
There was a problem hiding this comment.
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.
const serverImportChecks: ImportCheckPredicate[] = [ | ||
(moduleName: string) => moduleName.startsWith('wasp/server'), | ||
] |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
const waspProjectDirAbsPath = path.join(webAppDirAbsPath, '{= waspProjectDirFromWebAppDir =}') | ||
return waspProjectDirAbsPath |
There was a problem hiding this comment.
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.
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