-
Notifications
You must be signed in to change notification settings - Fork 295
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
Auto optimize deps in development #2106
Conversation
clearTimeout(showBannerUrlTimeout); | ||
showBannerUrlTimeout = setTimeout(showSuccessBanner, 2000); |
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 a best-effort debouncing to show the success banner after all of the dependencies are added to the list.
const unknownError = new BugError(headline + ': ' + colors.dim(message)); | ||
unknownError.stack = cleanStack; | ||
renderFatalError(unknownError); |
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.
Other non-CJS reference errors like asdf is not defined
should come here and be printed normally.
); | ||
} | ||
|
||
const result = await entryPointErrorHandler?.({optimizableDependency, stack}); |
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 entryPointErrorHandler
is passed from the CLI to the mini-oxygen plugin, and it's what writes to vite.config.js
(it has access to prettier and ast-grep).
const filepath = stack | ||
.match(/^\s+at\s([^:\?]+)(\?|:\d)/m)?.[1] | ||
?.replace(/^.*?\(/, '') | ||
.replace(/\?.+$/, ''); | ||
|
||
const nodeModulesPath = filepath?.split(/node_modules[\\\/]/).pop(); | ||
|
||
if (!filepath || !nodeModulesPath) 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.
Finds the filepath in the stack that comes from node_modules
because we only want to handle Reference Errors in dependencies, not in-app.
const mods = viteServer.moduleGraph.getModulesByFile(filepath); | ||
const modImporters = new Set<string>(); | ||
|
||
mods?.forEach((mod) => { | ||
mod.importers.forEach((importer) => { | ||
if (importer.file) modImporters.add(importer.file); | ||
}); | ||
}); | ||
|
||
for (const mod of modImporters) { | ||
const importersSet = new Set<string>(); | ||
|
||
const code = await readFile(mod, 'utf-8').catch(() => ''); | ||
const matches = | ||
code.matchAll(/import\s[^'"]+\sfrom\s+['"]((@|\w)[^'"]+)['"]/g) ?? []; | ||
|
||
for (const [, match] of matches) { | ||
importersSet.add(match); | ||
} | ||
|
||
const importers = Array.from(importersSet).sort( | ||
(a, b) => b.length - a.length, | ||
); | ||
|
||
const foundMatchingDependency = importers.find((importer) => | ||
nodeModulesPath.startsWith(importer), | ||
); | ||
|
||
if (foundMatchingDependency) return foundMatchingDependency; | ||
} |
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 knows which modules imported the file that has the CJS syntax. We get all of them and read their source code. Then, we try to find the exact string that is used to import the offending file.
Examples:
- With the stack line
.../node_modules/react-dom/server.browser.js
we'll find a file containingimport xyz from 'react-dom/server'
, and we get the stringreact-dom/server
to add it to optimizeDeps. - With the stack line
.../node_modules/set-cookie-parser/lib/index.js
we'll find a file containingimport xyz from 'set-cookie-parser'
, and we get the stringset-cookie-parser
to add it to optimizeDeps.
? entry | ||
: path.resolve(resolvedConfig.root, entry); | ||
configureServer: { | ||
order: 'pre', |
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 pre
here means we always run before Remix plugin, thus fixing the situation where the user adds remix(), oxygen()
in the wrong order.
const miniOxygenPromise = Promise.resolve(apiOptions.envPromise).then( | ||
(remoteEnv) => | ||
startMiniOxygenRuntime({ | ||
entry, | ||
viteDevServer, | ||
crossBoundarySetup: apiOptions.crossBoundarySetup, | ||
env: {...remoteEnv, ...apiOptions.env, ...pluginOptions.env}, | ||
debug: apiOptions.debug ?? pluginOptions.debug ?? false, | ||
inspectorPort: | ||
apiOptions.inspectorPort ?? pluginOptions.inspectorPort, | ||
requestHook: apiOptions.requestHook, | ||
logRequestLine: | ||
// Give priority to the plugin option over the CLI option here, | ||
// since the CLI one is just a default, not a user-provided flag. | ||
pluginOptions?.logRequestLine ?? apiOptions.logRequestLine, | ||
}), | ||
); |
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 were starting the MiniOxygen instance very early before. This has an issue where the instance could be disposed when the Vite server is disposed, but not restarted because this code doesn't run again.
I've delayed starting the instance until we get the first request. If the instance has been disposed, it will start it again the first time it's needed.
This happens every time you save vite.config.js
.
const ready = | ||
miniOxygen && !miniOxygen.isDisposed | ||
? Promise.resolve() | ||
: getMiniOxygenOptions().then((options) => { | ||
miniOxygenOptions = options; | ||
miniOxygen = startMiniOxygenRuntime(options); | ||
}); |
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.
Create or restart the instance if it has been disposed (when running viteServer.close()
, which happens on vite.config.js
save automatically).
const warmupWorkerdCache = async () => { | ||
await new Promise((resolve) => setTimeout(resolve, 200)); | ||
|
||
const viteUrl = getViteUrl(viteDevServer); | ||
|
||
if (viteUrl) { | ||
fetch(new URL(WARMUP_PATHNAME, viteUrl)).catch(() => {}); | ||
} | ||
}; | ||
|
||
viteDevServer.httpServer?.listening | ||
? warmupWorkerdCache() | ||
: viteDevServer.httpServer?.once('listening', warmupWorkerdCache); |
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 warmup request happens before the browser is open. Therefore, we still create a MiniOxygen instance quite early and start discovering unoptimized deps.
status: 503, | ||
statusText: 'executeEntrypoint error', |
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 just signals we couldn't load the entry point and MiniOxygen will log information accordingly in the Node/Vite process.
Oxygen deployed a preview of your
Learn more about Hydrogen's GitHub integration. |
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.
works like a charm
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.
💯 💯 💯 💯
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.
Very cool! 💯 Just some small tweaks to error message wording etc.
`\nAdded '${colors.yellow( | ||
optimizableDependency, | ||
)}' to your Vite config's ssr.optimizeDeps.include\n`, |
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 uses present tense when restarting so I think it makes sense for us to follow suit in this context.
`\nAdded '${colors.yellow( | |
optimizableDependency, | |
)}' to your Vite config's ssr.optimizeDeps.include\n`, | |
`\nAdding '${colors.yellow( | |
optimizableDependency, | |
)}' to Vite config in ssr.optimizeDeps.include...\n`, |
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 log line is shown after we add it, though. If we try to log it before or during, it will be hidden by Vite's "clear screen after restart logic".
Also, it's always printed after Vite's [vite] server restarted
log. That's why I wrote Added
in the first place.
With this info, can you confirm if we still want Adding
?
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.
Ah, I didn't quite get what was going on there. Yeah, let's keep Added
in that case!
.filter((line) => !line.includes('mini-oxygen')) | ||
.join('\n'); | ||
|
||
const optimizableDependency = await findOptimizableDependency( |
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.
Seems like the logic inside findOptimizableDependency
is possible to throw in some situations. If it does, it will end up in the outer catch that logs'MiniOxygen: Error during evaluation:
. That has the potential to confuse the developer. I'd rather see the original error from vite, than an error in our logic trying to optimize the dependencies. Maybe we should try catch around this, and throw the original entryPointErrorHandler
if it fails?
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.
In what situation can it throw? It already has a .catch()
in the only async call (readFile) and the rest shouldn't create any issue 🤔
I can catch it here as well I guess to be extra safe.
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.
Whenever I see I/O I assume it can fail (maybe I should also test this in Windows). Also not sure if there are situations vite would fail.
I'm curious, why are "react" and "react-dom" inside there? Are those necessary? They aren't in the skeleton template vite config. |
I did some testing, and it worked well! Great job! One package for some reason it did not automatically optimize is |
Co-authored-by: Graham F. Scott <gfscott@users.noreply.github.com>
@gfscott Great suggestion, thanks! Just left you a message in one of them.
@blittle React and react-dom are CJS dependencies by design, and they probably won't be in ESM to avoid issues with importing React twice (CJS & ESM) by mistake. Therefore, these deps need to be optimized by Vite to run on workerd. What the user need to add is custom dependencies for their own app code that we don't know about. Does answer your question?
@blittle It actually adds it to my file automatically: ![]() Maybe you are thinking about the message at the end of the screenshot from Vite? That's for a different field in vite.config.js. |
Oh this is probably because the regular expression I'm using checks Edit: fixed in 134d01a |
Problem
Workerd cannot run CJS. Certain dependencies are only published in CJS, or are published as ESM but incorrectly call CJS APIs and break at runtime. So far, we need to tell Vite to optimize these dependencies manually by adding them to Vite's
ssr.optimizeDeps.include
. This is really bad DX and there's not much we can do to figure out what dependencies are like this, because we don't know them until they break the app.Proposal
This PR catches the errors related to loading files in workerd and tries to figure out what dependencies are related to the error. Then, it adds them automatically to the user's
vite.config.js
and restarts the server.Example error in the terminal and browser that we get so far:
This will be now caught, processed, and fixed automatically:
Tophat
Try adding CJS dependencies such as
use-sync-external-store
(import fromuse-sync-external-store
oruse-sync-external-store/shim
).You can also disable any already optimized dep in the Hydrogen plugin (except
react/jsx-dev-runtime
which is a bit special and doesn't work) to see how they are added to the user config automatically.This should work ideally without opening the browser. However, if you do open the browser or change imports during development, it should still ideally work.
Note
I still need to test with PNPM. It might not work there yet because paths in node_modules are a bit different so it might need some extra tinkering.I've tested with demo-store + PNPM and it seems to work (at least after this change, but it's probably a rare thing with a very broken dependency).Errors
Browser error page
Using minimal CSS because this page is not rendered by Remix (plain strings):
When no
ssr.optimizeDeps.include
array is found in vite.config.tsThe following error shows up. We could try adding it automatically but it might be error prone.
When running with
--disable-deps-optimizer
flagWe don't write automatically anymore but we still read the error, parse the stack and try to provide helpful information:
Other non-CJS reference errors
Not every ReferenceError comes from an optimizable dependency. If the user has bad code it will be shown like this:
When we can't find the issue
In this situation we tried to optimize
textr
but that did not solve the issue. In this case, we print the stack for manual fixing. The user should findtypographic-base/index
in this case: