Skip to content

Conversation

cprecioso
Copy link
Member

@cprecioso cprecioso commented Sep 15, 2025

🎉 It's here! 🎉

This PR enables npm workspaces in the users' projects, pulling the dependencies from each folder's node_modules to a the common top-level node_modules (when possible, for most packages). This was in general easier than expected, and looks like it will be mostly transparent for our users. npm will adapt to whether the .wasp/{out,build} folders exist or not and fold them into the general dependency resolution.

  • Added workspaces to starters
  • Added workspaces to example projects
  • The compiler checks that package.json#workspaces exists and has the correct value
  • There was some complex logic in NpmDependencies to deduplicate dependencies between the user package and the framework packages. npm now takes care of this, so we don't need it.
  • This change does not require users to do anything different. The current https://wasp.sh/docs/project/dependencies docs are still the way to do, we're just now intelligent about it. No changes to docs.

Extra fixes that I had to do:

  • Update Wasp App Runner because of the behaviour of npx in workspaces (actually done in Correctly set dir to serve in wasp-app-runner #3168)
  • Ensure the node_modules folder is always created inside Docker (even if empty) so the instruction copying it doesn't fail.
  • I had to change the names of the generated packages. npm needs every package in a workspace to have a different name. The server and web-app packages have the same name whether output to .wasp/out or .wasp/build, so I made the generation change the name based on if it's dev or build: @wasp.sh/generated-{server,client}-{build,dev}. I added an issue to improve this here Unsplit .wasp/build and .wasp/out #3163

Left over:

  • Adding the SDK folder as a workspace too. Currently it is not necessary because we run the install and build ourselves anyways, and it is fully a dependency so the dependencies are correctly shared. Still, we should upgrade it to a workspace so we can remove the ugly file:.wasp/out/sdk dependency.
    • Currently a overly-zealous resolution makes an incompatible resolution for @types/react that would make the workspace installation fail. We should review when we Migrate to React 19 #2482.

Steps / Criteria

Copied from #3130

  • Check Wasp behaves correctly with package.json#workspaces
  • Edit the starter package.json's to have the workspaces key
  • Check that dependencies are correctly shared
  • Test that dependency checking logic inside waspc still works correctly
  • Add a check for workspaces in the compiler
  • Adapt wasp deps command not needed
  • Add a migration guide and documentation

@cprecioso cprecioso self-assigned this Sep 15, 2025
Copy link

cloudflare-workers-and-pages bot commented Sep 17, 2025

Deploying wasp-docs-on-main with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6a6868f
Status: ✅  Deploy successful!
Preview URL: https://234d88fa.wasp-docs-on-main.pages.dev
Branch Preview URL: https://cprecioso-npm-workspaces.wasp-docs-on-main.pages.dev

View logs

@cprecioso cprecioso force-pushed the cprecioso/npm-workspaces branch from b2c82e8 to e267c5b Compare September 18, 2025 14:03
Copy link
Member Author

Choose a reason for hiding this comment

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

This file is just stuff extracted from TsConfig.hs that I also wanted to use in PackageJson.hs.

getOppositePackageJsonDepedencyKey :: PackageRequirement -> Maybe String
getOppositePackageJsonDepedencyKey = \case
getOppositePackageJsonDependencyKey :: PackageRequirement -> Maybe String
getOppositePackageJsonDependencyKey = \case
Copy link
Member Author

Choose a reason for hiding this comment

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

Just fixing a typo

-getOppositePackageJsonDepedencyKey
+getOppositePackageJsonDependencyKey
                           ^ here

@cprecioso cprecioso marked this pull request as ready for review September 18, 2025 16:38
@cprecioso cprecioso changed the title Move to npm workspaces Turn the user project and the generated packages into an npm workspace Sep 18, 2025
@cprecioso cprecioso changed the title Turn the user project and the generated packages into an npm workspace Enable npm workspace for user project and the generated code Sep 18, 2025
@cprecioso
Copy link
Member Author

@infomiho ready

Copy link
Contributor

@infomiho infomiho left a comment

Choose a reason for hiding this comment

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

Looking good, I'm okay with the current state. I've tested it out locally as well. I'd like someone else from the team to also do a sanity check of the impl since it's a structural change.

@infomiho infomiho requested a review from sodic October 1, 2025 11:20
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 reviewed most of it but have to go now. I'll come back and review the remaining 3 or 4 files.

I pushed my comments so far in hope you'll have something to do by the time I'm back :)

Great work on this, can't wait to merge it!

# Building the server should come after Prisma generation.
RUN cd .wasp/build/server && npm run bundle

# Depending on `npm`'s dependency resolution, these folders may or may not exist.
Copy link
Contributor

Choose a reason for hiding this comment

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

