-
Notifications
You must be signed in to change notification settings - Fork 78
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
Much faster dev builds, lower memory usage, working TypeScript navigation in VSCode #1398
Conversation
@@ -20,7 +20,7 @@ import { Identity } from "@semaphore-protocol/identity"; | |||
import JSONBig from "json-bigint"; | |||
import { Proof, RLN, RLNFullProof } from "rlnjs"; | |||
import { v4 as uuid } from "uuid"; | |||
import verificationKeyJSON from "../artifacts/16.json"; | |||
import verificationKeyJSON from "./16.json"; |
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.
Interesting. Why did this file need to move when the artifacts of other ZK PCDs didn't?
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.
For TypeScript's language server to be able to generate its own in-memory representation of the full type structure, we need tsconfig.json
to accurately reflect the structure of the code base. This means that rootDir
needs to be the actual source root, and files cannot be included from outside of it. The JSON file is the only artifact file that gets included by TypeScript, and so has to move to somewhere inside of the source root, or TypeScript will fail to generate types.
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.
Looking deeper, it seems the answer is that the other ZK PCDs did in fact need this change, and I found the point where the file is moved and the artifact generation script is updated. I missed that last night, possibly because I was looking at the -ui
package instead of the main PCD package. Sorry for the confusion, I understand the reason for this change.
I only skimmed the changes since they seem to be mostly global/automated, but this all sounds pretty good. I tested this branch on my low-memory machine and at least a warm build had similar levels of memory usage as viewed in Activity Monitor. In the interim, would manually running I'm a bit nervous about skipping type checking in dev mode, from the general philosophy of always wanting to catch errors as early as possible, and not waste time debugging an issue which a type error would've caught. VSCode will show me errors in code I'm looking at actively, but not more spread-out results. Though maybe I should be paying more explicit attention to the "Problems" window in VSCode to address that? If we think the code/tools are now clean enough that it should normally be empty. |
This should not be the case. If I run
Turborepo caching ought to handle this, as per the above. I'm not sure why it would not be doing that here.
Yes, this just does transpilation.
The truth is that our dev mode from two months ago didn't do type checking either - we just had a Using There are other approaches we could try, that might produce better trade-offs. For instance, we could upgrade to TypeScript 5, create a top-level |
I did some more resting and it seems Turborepo is actually caching the type generation. I may not have seen that yesterday due to confusion when switching between branches, sorry. Experimentally, it seems it also behaves the way I'd expect if I change a single file. All the type-generation commands scroll by, but only one of them pauses for any meaningful time, suggesting only one was unached. I think I may never have experienced the "original" dev mode. I saw some evidence that the auto-rebuild wasn't reliable (may've depended on whether the modified source was inside passport-client or not), so defaulted to always running Separately: while experimenting I also saw this error pop up in VSCode, which I thought you might want to look at:
|
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.
Excellent documentation in the PR description! Thanks for digging deeper into this.
I left some comments with questions and small suggestions. LMK what you think. It looks like you're still in the middle of some changes so I've held off on testing this locally. LMK when you would want me to test locally.
re @artwyman's comment
Other people's expectations may vary. It sounds like I can continue to follow a safer workflow manually if I want to regardless, so I don't need to block on this, but I'll argue passionately about the philosophy of finding bugs as early in the toolchain as possible if it's a conversation you ever want to have. :)
I agree w/ this - it would be really nice to catch type errors as early as possible. I also wouldn't block on this here, but it's worth creating an issue for imo.
Re @robknight's comment:
There are other approaches we could try, that might produce better trade-offs. For instance, we could upgrade to TypeScript 5, create a top-level tsconfig.json containing references to all of the packages, and use that to generate the types for the entire project in one go (this approach is not supported in TypeScript 4.x, which is what we are using). We could then run this in "watch mode" to regenerate types on-the-fly. If what we want is a process running in the console that tells us about type errors immediately, I think this would do it.
How much work would it take to migrate do you think?
@@ -29,6 +29,7 @@ | |||
} | |||
}, | |||
"eslint.rules.customizations": [ | |||
{ "rule": "prettier/prettier", "severity": "off" } | |||
{ "rule": "prettier/prettier", "severity": "off" }, |
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.
👍
@@ -29,6 +29,7 @@ | |||
} | |||
}, | |||
"eslint.rules.customizations": [ | |||
{ "rule": "prettier/prettier", "severity": "off" } | |||
{ "rule": "prettier/prettier", "severity": "off" }, | |||
{ "rule": "import/no-unresolved", "severity": "off" } |
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 do you need this line?
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 is because TypeScript declaration files are a strange thing, part source (when they're being imported by other packages) and part build artifact (when they're sitting quietly in the dist
directory).
The TypeScript language server is smart enough to build its own model of the types, and this is how VSCode is able to do code navigation. But eslint
isn't smart enough to do this, and will flag missing type declaration files as a lint error because it can't resolve the TypeScript dependency unless it can see the type declaration files. This means that the codebase fails linting on a fresh checkout, which seems wrong (and is unfixable unless we check the type declarations in, which also seems wrong as they are build artifacts).
This configuration change only affects whether VSCode will display the error, not whether eslint
will detect it, so it still gets picked up when running yarn lint
. In CI, yarn lint
runs after yarn build
, which means that the type declaration files will be present.
Finally, if you're actually trying to import a package that doesn't exist, or which doesn't have an index.ts
, or is not correctly configured to export types, then you will get a different (and better) error from the TypeScript language server in VSCode anyway, making this one redundant.
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.
makes sense!
"clean:workspaces": "turbo run clean --parallel && yarn", | ||
"knip": "knip --no-gitignore", | ||
"check-references": "workspaces-to-typescript-project-references --check", | ||
"fix-references": "workspaces-to-typescript-project-references && prettier -w \"{apps/*,packages/*/*}/tsconfig.json\"" |
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 does this script run prettier?
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.
Because it causes changes to a JSON file but doesn't format the JSON file in the same way that prettier would, so we prettify it after the change is made.
@@ -40,6 +43,7 @@ | |||
}, | |||
"dependencies": { | |||
"@changesets/cli": "^2.26.0", | |||
"@monorepo-utils/workspaces-to-typescript-project-references": "^2.10.4", |
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.
cool project. i wonder what else is in @monorepo-utils
that we could yoink
packages/lib/util/tsconfig.json
Outdated
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.
comment unrelated to this particular file but it's where I had the thought so it's where I'm writing it: given the complexity of setting up a package in this monorepo, i wonder if it would make sense to add an example 'template' package that is intended to be:
- kept up to date with the latest modifications to how packages ought to work in our monorepo
- able to be copy-pasted almost verbatim when setting up a new package in the monorepo
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.
Adding documentation there about all the choices that may seem arbitrary and confusing to a new developer (as opposed to in every single package) may be a good way to keep track of what's going on. Your PR description here was really good, and could be split up into such documentation.
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.
Furthermore, it may make reviewing / proposing future changes to the packaging setup simpler.
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 included this in the new PR.
Good, that matches my experience.
I would agree that finding bugs as early as possible is good. Two things work against us for development builds:
(2) doesn't apply so much for running a full build, or for running tests, linting, or anything else. We also have VSCode checking for type errors, and so having the watcher process run the same checks that VSCode (or rather, the TypeScript language server) is already doing seems wasteful, especially because the watcher process can only do it quite inefficiently (by spawning a new process to compile the types, whereas the language server already has all of the details cached in RAM and can update much much faster). I think it makes sense to treat the language server as the development mode type-checker, rather than have the transpiler also do type checking.
I've fixed this. Mostly for aesthetic reasons, I wanted types to be exported to |
This file causes hard-to-debug issues, and is generally unhelpful when we are trying to generate types.
ESLint complains that it can't resolve TypeScript dependencies when it can't find the .d.ts files, even for dependencies within the same monorepo. This is annoying as these don't exist out-of-the-box, meaning that ESLint fills VSCode up with red squiggly lines on a fresh checkout. This turns the rule off in VSCode. `yarn lint` will still detect the errors, and so will notice missing declarations when run from the command line or in CI.
I agree in principle. However, the the TypeScript language server is already doing this, so long as VSCode is running. Agreed with creating an issue to come back to this later.
I've just tried migrating to TypeScript 5.3.3 and it seems quite easy. However, adding a whole different watcher seems like it could involve changing how we do watching in general. I did consider this option earlier on. It could be possible to use the TypeScript watcher to do almost everything: both transpilation and type generation. The only problem is that TypeScript won't do separate ESM/CommonJS builds, and this is something we need for type packaging. Now, maybe that's OK for development mode and not for packaging, but I'd want to take some time to reassure myself about that. I thought that fixing our existing |
f28c77e
to
61e1537
Compare
Closes #1387 This PR supersedes #1398 and includes many of the same changes, with some additions. As with the previous PR, we now have faster builds, resilience against missing/frequently changing type declaration files, and lower overall memory usage during development mode. A summary of the final changes is below: ## Replace `tsup` with `tsc` Previously we used `tsup` to transpile our TypeScript to JavaScript, with `tsc` used only for generating types. `tsup` has some advantages, and in particular does not require as much configuration as `tsc` to produce the kinds of outputs that we want. Since we now have extensive configuration for `tsc`, this advantage is diminished. In particular, getting `tsup` to build both CJS and ESM variants of JavaScript is a simple matter of passing `--format cjs,esm` to `tsup`, whereas `tsc` can, by default, only build a single variant, according to how `tsconfig.json` is set up. This low barrier to initial setup made `tsup` attractive. `tsup` also has some features for dealing with polyfills and node-specific builds. We made only minor use of these, in the `@pcd/passport-crypto` and `@pcd/util` packages, and it was possible to find simple solutions to the problems in those packages without using `tsup`. ## cjs/esm configuration and simplification Although `tsup` doesn't require config files, it does take configuration on the command line. This meant that some of our packages had several entries in `package.json` to cover the different variants. For example: ```json "build": "yarn build:browser && yarn build:node && yarn build:types", "build:browser": "tsup src/index.ts --platform=browser --out-dir=./dist/browser --format esm --clean", "build:node": "tsup src/index.ts --platform=node --out-dir=./dist/node --format cjs,esm --clean", "build:types": "rm -rf dist/types && tsc --emitDeclarationOnly --outDir dist/types", ``` Is now replaced by: ```json "build": "tsc -b tsconfig.cjs.json tsconfig.esm.json", ``` This single command transpiles both JavaScript variants, and generates type declarations. The down-side is that we require `tsconfig.esm.json` and `tsconfig.cjs.json` to exist. These are both fairly small files, and have an identical template in all packages: ```json { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist/esm", "module": "ESNext", "tsBuildInfoFile": "./tsconfig.esm.tsbuildinfo" }, "include": ["src", "src/*.json"] } ``` ```json { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist/cjs", "module": "CommonJS", "tsBuildInfoFile": "./tsconfig.cjs.tsbuildinfo" }, "include": ["src", "src/*.json"] } ``` The differing `outDir` settings tell TypeScript where to put the transpiled JavaScript, the `module` setting tells is which variant of JavaScript to produce, and `tsBuildInfoFile` tells it where to put the incremental build data. This setting is important as by default it would be placed alongside the exported JavaScript, meaning that it would get included in the package uploaded to NPM. Since these files are often quite large, it's important to avoid this. ## TypeScript references TypeScript references are entries that optionally appear in the `tsconfig.*.json` files when a package depends on another package. These allow the TypeScript compiler to build a dependency graph and build types for each package in the right order. This can be used both for the in-memory representation of the types used by the TypeScript language server (and therefore by VSCode), and helps to keep the representation of the types up-to-date irrespective of the on-disk type declaration files. This can also be used to drive the transpilation and generation of type declaration files. At the root of the project there are now two files called `tsconfig.cjs.json` and `tsconfig.esm.json` respectively. These do nothing other than hold references to all of the TypeScript packages in `packages/*`. Running `tsc -b tsconfig.cjs.json tsconfig.esm.json` at the root of the repository transpiles _all_ of the packages and generates _all_ of the type declarations in one go. This is much faster than the previous approach of spawning `tsup` processes per-package and running `tsc` on a per-package basis to generate types. It is sufficiently fast that it can be run in response to code changes, and is suitable for development mode. This means that all rebuilds, whether development mode or production builds, include type-checking. It is important that these references are kept up-to-date, and they may need to be updated whenever we: - Add a dependency between packages - Remove a dependency between packages - Add a new package - Remove an existing package Fortunately there is a script to update the references. Just run `yarn fix-references` and it will automatically update the `tsconfig` files and reformat them using `prettier`. ## Other fixes ### Polyfills In a couple of places (`@pcd/util` and `@pcd/passport-crypto`) we did some dynamic `require` calls to load the node `crypto` module. This is tricky to transpile because `require` isn't supported in ESM modules. I've removed this code and replaced it with a dependency on the `get-random-values` package, which handles the selection of the appropriate crypto engine for us. A similar problem occurs with the usage of `buffer` without a dependency on the `buffer` npm package for browser use. Adding an explicit dependency solves this. In general, polyfilling should be avoided in our packages, and left to the bundler used by the application that depends on the package (in our case, `esbuild` for `passport-client`). ### Icons Originally `passport-client` had a set of SVG icons that were loaded using an `esbuild` plugin. Because it became necessary to depend on these icons in the PCD UI packages, I moved them to `passport-ui` and used the equivalent `tsup` plugin. However, since we don't have `tsup` now, we needed another solution. The `tsup` plugin was just converting the SVG files to dataurl strings. I've written a simple script which loads the SVG files, converts them to data URLs, and then writes those as strings in a `.ts` file. This produces the same exported structure as previously, without the use of the plugin. It does mean that if we add or change the icons, we will need to run the script again. ## Template To make it easier to create new packages, I've created a template package at `templates/package`. This includes all of the basic files required for a package. When creating new packages, we should copy and modify this rather than copying an existing package - this will help to avoid accidentally copying some unwanted configuration or dependencies. ## Build vs dev The `yarn build` command runs `yarn build` for each package and application individually. Turborepo caching is currently disabled. However, in our base package `tsconfig` I have enabled `composite: true`, which enables incremental builds for TypeScript. This means that most of our packages don't need a full rebuild, making builds reasonably fast (though still not as fast as they were with Turborepo caching). `yarn dev` runs `scripts/watch.ts`, which watches the TypeScript source and runs `tsc -b` on the root tsconfig files, incrementally recompiling any packages that need it. This seems reasonably fast to me. I did _not_ use `tsc --watch`, since the benefit of doing so seemed minimal and the visual output it produces is a bit confusing. ~~Dev mode for `passport-server` and `consumer-server` now uses `ts-node-dev`, which will typecheck the application code on startup, and auto-restarts when changes to the application code or packages are observed.~~ Dev mode for consumer-server now uses `ts-node-dev`, which type-checks application code on startup and auto-restarts when the application or dependent packages change. I tried using this with `passport-server` and it doesn't quite work, because `passport-server`'s `MultiProcessService` relies on a transpiled JavaScript `worker.js` file existing at a specific path. This means that we need to do a full build of `passport-server` on every reset to ensure that the file exists.
Closes #1387
I've given a reasonably full history of the changes made below. The gist is that development builds are now much faster with vastly lower RAM usage, VSCode seems to have no trouble keeping track of types, and I've eliminated some annoyances in the way that TypeScript declaration files differed in their locations in the
dist
directory between packages.This PR makes a set of changes intended to solve some of the recent problems with:
a) Development build times
b) Development build memory usage
c) VSCode/TypeScript language server losing track of TypeScript types (#1387)
d) VSCode cmd-click going to type declarations rather than the original source definition
These problems began following the packaging changes designed to make packages easier for third-party developers to use. However, they worsened developer experience when working in the monorepo.
Prior to the packaging change, each package included full TypeScript source, and each
package.json
file simply referenced the TypeScript source in thetypes
field. This is how the TypeScript language server (and VSCode) were able to track types between packages easily, allowing cmd-click navigation and rapid reconciliation of type changes that affect multiple packages.Post-change, the
types
field pointed to a type declaration file (e.g.dist/types/index.d.ts
). This is great for third-party package consumers, because they no longer needed to be able to parse TypeScript files or worry about TypeScript dependencies. However, it was bad for monorepo development because:a) Type declaration files are build artifacts, so they don't exist unless you have run
yarn build
b) They also don't get updated unless you run
yarn build
, so type changes don't propagate between packages quicklyc) Cmd-clicking on a type in VSCode takes you to the type declaration file, not the original type in the relevant source file
(c) was fixed by using TypeScript declaration maps, which also necessitated switching to use
tsc
to generate types instead oftsup
. This meant that each build command involved runningtsup
to transpile TS->JS, then runningtsc
to generate the type declarations and declaration maps.In a separate change, we also increased the number of packages substantially, by splitting out the React components from the PCD packages.
This got us to a point where, in order to do a development build, we had to run
tsup
on each package to generate JS, then runtsc
on each of those packages. This was very slow. Parallelizing the processes helped with speed, but causes very high RAM usage - dangerously high on 8GB RAM, and still bad enough on 16GB RAM that it would cause a noticeable slowdown for around a minute.For development, we were running
tsup
in watch mode, meaning one watcher process per package. With the increased number of packages, the watcher processes started using a lot of RAM (and these watcher processes would also spawntsc
as a child process whenever they needed to rebuild types, which they did frequently).The last change I made replaced
tsup
's watch functionality with a separate script that watched the entire monorepo for changes, starting a new build job every time a change was detected. Combined with Turborepo caching, this meant that development builds were reasonably fast again, and mostly did not require spawning many new processes. This eliminated most of the pathological memory usage cases.However, there was still a problem in that builds would delete and re-create TypeScript declaration files, which would cause the TypeScript language server to lose track of them. VSCode would frequently complain that it could not find type declarations even when the new files clearly existed, and only restarting the language server would fix it.
At this point, I went back to the drawing board, to see how other projects solve this problem. The answers were quite varied, and the "how do we make nice packages for third-party developers while also having optimal monorepo developer experience" problem is also a problem for others.
The main thing that I learned was that the TypeScript language server runs a kind of "shadow" compilation of your project in RAM. If you have a TypeScript configuration that works well with this, the TypeScript language server can map out your entire project, with all of the cross-package type dependencies, even if the type declaration files don't exist on disk. This requires a few things to be just-so in the
tsconfig.json
file:rootDir
andoutDir
must be set incompilerOptions
types
field must point to the location inside theoutDir
where the type declaration files will be generated (which requires settingdeclarationDir
)tsconfig.build.json
file is used to exclude test file types from the packaged buildsTypeScript references are entries in a
tsconfig.json
that point to other packages. These exactly mirror the dependencies inpackage.json
, and there is an issue for TypeScript to infer them automatically. In the meantime, it's easy to generate them programatically.With all of these things done, we get:
dist/types/src
rather thandist/types
for some packages, because therootDir
was.
by default rather than./src
I've done a little bit of benchmarking, and this has improved development build times quite a bit:
This measures the time it takes from starting
yarn dev
to having a runningpassport-server
. "Cold start" is a fresh checkout, "warm start" is runningyarn dev
to completion, stopping it, then running it again with no changes.I haven't benchmarked memory usage with any precision, but it is obviously lower. This is because there is only a single watcher process now, and we never have to spawn
tsc
to generate type declaration files during development builds.A full build now has two distinct phases: transpilation (
build:ts
) and type declaration generation (build:types
).build:ts
is what we run during development. When we want to do a full build, we run bothbuilt:ts
andbuild:types
- the top-levelyarn build
command just runs both.passport-server
running in dev mode now usests-node-dev
with the--transpileOnly
flag. This means that it does not do any type generation/checking, and therefore doesn't need declaration files to exist on-disk. This also speeds up startup substantially. I think this is an improvement given that full builds still do type checking, and VSCode will accurately report any type errors.The TypeScript references mentioned earlier do need to be kept up-to-date. This is fairly easily done by running
yarn fix-references
- this just ensures that thetsconfig.json
references match the dependencies inpackage.json
.yarn check-references
has been added to CI to make sure we don't forget to do this.