Switching the runtime over to ES Modules #3998
Replies: 2 comments
-
There are a lot of stuff :D First, I would also like to be able to use A few various questions and remarks are following. On the problem:
On "the You say
Would we need to do that manually? Or would you use a tool for that? On "Ensuring retrocompatibility":
This seems to be some kind of "auto importing". I think we should do "optimized one-click web export" as a feature even if we don't migrate to ES Modules yet, because it's a value added for users (lighter game exports, good for web games/playable ads). On "Supporting NPM modules": A few questions:
On Changing the way "swappable" modules are being registered: Seems fair, I don't see anything much simpler. It's a bit more rigid. Overall my idea would be that:
|
Beta Was this translation helpful? Give feedback.
-
The gain would be mainly the page load speed, and this might not be super important to most users, but things like Time to First Contentful Paint (e.g. the canvas showing the loading screen), the time JavaScript needs to fully load, etc are metrics used by search engines and could therefore very much matter for both users' websites' and liluo's SEO.
I think we could do that ourselves in GDCore. IIRC per the spec imports have to be at the top level of the file so it shouldn't be too hard: while the line begins with import or is just a new line, take the part between quotes and add that as include.
The problem with that is that we'd be basically forcing users to use optimized exports, as an unoptimized one would contain all of GDJS and GDJS's extensions :/
🤔 Not sure to what extent we could optimize the games without es modules. Bundling by itself isn't that much an optimization today with HTTP2 having multiplexing making the cost of loading multiple JavaScript files inexistent. We cannot tree shake without es modules. We are already minifying the JS when transpiling via esbuild. Maybe we could compress the images but otherwise... 🤷
I think it is pretty low. Most users wouldn't use JavaScript anyways and those who do would probably stick mostly to modules they know and are trustworthy. All in all I don't think there is much to worry about here, since viruses will prefer targetting node over GDevelop.
I was suggesting to cache the module contents to filesystem but that's actually a better idea, yes. That'd make all extensions remain standalone, which is better imo.
To be clear I think that previews and manual web builds should be launchable directly as es modules without transpiling, bundling or anything. The bundling would be an optional service proposed as a part of the one click builds. |
Beta Was this translation helpful? Give feedback.
-
I think switching the GDevelop JavaScript runtime over to ES Modules is an important step we have to take to keep GDevelop up to date technologically, solve multiple issues in the codebase, like the hack we are using for PIXI typings, the usage of npm modules, the lack of optimization of the web build compared to todays standards...
I am making this discussion to discuss the strategy of such a switch, since ES Modules are very different from what we are currently using.
Problems to be solved
Let's first keep make a list of what goals we are trying to achieve with the switch to ES Modules, asides from modernity:
Those are in my opinion the most important changes that would come with ES Modules, but if you disagree well that's what this discussion is for.
My Startegy Suggestion
Making the GDJS Platform aware of the
import
syntaxOne of the major features of ES Modules is that they have to define all their dependencies statically: for a given module, we can know at build time what are all the other modules it requires. I think that we should make the GDJS exporter take that into account: when including a file, it should recursively scan the imports of the module to know automatically from one module what other modules need to be exported.
This is important for two reasons:
It is also interesting if we add this functionality to JS events too: if an import statement is used there, the file containing the API would automatically included, eliminating issues in JS events where one wants to use an API that isn't included by default, requiring to put a dummy action that is not actually used to import the needed API. Imports in there would be combined and hoisted at the top of the generated events code's file at code generation.
Ensuring retrocompatibility
JavaScript events are used a lot in GDevelop, and we don't want the user's games to break, nor do we want our extensions to break. So whatever we do, we must ensure we keep the API untouched. We could make a bundle of the GDJS API that takes all exports of the index module and throws them into a global
gdjs
object. I think that this wouldn't be such a good idea though, since that would remove one of the main gains from such a switch: allowing tree-shaking on the runtime. Tree shaking being crucial to reducing bundle size, one of the most important metrics for games today, I think we should make JavaScript events tree shakable, while keeping compatibility with the old syntax.There are two cases where
gdjs
is used - either to use GDevelop code that should be imported, or to use as a global namespace, similarly towindow
. Therefore, all we would need to do to still support both cases would be togdjs.Logger
orgdjs.myExtensionNamespace
4.1. If it is in the list, it is a GDevelop API that needs to be imported, remove the left side and the separator (
gdjs.
) and add the right side to the set of imports from GDJS to be put at the top of the file by the code generator, likeimport
statements.4.2 If it isn't in the list, we know it must be a user defined object, so let's not modify anything. We will always continue to provide a global gdjs object, even if it is empty unless someone adds something to it.
Since we know what file each API come from thanks to the generated mappings, we do not need to make an "index" file for GDJS that imports everything, allowing to only include GDJS Runtime files that are actually used. We could also provide a magic
gdjs
module that translates using those mappings to the real file they are used in. For example,import { Logger, RuntimeScene } from "gdjs";
would be transformed at the top of the file automatically toThe only problem with this solution is that something like
gdjs["Logger"]
wouldn't work as intended, but that code is not something many users would do, and by pushing theimport
syntax over thegdjs.
syntax, by deprecating the later for example, we can avoid users using it in the first place and falling in such pitfalls.We also need to make the
PIXI
andHowler
global namespace work similarly, since some users might have been using them directly.Bundling
One of the main advantages of modules is that it allows bundling. Bundling, like compilation, requires a build tool, which shouldn't be part of GDevelop by default. Therefore, I think there should be two web exports:
The "classic web export"
Such a manual web export would simply output modules usable directly by the browser. No build tools or anything, just vanilla ES Modules. This would be used for previews or for manual web exports. This would allow to make a web export with ES Modules that Just Works™, without needing any kind of build tooling or build time. IIRC, using ES Modules directly without bundling is also what vite (the bundler) uses to make development builds blazing fast.
The "optimized one-click web export"
Such a manual export would use the GDevelop services to run a bundler on the exported code, to tree shake, minimize, code split, etc. This allows to have an optimized web build, ready for serious game developers to use in production. This could also be used on liluo to make the games faster 😎. One-click electron and cordova export could also use this for slightly faster loading times.
Supporting NPM modules
Many NPM modules are made for the web. JavaScript users are very familiar with such web NPM modules, and expect them mostly to "just work". We also use them, whether in the runtime directly (PIXI, Howler), or in extensions (pako, peerjs, MQTT.js, ...). The npm ecosystem is something that would be great to be accessible more easily in GDevelop.
However, the current way we use those npm modules is pretty unintuitive, you have to go seek a prebundled version of the library that you want to use which contains the API you require in a global namespace. This makes the usage of an NPM module unnecessarily hard, long and tedious. This will become even more so with a switch to es modules since they all have their own scope, forcing to modify the bundle to ensure it is putting its exports in the global scope, which might break stuff, and we also won't get any of the advantages of tree-shaking with those since non-es modules cannot be tree shaken.
We need a solution to use npm modules in esm efficiently. What could we do? A naive solution would be to use a client like yarn or npm to download those dependencies, but that is a no go for a few reasons:
Another solution would be to just bundle required npm modules into the runtime, except that would mean that we are bundling the runtime, which we do not want. We want to bundle after code generation, so that we can tree shake anything not used by the events code.
My solution would be to use a "bundler CDN": A CDN that creates an ES-Module bundle on the fly for an npm module. We cannot use them directly, since that would make GDevelop games (and previewing in the IDE) require an internet connection and download data on every launch of the game 💀. Instead, we would download them from those CDNs to them as regular include files. We can make this work on the web by storing them in, for example, indexed db. We do not want to download any code on our users computer without asking them though, so there'd be a procedure:
Dependency files would be cached across all projects: once you downloaded say
pako
, you do not need to install it again (unless a newer version is required).When downloading a dependency, we'd need to check if it has any imports statements. All of those "bundler CDNs" don't bundle imports from other npm modules, so we'd need to download those as well and rewrite the imports to use the locally downloaded version of those dependencies.
The biggest CDNs of this kind I know of are https://esm.run/ and https://www.skypack.dev/. esm.run is powered by jsDelivr, which has a lot of big sponsors like cloudflare, fastly, digital ocean... and is open source. I therefore believe it would be a good choice, if it wasn't for the fact that they have a banner on their site saying it is still in beta and subject to breaking changes. Skypack doesn't seem to say it isn't production ready, and seems to be fully ready to use. But they have a few red flags: they are closed source, have not updated their docs in a year, no apparent way of funding, and don't say much about themselves in the about us page... After digging for a bit, it seems it is made by the guy who is behind snowpack and astro. Those are pretty big and widely used projects, so the developer is somewhat credible. I think both options would work great for our use case, we'd need to look more into them to see which one is more fit, or, of we do not want to rely on a 3rd party, make such a thing ourselves, though personally I don't think it'd be a good idea.
Changing the way "swappable" modules are being registered
Currently, there are a few "swappable" modules: The debugger client, and the renderers. They are parts of the code designed to be modular and swapped, to change renderers or the connection method to the debugger fairly easily. Currently, the different implementations are registering themselves by setting a global variable, and interacting with this global. With modules, this wouldn't work since, by design, modules are not supposed to be dealing with global variables. What we can do though is replace modules at compilation time. We would basically have the following structure:
The interface file uses a typescript interface to describe what the shared API should look like. That ensures consistency between different implementations and the respect of the API contract.
The default index.js exports an no-op version of the interface. This allows to import the index file in other modules normally and get autocomplete & type-checking normally.
The implementation files contain the different implementations. The GDJS Exporter code will decide based on the export settings which one to use, copy it and write it in the export folder as the index.js. Since the exported files are not in typescript but already transpiled, the interface file is not exported. The individual implementations file are not exported either.
That way, we can have modulable code with a stable API across implementations, type checking, autocomplete and everything, and can use any of the implementations as the index, allowing parts of the code using those swappable modules to just import the index and get the correct implementations depending on the export settings.
Recapitulation
I've written now about all of the main design decisions that I believe would need to come with supporting ES modules in my opinion. NPM modules support and making the exporter
import
aware may sound like overscoping, but I think they really aren't if we want to actually gain something from the switch to es modules, since they are required to tree shake everything.Supporting all of that in JavaScript events is not a must though, and does go a bit out of scope, but if we are already doing all the work for included files, we might as well do it for JavaScript events and make JavaScript way more usable in GDevelop, since we have multiple users making great stuff while using primarily JavaScript (like FlokiTV)
We could make it easier by "simply" converting the runtime over to es modules, making it throw all its exports in a global variable and bundle that, but except for pixi.js typings and forcing us to explicitly import anything we want to use, this really wouldn't change anything, and not justify the multiple costs of the operation:
Therefore, unless we get the "compile time optimisations", NPM dependencies support and usability of
import
in JavaScript events to import for example extensions unused outside of JavaScript, all important features, ES Modules just aren't worth the effort.Thanks for reading that far into my thoughts on the matter, I hope to hear your opinions on all that.
Beta Was this translation helpful? Give feedback.
All reactions