pkg/dynamic_modules/README.md
Dart Dynamic Modules is a work-in-progress and experimental. Like any experimental Dart feature, that means it might change in breaking ways and we are not yet committed to adopting it. Please be aware of the risks if you consider using it beyond local experiments.
At this time, we are prioritizing work for Dynamic Modules and Dart Bytecodes that helps us improve specific developer journeys. We have focused on collaborative sharing of prototypes, and mobile development without JITing. We are not currently prioritizing work related to other applications of Dynamic Modules in production applications, like server-driven UI use-cases.
Regardless of the use case, this feature needs to be treated with responsibility and care. Further below we share many considerations, from security to policy, that you should be aware of before proceeding.
Dart Dynamic Modules allows delivering code to an existing application and loading it dynamically much like a dynamic import in JavaScript or a dynamically linked library in C.
We'll dive into two parts in a bit more detail:
Semantics: Until now Dart primarily supported statically compiled applications. This feature provides semantic reasoning for how to expand applications dynamically, mechanisms to declare these expansions, and validation needed to ensure these expansions don't break memory safety.
Technology: How we implemented it for AOT applications by using Dart bytecodes to deliver dynamic modules.
Even though we don't have implementations for Dart web backends today, we have explored prototypes enough to convince ourselves that the semantics can be achieved using different technologies that fit naturally with those backends as well.
Dart Dynamic Modules makes it possible to dynamically load a set of Dart libraries in a running Dart application. It defines how those libraries can interact with the rest of the application, and provides utilities to facilitate their construction and correct usage.
Historically, we have mostly treated Dart applications as a monolith. Every time we build a Dart binary, we typically build the entire application as a unit. Yes, there are some exceptions in development scenarios where we support modular compilation, like DDC on the web, but this whole-program approach is generally true across our backends (dart2wasm, dart2js, AOT). Even features like deferred loading that allow developers to split their binary into pieces to lower latency during an initial load, operate with the whole program first and only later they split the code into chunks.
Dart Dynamic Modules moves us away from this monolith concept. Now, an application can be built and more modules can be defined, compiled, and loaded after the fact. This represents architectural change: the application now acts as a host that is extensible through dynamic modules over time. The main semantic restriction: all extensions are additive. Dynamic modules cannot replace an existing declaration in the application, not even if that declaration was delivered through a dynamic module earlier.
To extend an application, developers are required to define an API surface area that establishes how dynamic modules will interact with the rest of the host application. We call the declaration of this surface area a Dynamic Interface. With a surface area defined, a host application is built as a single binary like before, but with the caveat that the API surface area is now exposed. More libraries can be compiled later on and loaded as dynamic modules, as long as they are compatible with the API surface area defined by the host.
Quick recap on our terminology:
Let's dive into more details on how this architecture plays out. If you are interested in how to use them directly and experiment with them, skip ahead and look at our example. Here we describe the conceptual steps that are needed when adopting an application architecture that uses dynamic modules:
Step 1: First, you will need to have a special build of Dart that supports dynamic modules. We don't ship this as part of the Dart or Flutter SDKs, so at this time this needs to be built from sources (we show how in the example below).
Step 2: You need to determine an API surface area. To do so, you
usually start by imagining what logic will be loaded dynamically
and how it will be integrated into your application. This helps
you figure out the right contract between the application and the
dynamic modules, when and how the application will load them, and
identify which APIs need to be exposed. By default, nothing is
exposed, not even APIs in dart:core, unless you opt into them.
Step 3: Once you decide which APIs you want to expose from the
host, you write a special dynamic_interface.yaml file that
defines the dynamic interface (see
format specification).
This file is later used as an input to all compilation steps.
Step 4: With a dynamic interface in hand, you can build the host application. There are three differences from how normal Dart applications are built:
First, the compiler and runtime are slightly different in order to include support for executing the dynamic modules. As we'll see later below, for AOT we decided to adopt Dart bytecodes and embed an interpreter in the runtime as the technology to execute dynamic modules in AOT.
Second, the application code is compiled differently by informing the compiler about the dynamic interface. Traditionally, Dart's AOT compiler performs closed-world optimizations assuming all code is known to the compiler, dynamic modules break that assumption so the compiler needs guidance to produce correct and sound applications. Usually this means less code is tree-shaken and fewer calls can be specialized because we don't know how exposed APIs will be used.
Third, the application code needs to embed some provenance information that will be used later to validate the compatibility of what we load dynamically. This is important to guarantee correctness and memory safety and is a consequence of the technological choices we made for implementing dynamic modules. (Note: provenance is work in progress and not available yet in the current implementation of the package).
Step 5: At this stage the application is ready to be delivered and run. The logic of what comes in a dynamic module can be defined later, even after the application is running.
Step 6: Create and build a dynamic module. This involves writing a new set of Dart libraries with a special entrypoint. There is an important constraint, dynamic modules can only define new code. If they define libraries that already exist in the host application or in previous dynamic modules, the module is going to be rejected during the load. To deliver a dynamic module, we first compile it to a format compatible with the runtime where the application is running. In the case of AOT, we use a bytecode compiler to produce the dynamic module. As hinted above, bytecode compilation not only produces the bytecode that we will run, but also provenance attestations needed in the validation process.
Step 7: Deliver the dynamic module to the app. Since dynamic modules are delivered dynamically, some effort is needed to set up the right way to deliver the dynamic module properly and securely. This often involves some form of transport-level security (TLS).
Step 8: Validate and run the dynamic module. Before running the dynamic module — which mainly consists of invoking the designated entry point of the dynamic module and returning the result to the host application — we need assurances that the dynamic module won’t break the integrity of the application. Without validation, loading a dynamic module could break the application in unexpected ways (e.g. crashes, heap corruption, and more). Validation typically confirms compatibility of the dynamic module format and APIs in use. Depending on the technology, the verification may be done differently. Our implementation based on bytecodes relies on provenance checks to provide validation. Dynamic modules are allowed to load only if the same application code, dynamic-interface, and version of the SDK tools were used to build both the host app and the dynamic module.
That's the basic principle.
This isn't a complete or formal spec, but here are some details to expect about dynamic modules:
By default, compilers will reject the use of a dynamic call unless the selector name used in the dynamic call was previously allowed.
A selector name can be allowed if elements with the same name are exposed as "dynamically-callable" in the dynamic interface. This makes it possible to use dynamic calls that originate in dynamic modules but target a member in the host application.
A selector name can also be allowed through an allow-list flag of the dynamic module compiler. This makes it possible to use dynamic calls that originate from dynamic modules and target members declared also in dynamic modules.
Unlike all other parts of the API surface area, runtime checks are performed to enforce that the dynamic interface was respected. Runtime errors may be reported on dynamic calls that originate in the dynamic module if the target of the call was a member of the host application that was not exposed as "dynamically-callable".
For each backend, there are many possible ways to bring dynamic modules to life. DDC, for instance, is a modular compiler, so a dynamic module can directly be implemented as a new module with very little change to the compiler architecture. For AOT, we decided to adopt an architecture that uses an interpreter.
The Dart bytecode interpreter exists in the Dart VM codebase. It requires a tight integration with the runtime, which makes it difficult to deliver separately from the Dart runtime itself. At the same time, it has an implicit code-size cost on the runtime that we don't want to include by default on all Dart applications today. Because of these reasons, we don't ship it by default as part of our SDK today. Instead, to use the dynamic modules experiment, we require passing a build-time flag when building Dart.
We also have a dart2bytecode compiler, which we use to compile dynamic modules to bytecode. In the future, we expect the format to deliver dynamic modules will be expanded beyond bytecode to allow embedding resources (e.g. icons, images, provenance).
Finally, we are in the process of putting together tools to produce provenance information. This includes information about how an app and dynamic module were created, including versioning details about the inputs and tools used in the process. This may be embedded from the bytecode compiler or provided separately. We'll update this README as we make progress on the implementation.
The libraries in package:dynamic_modules currently provide an API
to load bytecode directly. This will be updated in the near future
once we update the format and embed provenance information. Stay
tuned for changes.
The example/ directory contains a simple example that
illustrates the steps described above. The example is a simple
calculator command line interface that lets users extend it with new
operations beyond those built-in.
The example README.md contains details on how to navigate it. Here, we'd like to highlight how the example connects with some of the conceptual steps we described above.
The calculator consists of 3 parts:
An API surface area exposes the interface used by dynamic
modules to define new operations, as well as the API to register
them. This corresponds to steps 2 and 3 above. See
dynamic_interface.yaml
and common/common.dart.
The host app defines the main REPL (read–eval–print loop) and
provides one built-in operation (math addition). This corresponds
to step 4 above. See host/main.dart.
Dynamic modules define new operations for the calculator
following the API surface area defined by the host application.
This corresponds to step 6 above. See the
modules/ subfolder.
Finally everything comes together in the run.sh
script. This script shows:
How to build Dart SDK artifacts with support for Dart Dynamic
Modules (Step 1 above) by providing the --dart-dynamic-modules
build-time flag.
How to use the dynamic interface to build artifacts of steps 4 and 6.
This example doesn't show how dynamic modules are delivered over the network (step 7), instead bytecode files are copied over to a data directory as an over simplistic way to represent the delivery process.
Dynamic modules are designed so they can eventually be supported in any Dart backend, however we have primarily focused on the AOT implementation. All other backends are either unsupported or incomplete.
What works?
What doesn't work yet?
dynamic_interface.yaml format has quirks
especially around private members, which sometimes need to be
exposed due to low-level implementation details of the language.What is currently not planned?
Q: How are dynamic modules executed in AOT?
The current implementation in AOT focuses entirely on using bytecode to deliver dynamic modules and executing it using a bytecode interpreter. That said, in general Dart Dynamic Modules as a concept is agnostic of how code gets executed. We only define how code extends an existing application and how it interacts with it. In fact, prototypes we have built with DDC and dart2wasm leveraged their own JS or wasm module systems to load new code.
Q: Are dynamic modules fully sandboxed?
No. Dynamic modules run in an interpreter, but you are loading code into the same context of the application. Both the original host application code and the dynamic modules run together as a single program with a shared heap. Even though dynamic modules have restricted APIs to refer to, there are no restrictions on what those APIs can access indirectly.
You can create some lightweight isolation by loading dynamic modules in a new Dart isolate. This separates the heap from the rest of the host application, but won’t provide a full sandbox as it will still be running in the same OS process as the app.
We think it is an interesting future direction to explore creating trim-down versions of the Dart runtime that are fully sandboxed (e.g. absolute no access to IO or FFI), though.
Q: Is it OK to load untrusted/external/third-party code as a dynamic module?
No. As mentioned above, there is no sandbox. Anything you load with a dynamic module requires a lot of trust. Just like it's not OK to load untrusted third-party code without a sandbox in the same context as your web application, it's not OK to do so in a Dart application. Any code that is not developed by your team (external contributions, unreviewed LLM generated code, etc) should be treated as untrusted.
Q: Why will the API change to require provenance information?
We will require provenance information to validate compatibility before a dynamic module is loaded and run in a host application. Unlike other bytecode formats, like Java bytecodes, the Dart bytecode format has no stable ABI and no mechanism to verify that APIs used from the bytecode are compatible. As such, we need additional mechanisms to prove compatibility and verify that a dynamic module will behave as expected when loaded into a running host application. Since compilation to bytecode already performs most of the verification steps we need, we are designing the system to plumb provenance information as a way to give the host application the proof it needs to confirm everything is in order before proceeding.
Q: Why are Dart bytecodes and Dart dynamic modules being developed?
We are currently exploring improvements to the development story of Dart and Flutter applications. Historically we relied heavily on JIT compilation, but we were exploring using modular AOT compilation in combination with Dart bytecodes to provide alternative development workflows that can be supported in environments where JIT compilation is not feasible. We discovered that the same technology can be used for loading code dynamically, which led us to explore new development use cases of the technology. One such use-case is to share prototypes between team members at a low cost.
Q: Can I use dynamic modules today in my Dart/Flutter app?
We are in an early experimental stage of this feature, so we don't recommend taking a dependency on it for projects used in production today. You'll note that the feature is also not available by default in Dart and Flutter SDKs, in part because just enabling it introduces code-size bloat and performance effects that are unnecessary for existing applications today.
We are prioritizing dynamic modules for development use cases, where enabling dynamic modules at build time is an option and the trade-offs are less of an issue.
Q: Why are you only prioritizing development use cases?
There are many considerations that need to be factored when supporting a feature for production use cases. In the case of Dart Dynamic Modules, the consideration list is long and requires a lot of diligence, care, and effort, both from the SDK and from actual developers adopting it. We decided to focus only on development use cases that allow us to reduce the scope of these considerations.
Here are some of the top-of-mind factors for this technology:
This feature can increase application size unnecessarily. The interpreter has a fixed cost, but there are additional effects on AOT binaries from disabling tree-shaking and optimizations related to the dynamic interface. If used naively, that extra size bloat can be substantial.
If used improperly, this feature can introduce security risks. Dynamic modules are loaded in a trusted environment with no extra sandbox, so care needs to be in place to ensure bytecode has the right provenance and is delivered safely. Our work on requiring provenance is an effort to reduce this risk, but it doesn't eliminate all attack vectors.
If used improperly, this feature can break applications or cause them to behave unexpectedly. For example, accidentally mixing bytecode compiled against a different version of the app can cause issues. Our work on provenance also helps reduce this risk by validating compatibility, but it doesn't reduce the complexity required to manage versioning in the first place.
Proper version management involves non-trivial efforts, including creating server components and properly managing creation and storage of bytecode. We think a production-ready solution should have clear guidance and designs for how to address this challenge.
By focusing on the development use cases we found that some of these considerations have a lower impact or lower complexity. For instance, code-size is a lesser concern and we can focus on single-version scenarios that ignore the more general versioning questions. We still believe these considerations are important and relevant, so we have used the development scenarios to help us make progress on them (e.g. provenance support).
Q: Is Dart Dynamic Modules a "code-push" implementation?
No. Dart Dynamic Modules is a language runtime technology, similar to that available in many other languages. It resembles C dynamic linking, JavaScript dynamic imports, wasm module instantiations.
Code-push approaches are full-stack solutions that use multiple technologies and services underneath to enable developers to send over-the-air updates that patch existing applications. A general code-push solution needs hosting services to track releases and patches over time, mechanisms to make the application updatable in-place, to determine what has changed between patches, to encode updates in a deliverable format, to deliver and apply such updates, and more.
We believe Dart Dynamic Modules is not an ideal technology for a general-purpose codepush solution for a few reasons. First, the additive semantics: dynamic modules can only introduce new libraries, but can't update any existing declaration in an application. Second, the API surface area: dynamic modules can only invoke code that was previously exposed. In contrast, code-push solutions want to update existing declarations and want patches to be able to invoke anything already in the application. For a dynamic module to be able to invoke anything in an application, we would need to expose the entire app in the dynamic interface. That would practically disable tree-shaking and all whole-program optimizations, dramatically affecting the original application performance and code-size to the point that we doubt it would be suitable for use in production environments.
Q: Are there security concerns behind dynamic modules?
Yes. As hinted above in the earlier questions about how it works, some top security considerations are that:
A dynamic module loads in the same context as the host app,
without an additional sandbox. Compared to the web, this is
equivalent to appending <script> tags without using an
iframe. As such, similar precautions are needed to use dynamic
modules safely. This includes ensuring the code is trusted,
preventing injection scenarios that would tamper the integrity of
the bytecode. For example, you should never use dynamic modules to
load code provided by others or provided by LLMs. Also, even if
the source of the code is trusted, the delivery needs to be
secured with proper TLS.
Our bytecode doesn't have a stable ABI, so compatibility is only guaranteed if the bytecode loaded matches the format supported by the host app. A skew in the ABI can cause memory corruption or other semantic errors and lead the application to misbehave.
Similarly, incompatible APIs can also lead to the same result.
Our work to provide provenance validation is designed to help validate ABI and API compatibility before a load is allowed. Unlike Java bytecodes, Dart bytecodes don't offer verification that the APIs in the bytecode match the APIs in the host app, so we resort to provenance as an alternative technique. Just like the web, provenance alone does not guarantee the safety of the code being loaded. Special diligence is required by developers to ensure that only code that is trusted and delivered securely is ever loaded in the context of the application.
Q: Are there store policy considerations to consider when using dynamic modules?
Yes, there are policy considerations, regardless of how code is run. For example, you might want to validate that any store you are publishing to allows running interpreted code in a custom interpreter. And stores might have policies around how much an app is allowed to change after it has been installed by the user.