But the top-level folder will always exist, right? If not, we might have a bigger problem because I think other parts of Wasp rely on it existing.


Remember to check out the [migration guide](https://wasp.sh/docs/migration-guides/migrate-from-0-18-to-0-19) for step-by-step documentation on how to upgrade.

- Wasp now requires your project's `package.json` to contain `"workspaces": [".wasp/build/*", ".wasp/out/*"]`. ([#3159](https://github.com/wasp-lang/wasp/pull/3159))
Copy link
Contributor

Choose a reason for hiding this comment

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

I was hoping we could get away without leaking this to the user.

@cprecioso Do you maybe know how other frameworks do it? Do they use workspaces at all? If so, do they leak it in their package.json files?

Copy link
Member Author

Choose a reason for hiding this comment

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

The workspaces key is our best solution for participating in dependency resolution, without changing our whole generated code architecture. It has to be at the top-level package (the user's project), there's no way around that.

Other frameworks can solve these problems by generating code into the user's project directly, and shipping their runtime dependencies into an npm-published package. The templates → libs initiatives is already taking steps in this direction, if we want to go all the way in the future

Blog-post-length explanation

The meta-framework problem

While we allow the user to believe that their project files are the entry point, a meta-framework like ours actually wants to generate their own entry point files (in our case, the server and web-app projects) that are the ones that are in charge, setup the server or the client, and choose when to call into the user-authored code.

These entry points might also have their own runtime dependencies themselves, that we want to both hide from users (they don't need to manually install them or care about them) and make them participate in the dependency tree resolution (so we participate in dependency deduplication and version resolution).

So we end up with these requirements:

  1. We want user-authored code to be the top-level project
  2. We want to import user-authored code
  3. We want to hide framework dependencies
  4. We want to participate in dep resolution

Wasp until now

  • By generating code in .wasp/out/, we satisfy (1).
  • We take advantage of the compiler to locate and generate precise relative paths (../../../../src/my-entry.ts), so we satisfy (2).
  • By making it a package by itself (it has a package.json), it can independently declare its own dependencies, so it satisfies (3).
  • not (4), since we were running the installation for the framework dependencies separately from the user project.

The requirement 4 is the one we want to address.

Other frameworks

In general what other frameworks do is:

  • For (1) and (2), they generate code straight in the user's project.

They do this by either actually writing files (like TanStack Router/Start), or by generating virtual files at build time with bundler plugins (like Next)

  • For (3) and (4), they ship all their possible runtime dependencies as part of an npm-published package, that exports functions for the generated code to call into.

This can be done both in the same package as their compiler (like the next package having internal imports), or in a support package (like prisma making you install @prisma/client).

This solution

Our main problem is that we're not generating the code in the user's project, but as separate packages.

  • We can't write these packages to node_modules because npm only resolves the dep tree when fetching the packages from the registry, and doesn't look into the written package.jsons for changes.

  • We can't use file:.wasp/out/server because that doesn't resolve transitive dependencies.

So then the final option is to tell npm that these projects are actually user-authored. This signals to npm that they can change their dependency, and that it should look into their package.jsons on disk to correctly resolve the dep tree. We do that through the workspaces key.

The main constraint is that the workspaces key has to be in the top-level project. Because we want to maintain the fiction of requirement (1), we have no other way but to write this key into the user project's package.json.

Wasp in the future

For us, the same architecture as other frameworks could be reached by

  1. First option
    1. Completely adopting the templates → libs RFC
    2. Reducing our generated code to the minimum
    3. Writing this minimum-viable generated code to the user project
  2. Second option
    1. Creating a package that declares all of the possible dependencies we might need
    2. Moving the Wasp compiler to be a bundler plugin and emit virtual files, using dependencies from that package

Copy link
Member

Choose a reason for hiding this comment

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

Nice write up! I certainly would like to understand this better in the future, I hope we discuss this more.

Couple of quick questions without getting too deep into it right nowm fccused on the very end of your comment, the "Wasp in the future" section:

  1. "Writing this minimum-viable generated code to the user project" -> what does that mean, we would actually spam their project with our code? Would it just sit there, or would they need to import it / use it somehow?
  2. "Moving the Wasp compiler to be a bundler plugin and emit virtual files, using dependencies from that package" -> what could this look like? Does it mean we need to rewrite it to TypeScript? Does it mean we don't have wasp CLI more? Are we limited for our big viison by being "just" a bundler plugin? Or maybe there is a way to have bundler plugin that calls Wasp CLI or something like that?

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks! Just a disclaimer that those are my own opinions on where I'd take Wasp in the future, haven't talked about it with anyone, just ideas (haven't even tried them out).

  1. [...] what does that mean, we would actually spam their project with our code?

Yeah, maybe, if our generated code becomes small enough to not be a hassle. That's what TanStack does, and it provides you great visibility, and no need for LSP plugins or anything because the code is actually all there.

  1. [...] Does it mean we need to rewrite it to TypeScript? Does it mean we don't have wasp CLI more? Are we limited for our big viison by being "just" a bundler plugin?

None of this, necessarily. Implementing part of our functionality can just be an implementation detail. The bundler plugin can call out to external programs no problem (e.g. esbuild), so our current compiling model doesn't need to change dramatically. (And while there's no need to rewrite to TS, I would argue that we could compile to WASM so no need for native binaries, but even that is optional). The CLI can still exist and call out e.g. vite when needed, as an implementation detail.

But implementing the compiler as a bundler plugins could give us a lot of functionality "for free", wide hooks into modifying or adapting the user code unobtrusively; and give users a know quantity for adding functionality as e.g. vite plugins. To drive the point home, large swathes of next are implemented as Webpack plugins, but you don't need to know that at all.

Copy link
Member

Choose a reason for hiding this comment

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

You lost me on some points here, but I think got some idea of it. Ok, future discussions!

Copy link
Contributor

@sodic sodic Oct 9, 2025

Choose a reason for hiding this comment

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

This is awesome. @cprecioso Do you mind copying your comment with Martin's points addressed into a notion document. Everyone on the team should read and understand this better (myself included).

Copy link
Member Author

Choose a reason for hiding this comment

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

Comment on lines +185 to +202
genNpmrc :: AppSpec -> Generator [FileDraft]
genNpmrc spec
-- We only use `.npmrc` to force `npm` to error out if the Node.js version is incompatible.
--
-- In dev mode, we already check the Node.js version ourselves before running any `npm` commands,
-- so we don't need this there.
--
-- We do expect users to manually go into the generated directories when bundling the built ouput.
-- So we do add the `.npmrc` there to help them avoid using an incompatible Node.js version.
| AS.isBuild spec =
return
[ C.mkTmplFdWithDstAndData
(C.asTmplFile [relfile|npmrc|])
(C.asServerFile [relfile|.npmrc|])
Nothing
]
| otherwise =
return []
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, any ideas of how we can reduce this duplication without getting too fancy?

Copy link
Member Author

Choose a reason for hiding this comment

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

What duplication?

Copy link
Contributor

@sodic sodic Oct 9, 2025

Choose a reason for hiding this comment

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

This code is essentially the same as the one in WebGenerator, including the comment.

Copy link
Member Author

@cprecioso cprecioso Oct 9, 2025

Choose a reason for hiding this comment

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

Oh I see... Mmmh I can extract maybe to the NpmWorkspaces.hs seeing as it is tangentially related? Not 100% on it tho

toWorkspacesField =
-- While the trailing slashes do not matter, we drop them because they will be user-visible in
-- their `package.json`, and it is more customary without them.
fmap (FP.Posix.dropTrailingPathSeparator . fromRelDirP)
Copy link
Contributor

Choose a reason for hiding this comment

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

The type signature already limits the function to work only on lists, so IMO the polymorphic fmap is unnecessary and confusing.

This is a loong running discussion in Wasp btw, so don't feel obliged to change it. If anything, Martin will be happy he finally got someone on his side :)

If you're curious:

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll turn it to map. Reading that context, I also realized that sneaky Martin also led me to believe that "all the true Haskellers" use <> instead of ++ while I prefer the latter too. 😂

Copy link
Member

@Martinsos Martinsos Oct 9, 2025

Choose a reason for hiding this comment

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

They indeed do! You will see them using <$> and <>, like I also do these days. I was once in the past in the ++ and map camp, but I grew out of it. @infomiho and @sodic haven't yet :D.

p.s. map and ++ were made for learning really, there isn't much purpose for them when there are fmap and <>. But they had/have simpler types and are therefore more friendly for beginners (simpler error messages, no typeclasses and stuff in them).

Copy link
Contributor

Choose a reason for hiding this comment

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

I grew out of it. @infomiho and @sodic haven't yet :D.

I did grow out of Haskell tho. Some day I hope you join me.

@cprecioso Welcome to the right side :)

-- Review when we upgrade React 19 (#2482).
]
where
globFor dir =
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can make this function clearer with a type signature. After all, it can't just take any dir. I believe we have a special type somewhere that captures the values for build and out dir.

Copy link
Contributor

Choose a reason for hiding this comment

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

... and now we can also change the argument name :)

Copy link
Contributor

@sodic sodic Oct 9, 2025

Choose a reason for hiding this comment

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

Plus, I advise naming functions using verbs:

  • makeGlobFor
  • makeNonPosixError

I know we're all serious Haskellers pretending that "yo function is no different than a number, why would it have special treatment" (render in sponge bob mock text). But, in reality, the human mind does consider functions different than regular values, and naming them with verbs helps out.

I won't block on this though, it's a matter of style.

@cprecioso
Copy link
Member Author

@sodic ready

@cprecioso cprecioso requested a review from sodic October 8, 2025 09:35
Copy link
Member

@Martinsos Martinsos left a comment

Choose a reason for hiding this comment

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

I just took a glance and did no real reviewing, mostly to keep myself up to date with changes. Left a couple random comments.
Unsubscribing myself!

where
validateOptional packageSpec = validatePackageJsonDependency packageJson packageSpec Optional

validateWorkspaces :: P.PackageJson -> [GeneratorError]
Copy link
Member

Choose a reason for hiding this comment

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

Nice job on adding validation!

import qualified Data.Set as S
import Wasp.Generator.Monad (GeneratorError (GenericGeneratorError))

class JsonValue a where
Copy link
Member

Choose a reason for hiding this comment

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

I see we are doing some logic in this file for modeling JSON and validating it.

I wonder if we could reuse some existing library for this instead of implementing it on our own?

How about Aeson itself, could it also be used for this? Maybe not, I am not sure, but worth checking.

Extremely quick googling gave me https://hackage.haskell.org/package/forma (by mark karpov, he is serious), and also https://github.com/silkapp/json-schema , maybe worth checking.

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree with the intention, just a note that this is not new code but extracting our old code for tsconfig validation into a common file. I'd not change this code for now to keep the PR scoped, but I'll create an issue for it (the task sounds fun actually)

Copy link
Member Author

Choose a reason for hiding this comment

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

@cprecioso
Copy link
Member Author

@sodic ready

Comment on lines +36 to +37
-- We force this to be POSIX because Windows-style paths do not accept wildcard characters.
packageWildcard = [SP.reldirP|*|]
Copy link
Contributor

Choose a reason for hiding this comment

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

This is also something that probably doesn't need to be in StrongPath. When we're constructing wildcards, we're already in string teritorry, so there's no point in pretending we're dealing with real paths, discussing Posix etc.

{ dependenciesConflictErrors = conflictErrors,
devDependenciesConflictErrors = devConflictErrors
}
conflictErrors ++ devConflictErrors
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we lose something by not separating dev dependency conflicts from regular dependency conflicts?

validateFieldValue fileName fullyQualifiedFieldName (Just expectedValue) fieldValue

validateFieldValue :: (Eq a, JsonValue a) => String -> FullyQualifiedFieldName -> Maybe a -> Maybe a -> [GeneratorError]
validateFieldValue fileName fullyQualifiedFieldName expectedValue actualValue =
Copy link
Contributor

@sodic sodic Oct 10, 2025

Choose a reason for hiding this comment

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

When doing refactors, it's best to do it in a PR different than the one introducing a feature.
If a refactor includes both code migration and functional changes, then those two should be separated as well.
I'll leave another pointer to this great article :)

This PR includes:

  • NPM workspaces changes
  • A refactor of validation modules with both:
    • Functional changes
    • Moving code around

Since Git treats everything as new code, changes to logic are extremely easy to miss. Examples:

  • I think validateArrayFieldIncludesRequired is new.
  • The fileName argument in this function is also new.
  • I think the rest of it is just moved.

This results in:

  • Properly managing context and focusing attention on new changes is more difficult, so most reviewers will only do a shallow review of both the refactor and the new change.
  • If the refactor needs changing, NPM workspaces have to wait.
  • The review takes longer because there's more context to manage and more things to comment on. Specifically, half of my time went to the workspaces, the other half went to these three files.

I've taken a look at the refactor and have some reservations:

  • A lot of the things in Common aren't common. Most of it is used either by one module or the other, the only common thing being the JsonValue type class.
  • It seems like the refactor is half done and we could go for more reusability. Only a single function in the package.json validation does things the "TS Config" way, all others do it "The old PackageJson way" without using JsonValue (i.e., there's little overlap between the two call stacks). I didn't go too deep, but that seems fishy.
  • If we're going down the JSONValue route, I think a better approach is deriving instance JsonValue a => JsonValue [a] than doing (JsonValue [a]) => ... in a type signature.
  • etc.

So, since I have more reservations about the refactor than I have for the worksapces part, instead of discussing the refactor here, I suggest we move it out of this PR and into a different one? Or is it instrumental to the change?
I took a look and it doesn't seem instrumental, but I could be wrong.

I approve everything else btw (the workspaces I mean). Once this has been taken care of, you can merge without me :)

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.

Share dependencies through an npm workspace

5 participants